Virtual Threads와 리액티브 — Mutiny, @RunOnVirtualThread, 그리고 선택 기준
리액티브 프로그래밍은 성능은 좋지만 코드가 복잡하다. Virtual Threads는 동기 코드로 비동기 성능을 얻을 수 있다고 한다. 그렇다면 리액티브는 이제 필요 없는 걸까?
Mutiny — Quarkus의 리액티브 라이브러리
Spring 생태계에서 리액티브 프로그래밍 하면 Project Reactor(Mono/Flux)를 떠올리게 됩니다. Quarkus 생태계에서는 Mutiny 가 그 역할을 합니다.
Mutiny는 SmallRye 프로젝트의 일부로, Reactor나 RxJava보다 API를 의도적으로 단순하게 설계 한 것이 특징입니다.
핵심 타입: Uni와 Multi
Mutiny에는 딱 두 가지 핵심 타입만 있습니다.
| 타입 | 의미 | Spring 대응 |
|---|---|---|
Uni<T> | 0 또는 1개의 값을 방출 | Mono<T> |
Multi<T> | 0~N개의 값을 스트림으로 방출 | Flux<T> |
// Uni — 단일 값
Uni<String> greeting = Uni.createFrom().item("Hello Mutiny");
// Multi — 스트림
Multi<Integer> numbers = Multi.createFrom().range(1, 10);
Uni 기본 사용법
Uni<User> findUser(Long id) {
return Uni.createFrom().item(() -> userRepository.findById(id))
.onItem().ifNull().failWith(new NotFoundException("사용자를 찾을 수 없습니다"))
.onFailure().recoverWithItem(User.anonymous());
}
체이닝 방식으로 비동기 파이프라인을 구성합니다. Reactor와 비슷하지만 메서드 이름이 더 직관적입니다.
// Reactor
Mono.just("hello")
.flatMap(s -> Mono.just(s.toUpperCase()))
.onErrorReturn("default");
// Mutiny
Uni.createFrom().item("hello")
.onItem().transform(s -> s.toUpperCase())
.onFailure().recoverWithItem("default");
공부하면서 느낀 건데, Reactor의
flatMap,map,switchIfEmpty같은 메서드명은 함수형 프로그래밍에 익숙하지 않으면 헷갈립니다. Mutiny의onItem().transform(),onFailure().recoverWithItem()같은 네이밍이 "무슨 일이 일어나는지" 더 명확하게 전달합니다.
Multi — 스트림 처리
Multi<String> logs = Multi.createFrom().ticks().every(Duration.ofSeconds(1))
.onItem().transform(tick -> "Log entry at " + Instant.now())
.select().first(100); // 처음 100개만
// Kafka에서 메시지를 읽어서 처리하는 예제
@Incoming("sensor-data")
@Outgoing("processed-data")
public Multi<ProcessedReading> processStream(Multi<SensorReading> readings) {
return readings
.group().intoLists().of(100) // 100개씩 배치
.onItem().transformToUniAndMerge(batch ->
Uni.createFrom().item(() -> processBatch(batch))
);
}
Multi는 백프레셔(backpressure) 를 지원합니다. 소비자가 처리할 수 있는 속도에 맞춰 생산 속도를 조절합니다.
REST 엔드포인트에서의 Mutiny
Quarkus REST에서 Uni/Multi를 반환하면 자동으로 논블로킹으로 처리됩니다.
@Path("/api/users")
public class UserResource {
@Inject
UserRepository userRepository;
@GET
@Path("/{id}")
public Uni<User> getUser(@PathParam("id") Long id) {
return userRepository.findByIdAsync(id);
}
@GET
public Multi<User> streamUsers() {
return userRepository.streamAll();
}
}
Multi<T>를 반환하면 Server-Sent Events(SSE) 스트리밍이 됩니다. 실시간 데이터를 클라이언트에 푸시할 때 유용합니다.
@GET
@Path("/stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<StockPrice> streamPrices() {
return Multi.createFrom().ticks().every(Duration.ofSeconds(1))
.onItem().transform(tick -> stockService.getCurrentPrice());
}
@RunOnVirtualThread — 동기 코드로 비동기 성능
Java 21에서 정식 도입된 Virtual Threads 는 리액티브 프로그래밍의 대안으로 주목받고 있습니다. Quarkus는 @RunOnVirtualThread 어노테이션으로 이를 지원합니다.
@Path("/api/orders")
public class OrderResource {
@GET
@Path("/{id}")
@RunOnVirtualThread
public Order getOrder(@PathParam("id") Long id) {
// 평범한 동기 코드 — 하지만 Virtual Thread에서 실행됨
Order order = orderRepository.findById(id);
Customer customer = customerService.getCustomer(order.customerId);
List<OrderItem> items = itemRepository.findByOrderId(id);
order.customer = customer;
order.items = items;
return order;
}
}
이 코드는 완전히 동기적인 스타일입니다. 블로킹 호출이 세 번 있지만, Virtual Thread 위에서 실행되기 때문에 **플랫폼 스레드를 점유하지 않습니다 **.
Virtual Thread의 동작 원리
플랫폼 스레드 (OS 스레드)
┌─────────────────────────────────────┐
│ Virtual Thread A ─── DB 쿼리 대기 │ → 마운트 해제, 다른 VT 실행
│ Virtual Thread B ─── 실행 중 │
│ Virtual Thread C ─── HTTP 호출 대기 │ → 마운트 해제, 다른 VT 실행
│ Virtual Thread D ─── 실행 중 │
└─────────────────────────────────────┘
- Virtual Thread는 블로킹 I/O를 만나면 ** 자동으로 플랫폼 스레드에서 분리 **(unmount)됨
- 다른 Virtual Thread가 그 플랫폼 스레드를 사용
- I/O가 완료되면 다시 마운트되어 실행 계속
결과적으로 ** 동기 코드를 작성하면서 논블로킹의 효율성 **을 얻을 수 있습니다.
기존 모델과의 비교
// 1. 전통적인 블로킹 (플랫폼 스레드)
@GET
@Blocking
public Order getOrderBlocking(Long id) {
// 플랫폼 스레드를 점유 — 동시 요청 수 = 스레드 풀 크기에 제한
return orderRepository.findById(id);
}
// 2. 리액티브 (Mutiny)
@GET
public Uni<Order> getOrderReactive(Long id) {
// 논블로킹이지만 코드가 복잡
return orderRepository.findByIdAsync(id)
.onItem().transformToUni(order ->
customerService.getCustomerAsync(order.customerId)
.onItem().transform(customer -> {
order.customer = customer;
return order;
})
);
}
// 3. Virtual Thread
@GET
@RunOnVirtualThread
public Order getOrderVT(Long id) {
// 동기 코드 + 논블로킹 효율 = 최적의 조합
Order order = orderRepository.findById(id);
order.customer = customerService.getCustomer(order.customerId);
return order;
}
세 번째 방식이 코드도 간결하고 성능도 좋습니다. 그렇다면 리액티브는 이제 필요 없는 걸까요?
리액티브가 여전히 필요한 경우
Virtual Threads가 리액티브를 완전히 대체하지는 않습니다. 각각 강점이 있는 영역이 다릅니다.
Virtual Threads가 적합한 경우
- ** 일반적인 CRUD API**: 요청-응답 패턴의 동기 처리
- ** 기존 블로킹 라이브러리 활용 **: JDBC, 블로킹 HTTP 클라이언트
- ** 팀의 리액티브 경험이 부족할 때 **: 동기 코드 그대로 유지
Mutiny(리액티브)가 적합한 경우
- ** 스트리밍 데이터 **: WebSocket, SSE, Kafka 스트림 처리
- ** 복잡한 비동기 조합 **: 여러 비동기 작업의 조합, 조건부 분기
- ** 백프레셔가 필요한 경우 **: 생산 속도 > 소비 속도일 때 흐름 제어
- ** 이벤트 드리븐 아키텍처 **: 이벤트 스트림의 변환, 필터링, 집계
// 이런 스트리밍 처리는 Virtual Thread로는 표현하기 어려움
@Incoming("sensor-data")
@Outgoing("alerts")
public Multi<Alert> processAlerts(Multi<SensorData> data) {
return data
.filter(d -> d.temperature() > 100)
.group().intoLists().of(10, Duration.ofSeconds(5))
.onItem().transform(batch -> Alert.fromBatch(batch));
}
Virtual Thread는 "요청을 받고 → 처리하고 → 응답하는" 패턴에 최적화되어 있습니다. 반면 "데이터가 계속 흘러오고 → 변환하고 → 다른 곳으로 보내는" 스트리밍 패턴은 리액티브가 더 자연스럽습니다.
공존 전략 — 하이브리드 접근
Quarkus에서는 같은 애플리케이션 안에서 두 모델을 자유롭게 섞어 쓸 수 있습니다.
@Path("/api")
public class HybridResource {
// 일반 CRUD — Virtual Thread
@GET
@Path("/users/{id}")
@RunOnVirtualThread
public User getUser(@PathParam("id") Long id) {
return userRepository.findById(id);
}
// 실시간 스트리밍 — Mutiny
@GET
@Path("/notifications/stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<Notification> streamNotifications() {
return notificationService.subscribe();
}
// 복잡한 비동기 조합 — Mutiny
@GET
@Path("/dashboard")
public Uni<Dashboard> getDashboard() {
return Uni.combine().all().unis(
userService.getProfileAsync(),
orderService.getRecentOrdersAsync(),
notificationService.getUnreadCountAsync()
).with(Dashboard::new);
}
}
전환도 쉽다
Mutiny와 Virtual Thread 간 전환은 어노테이션 하나로 가능합니다.
// 리액티브에서 Virtual Thread로 전환
// 변경 전
@GET
public Uni<User> getUser(Long id) {
return userRepository.findByIdAsync(id);
}
// 변경 후
@GET
@RunOnVirtualThread
public User getUser(Long id) {
return userRepository.findById(id);
}
단, 주의할 점이 있습니다.
- Virtual Thread를 사용하면 JDBC(블로킹) 드라이버를 써야 합니다
- Mutiny를 사용하면 Hibernate Reactive 같은 논블로킹 드라이버를 써야 합니다
- ** 같은 엔드포인트에서 둘을 섞지 마세요** — 하나의 엔드포인트는 하나의 모델을 따르는 게 좋습니다
성능 비교
실제 성능은 워크로드에 따라 다르지만, 일반적인 경향을 정리하면 이렇습니다.
| 시나리오 | 플랫폼 스레드 | Virtual Thread | Mutiny |
|---|---|---|---|
| 단순 CRUD (DB 1회 접근) | 보통 | 좋음 | 좋음 |
| 다중 외부 호출 (순차) | 나쁨 | ** 매우 좋음** | 좋음 |
| 다중 외부 호출 (병렬) | 보통 | 좋음 | ** 매우 좋음** |
| 스트리밍/이벤트 | 불가 | 제한적 | ** 매우 좋음** |
| 코드 복잡도 | 낮음 | ** 낮음** | 높음 |
| 디버깅 용이성 | 좋음 | ** 좋음** | 나쁨 |
면접에서 "리액티브와 Virtual Thread 중 어떤 걸 쓰겠느냐"고 물어보면, "워크로드에 따라 다르다"가 정답입니다. 일반적인 CRUD API는 Virtual Thread, 스트리밍 처리는 리액티브, 그리고 둘 다 있으면 하이브리드로 가는 것이 현실적입니다.
Virtual Thread 사용 시 주의사항
1. synchronized 블록 주의
Virtual Thread는 synchronized 블록 안에서 블로킹되면 ** 플랫폼 스레드까지 점유 **(pinning)합니다.
// 나쁜 예 — pinning 발생
synchronized (lock) {
database.query(); // 블로킹 I/O → 플랫폼 스레드도 블로킹
}
// 좋은 예 — ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
database.query(); // Virtual Thread만 대기, 플랫폼 스레드는 해제
} finally {
lock.unlock();
}
2. ThreadLocal 사용 최소화
Virtual Thread는 수백만 개가 생성될 수 있으므로 ThreadLocal의 메모리 사용량이 문제가 됩니다. ScopedValue(Java 21 프리뷰)를 고려해야 합니다.
3. CPU 집약적 작업에는 부적합
Virtual Thread는 I/O 대기가 많은 작업에 최적화되어 있습니다. CPU를 오래 점유하는 작업에는 플랫폼 스레드나 별도 스레드 풀을 사용해야 합니다.
정리
Quarkus에서의 동시성 모델 선택을 요약하면 이렇습니다.
- Mutiny(Uni/Multi): 스트리밍, 이벤트 드리븐, 복잡한 비동기 조합에 강함. Reactor보다 API가 단순
- @RunOnVirtualThread: 동기 코드 스타일로 논블로킹 성능 확보. 일반 CRUD에 최적
- ** 하이브리드 **: 같은 앱에서 두 모델을 혼합 가능. 엔드포인트 특성에 따라 선택
Virtual Threads의 등장으로 "모든 것을 리액티브로"라는 방향은 변했지만, 리액티브가 사라지는 것은 아닙니다. ** 요청-응답 패턴은 Virtual Thread, 스트리밍 패턴은 리액티브 **라는 실용적인 선택이 Quarkus에서는 자연스럽게 가능합니다.