Clickin Devlog

Micronaut 5와 Virtual Thread — Reactive에서 blocking으로 돌아갈 수 있을까

· Java > Micronaut
시리즈: micronaut-5-guide (3편)
  1. Micronaut 5 Migration Guide — Java 25, Jackson 3, JSpecify와 Breaking Changes
  2. Micronaut 5와 Virtual Thread — Reactive에서 blocking으로 돌아갈 수 있을까 (현재)
  3. Servlet 없이 Netty와 Virtual Thread를 결합하는 법 — Micronaut 5의 offload 구조

이전 글에서는 Micronaut 4 애플리케이션을 Java 25, Jackson 3, JSpecify와 module별 breaking change에 맞춰 올리고, 기존 Reactive 실행 모델로 회귀 테스트하는 단계까지 마쳤습니다.

이번 글은 그 다음 단계입니다. Micronaut 5에서 TaskExecutors.BLOCKING이 실제로 어떤 executor를 선택하는지 확인하고, request/response 코드를 Reactive에서 VT + blocking style로 옮길 범위를 정합니다. framework upgrade와 실행 모델 변경을 분리했으므로, 여기서 발생하는 차이는 thread dispatch와 I/O contract 변화에 집중해서 볼 수 있습니다.

다만 “Micronaut 5부터 모든 코드가 자동으로 VT에서 돈다”는 설명은 틀립니다. 기본 thread selection은 여전히 MANUAL이고, SSE·streaming HTTP client·WebSocket client 연결처럼 Publisher만 노출하는 API도 남아 있습니다. 이 글은 2026년 7월 26일 기준 v5.0.0 태그와 5.0.x 소스를 확인해 그 경계를 정리합니다.


먼저 결론

  • 출발점은 Micronaut 5 upgrade와 기존 Reactive 회귀 테스트가 끝난 상태입니다.
  • TaskExecutors.BLOCKING은 VT executor가 존재하면 그것을 사용하고, 없으면 IO executor로 fallback합니다. Micronaut 5의 지원 런타임은 Java 25이므로 정상적인 구성에서는 VT가 선택됩니다.
  • 그러나 HTTP 서버 기본값은 micronaut.server.thread-selection=manual입니다. blocking-first 애플리케이션이라면 blocking을 명시하거나, 혼합 애플리케이션이라면 @ExecuteOn(TaskExecutors.BLOCKING)을 붙여야 합니다.
  • Micronaut 5에서는 thread selection이 컨트롤러뿐 아니라 HTTP filter, 요청 수신·종료 event listener, WebSocket server handler까지 확장됐습니다.
  • Reactive를 없앨 수 있는 범위는 일반적인 request/response, JDBC, 동기 선언형 HTTP client, blocking multipart 처리입니다.
  • SSE client, streaming HTTP client, WebSocket client의 연결 API, typed streaming response에는 VT용 blocking API가 없습니다. 이 구간은 Reactive Streams를 유지하거나 JDK API 등 다른 구현을 선택해야 합니다.
  • 실험적인 Netty loom carrier는 Micronaut 4.9에서 들어왔고 5.0에서도 여전히 @Experimental입니다. Java 25 baseline과 별개의 기능이며 기본값도 아닙니다.

소스코드로 확인한 VT adoption

BLOCKING은 별도 VT pool이 아니라 VIRTUAL bean의 선택자다

Micronaut 5의 IOExecutorServiceConfig를 보면 기본 executor 관계가 명확합니다.

@Named(TaskExecutors.VIRTUAL)
ExecutorConfiguration virtual() {
    UserExecutorConfiguration cfg = UserExecutorConfiguration.of(
        TaskExecutors.VIRTUAL,
        ExecutorType.THREAD_PER_TASK
    );
    cfg.setVirtual(true);
    return cfg;
}

@Named(TaskExecutors.BLOCKING)
ExecutorService blocking(
    @Named(TaskExecutors.IO) BeanProvider<ExecutorService> io,
    @Named(TaskExecutors.VIRTUAL) BeanProvider<ExecutorService> virtual
) {
    return virtual.isPresent() ? virtual.get() : io.get();
}

