WebFlux vs MVC + Virtual Threads — 2026년 기준 선택 가이드
"Virtual Threads가 나왔으니까 WebFlux 배울 필요 없는 거 아닌가요?" 이 질문, 2024년부터 지금까지 정말 많이 봤습니다. 결론부터 말하면 — 반은 맞고, 반은 틀립니다.
WebFlux가 해결하려 했던 문제 중 상당 부분을 Virtual Threads가 더 간단하게 해결합니다. 하지만 WebFlux만이 할 수 있는 영역이 여전히 존재해요. 이 글에서는 2026년 기준으로 두 기술을 비교하고, 어떤 상황에서 어떤 선택이 합리적인지 정리해보겠습니다.
왜 이 논쟁이 생겼나
WebFlux가 등장한 이유
Spring MVC는 요청 하나당 스레드 하나를 할당합니다. I/O 대기 중에도 스레드를 잡고 있으니, 동시 요청이 많아지면 스레드 풀이 고갈돼요. WebFlux는 이 문제를 논블로킹 I/O + 이벤트 루프 로 해결했습니다.
하지만 대가가 컸어요:
Mono/Flux기반의 완전히 새로운 프로그래밍 모델- JPA, JDBC 같은 블로킹 라이브러리 사용 불가
- 스택 트레이스가 읽기 어려움
- 러닝 커브가 가파름
Virtual Threads가 바꾼 판도
Java 21에서 정식 릴리스된 Virtual Threads는 접근 방식이 완전히 다릅니다. 블로킹 코드를 그대로 쓰면서 I/O 대기 시 자동으로 캐리어 스레드를 양보해요. 프로그래머 입장에서는 아무것도 바뀌지 않았는데, 런타임이 알아서 최적화해주는 겁니다.
// MVC + Virtual Threads — 기존 블로킹 코드 그대로 사용
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
// JPA 블로킹 호출이지만, Virtual Thread가 I/O 대기 시 양보
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
// 외부 API 블로킹 호출 — 역시 자동으로 양보
CreditScore score = creditService.getScore(user.getSsn());
return UserResponse.from(user, score);
}
// WebFlux — 동일한 로직을 리액티브로 작성
@GetMapping("/users/{id}")
public Mono<UserResponse> getUser(@PathVariable Long id) {
return userRepository.findById(id) // R2DBC 사용 필수
.switchIfEmpty(Mono.error(new UserNotFoundException(id)))
.flatMap(user ->
creditService.getScore(user.getSsn()) // 논블로킹 클라이언트 필요
.map(score -> UserResponse.from(user, score))
);
}
같은 기능인데 코드 복잡도의 차이가 느껴지시나요?
Virtual Threads 동작 원리 (짧게)
Virtual Threads의 핵심을 한 줄로 요약하면: JVM이 관리하는 경량 스레드로, 블로킹 I/O를 만나면 자동으로 캐리어 스레드에서 언마운트된다.
캐리어 스레드 (OS 스레드, 소수)
├── Virtual Thread A → DB 쿼리 중 (언마운트, 대기)
├── Virtual Thread B → 실행 중
├── Virtual Thread C → API 호출 중 (언마운트, 대기)
└── Virtual Thread D → 실행 중
// Virtual Thread는 수십만 개 생성 가능
// 캐리어 스레드는 CPU 코어 수만큼만 유지
기존 플랫폼 스레드는 1MB 스택 메모리를 차지하지만, Virtual Thread는 수 KB로 시작하고 필요에 따라 늘어납니다. 그래서 수십만 개를 생성해도 메모리 부담이 적어요.
Virtual Threads가 마법처럼 보이지만, 본질은 ** 커널 스케줄링을 JVM 스케줄링으로 대체 **한 것이다. Go의 고루틴이나 Kotlin의 코루틴과 같은 계열이다.
성능 비교: 처리량, 지연 시간, 메모리
처리량 (Throughput)
I/O 바운드 워크로드 기준으로, 두 방식 모두 ** 비슷한 수준의 동시성 **을 달성합니다. 여러 벤치마크에서 확인된 결과예요:
| 항목 | MVC (플랫폼 스레드) | MVC + Virtual Threads | WebFlux |
|---|---|---|---|
| 동시 요청 1,000 | 정상 | 정상 | 정상 |
| 동시 요청 10,000 | 스레드 풀 고갈 | 정상 | 정상 |
| 동시 요청 100,000 | 불가 | 정상 (메모리 여유 시) | 정상 |
지연 시간 (Latency)
- MVC + VT: 블로킹 호출의 실제 I/O 시간에 가까운 지연 시간
- WebFlux: 이벤트 루프 오버헤드가 있지만 매우 작음 (마이크로초 단위)
- 실무에서 두 방식의 차이는 ** 대부분 무시할 수 있는 수준**
메모리
- WebFlux: 이벤트 루프 기반이라 기본 메모리 사용이 적음
- MVC + VT: Virtual Thread당 수 KB지만, 수십만 개면 누적됨
- 극한의 메모리 효율이 필요하면 WebFlux가 약간 유리
성능 차이보다 ** 개발 생산성과 유지보수성 **이 대부분의 프로젝트에서 더 중요한 선택 기준이다. 초당 수십만 요청이 아닌 이상, 두 방식 모두 충분하다.
생태계 호환성
이 부분이 실무에서 가장 크게 체감되는 차이입니다.
MVC + Virtual Threads
// JPA, JDBC — 그냥 된다
@Transactional
public Order createOrder(OrderRequest request) {
// JPA 엔티티 저장 — 블로킹이지만 VT가 알아서 처리
Order order = orderRepository.save(Order.from(request));
// JDBC 기반 레거시 라이브러리 — 역시 그냥 동작
legacyBillingSystem.charge(order.getPaymentInfo());
return order;
}
- JPA, MyBatis, JDBC 모두 호환
- 기존 블로킹 라이브러리 전부 사용 가능
- Spring Security, Spring Batch 등 전체 생태계 활용
WebFlux
// R2DBC 필수 — JPA 사용 불가
public Mono<Order> createOrder(OrderRequest request) {
return r2dbcOrderRepository.save(Order.from(request))
.flatMap(order ->
// 레거시 블로킹 라이브러리? Schedulers.boundedElastic()으로 감싸야 함
Mono.fromCallable(() -> legacyBillingSystem.charge(order.getPaymentInfo()))
.subscribeOn(Schedulers.boundedElastic())
.thenReturn(order)
);
}
- R2DBC, Spring Data Reactive만 지원
- JPA, MyBatis 사용 불가 (블로킹 I/O)
- 블로킹 라이브러리를 쓰려면 별도 스케줄러에서 실행해야 함
- 일부 서드파티 라이브러리가 리액티브 미지원
팀에 리액티브 경험이 부족한 상태에서 WebFlux를 도입하면, 가장 먼저 부딪히는 게 "이 라이브러리가 리액티브를 지원하나?"다. 생태계 호환성 문제는 생각보다 개발 속도를 많이 떨어뜨린다.
학습 곡선과 디버깅
학습 곡선
MVC + Virtual Threads는 사실상 ** 추가 학습이 거의 없습니다.** Spring Boot 설정에 한 줄 추가하면 끝이에요:
# application.yml — Virtual Threads 활성화 (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
반면 WebFlux는:
- Reactive Streams 스펙 이해
Mono/Flux오퍼레이터 숙지 (수십 개)flatMap,switchIfEmpty,zip,concat등 조합 학습- 에러 처리 패턴 (
onErrorResume,onErrorReturn) - 테스트 방법 (
StepVerifier)
디버깅 경험
// MVC + VT — 스택 트레이스가 깔끔하다
java.lang.NullPointerException
at com.example.UserService.getUser(UserService.java:25)
at com.example.UserController.getUser(UserController.java:18)
// 읽기 쉬운 동기식 스택 트레이스
// WebFlux — 스택 트레이스가 복잡하다
java.lang.NullPointerException
at com.example.UserService.lambda$getUser$0(UserService.java:25)
at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:106)
at reactor.core.publisher.FluxFlatMap$FlatMapMain.tryEmit(FluxFlatMap.java:543)
// 리액터 내부 스택이 섞여서 원인 파악이 어려움
Hooks.onOperatorDebug()를 켜면 좀 나아지지만, 프로덕션에서 켜면 성능이 크게 떨어진다. ReactorDebugAgent를 쓰면 오버헤드 없이 디버깅할 수 있지만, 이런 도구를 알아야 한다는 것 자체가 러닝 커브다.
백프레셔: WebFlux의 킬러 피처
여기서부터가 WebFlux를 아직 버릴 수 없는 이유입니다.
** 백프레셔(Backpressure)** 는 소비자가 처리할 수 있는 속도로 생산자에게 데이터를 요청하는 메커니즘이에요. Reactive Streams 스펙에 내장되어 있습니다.
// WebFlux — 백프레셔가 자연스럽게 동작
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
return eventService.getEventStream()
.onBackpressureBuffer(1000) // 버퍼 크기 제한
.map(event -> ServerSentEvent.<String>builder()
.data(event.toJson())
.build());
// 클라이언트가 느리면 자동으로 속도 조절
}
MVC + Virtual Threads에서 동일한 패턴을 구현하려면? 직접 큐를 관리하고 흐름을 제어해야 합니다. 불가능은 아니지만, WebFlux가 제공하는 것처럼 선언적으로 할 수는 없어요.
언제 WebFlux를 선택하나
다음 조건 중 하나라도 해당하면 WebFlux가 더 적합합니다:
- ** 실시간 스트리밍 **: SSE, WebSocket으로 지속적인 데이터 푸시가 핵심
- ** 백프레셔 필수 **: 생산자-소비자 간 속도 차이를 제어해야 함
- ** 이벤트 기반 파이프라인 **: 여러 비동기 소스를 조합하는 복잡한 데이터 흐름
- ** 이미 리액티브 생태계 **: R2DBC, Reactive Redis, Reactive Kafka 등 이미 사용 중
// WebFlux가 빛나는 전형적인 예 — 실시간 대시보드
@GetMapping(value = "/dashboard", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<DashboardData> streamDashboard() {
// 세 개의 실시간 소스를 결합
Flux<CpuMetric> cpu = metricsService.streamCpu();
Flux<MemoryMetric> memory = metricsService.streamMemory();
Flux<RequestRate> requests = metricsService.streamRequests();
return Flux.combineLatest(cpu, memory, requests,
(c, m, r) -> new DashboardData(c, m, r))
.onBackpressureLatest(); // 느린 클라이언트에게는 최신 값만 전달
}
언제 MVC + Virtual Threads를 선택하나
** 대부분의 프로젝트가 여기에 해당합니다:**
- CRUD 중심 API: REST API, 관리 페이지 백엔드
- ** 복잡한 비즈니스 로직 **: 트랜잭션, 조건 분기가 많은 도메인
- ** 레거시 통합 **: JPA, JDBC, 블로킹 라이브러리 사용 필수
- ** 팀 역량 **: 리액티브 경험이 적은 팀
- ** 빠른 개발 **: 프로토타이핑, MVP
// MVC + VT로 충분한 전형적인 예 — 주문 처리 API
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) {
// 복잡한 비즈니스 로직도 동기 코드로 깔끔하게
Order order = orderService.create(request);
return ResponseEntity.created(URI.create("/orders/" + order.getId()))
.body(OrderResponse.from(order));
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository; // JPA
private final InventoryService inventoryService; // 블로킹 호출
private final PaymentGateway paymentGateway; // 외부 API
@Transactional
public Order create(OrderRequest request) {
// 재고 확인 → 결제 → 주문 저장
// 세 단계 모두 블로킹이지만, VT 덕분에 동시성 문제 없음
inventoryService.reserve(request.getItems());
PaymentResult payment = paymentGateway.charge(request.getPaymentInfo());
return orderRepository.save(Order.from(request, payment));
}
}
하이브리드 접근: MVC + VT + WebClient
실무에서 가장 현실적인 조합입니다. 전체 아키텍처는 MVC + Virtual Threads로 가져가되, 외부 API 호출이 많은 부분에서만 WebClient를 활용하는 방식이에요.
// 하이브리드 — MVC 컨트롤러 + WebClient 비동기 호출
@Service
@RequiredArgsConstructor
public class AggregationService {
private final WebClient webClient;
private final UserRepository userRepository; // JPA — 블로킹
public AggregatedData aggregate(Long userId) {
// JPA 블로킹 호출 — VT가 처리
User user = userRepository.findById(userId)
.orElseThrow();
// 여러 외부 API를 병렬 호출 — WebClient로 효율적으로
CompletableFuture<Profile> profileFuture = webClient.get()
.uri("/api/profiles/{id}", userId)
.retrieve()
.bodyToMono(Profile.class)
.toFuture();
CompletableFuture<List<Activity>> activitiesFuture = webClient.get()
.uri("/api/activities/{id}", userId)
.retrieve()
.bodyToFlux(Activity.class)
.collectList()
.toFuture();
// 병렬 결과 취합
Profile profile = profileFuture.join();
List<Activity> activities = activitiesFuture.join();
return new AggregatedData(user, profile, activities);
}
}
이 방식의 장점:
- JPA, 트랜잭션 등 기존 코드 유지
- 외부 호출은 WebClient로 비동기 처리
- 필요한 곳에서만 리액티브 API 사용
- 팀 전체가 리액티브를 숙지할 필요 없음
의사결정 매트릭스
빠른 판단 플로차트
새 프로젝트 시작
│
├── 실시간 스트리밍/SSE/백프레셔가 핵심인가?
│ ├── Yes → WebFlux
│ └── No ↓
│
├── JPA/JDBC/블로킹 라이브러리를 써야 하나?
│ ├── Yes → MVC + Virtual Threads
│ └── No ↓
│
├── 팀에 리액티브 경험이 있나?
│ ├── Yes → WebFlux 고려 가능
│ └── No → MVC + Virtual Threads
│
└── 기본 선택: MVC + Virtual Threads
항목별 비교표
| 비교 항목 | MVC + Virtual Threads | WebFlux |
|---|---|---|
| 학습 곡선 | 낮음 (기존 MVC 그대로) | 높음 (리액티브 패러다임) |
| 디버깅 | 쉬움 (동기식 스택 트레이스) | 어려움 (비동기 스택) |
| JPA/JDBC | 완벽 지원 | 불가 (R2DBC 필요) |
| 서드파티 호환 | 거의 모든 라이브러리 | 리액티브 지원 라이브러리만 |
| 백프레셔 | 직접 구현 필요 | 네이티브 지원 |
| 스트리밍 | 가능하지만 불편 | 최적화됨 |
| 메모리 효율 | 좋음 | 약간 더 좋음 |
| 코드 가독성 | 높음 | 익숙해지면 괜찮음 |
| 테스트 용이성 | 높음 (JUnit 그대로) | 보통 (StepVerifier 필요) |
VT 사용 시 주의할 점
Virtual Threads가 만능은 아닙니다. 알아둬야 할 함정이 있어요.
synchronized 핀닝
// 주의: synchronized 블록 안에서 블로킹 I/O를 하면 캐리어 스레드가 핀된다
synchronized (lock) {
// 이 안에서 I/O 하면 Virtual Thread가 양보하지 못함
database.query("SELECT ..."); // 캐리어 스레드 점유!
}
// 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
database.query("SELECT ..."); // Virtual Thread가 정상적으로 양보
} finally {
lock.unlock();
}
ThreadLocal 남용
// 주의: Virtual Thread는 매우 많이 생성되므로 ThreadLocal이 메모리를 많이 차지
private static final ThreadLocal<ExpensiveObject> cache =
ThreadLocal.withInitial(ExpensiveObject::new);
// VT 100만 개 = ExpensiveObject 100만 개 생성 가능!
// 대안: ScopedValue (Java 21+ preview, Java 25 정식)
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
synchronized 대신
ReentrantLock, ThreadLocal 대신ScopedValue— Virtual Threads 시대의 새로운 관용구다. 기존 코드 마이그레이션 시 이 두 가지를 가장 먼저 점검하자.
정리
2026년 기준, 결론은 명확합니다:
- ** 기본 선택은 MVC + Virtual Threads.** 대부분의 웹 애플리케이션에서 충분한 동시성을 제공하면서, 기존 생태계와 개발 경험을 그대로 활용할 수 있습니다.
- WebFlux는 전문 도구. 실시간 스트리밍, 백프레셔, 이벤트 기반 파이프라인이 핵심 요구사항일 때 선택합니다.
- ** 하이브리드가 현실적.** MVC + VT를 기본으로 가져가고, WebClient나 일부 리액티브 컴포넌트를 필요한 곳에서 섞어 쓰는 게 실무에서 가장 많이 보이는 패턴입니다.
WebFlux를 "레거시"로 취급할 단계는 아닙니다. 하지만 "높은 동시성이 필요하니까 WebFlux"라는 등식은 더 이상 성립하지 않아요. Virtual Threads가 그 등식을 깨뜨렸으니까요.