리액티브 프로그래밍은 성능은 좋지만 코드가 복잡하다. 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>
JAVA
// Uni — 단일 값
Uni<String> greeting = Uni.createFrom().item("Hello Mutiny");

// Multi — 스트림
Multi<Integer> numbers = Multi.createFrom().range(1, 10);

Uni 기본 사용법

JAVA
Uni<User> findUser(Long id) {
    return Uni.createFrom().item(() -> userRepository.findById(id))
              .onItem().ifNull().failWith(new NotFoundException("사용자를 찾을 수 없습니다"))
              .onFailure().recoverWithItem(User.anonymous());
}

체이닝 방식으로 비동기 파이프라인을 구성합니다. Reactor와 비슷하지만 메서드 이름이 더 직관적입니다.

JAVA
// 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 — 스트림 처리

JAVA
Multi<String> logs = Multi.createFrom().ticks().every(Duration.ofSeconds(1))
    .onItem().transform(tick -> "Log entry at " + Instant.now())
    .select().first(100);  // 처음 100개만
JAVA
// 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를 반환하면 자동으로 논블로킹으로 처리됩니다.

JAVA
@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) 스트리밍이 됩니다. 실시간 데이터를 클라이언트에 푸시할 때 유용합니다.

JAVA
@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 어노테이션으로 이를 지원합니다.

JAVA
@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의 동작 원리

PLAINTEXT
플랫폼 스레드 (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가 완료되면 다시 마운트되어 실행 계속

결과적으로 ** 동기 코드를 작성하면서 논블로킹의 효율성 **을 얻을 수 있습니다.


기존 모델과의 비교

JAVA
// 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 스트림 처리
  • ** 복잡한 비동기 조합 **: 여러 비동기 작업의 조합, 조건부 분기
  • ** 백프레셔가 필요한 경우 **: 생산 속도 > 소비 속도일 때 흐름 제어
  • ** 이벤트 드리븐 아키텍처 **: 이벤트 스트림의 변환, 필터링, 집계
JAVA
// 이런 스트리밍 처리는 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에서는 같은 애플리케이션 안에서 두 모델을 자유롭게 섞어 쓸 수 있습니다.

JAVA
@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 간 전환은 어노테이션 하나로 가능합니다.

JAVA
// 리액티브에서 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 ThreadMutiny
단순 CRUD (DB 1회 접근)보통좋음좋음
다중 외부 호출 (순차)나쁨** 매우 좋음**좋음
다중 외부 호출 (병렬)보통좋음** 매우 좋음**
스트리밍/이벤트불가제한적** 매우 좋음**
코드 복잡도낮음** 낮음**높음
디버깅 용이성좋음** 좋음**나쁨

면접에서 "리액티브와 Virtual Thread 중 어떤 걸 쓰겠느냐"고 물어보면, "워크로드에 따라 다르다"가 정답입니다. 일반적인 CRUD API는 Virtual Thread, 스트리밍 처리는 리액티브, 그리고 둘 다 있으면 하이브리드로 가는 것이 현실적입니다.


Virtual Thread 사용 시 주의사항

1. synchronized 블록 주의

Virtual Thread는 synchronized 블록 안에서 블로킹되면 ** 플랫폼 스레드까지 점유 **(pinning)합니다.

JAVA
// 나쁜 예 — 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에서는 자연스럽게 가능합니다.

댓글 로딩 중...