ExecutorFactoryTHREAD_PER_TASKLoomSupport.newThreadPerTaskExecutor(...)로 만듭니다. 즉 Micronaut 5에서 다음과 같은 별도 설정은 필요하지 않습니다.

# 필요하지 않으며, 기본 구성의 의미를 오히려 흐린다.
micronaut:
  executors:
    blocking:
      virtual: true

blocking이라는 이름은 기본적으로 virtual executor bean을 가리킵니다. 커스텀 micronaut.executors.blocking 구성을 선언하면 이 기본 bean을 대체하므로, 그때는 executor type과 thread factory까지 직접 책임져야 합니다.

중요한 기본값: HTTP 요청은 자동으로 VT에 가지 않는다

HttpServerConfiguration의 기본값은 ThreadSelection.MANUAL입니다. Java 25 baseline은 VT 사용 가능성을 보장할 뿐, 모든 route를 자동으로 VT에 dispatch하지 않습니다.

blocking-first 애플리케이션은 다음 설정이 가장 단순합니다.

micronaut:
  server:
    thread-selection: blocking

이 설정은 일반 route만이 아니라 Micronaut 5에서 확대된 filter와 request event 처리에도 적용됩니다. 한 애플리케이션에서 reactive route와 blocking route를 함께 운영한다면 전역 설정 대신 명시적으로 범위를 좁힙니다.

import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

@Controller("/orders")
@ExecuteOn(TaskExecutors.BLOCKING)
final class OrderController {
    // 이 controller의 route는 BLOCKING executor, 즉 기본 구성에서는 VT에서 실행된다.
}

AUTO도 선택할 수 있습니다. DefaultExecutorSelector는 plain return type과 @Blocking method를 BLOCKING으로 보내고, reactive return type과 @NonBlocking method는 executor를 바꾸지 않습니다. 그러나 method 정보가 없는 request event listener는 AUTO에서 EventLoop에 남습니다. 애플리케이션 전체를 blocking style로 통일하려는 목적이라면 추론에 맡기는 AUTO보다 BLOCKING이 명확합니다.

Micronaut 5의 실제 진전: 한 번만 VT로 옮기고 계속 실행한다

Micronaut 4에서는 thread selection의 중심이 controller route였습니다. Micronaut 5의 thread-selection 확장 PR은 적용 범위를 다음까지 넓혔습니다.

  • HTTP request·response filter
  • HttpRequestReceivedEvent, HttpRequestTerminatedEvent listener
  • error route와 exception handler
  • WebSocket server의 @OnOpen, @OnMessage, @OnError, @OnClose handler

새 기본값 micronaut.server.redispatch-non-blocking-only=true도 중요합니다. 요청이 EventLoop 같은 non-blocking thread에 있을 때만 선택된 executor로 옮기고, 이미 VT나 일반 blocking thread에 올라온 뒤에는 filter와 controller 사이에서 다시 dispatch하지 않습니다. 요청 하나가 filter마다 새 VT를 만드는 구조가 아니라, 처음 blocking 구간에서 한 번 VT로 넘어간 뒤 direct style로 이어지는 구조입니다.1

이 변화가 Micronaut 5의 VT adoption에서 가장 실질적인 진전입니다. VT executor 자체는 Micronaut 4.0부터 있었고 loom carrier도 4.9에서 시작했습니다. 5.0은 새 VT 기능을 하나 더 얹었다기보다, Java 25를 강제하고 HTTP lifecycle 전체를 같은 blocking 실행 모델로 묶었습니다.


Reactive flow를 VT + blocking style로 옮기기

1) 먼저 실행 경계를 정한다

서비스 전체가 CRUD와 유한한 request/response 중심이라면 전역 thread-selection: blocking을 사용합니다. 점진적으로 옮기거나 SSE 같은 reactive route가 섞여 있다면 controller 또는 method에 @ExecuteOn(TaskExecutors.BLOCKING)을 붙입니다.

