Servlet 없이 Netty와 Virtual Thread를 결합하는 법 — Micronaut 5의 offload 구조
이 글은 이전 Micronaut 가이드에서 설명한 HTTP 실행 모델과 Virtual Thread의 기본 동작을 전제로 합니다. 아래 개념이 낯설다면 해당 절을 먼저 읽는 편이 좋습니다.
선수지식
- Servlet과 Netty가 socket readiness 이후에 요청을 처리하는 방식: Tomcat과 Netty — “둘 다 epoll을 쓴다”는 말의 함정
- 하나의 EventLoop가 여러 연결을 처리하고 blocking에 취약한 이유: EventLoop가 동작하는 방식 — 협력적 스케줄링, Netty의 약점 — Blocking I/O와 JDBC
- VT가 carrier를 점유하지 않고 park·resume되는 원리: Virtual Thread의 핵심, JDBC + Virtual Thread
Channel, ChannelPipeline, ExecutionFlow처럼 이전 글에서 다루지 않은 소스 수준의 용어는 이 글의 첫 등장 지점에서 설명합니다.
Micronaut의 기본 HTTP server는 Netty입니다. Micronaut 5가 Java 25를 baseline으로 올렸다고 해서 이 server를 Servlet container로 바꾸거나 Netty 위에 Servlet API를 한 겹 추가한 것은 아닙니다.
대신 transport는 계속 Netty EventLoop가 담당하고, filter·controller처럼 blocking할 수 있는 application stage만 TaskExecutors.BLOCKING으로 dispatch합니다. Java 25의 기본 구성에서 이 executor는 virtual thread-per-task executor입니다.
이 구조를 짧게 표현하면 다음과 같습니다.
event-driven transport + virtual-thread-per-task application
중요한 차이가 있습니다. Micronaut가 JDBC method에 진입한 뒤 “이 호출은 blocking이다”라고 감지해 그 호출만 VT로 옮기는 것이 아닙니다. route나 filter를 실행하기 전에 executor를 선택하고, 선택된 handler 전체를 VT에서 시작합니다. 그 안에서 JDBC나 동기 HTTP call이 block하면 JDK가 해당 VT를 park하고 carrier thread를 다른 작업에 내어줍니다.
이 글은 Micronaut Core v5.0.0 소스에서 요청이 들어와 응답이 나갈 때까지의 thread 경계를 따라갑니다.
먼저 바로잡을 표현
“Micronaut는 Servlet을 지원하지 않는다”
정확하지 않습니다. Micronaut에는 별도 Micronaut Servlet project가 있고 Tomcat, Jetty, Undertow, WAR deployment를 지원합니다. HttpServletRequest와 HttpServletResponse를 직접 받는 코드도 작성할 수 있습니다.
다만 기본 micronaut-http-server-netty runtime의 VT 전략이 Servlet 기반이 아닙니다. Servlet 환경이 필요한 사용자는 별도 module을 선택하고, 기본 Netty 사용자는 Servlet abstraction 없이 EventLoop + offload 구조를 사용합니다.
“Micronaut가 blocking call을 자동으로 찾아 VT로 보낸다”
이것도 정확하지 않습니다. framework가 선택하는 단위는 개별 blocking call이 아니라 method execution stage입니다.
@Get("/{id}")
OrderView show(long id) {
Order order = repository.findById(id).orElseThrow(); // JDBC
Stock stock = inventoryClient.stock(order.sku()); // 동기 HTTP
return OrderView.from(order, stock);
}
이 코드에서 repository 호출 시점에 thread를 바꾸지 않습니다. show에 들어오기 전에 route 전체가 VT에 dispatch됩니다. 두 blocking call과 그 사이의 business logic, exception handling, response object 생성까지 같은 VT에서 이어집니다.
“Netty EventLoop를 VT로 교체한다”
기본 모드에서는 교체하지 않습니다. socket readiness, HTTP frame 처리, channel pipeline, connection state는 계속 Netty EventLoop가 소유합니다. VT는 application code를 실행하는 별도 계층입니다.
여기서 Netty Channel은 request가 아니라 network connection의 상태와 I/O를 나타내는 abstraction입니다. ChannelPipeline은 그 Channel의 inbound·outbound event가 통과하는 handler chain입니다.
Micronaut의 실험적인 loom carrier mode는 EventLoop와 VT scheduler의 관계를 더 깊게 바꾸지만, 기본 offload와는 별도 기능입니다. 이 차이는 뒤에서 다룹니다.
Servlet + VT와 Netty + VT의 구조 차이
Servlet + VT
Servlet container가 VT를 request execution thread로 사용하면 application이 보는 모델은 전통적인 thread-per-request입니다.
Connector / socket polling
→ Servlet request dispatch
→ request Virtual Thread
→ Filter / Controller / JDBC / response write
application은 HttpServletRequest, HttpServletResponse, filter chain과 synchronous read/write contract를 사용합니다. container가 request lifetime과 thread association을 제공합니다.
Micronaut Netty + VT
Micronaut의 기본 server는 다음처럼 나뉩니다.
application code는 direct style로 작성하지만 transport는 thread-per-request Servlet transport가 아닙니다. Micronaut의 ExecutionFlow가 EventLoop와 executor 사이의 비동기 경계를 연결합니다.
| 항목 | Servlet + VT | Micronaut Netty + VT |
|---|---|---|
| HTTP abstraction | Servlet API | Micronaut HTTP API + Netty |
| transport state 소유 | Servlet container | Netty EventLoop |
| application 실행 | request VT | 선택된 stage를 VT로 offload |
| request body streaming | Servlet stream contract | Netty ByteBody·Reactive Streams contract |
| filter | Servlet Filter | Micronaut server filter |
| response write | Servlet response API | HttpResponse → Netty outbound pipeline |
| thread 선택 | container configuration | thread-selection, @ExecuteOn |
어느 쪽이 더 “진짜 VT”인 것은 아닙니다. VT가 적용되는 abstraction boundary가 다릅니다.
요청 하나를 소스코드로 따라가기
1. Netty가 request를 받는다
Micronaut Netty server의 RoutingInBoundHandler.accept는 Netty request와 CloseableByteBody를 받아 NettyHttpRequest를 만듭니다.
이 시점은 Netty channel pipeline 안입니다. URI parsing, request object 생성, channel attachment는 EventLoop가 담당합니다.
public void accept(
ChannelHandlerContext ctx,
io.netty.handler.codec.http.HttpRequest request,
CloseableByteBody body,
OutboundAccess outboundAccess
) {
NettyHttpRequest<Object> mnRequest = new NettyHttpRequest<>(
request, body, ctx, conversionService, serverConfiguration
);
// request event 처리 후 lifecycle 진입
}
Micronaut 5에서는 HttpRequestReceivedEvent에도 thread selection이 적용됩니다. event가 다른 executor에서 처리됐다면 executeOnEventLoopIfNeeded가 request lifecycle 시작을 다시 channel EventLoop로 보냅니다.
private void executeOnEventLoopIfNeeded(
ChannelHandlerContext ctx,
Runnable runnable
) {
if (ctx.executor().inEventLoop()) {
runnable.run();
} else {
ctx.executor().execute(PropagatedContext.wrapCurrent(runnable));
}
}
여기서 확인되는 첫 번째 원칙은 명확합니다.
request lifecycle의 transport 진입점은 여전히 EventLoop다.
2. filter와 route의 executor를 고른다
Micronaut 5의 DefaultExecutorSelector는 annotation과 server thread selection을 보고 executor를 고릅니다.
if (@ExecuteOn이 있으면) {
return 이름으로 지정한 executor;
} else if (threadSelection == AUTO) {
if (@NonBlocking || reactive/async return) {
return executor 없음;
}
return BLOCKING executor;
} else if (threadSelection == IO) {
return IO executor;
} else if (threadSelection == BLOCKING) {
return BLOCKING executor;
}
return executor 없음; // MANUAL
실제 우선순위는 다음과 같습니다.
@ExecuteOn("이름")AUTO의@Blocking,@NonBlocking, return type 추론- 전역
IO또는BLOCKING MANUAL이면 자동 선택 없음
따라서 blocking code가 있다고 해서 runtime inspection으로 VT가 선택되는 것이 아닙니다. 설정과 compile-time method metadata를 기준으로 handler를 실행하기 전에 결정합니다.
3. BLOCKING은 VT executor를 가리킨다
IOExecutorServiceConfig는 기본 executor bean을 만듭니다.
@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();
}
Micronaut 5는 Java 25를 요구하므로 기본 환경에서는 VIRTUAL bean이 존재합니다. 결과적으로 BLOCKING은 virtual thread-per-task executor를 사용합니다.
TaskExecutors.BLOCKING은 VT pool이라는 뜻이 아닙니다. task마다 새 VT를 만드는 executor입니다. VT를 platform thread처럼 재사용하기 위해 pool에 넣지 않습니다.
4. ExecutionFlow.async가 stage 전체를 executor에 제출한다
route 실행에서 핵심은 RouteExecutor.callRoute입니다.
ExecutorService executor = routeInfo.getExecutor(
serverConfiguration.getThreadSelection()
);
if (executor != null && routeInfo.isReactive()) {
return ReactiveExecutionFlow.async(
executor,
() -> executeRouteAndConvertBody(...)
);
}
if (executor != null) {
return ExecutionFlow.async(
executor,
() -> executeRouteAndConvertBody(...)
);
}
return executeRouteAndConvertBody(...);
imperative route라면 ExecutionFlow.async가 executeRouteAndConvertBody supplier를 executor에 제출합니다. 이 supplier 안에서 다음 작업이 이어집니다.
requestArgumentSatisfier.fulfillArgumentRequirementsAfterFilters(...);
Object body = routeMatch.execute();
return createResponseForBody(...);
즉 VT에서 실행되는 범위는 JDBC 한 줄만이 아닙니다.
- filter 이후 argument binding
- controller bean method 호출
- service와 repository call
- controller 내부의 local computation
- return value를
HttpResponse로 변환하는 application 구간
route가 Publisher를 반환하면 Micronaut은 ReactiveExecutionFlow와 Reactor scheduler를 사용합니다. 전역 BLOCKING을 선택하면 reactive route의 subscription stage도 선택된 executor로 dispatch될 수 있지만, stream 자체의 demand와 signal 처리는 계속 Reactive contract를 따릅니다. return type을 plain object로 바꾸지 않는 한 Servlet 같은 synchronous response model이 되는 것은 아닙니다.
5. JDBC가 block하면 JDK가 VT를 park한다
이제 application VT 안에서 JDBC driver가 socket read를 기다립니다.
VT-A: controller → JDBC executeQuery → socket read → unmount / park
carrier: VT-B 실행
DB response ready → VT-A mount / resume → ResultSet 반환
Micronaut는 이 park/unmount를 구현하지 않습니다. JEP 444에 포함된 JDK VT runtime이 blocking JDK operation과 연동해 처리합니다. Micronaut의 책임은 blocking call이 EventLoop에서 시작되지 않도록 application stage를 적절한 executor에서 시작하는 데 있습니다.
Java 25에는 JEP 491도 포함됩니다. synchronized 안에서 blocking할 때 carrier까지 함께 pinning되던 문제는 거의 제거됐습니다. 다만 slow query, 부족한 DB connection, driver timeout은 그대로입니다. VT는 I/O를 빠르게 만드는 것이 아니라 기다리는 thread의 비용을 낮춥니다.
6. completion을 Netty outbound로 넘긴다
controller가 HttpResponse 또는 body를 반환하면 ExecutionFlow가 완료됩니다. RoutingInBoundHandler.writeResponse는 response를 ByteBodyHttpResponse로 encode하고 OutboundAccess에 전달합니다.
finalResponse.onComplete((encodedResponse, error) -> {
outboundAccess.write(
NettyMutableHttpResponse.toNoBodyResponse(encodedResponse),
encodedResponse.byteBody()
);
});
application VT가 socket을 직접 소유해 OutputStream에 쓰는 Servlet 구조가 아닙니다. response object와 body를 Netty outbound pipeline에 넘기고, channel write와 connection state는 Netty가 관리합니다.
이것이 “Servlet 없이 blocking style”이 가능한 핵심입니다. synchronous하게 보이는 것은 business code의 control flow이고, transport boundary는 계속 async입니다.
filter부터 controller까지 VT 하나로 이어지는 이유
Micronaut 5는 thread selection을 controller route에서 filter, request event, error handler, WebSocket server callback까지 확대했습니다. 이 과정에서 단순히 각 stage를 BLOCKING executor에 제출하면 문제가 생깁니다.
EventLoop
→ VT-1 request listener
→ VT-2 request filter A
→ VT-3 request filter B
→ VT-4 controller
→ VT-5 response filter
VT 생성 비용은 작지만 stage마다 executor queue와 context propagation, scheduling 경계가 추가됩니다. 그래서 micronaut.server.redispatch-non-blocking-only의 기본값은 true입니다.
ExecutorSelector.selectExecutor는 선택된 executor를 조건부 executor로 감쌉니다.
if (현재 thread가 non-blocking thread) {
blockingExecutor.execute(command); // EventLoop → VT
} else {
command.run(); // 이미 VT면 현재 VT에서 계속
}
내부 판별은 Reactor의 Schedulers.isInNonBlockingThread()와 NonBlocking marker를 사용합니다. ExecutionFlow.async도 ConditionalExecutionExecutor.canExecuteImmediately()가 true이면 supplier를 현재 thread에서 바로 실행합니다.1
결과는 다음에 가깝습니다.
EventLoop -- 한 번만 dispatch --> VT-1
VT-1: filter A → filter B → controller → response filter
VT-1 -- completion --> EventLoop
단, request lifecycle이 항상 처음부터 끝까지 정확히 하나의 VT에 고정된다고 일반화하면 안 됩니다. reactive publisher가 scheduler를 바꾸거나 application이 다른 executor를 사용하면 thread는 달라질 수 있습니다. redispatch-non-blocking-only가 보장하는 것은 이미 blocking-capable thread일 때 framework가 불필요한 재-dispatch를 생략한다는 것입니다.
thread-selection 네 가지 모드
MANUAL — 기본값
micronaut:
server:
thread-selection: manual
자동 offload가 없습니다. blocking method에 직접 executor를 지정합니다.
@ExecuteOn(TaskExecutors.BLOCKING)
@Get("/{id}")
OrderView show(long id) {
return service.find(id);
}
MANUAL에서는 @Blocking만 붙여도 executor가 선택되지 않습니다. @ExecuteOn을 사용해야 합니다.
혼합 application에서 경계를 가장 명시적으로 제어할 수 있지만 blocking filter에 annotation을 빠뜨리기 쉽습니다.
AUTO — return type 기반 추론
micronaut:
server:
thread-selection: auto
plain return type은 BLOCKING, reactive·async return은 EventLoop에 남습니다. @Blocking, @NonBlocking으로 override할 수 있습니다.
@Get("/blocking")
Order show() { ... } // BLOCKING
@Get("/reactive")
Mono<Order> showReactive() { ... } // executor 없음
method metadata가 없는 request event listener는 AUTO에서 자동으로 blocking으로 분류되지 않습니다. application 전체를 blocking-first로 통일하려면 AUTO보다 BLOCKING이 명확합니다.
IO — platform-thread IO executor
micronaut:
server:
thread-selection: io
기본 cached IO executor를 사용합니다. Micronaut 5의 Java 25 환경에서 일반적인 blocking request를 위해 이 모드를 선택할 이유는 많지 않습니다. VT와 호환되지 않는 library를 격리해야 하는 경우처럼 platform thread가 명시적으로 필요할 때 사용합니다.
BLOCKING — blocking-first application
micronaut:
server:
thread-selection: blocking
filter와 route 등 선택 대상 전체를 TaskExecutors.BLOCKING으로 보냅니다. REST + JDBC + 동기 HTTP client application에 가장 단순한 설정입니다.
전역 설정은 강력합니다. CPU-bound 작업까지 VT로 보내도 병렬 처리 능력이 늘지는 않습니다. 큰 이미지 변환, 암호화, 압축 같은 작업은 CPU 수에 맞춘 별도 executor와 admission control을 사용하는 편이 맞습니다.
request body는 어디에서 읽히는가
Servlet API를 쓰지 않는 차이는 body에서 더 분명해집니다.
Micronaut Netty는 body를 CloseableByteBody와 ByteBody abstraction으로 다룹니다. HTTP frame 수신, flow control, buffer lifetime은 Netty pipeline에 남습니다. route argument가 POJO라면 framework가 필요한 body를 수집하고 message body reader로 변환한 뒤 method argument를 채웁니다.
일반 JSON request/response는 controller 입장에서 plain object로 보입니다.
@Post("/orders")
Order create(@Body CreateOrder command) {
return service.create(command);
}
하지만 이것이 ServletInputStream을 VT에서 직접 읽는 구조라는 뜻은 아닙니다. body delivery와 buffering은 Netty lifecycle에 연결되어 있고, application argument binding이 준비된 뒤 controller가 실행됩니다.
streaming body에서는 차이가 더 커집니다.
@Post(uri = "/upload", consumes = MediaType.MULTIPART_FORM_DATA)
void upload(StreamingFileUpload file) {
try (InputStream in = file.asInputStream()) {
// VT에서 blocking style로 소비 가능
}
}
asInputStream() blocking adapter는 있지만 upstream은 여전히 Netty body stream입니다. 소비 속도가 느리면 backpressure와 buffer lifetime이 중요합니다. 무제한으로 body 전체를 memory에 모으는 방식으로 바꾸면 VT의 이점과 무관하게 memory pressure가 발생합니다.
SSE, JSON element stream, streaming HTTP client처럼 stream의 element 경계와 demand가 contract인 API는 계속 Publisher 중심입니다. Servlet API를 추가하지 않은 선택은 이 streaming architecture를 유지하는 데도 의미가 있습니다.
context propagation은 ThreadLocal만의 문제가 아니다
EventLoop에서 VT로 thread가 바뀌면 request context, tracing, security context, logging MDC 같은 값도 함께 넘어가야 합니다.
Micronaut 5의 ExecutionFlow.async는 현재 PropagatedContext로 submitted task를 감쌉니다.
executor.execute(
PropagatedContext.wrapCurrent(() -> {
supplier.get().onComplete(...);
})
);
route 실행에서는 ServerHttpRequestContext를 propagated context에 추가합니다. 그래서 framework가 관리하는 request context는 executor boundary를 건널 수 있습니다.
하지만 application이 직접 만든 raw ThreadLocal이 자동으로 복사되는 것은 아닙니다. 다음 패턴을 점검해야 합니다.
- 사내 tracing library의 custom
ThreadLocal - 직접 넣고 지우는 MDC field
- transaction context를 static
ThreadLocal에 저장하는 utility - executor에 직접 제출한 task
- Reactor Context와 ThreadLocal을 혼합한 bridge
Micronaut 5는 Java 25 ScopedValue 기반 propagation도 opt-in으로 제공합니다.
micronaut:
propagation: scoped-value
ScopedValues는 PropagatedContext를 lexical scope에 묶습니다. custom context code가 있다면 설정만 켜지 말고 실제 filter → controller → outbound client 경로에서 값을 확인해야 합니다.
timeout과 cancellation은 자동으로 해결되지 않는다
VT에서 실행되는 task는 cheap thread를 얻지만 무한한 실행 시간을 얻는 것은 아닙니다.
ExecutionFlow.async는 executor에 Runnable을 제출합니다. client disconnect나 upper-level cancellation이 이미 실행 중인 JDBC statement를 자동으로 취소 가능한 operation으로 바꾸지는 않습니다. 특히 driver가 interrupt에 반응하지 않으면 VT interrupt만으로 socket read가 끝난다고 보장할 수 없습니다.
따라서 timeout은 실제 resource layer에 있어야 합니다.
HTTP server 전체 deadline
- JDBC: connection acquisition · statement / query timeout
- outbound HTTP: connect · read / request timeout
- transaction timeout
또한 VT가 connection pool을 없애지 않습니다.
Virtual Threads: 50,000
HikariCP connections: 30
동시에 실행 가능한 DB query: 최대 약 30개
나머지 VT: connection 반환 대기
VT 수를 제한하기 위해 thread pool을 만들지 말고 connection pool, semaphore, queue처럼 실제 희소 자원에 제한을 둡니다.
EventLoop를 block하면 어떻게 되는가
MANUAL 기본값에서 @ExecuteOn을 빠뜨린 controller가 JDBC를 호출하면 해당 EventLoop가 query 완료까지 멈춥니다.
하나의 EventLoop가 여러 channel을 담당하므로 영향은 요청 하나에 머물지 않습니다. 이 때문에 Micronaut의 BlockingHttpClient 구현은 Netty EventLoop에서 blocking operation을 호출하면 기본적으로 HttpClientException을 던집니다.2
보호 설정을 끄는 것보다 thread 경계를 고치는 것이 맞습니다.
검증용 filter
local 환경에서 filter와 controller가 어느 thread에서 실행되는지 기록하면 annotation 누락을 찾기 쉽습니다.
@ServerFilter("/**")
final class ThreadProbeFilter {
@RequestFilter
void before(HttpRequest<?> request) {
Thread thread = Thread.currentThread();
LOG.debug(
"before {} thread={} virtual={}",
request.getPath(),
thread.getName(),
thread.isVirtual()
);
}
}
production에서 모든 요청을 logging하면 비용이 생기므로 sampling하거나 migration 검증 환경에서만 활성화합니다.
loom carrier mode는 기본 offload와 다르다
일반 VT executor는 JDK scheduler를 사용합니다.
Netty EventLoop → JDK VT scheduler / carrier → Application Virtual Thread
Micronaut 4.9에서 시작한 실험적인 loom carrier mode는 Netty EventLoop에 연결된 carrier에서 VT를 실행해 thread switch와 locality를 줄이려는 시도입니다.3
micronaut:
server:
thread-selection: blocking
netty:
event-loops:
default:
loom-carrier: true
현재 구현은 private JDK 내부에 접근하므로 JVM option이 필요합니다.
--add-opens=java.base/java.lang=ALL-UNNAMED
PrivateLoomSupport와 관련 configuration은 여전히 @Experimental입니다.
공식 prototype 결과도 workload에 따라 다릅니다.
- 짧은 Netty HTTP client call에서는 locality와 CPU 사용이 개선될 수 있습니다.
- JDBC는 JDK blocking I/O 경로 때문에 일반 FJP scheduler와 비슷하거나 더 낮은 request rate가 나올 수 있습니다.
- private API 의존성과 lock ordering 위험이 있습니다.
따라서 production 기본값은 일반 TaskExecutors.BLOCKING입니다. loom carrier는 reactive, 일반 VT offload, carrier mode를 같은 workload에서 비교할 수 있을 때만 검토합니다.
Reactive를 없애지 말아야 할 경계
VT가 해결하는 문제는 blocking 대기 중 platform thread 비용입니다. 다음 문제까지 해결하지는 않습니다.
- 무한 stream의 demand
- backpressure
- element 단위 cancellation
- WebSocket frame 수명
- SSE reconnect와 event stream
- streaming HTTP client의 buffer 소비 속도
Micronaut 5에도 blocking counterpart가 없는 API가 있습니다.
| API | 반환 contract | 내장 blocking 대안 |
|---|---|---|
StreamingHttpClient |
Publisher<HttpResponse<ByteBuffer<?>>> 등 |
없음 |
SseClient.eventStream |
Publisher<Event<B>> |
없음 |
WebSocketClient.connect |
Publisher<T> |
없음 |
StreamingHttpClient, SseClient, WebSocketClient의 public signature를 기준으로 확인했습니다.
일반 REST request/response와 JDBC transaction은 VT + direct style로 단순화할 수 있습니다. 장기 stream은 Reactive Streams contract를 유지하는 편이 더 정확합니다. 한 application 안에서 두 모델이 공존해도 됩니다.
운영 전에 확인할 것
thread boundary
-
MANUAL,AUTO,BLOCKING중 의도한 값을 명시 - blocking controller의
Thread.currentThread().isVirtual()확인 - blocking request·response filter 확인
- error handler와 request event listener 확인
-
@ExecuteOn없는 EventLoop blocking call 탐지
resource boundary
- JDBC connection acquisition·query timeout
- outbound HTTP connection pool·timeout
- client disconnect 후 실행 중 작업의 종료 시점
- VT 수가 아니라 실제 희소 자원에 동시성 제한
- CPU-bound 작업을 별도 bounded executor로 분리
stream boundary
- request body buffering 상한
- multipart
InputStreamclose - SSE·WebSocket·streaming client backpressure
- reactive route에 전역
BLOCKING을 적용했을 때 scheduler 변화
context boundary
- tracing span과 MDC가 filter → controller → client로 전파
- custom
ThreadLocal제거 또는 명시적 propagation - transaction context 유지
-
scoped-valueopt-in 사용 시 custom context code 검증
결론
Micronaut 5의 선택은 Servlet thread-per-request 모델을 Netty에 이식하는 것이 아닙니다. Netty가 잘하는 transport와 streaming은 EventLoop에 남기고, direct style이 유리한 application handler만 VT에서 실행합니다.
요청 흐름의 핵심은 네 단계입니다.
- Netty EventLoop가 request를 decode하고 lifecycle을 시작한다.
ExecutorSelector가 설정과 method metadata로 handler executor를 고른다.ExecutionFlow.async가 filter나 route stage 전체를TaskExecutors.BLOCKING에 제출한다.- VT에서 만든 response를 Netty outbound pipeline으로 돌려보낸다.
이 구조에서 VT는 Servlet API의 대체물이 아닙니다. EventLoop를 block하지 않으면서 blocking business code를 유지하기 위한 execution substrate입니다. 그래서 일반 REST와 JDBC에는 잘 맞지만, SSE·WebSocket·streaming client의 Reactive contract까지 제거하지는 않습니다.
Micronaut 5 migration에서 중요한 것은 “VT를 켰다”가 아닙니다. 어느 stage가 EventLoop에 남고, 어디서 VT로 넘어가며, timeout과 connection pool이 어느 resource를 제한하는지를 코드와 운영 지표로 확인하는 것입니다.
이전 글: Micronaut 5와 Virtual Thread — Reactive에서 blocking으로 돌아갈 수 있을까
관련 글: Micronaut Data JDBC와 Virtual Thread — 컴파일 타임 쿼리 생성과 데이터 접근 계층
Footnotes
-
Thread-selection 확장 PR #12439,
ExecutionFlow.async,HttpServerConfiguration. ↩ -
NettyHttpClient는 EventLoop에서 blocking client를 호출하는 경우@ExecuteOn(TaskExecutors.BLOCKING)사용을 안내합니다. ↩ -
Jonas Konrad, Transitioning to virtual threads using the Micronaut loom carrier. ↩
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...