Spring 기반 웹 애플리케이션의 동시성 모델을 비교하고, JDK 21의 Virtual Thread가 가져온 패러다임 변화를 정리합니다.
1. 두 가지 동시성 모델
Spring MVC + Tomcat: Thread-per-Request
Spring MVC는 thread-per-request 모델을 사용한다. 요청이 들어오면 Tomcat의 스레드 풀(기본 200개)에서 스레드 하나를 할당하고, 해당 스레드가 컨트롤러 → 서비스 → DB 호출까지 전부 담당한다. DB I/O 같은 블로킹 작업이 발생하면 해당 스레드는 응답이 올 때까지 대기(blocking) 상태에 놓인다.
Spring WebFlux + Netty: Event Loop
Spring WebFlux는 Event Loop 모델을 사용한다. Netty의 Event Loop 스레드(기본 CPU 코어 수)가 요청을 받아서 논블로킹으로 처리한다. I/O 작업이 필요하면 요청을 던져놓고 스레드는 즉시 다른 작업을 처리하러 간다. 응답이 오면 콜백으로 이어서 처리하는 방식이다.
비유로 이해하기
- MVC + Tomcat: 식당에 웨이터가 200명 있는데, 각 웨이터가 한 테이블에 붙어서 음식 나올 때까지 옆에 서서 기다린다.
- WebFlux + Netty: 웨이터가 4명인데, 주문 받고 → 바로 다음 테이블 가고 → 음식 나오면 알림 받아서 서빙한다.
동시 접속자가 많고 I/O 대기가 긴 상황에서는 WebFlux가 훨씬 적은 리소스로 더 많은 요청을 처리할 수 있다.
2. Event Loop 모델 깊이 이해하기
WebFlux의 동작 원리를 제대로 이해하려면 Event Loop 모델을 먼저 알아야 한다. Event Loop는 단일(또는 소수의) 스레드가 이벤트 큐를 계속 순회하면서 작업을 처리하는 패턴으로, Node.js가 대표적이고 Netty도 이 모델을 사용한다.
동작 원리
┌─────────────────────────┐
│ Event Queue │
│ ┌───┬───┬───┬───┬───┐ │
요청/이벤트 → │ │ E │ D │ C │ B │ A │ │
│ └───┴───┴───┴───┴───┘ │
└────────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Event Loop │
│ │
│ while (true) { │
│ event = queue.poll() │
│ handle(event) │
│ } │
│ │
└────────────┬─────────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
CPU 작업이면 I/O 작업이면 타이머면
즉시 처리 OS에 위임하고 예약 후
다음 이벤트로 다음 이벤트로
│
▼
I/O 완료 시
콜백이 다시
Event Queue에 등록
흐름을 정리하면 다음과 같다:
- 모든 요청/이벤트가 Event Queue에 들어간다.
- Event Loop 스레드가 큐에서 이벤트를 하나씩 꺼내서 처리한다.
- CPU 작업(JSON 파싱, 비즈니스 로직 등)은 그 자리에서 즉시 처리한다.
- I/O 작업(DB 쿼리, 네트워크 호출 등)은 OS 커널에 논블로킹으로 위임하고, 바로 다음 이벤트 처리로 넘어간다.
- I/O가 완료되면 OS가 알려주고, 완료 콜백이 다시 Event Queue에 등록된다.
- Event Loop가 그 콜백을 꺼내서 이어서 처리한다.
Netty에서의 Event Loop
Netty는 이 패턴을 좀 더 정교하게 구현한다. Node.js는 Event Loop가 1개지만, Netty는 EventLoopGroup이라는 스레드 그룹을 운영한다.
┌─────────────────────────────────────────────┐
│ Boss EventLoopGroup │
│ ┌───────────┐ │
│ │ BossLoop │ ← 새 연결(accept)만 담당 │
│ └─────┬─────┘ │
└────────┼────────────────────────────────────┘
│ 연결 수립 후 Worker에게 넘김
▼
┌─────────────────────────────────────────────┐
│ Worker EventLoopGroup │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │WorkerLoop│ │WorkerLoop│ │WorkerLoop│ │
│ │ #1 │ │ #2 │ │ #3 │ │
│ │ │ │ │ │ │ │
│ │ Ch-A │ │ Ch-D │ │ Ch-G │ │
│ │ Ch-B │ │ Ch-E │ │ Ch-H │ │
│ │ Ch-C │ │ Ch-F │ │ Ch-I │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
Boss EventLoopGroup은 새로운 클라이언트 연결(accept)만 담당한다. TCP 핸드셰이크를 처리하고, 연결이 수립되면 해당 채널을 Worker에게 넘긴다.
Worker EventLoopGroup은 실제 데이터 읽기/쓰기를 처리한다. 코어 수만큼의 스레드가 있고, 각 스레드가 여러 채널(연결)을 담당한다. 중요한 건 하나의 채널은 항상 같은 Worker 스레드에 바인딩된다는 점이다. 이 덕분에 채널 내에서는 동기화(lock) 없이 안전하게 데이터를 처리할 수 있다.
왜 적은 스레드로 많은 연결을 처리할 수 있는가
전통적인 thread-per-request 모델에서는 스레드가 I/O를 기다리면서 대부분의 시간을 idle 상태로 보낸다. 예를 들어 DB 쿼리가 50ms 걸린다면, 그 50ms 동안 스레드는 아무 일도 하지 않는다.
Event Loop는 이 "빈 시간"을 다른 연결의 작업을 처리하는 데 쓴다. I/O 대기가 없으니 스레드가 쉬는 시간이 거의 없고, CPU 시간을 최대한 활용할 수 있다. 이것이 Netty가 Worker 스레드 4개로도 수만 개의 동시 연결을 처리할 수 있는 이유다.
Event Loop의 치명적 규칙: 절대 블로킹하지 마라
Event Loop 모델에는 반드시 지켜야 할 규칙이 하나 있다. Event Loop 스레드에서 블로킹 작업을 해서는 안 된다. Event Loop 스레드가 하나의 작업에서 멈추면, 그 스레드가 담당하는 모든 채널의 처리가 함께 멈춘다. Worker 스레드가 3개인데 1개가 블로킹되면, 해당 스레드에 바인딩된 모든 연결(Ch-G, Ch-H, Ch-I)이 응답 불가 상태에 빠진다.
이것이 WebFlux에서 JDBC를 쓰면 안 되는 이유다. JDBC는 블로킹 드라이버이기 때문에 Event Loop 스레드를 멈추게 하고, 이는 곧 전체 시스템의 처리량 급감으로 이어진다.
3. 핵심 비교
| 관점 | MVC + Tomcat | WebFlux + Netty |
| I/O 모델 | Blocking I/O | Non-blocking I/O |
| 스레드 수 | 많음 (기본 200) | 적음 (코어 수) |
| 스레드 상태 | I/O 시 대기(idle) | I/O 시 다른 작업 수행 |
| 메모리 사용 | 스레드당 ~1MB 스택 → 높음 | 적은 스레드 → 낮음 |
| 프로그래밍 모델 | 명령형 (Imperative) | 반응형 (Reactive: Mono/Flux) |
| DB 드라이버 | JDBC (blocking) | R2DBC (non-blocking) |
| 디버깅 | 스택트레이스 직관적 | 체인 형태라 추적 어려움 |
| 학습 곡선 | 낮음 | 높음 |
성능 특성
MVC가 유리한 경우: CPU 바운드 작업이 많거나, 동시 요청 수가 스레드 풀 범위 내인 일반적인 CRUD 서비스. 요청당 처리 시간이 짧으면 blocking이어도 스레드 회수가 빨라서 문제가 되지 않는다.
WebFlux가 유리한 경우: 동시 연결이 수만 개 이상이거나, 외부 API 호출이 많아서 I/O 대기 시간이 긴 경우(게이트웨이, 채팅, SSE, 스트리밍 등). 스레드가 놀지 않으니까 처리량(throughput)이 크게 올라간다.
주의할 점
WebFlux에서 블로킹 코드를 쓰면 오히려 재앙이다. Event Loop 스레드가 4개뿐인데 하나가 블로킹되면 전체 처리량의 25%가 날아간다. JDBC, Thread.sleep(), synchronized 블록은 절대 쓰면 안 되고, 꼭 써야 한다면 Schedulers.boundedElastic()으로 별도 스레드에 위임해야 한다.
참고: 프레임워크와 서버 엔진은 독립적이다
흔히 "MVC = Tomcat, WebFlux = Netty"로 묶어서 생각하기 쉽지만, Spring에서 웹 프레임워크와 서버 엔진은 독립적으로 선택할 수 있다.
| 조합 | 가능 여부 | 비고 |
| MVC + Tomcat | ✅ | 가장 일반적인 기본 조합 |
| MVC + Jetty / Undertow | ✅ | Tomcat 대안으로 사용 |
| WebFlux + Netty | ✅ | 가장 일반적인 기본 조합 |
| WebFlux + Tomcat | ✅ | 기존 Tomcat 인프라 활용 시 |
| WebFlux + Jetty / Undertow | ✅ | 가능하지만 드묾 |
| MVC + Netty | ❌ | MVC는 Servlet API에 의존, Netty는 서블릿 컨테이너가 아님 |
특히 WebFlux + Tomcat 조합은 실무에서도 종종 보인다고 한다. spring-boot-starter-webflux만 넣으면 기본적으로 Netty가 뜨지만, spring-boot-starter-web을 함께 추가하면 Tomcat 위에서 WebFlux가 동작한다. Tomcat도 3.1 버전부터 논블로킹 I/O를 지원하기 때문에 가능한 조합이다.
다만 Netty가 처음부터 논블로킹을 위해 설계된 반면, Tomcat은 블로킹 모델 위에 논블로킹을 얹은 구조이므로 극한의 동시 연결 처리에서는 Netty가 유리하다. 이미 Tomcat 기반 인프라가 구축된 조직에서 WebFlux로 점진적 전환할 때 이 조합을 활용하기도 한다.
4. JDK 21 Virtual Thread가 바꾸는 판도
기존 MVC + Tomcat의 문제는 결국 플랫폼 스레드가 비싸다는 것이었다. 스레드당 ~1MB 스택 메모리, OS 레벨 컨텍스트 스위칭 비용 때문에 200개 정도가 현실적 한계였고, I/O 대기 중 스레드가 놀고 있으면 낭비가 심했다.
JDK 21의 Virtual Thread는 이 전제를 깨버린다.
- 경량: JVM이 관리하며 스택 메모리가 수 KB 수준이라 수십만 개 생성 가능
- I/O 시 자동 언마운트: Virtual Thread가 블로킹 I/O를 만나면 캐리어 스레드에서 자동으로 분리됨
- 기존 코드 그대로: 명령형 코드, JDBC,
Thread.sleep()전부 그대로 사용 가능
Spring Boot 3.2+에서는 설정 한 줄이면 Tomcat이 Virtual Thread 기반으로 동작한다.
spring:
threads:
virtual:
enabled: true
캐리어 스레드와 Virtual Thread의 관계
Virtual Thread는 혼자 실행되는 게 아니라, 실제 OS 스레드(= 캐리어 스레드) 위에 올라타서 실행된다. JVM이 내부적으로 ForkJoinPool을 유지하고 있고, 여기에 캐리어 스레드가 보통 CPU 코어 수만큼 존재한다.
캐리어 스레드 (OS 스레드, 4개라고 가정)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Carrier-1│ │ Carrier-2│ │ Carrier-3│ │ Carrier-4│
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
VT-A 실행 VT-B 실행 VT-C 실행 VT-D 실행
대기 중인 Virtual Thread: VT-E, VT-F, VT-G, VT-H ...
Virtual Thread가 수만 개 있어도 실제로 동시에 CPU에서 돌아가는 건 캐리어 스레드 수만큼이다.
언마운트(Unmount)는 어떻게 동작하는가
Virtual Thread의 핵심 메커니즘을 구체적으로 살펴보자. VT-A가 DB 쿼리를 날린다고 가정한다.
// Virtual Thread VT-A에서 실행 중
User user = jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?", mapper, id
); // ← 여기서 블로킹 I/O 발생 (네트워크 응답 대기)
기존 플랫폼 스레드였다면 OS 스레드가 그 자리에서 멈춰서 DB 응답 올 때까지 아무것도 못 한다. 스레드 하나가 통째로 낭비된다.
Virtual Thread에서는 JVM이 블로킹 I/O 진입을 감지하고 다음 과정을 수행한다:
[블로킹 I/O 진입 시]
1. JVM이 VT-A의 실행 상태(스택, 로컬 변수 등)를 힙 메모리에 저장
2. VT-A를 Carrier-1에서 분리 (unmount)
3. Carrier-1은 이제 자유 → 대기열에서 VT-E를 꺼내서 실행 (mount)
┌──────────┐
│ Carrier-1│ ← VT-E 실행 중 (놀지 않음!)
└──────────┘
VT-A: 힙 메모리에서 대기 중... (OS 스레드 점유 안 함)
[DB 응답 도착 시]
4. VT-A가 다시 실행 가능 상태로 전환
5. 빈 캐리어 스레드(꼭 Carrier-1이 아니어도 됨)에 VT-A를 올림 (mount)
6. VT-A는 저장해뒀던 상태에서 이어서 실행
코드상으로는 queryForObject()에서 블로킹된 것처럼 보이지만, 실제로 OS 스레드는 블로킹되지 않는다. JVM이 중간에서 Virtual Thread를 갈아끼우는 협력적 스케줄링(cooperative scheduling)을 수행하는 것이다.
이를 놀이공원 회전목마에 비유하면 이해가 쉽다. 캐리어 스레드는 회전목마 좌석(4자리), Virtual Thread는 탑승객(수만 명), 블로킹 I/O는 "잠깐 화장실 갔다 올게"에 해당한다. 플랫폼 스레드 방식이면 화장실 간 사람의 좌석을 비워두고 회전목마가 돌지만, Virtual Thread 방식이면 화장실 간 사람을 내리고 대기줄에서 다음 사람을 태운다. 화장실 다녀온 사람은 다시 대기줄에 서서 빈 좌석에 타면 된다.
그래서 Virtual Thread를 "저렴한 스레드"라고 부른다. 생성 비용도 싸고, 블로킹 상태에서 점유하는 리소스도 거의 없기 때문이다. 개발자는 그냥 동기 코드를 짜면 되고, WebFlux처럼 Mono.flatMap().zipWith() 같은 복잡한 체이닝이 필요 없다. 그러면서도 내부적으로는 논블로킹에 준하는 스레드 효율성을 얻을 수 있다.
Virtual Thread의 한계
Virtual Thread가 만능은 아니다. 몇 가지 주의할 점이 있다.
synchronized 블록에서의 pinning: Virtual Thread가 synchronized 블록이나 네이티브 메서드 안에 있으면 캐리어 스레드에 고정(pinning)되어 언마운트가 일어나지 않는다. 이 경우 플랫폼 스레드와 동일하게 캐리어 스레드가 블로킹된다. JDK 24에서 상당 부분 개선됐지만, 서드파티 라이브러리 내부에 숨어있는 synchronized가 여전히 문제될 수 있으므로 -Djdk.tracePinnedThreads=short 옵션으로 pinning 발생 여부를 모니터링하는 것이 좋다.
진정한 논블로킹이 아님: Virtual Thread는 블로킹 코드를 효율적으로 스케줄링하는 것이지, I/O 자체가 논블로킹이 되는 건 아니다. WebFlux + Netty는 커널 레벨에서 epoll/kqueue 기반 논블로킹 I/O를 사용하기 때문에 극한의 처리량에서는 여전히 우위가 있을 수 있다.
배압(Backpressure) 부재: Reactive Streams의 배압 메커니즘은 Virtual Thread에 없는 고유한 장점이다. 느린 컨슈머가 빠른 프로듀서의 속도를 제어할 수 있는 기능은 WebFlux만의 것이다. 예를 들어 대용량 데이터 스트리밍에서 클라이언트의 처리 속도에 맞춰 서버가 데이터 전송량을 조절해야 하는 경우, WebFlux의 Flux는 이를 선언적으로 처리할 수 있지만 Virtual Thread에서는 별도의 제어 로직을 직접 구현해야 한다.
5. MSA 환경에서의 기술 선택
MSA(Microservices Architecture) 환경에서는 WebFlux가 자주 언급되는데, 모든 서비스에 WebFlux를 써야 하는 것은 아니다. MSA의 어떤 계층에서 어떤 기술을 쓸지를 나눠서 생각해야 한다.
API Gateway 계층: WebFlux가 적합
Spring Cloud Gateway가 WebFlux 기반인 것은 우연이 아니다. 게이트웨이는 자체 비즈니스 로직은 거의 없고, 들어온 요청을 적절한 서비스로 라우팅하고 응답을 집계하는 순수 I/O 중계자다. 동시에 수만 개의 연결을 유지하면서 인증, 라우팅, 로드밸런싱을 처리해야 하므로 논블로킹 모델이 매우 효율적이다.
BFF(Backend For Frontend) 계층: WebFlux가 적합
BFF는 특정 클라이언트(웹, 모바일, 데스크톱 등)에 최적화된 API를 제공하는 중간 서버다. 예를 들어 모바일 앱의 홈 화면을 구성하기 위해 사용자 서비스, 추천 서비스, 알림 서비스 등 여러 마이크로서비스를 호출하고, 그 결과를 모바일에 맞게 조합해서 하나의 응답으로 내려주는 역할을 한다.
[모바일 앱] ──→ [BFF for Mobile] ──→ 사용자 서비스
──→ 추천 서비스
──→ 알림 서비스
[웹 브라우저] ──→ [BFF for Web] ──→ 사용자 서비스
──→ 상품 서비스
──→ 리뷰 서비스
BFF의 본질은 여러 서비스를 동시에 호출하고 응답을 집계하는 것이다. 자체 비즈니스 로직이나 DB 접근은 거의 없고, 대부분의 시간을 다른 서비스의 응답을 기다리는 데 쓴다. 이런 I/O 집약적 특성 때문에 WebFlux가 잘 맞는다.
// BFF에서 모바일 홈 화면 데이터 집계
Mono.zip(
userClient.getProfile(userId),
recommendClient.getItems(userId),
notificationClient.getUnread(userId)
).map(tuple -> MobileHomeResponse.builder()
.profile(tuple.getT1())
.recommendations(tuple.getT2())
.notifications(tuple.getT3())
.build());
개별 마이크로서비스: 서비스 특성에 따라 다르다
여기서 중요한 포인트는, API Gateway나 BFF가 WebFlux라고 해서 뒤의 개별 서비스까지 WebFlux를 써야 하는 것은 아니라는 점이다. 각 서비스는 자기 자신의 워크로드 특성에 맞게 독립적으로 기술을 선택하면 된다. 이것이 MSA의 핵심 이점이기도 하다.
MVC + Virtual Thread가 적합한 서비스: 대부분의 CRUD 중심 서비스가 여기에 해당한다. 주문 서비스, 사용자 서비스, 상품 서비스 등 JPA/JDBC로 자체 DB를 조회하고 비즈니스 로직을 처리하는 서비스는 MVC + Virtual Thread가 더 실용적이다. 기존 Spring 생태계(JPA, Spring Security, Spring Batch 등)를 그대로 활용할 수 있고, 팀원들의 학습 부담도 적다. Virtual Thread 덕분에 다른 서비스로의 동기 호출이 많더라도 스레드 고갈 문제가 발생하지 않는다.
WebFlux가 적합한 서비스: 알림 서비스(SSE/WebSocket), 실시간 채팅 서비스, 이벤트 스트리밍 처리 서비스 등 장시간 연결 유지나 스트리밍이 핵심인 서비스는 WebFlux가 더 자연스럽다. 또한 Kafka 컨슈머에서 받은 이벤트를 실시간으로 가공하여 다수의 클라이언트에게 전달하는 서비스처럼 Reactive Streams의 배압 메커니즘이 필요한 경우에도 WebFlux가 적합하다.
정리하면 MSA에서의 현실적인 기술 배치는 다음과 같은 그림이 된다:
[클라이언트]
│
▼
[API Gateway - WebFlux] ← 순수 I/O 라우팅, 수만 동시 연결
│
├──→ [BFF - WebFlux] ← 여러 서비스 호출 집계
│ │
│ ├──→ [주문 서비스 - MVC + VT] ← CRUD, JPA
│ ├──→ [사용자 서비스 - MVC + VT] ← CRUD, JPA
│ └──→ [추천 서비스 - MVC + VT] ← ML 모델 호출
│
└──→ [알림 서비스 - WebFlux] ← SSE, 장시간 연결
└──→ [채팅 서비스 - WebFlux] ← WebSocket, 실시간 스트리밍
6. 현실적인 선택 기준
| 상황 | 추천 |
| 일반 CRUD, JPA 중심 서비스 | MVC + Virtual Thread |
| API Gateway, BFF | WebFlux |
| 외부 API 호출이 많은 서비스 | 둘 다 가능, Virtual Thread가 더 쉬움 |
| 실시간 스트리밍, SSE, WebSocket | WebFlux |
| 팀의 Reactive 경험 부족 | MVC + Virtual Thread |
| 극한 처리량 + 배압 필요 | WebFlux |
| MSA 개별 비즈니스 서비스 | MVC + Virtual Thread (대부분) |
Virtual Thread의 등장으로 "논블로킹이 필요하니까 WebFlux"라는 논리는 많이 약해졌다. 이제는 WebFlux를 선택할 때 "Reactive Streams의 고유한 이점(배압, 스트리밍, 선언적 데이터 흐름)이 정말 필요한가?"를 기준으로 판단하는 것이 적절하다.
마무리
Spring MVC + Tomcat은 직관적이고 생태계가 풍부하며, Virtual Thread를 통해 동시성 문제를 상당 부분 해결할 수 있게 되었다. Spring WebFlux + Netty는 배압, 스트리밍, 극한의 I/O 처리량이 필요한 시나리오에서 여전히 강력한 선택지다. 중요한 것은 어느 쪽이 "더 좋다"가 아니라, 서비스의 워크로드 특성에 맞는 선택을 하는 것이다. 특히 MSA 환경에서는 서비스마다 다른 기술 스택을 선택할 수 있다는 점을 적극 활용하는 것이 바람직하다.
'Spring Framework > Spring' 카테고리의 다른 글
| [Spring] Spring은 DB 예외를 어떻게 바꾸는가 (0) | 2026.05.27 |
|---|---|
| [Spring] Spring의 비동기 처리 — @Async부터 Virtual Thread 통합까지 (0) | 2026.03.16 |
| [Spring] Spring 캐시(Cache) 핵심정리 (2) | 2025.08.26 |