주의할 점이 있습니다. MANUAL 상태에서 controller에만 @ExecuteOn을 붙이면 그 controller 앞의 custom filter가 자동으로 VT에 올라가는 것은 아닙니다. filter도 blocking한다면 filter에 @ExecuteOn을 붙이거나 전역 BLOCKING을 선택해야 합니다.

2) return type만 바꾸지 말고 I/O API를 함께 바꾼다

기존 코드가 R2DBC와 reactive HTTP client를 조합한다고 가정하겠습니다.

@Get("/{id}")
Mono<OrderView> show(long id) {
    return orderRepository.findById(id)
        .switchIfEmpty(Mono.error(new OrderNotFound(id)))
        .flatMap(order -> Mono.zip(
            inventoryClient.stock(order.sku()),
            customerRepository.findById(order.customerId())
        ))
        .map(tuple -> OrderView.from(tuple.getT1(), tuple.getT2()));
}

blocking 전환은 Mono.block()을 중간에 넣는 작업이 아닙니다. Data JDBC repository와 동기 HTTP client처럼 실제 blocking API로 경계를 교체합니다.

@Controller("/orders")
@ExecuteOn(TaskExecutors.BLOCKING)
final class OrderController {
    private final OrderRepository orderRepository;       // Micronaut Data JDBC
    private final CustomerRepository customerRepository; // Micronaut Data JDBC
    private final InventoryClient inventoryClient;       // 동기 선언형 @Client

    OrderController(
        OrderRepository orderRepository,
        CustomerRepository customerRepository,
        InventoryClient inventoryClient
    ) {
        this.orderRepository = orderRepository;
        this.customerRepository = customerRepository;
        this.inventoryClient = inventoryClient;
    }

    @Get("/{id}")
    OrderView show(long id) {
        Order order = orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFound(id));
        Stock stock = inventoryClient.stock(order.sku());
        Customer customer = customerRepository.findById(order.customerId())
            .orElseThrow(() -> new CustomerNotFound(order.customerId()));
        return OrderView.from(order, stock, customer);
    }
}

@Client("${inventory.url}")
interface InventoryClient {
    @Get("/stocks/{sku}")
    Stock stock(String sku);
}

이 코드에서 JDBC와 동기 HTTP 호출이 기다리는 동안 VT는 unmount될 수 있고 carrier는 다른 VT를 실행합니다. 호출 순서, local variable, try/catch, transaction 경계는 일반 Java 코드 그대로 유지됩니다.

Micronaut의 low-level HTTP client를 직접 쓰면 toBlocking()을 사용합니다.

Stock stock = httpClient.toBlocking().retrieve(
    HttpRequest.GET("/stocks/" + sku),
    Stock.class
);

BlockingHttpClient 구현은 Netty EventLoop thread에서 호출되면 기본적으로 예외를 던집니다. 이 보호 장치를 끄는 대신 먼저 thread-selection: blocking 또는 @ExecuteOn이 실제로 적용됐는지 확인해야 합니다.2

3) transaction 모델도 함께 바꾼다

R2DBC transaction은 subscriber context와 reactive chain의 수명에 맞춰집니다. 반환 타입만 plain object로 바꾸고 내부에서 block()하면 transaction propagation과 cancellation 의미가 어긋날 수 있습니다.

VT + blocking으로 옮길 때는 다음을 한 단위로 바꿉니다.

  1. R2DBC driver와 repository를 JDBC 대응으로 변경
  2. reactive transaction operator를 @Transactional 경계로 변경
  3. reactive HTTP client를 동기 선언형 client 또는 BlockingHttpClient로 변경
  4. timeout과 cancellation 정책을 blocking API 기준으로 다시 설정
  5. 실제 호출 thread가 VT인지 Thread.currentThread().isVirtual()로 smoke test

VT는 thread 비용을 줄일 뿐 connection을 늘리지 않습니다. DB connection pool이 30개면 동시에 실행되는 query도 결국 그 근처에서 제한됩니다. VT 수를 제한하기 위해 thread pool을 만들지 말고, DB pool·HTTP connection pool·Semaphore처럼 실제 희소 자원에 제한을 둬야 합니다. JEP 444도 VT를 pool에 넣지 말고 task마다 만들라고 명시합니다.

4) flatMap의 병렬성은 자동으로 보존되지 않는다

위 reactive 예제의 Mono.zip은 inventory와 customer 조회를 동시에 시작할 수 있습니다. blocking 코드로 줄줄이 호출하면 읽기는 쉬워지지만 두 호출은 순차 실행됩니다. VT가 코드를 자동으로 병렬화하지는 않습니다.

병렬 fan-out이 실제 latency에 중요하면 TaskExecutors.BLOCKING executor에 두 작업을 각각 제출합니다.

@Singleton
final class OrderQueryService {
    private final ExecutorService blocking;

    OrderQueryService(
        @Named(TaskExecutors.BLOCKING) ExecutorService blocking
    ) {
        this.blocking = blocking;
    }

    OrderView assemble(Order order) throws Exception {
        Future<Stock> stock = blocking.submit(
            () -> inventoryClient.stock(order.sku())
        );
        Future<Customer> customer = blocking.submit(
            () -> customerRepository.findById(order.customerId()).orElseThrow()
        );

        return OrderView.from(order, stock.get(), customer.get());
    }
}

실제 코드에서는 InterruptedException에서 interrupt flag를 복원하고, 한 작업 실패 시 다른 작업을 cancel해야 합니다. Java 25의 Structured Concurrency는 이런 수명 관리를 더 잘 표현하지만 여전히 preview이므로, production baseline에 넣으려면 --enable-preview 정책을 별도로 결정해야 합니다.


어디까지 blocking으로 쓸 수 있나

Micronaut 5 core API와 구현을 기준으로 표를 만들었습니다.

기능 VT + blocking style 근거·주의사항
일반 controller request/response 가능 plain return type + BLOCKING thread selection
error route·exception handler 가능 Micronaut 5 thread selection 경로에 포함
request·response filter 가능 5.0에서 적용 범위 확대
request received·terminated listener 가능 5.0에서 적용 범위 확대
WebSocket server callback 가능 NettyServerWebSocketHandler가 method별 executor 선택
WebSocket send 가능 WebSocketSession.sendSync(...) 제공
전체 body HTTP client 가능 동기 @Client, BlockingHttpClient 제공
multipart upload 소비 가능 StreamingFileUpload.asInputStream() 제공
JDBC·Data JDBC 가능 blocking API를 VT에서 실행
R2DBC 해당 없음 API 자체가 Reactive Streams이며 JDBC로 바꿔야 blocking 전환
streaming HTTP client 내장 blocking 대안 없음 dataStream, exchangeStream, jsonStreamPublisher만 반환
SSE client 내장 blocking 대안 없음 eventStreamPublisher만 반환
WebSocket client connect 내장 blocking 대안 없음 WebSocketClient.connectPublisher<T>만 반환
typed SSE·JSON streaming response 동등한 blocking contract 없음 built-in backpressure와 element stream은 Publisher 중심

표의 “없음”은 blocking이 물리적으로 불가능하다는 뜻이 아닙니다. 예를 들어 Flux.from(sseClient.eventStream(...)).toIterable()처럼 adapter를 만들 수 있습니다. 그러나 내부 contract는 여전히 Reactive Streams이고 cancellation·backpressure·resource close를 직접 다뤄야 합니다. Micronaut가 제공하는 VT-native blocking API로 전환된 것은 아닙니다.3

raw byte stream은 InputStream이나 CloseableByteBody.toInputStream()으로 처리할 수 있습니다. 하지만 SSE event, JSON element stream, WebSocket frame처럼 경계와 backpressure가 프로토콜 의미의 일부인 기능에는 blocking iterator 형태의 동등한 built-in API가 없습니다. 이 구간까지 억지로 reactive를 제거하는 것은 목표가 수단을 이기는 선택입니다.

Micronaut 5에 추가된 AsyncHttpClient도 이 경계를 바꾸지는 않습니다. exchangeretrieveCompletionStage로 제공해 애플리케이션이 Reactor에 직접 의존하지 않게 하지만, 이름 그대로 비동기 API이고 streaming method는 없습니다. VT 안에서 단일 응답을 기다리는 코드라면 BlockingHttpClient가 더 직접적입니다.


실험적인 loom carrier는 별도 판단이 필요하다

기본 VT executor는 JDK의 ForkJoinPool scheduler에서 실행되고 Netty EventLoop와 요청·응답을 주고받습니다. 이 과정에는 EventLoop와 VT carrier 사이의 thread 전환이 생깁니다.

Micronaut 4.9가 도입한 loom carrier mode는 EventLoop별 carrier를 두고 같은 carrier에서 request VT를 실행해 locality와 client affinity를 개선하려는 실험입니다. Micronaut 5에도 코드가 남아 있지만 EventLoopGroupConfiguration.isLoomCarrier()의 기본값은 false이고 @Experimental입니다.

실험 설정은 다음과 같습니다.

micronaut:
  server:
    thread-selection: blocking
  netty:
    event-loops:
      default:
        loom-carrier: true
  http:
    client:
      event-loop-group: default

그리고 현재 구현은 java.lang.VirtualThread의 private field에 reflection으로 접근하므로 다음 JVM option도 요구합니다.

--add-opens=java.base/java.lang=ALL-UNNAMED

PrivateLoomSupport가 이 제약을 코드로 검사합니다. public JDK scheduler API에만 기대는 기능이 아니므로 patch release나 JDK update에도 민감할 수 있습니다.

공식 실험 결과도 한 방향으로만 좋지 않습니다.4

  • 짧은 응답과 Micronaut Netty HTTP client affinity에서는 일반 FJP 기반 VT보다 latency와 CPU 사용량이 reactive 구현에 가까워졌습니다.
  • JDBC PostgreSQL/HikariCP에서는 latency와 CPU가 FJP와 비슷했지만 최대 request rate는 더 낮았습니다.
  • 일반 JDK blocking I/O는 Micronaut가 전용 sub-poller를 제어할 수 없어, 작업을 FJP로 옮겼다가 EventLoop carrier로 되돌리는 전환이 남습니다.
  • 내부 JDK API 의존성과 carrier/VT 사이 lock deadlock 위험을 설계 차원에서 다뤄야 합니다.

따라서 production migration의 기본 선택은 일반 TaskExecutors.BLOCKING + JDK scheduler입니다. loom carrier는 실제 workload로 reactive, FJP VT, carrier VT를 비교할 수 있을 때만 실험하는 편이 안전합니다.


Java 생태계는 CPS를 없애는 것이 아니라 런타임 아래로 내리고 있다

Reactive 코드의 map, flatMap, callback chain은 다음에 실행할 계산을 object와 function으로 명시합니다. 넓게 보면 애플리케이션이 continuation을 직접 구성하는 CPS(Continuation-Passing Style)에 가깝습니다.

VT에서는 소스코드가 CPS로 바뀌지 않습니다. 오히려 return, loop, local variable, try/catch를 쓰는 direct style을 유지합니다. JDK/JVM 내부가 stackful continuation과 heap의 stack chunk를 이용해 중단 지점을 보관하고 재개합니다. 정확히 말하면 “Java 코드가 JVM-managed CPS로 바뀐다”기보다, 개발자가 작성하던 명시적 continuation을 런타임이 관리하는 continuation으로 내린다고 보는 편이 맞습니다.5

Micronaut 5가 context propagation에 Java 25의 ScopedValue 구현을 추가한 것도 같은 흐름에 있습니다. 기본값은 여전히 thread-local이지만 다음 opt-in을 제공합니다.

micronaut:
  propagation: scoped-value

소스의 ScopedValuesPropagatedContextScopedValue.Carrier에 묶어 lexical scope 안에서 실행합니다. 다만 기존 PropagatedContext.propagate() scope API는 thread-local 전용으로 deprecated됐습니다. custom context propagation 코드가 있다면 설정만 바꾸지 말고 propagate(Runnable|Supplier|Callable) 형태로 옮겨야 합니다.

이 흐름이 Reactive Streams의 종말을 뜻하지는 않습니다. VT는 waiting thread 비용을 해결하지만 stream의 demand, backpressure, cancellation, 무한 수명은 자동으로 모델링하지 않습니다. 단일 응답과 유한한 transaction은 direct style이 단순해졌고, 장기 stream은 여전히 reactive contract가 정확합니다.


Reactive-to-blocking 전환 checklist

VT 전환

  • blocking-first면 micronaut.server.thread-selection=blocking 명시
  • 혼합 모델이면 controller·filter의 blocking 경계에 @ExecuteOn(TaskExecutors.BLOCKING) 명시
  • micronaut.executors.blocking.virtual=true 같은 불필요한 override 제거
  • R2DBC를 유지할지 Data JDBC로 옮길지 transaction 단위로 결정
  • Mono.block() adapter가 아니라 실제 blocking client·repository로 교체
  • Mono.zip·flatMap이 제공하던 동시 실행을 순차 호출로 잃지 않았는지 확인
  • DB와 HTTP connection pool은 VT 수와 별개로 제한
  • request, filter, error handler, WebSocket handler에서 Thread.currentThread().isVirtual() 확인

Reactive를 남길 경계

  • SSE client와 server event stream
  • StreamingHttpClient의 byte·JSON stream
  • WebSocketClient.connect
  • cancellation과 backpressure가 contract인 장기 stream
  • loom carrier는 별도 benchmark와 운영 위험 검토 없이 활성화하지 않음

결론

Micronaut 5의 VT adoption은 “새 executor를 추가했다”보다 더 현실적인 방향으로 진전됐습니다. Java 25를 baseline으로 고정해 BLOCKING executor의 VT 사용을 사실상 보장하고, filter·request event·WebSocket server까지 thread selection 범위를 넓혔습니다. 일반적인 REST API + JDBC 서비스라면 controller부터 transaction, HTTP client까지 plain Java blocking style로 작성할 수 있습니다.

동시에 경계도 분명합니다. 기본값은 여전히 MANUAL이고, Netty transport 자체가 blocking server로 바뀐 것도 아닙니다. SSE, streaming HTTP client, WebSocket client 연결처럼 backpressure와 장기 stream이 핵심인 API는 Reactive Streams에 남아 있습니다. loom carrier도 production 기본 기능이 아니라 private JDK API를 사용하는 실험입니다.

제가 소스에서 확인한 결론은 단순합니다. Micronaut 5에서는 request/response business flow를 VT + blocking으로 옮겨도 됩니다. 그러나 streaming flow까지 억지로 옮기지는 않는 것이 맞습니다. 프레임워크 전체를 한 패러다임으로 통일하는 것보다, VT가 해결한 문제와 Reactive Streams가 계속 해결해야 하는 문제를 나누는 편이 코드와 운영 양쪽에서 안전합니다.


이전 글: Micronaut 5 Migration Guide — Java 25, Jackson 3, JSpecify와 Breaking Changes

다음 글: Servlet 없이 Netty와 Virtual Thread를 결합하는 법 — Micronaut 5의 offload 구조

배경 시리즈: Micronaut 완전 가이드 5편 — HTTP 서버 모델과 Virtual Thread

Footnotes

  1. ExecutorSelector.selectExecutor, ThreadSelectionSpec, NettyServerWebSocketHandler.

  2. NettyHttpClient.toBlocking() 구현은 EventLoop에서 blocking client를 호출하면 HttpClientException을 발생시키고 @ExecuteOn(TaskExecutors.BLOCKING) 사용을 안내합니다.

  3. StreamingHttpClient, SseClient, WebSocketClient, StreamingFileUpload의 public method signature를 기준으로 판별했습니다.

  4. Jonas Konrad, Transitioning to virtual threads using the Micronaut loom carrier. 글의 benchmark는 prototype 비교이며 절대 성능 수치가 아니라 구현별 분포와 경향을 제시합니다.

  5. JEP 444: Virtual Threads는 asynchronous pipeline이 애플리케이션의 concurrency unit과 platform thread를 분리한다고 설명하고, VT는 blocking I/O에서 자동으로 unmount되어 direct thread-per-request style을 보존한다고 설명합니다.

댓글 영역에 가까워지면 자동으로 불러옵니다.

Preparing comments...