HTTP 서버 모델과 Virtual Thread — Netty, EventLoop, 그리고 가상 스레드가 바꾼 선택 기준
4편에서 Spring Boot와 Micronaut을 비교하며 “무엇을 선택할까”를 살펴봤습니다. 이번 편에서는 한 발 더 들어가서 왜 그 선택이 맞는가를 HTTP 서버 모델과 Virtual Thread의 관점에서 정리합니다.
Tomcat과 Netty — “둘 다 epoll을 쓴다”는 말의 함정
Netty 기반 서버가 Tomcat보다 빠르다고 말할 때, 흔히 “Netty는 비동기 I/O를 쓰고 Tomcat은 blocking I/O를 쓴다”고 설명하는 경우가 있습니다. 이것은 정확하지 않습니다.
Tomcat, Jetty 등 현대 웹 컨테이너들도 오래전부터 NIO를 사용하고, Linux에서는 epoll로 소켓 readiness를 감지합니다. 소켓이 읽을 준비가 됐는지 확인하는 단계는 양쪽 모두 비동기입니다.
핵심 차이는 epoll 이벤트를 수신한 이후의 처리 모델입니다.
| 항목 | Tomcat (NIO Connector) | Netty |
|---|---|---|
| epoll 이벤트 수신 | Poller thread | EventLoop thread |
| 이후 처리 | Worker thread pool로 dispatch | 동일 EventLoop에서 처리 |
| 스레드 개수 | 요청 수 비례 (기본 200개) | CPU 코어 수 비례 (기본 CPU×2) |
| blocking 코드 허용 | 자연스러움 | 원칙적으로 금지 |
| context switching | 요청마다 발생 | 최소화 |
| 메모리 | 스레드 스택 × 스레드 수 | 소량 |
Tomcat은 epoll이 “이 소켓에 데이터가 있다”고 알려주면, 그 요청을 처리할 worker thread를 스레드 풀에서 꺼내서 맡깁니다. 그 worker thread가 응답을 보낼 때까지 스레드를 점유합니다.
Netty는 epoll이 알려주면, 이벤트를 수신한 EventLoop thread 자체가 파이프라인을 따라 직접 처리합니다. 별도 스레드로 넘기지 않습니다.
EventLoop가 동작하는 방식 — 협력적 스케줄링
전제: 스레드는 한 번에 하나만 실행한다
현대 CPU는 명령어 캐시(i-cache)와 데이터 캐시(d-cache)를 분리하고, 단일 코어는 하나의 명령어 스트림을 순차적으로 실행합니다. 멀티코어가 동시 실행을 가능하게 하지만, 하나의 스레드는 어느 순간에도 하나의 작업만 수행합니다.
EventLoop도 이 원칙에서 자유롭지 않습니다. EventLoop thread가 핸들러 A의 코드를 실행하는 동안, 그 스레드에 할당된 다른 모든 커넥션은 대기합니다. EventLoop는 “동시에 여러 요청을 처리”하는 것이 아니라, 매우 빠르게 교대하는 협력적 스케줄링(cooperative scheduling) 입니다.
EventLoop thread (단일 코어):
[epoll 폴링] → [커넥션 A 핸들러] → [epoll 폴링] → [커넥션 B 핸들러] → ...
↑
이 구간 동안 B, C, D... 는 전부 대기
EventLoop가 효율적인 진짜 이유 — 교대 비용
그럼 왜 EventLoop가 thread-per-request보다 높은 처리량을 낼 수 있을까요? 핵심은 “교대하는 비용” 의 차이입니다.
thread-per-request에서 교대는 OS 컨텍스트 스위칭입니다. OS 스케줄러가 개입하여 현재 스레드의 레지스터와 스택 포인터를 저장하고, 다음 스레드의 상태를 복원합니다. TLB와 CPU 파이프라인이 플러시되고, 캐시도 오염됩니다. 이 비용은 수 마이크로초입니다.
EventLoop에서 교대는 함수 리턴입니다. 핸들러가 반환하면 EventLoop 루프가 다음 이벤트를 처리합니다. OS 스케줄러가 개입하지 않고, 레지스터 저장/복원이 없습니다. 비용은 수십 나노초입니다.
| 교대 방식 | OS 개입 | 비용 |
|---|---|---|
| OS 스레드 컨텍스트 스위칭 | 있음 | 레지스터 저장/복원, TLB flush, 캐시 오염 (~수 μs) |
| EventLoop 핸들러 간 전환 | 없음 | 함수 리턴 + 다음 호출 (~수십 ns) |
이것이 동작하는 전제 조건이 있습니다: 핸들러가 빠르게 반환해야 합니다. I/O-bound 서비스에서 요청 처리 시간의 대부분은 네트워크 I/O 대기입니다. 실제 비즈니스 로직 실행 시간은 수십~수백 마이크로초에 불과합니다. 핸들러가 이렇게 짧게 실행되고 반환하면, EventLoop는 초당 수십만 번 루프를 돌며 다수의 커넥션을 교대로 처리할 수 있습니다.
추가로 i-cache 관점에서 보면, EventLoop 루프 자체의 코드와 Netty의 공통 코덱 코드(HttpRequestDecoder, HttpResponseEncoder 등)는 모든 요청에 공통이므로 i-cache에 warm하게 유지됩니다. 다만 엔드포인트별 비즈니스 로직 핸들러는 각기 다른 코드이므로, 이 이점은 공통 인프라 코드에 한정됩니다.
스레드 수와 메모리
OS 스레드는 스택 메모리를 점유합니다. Linux 기본값은 보통 1MB, Windows는 1MB, macOS는 512KB입니다1. 200개 스레드에 1MB 스택을 기준으로 하면 스택만으로 약 200MB를 씁니다. EventLoop는 CPU 코어 수 기준으로 스레드를 생성하므로(기본값 CPU 코어 × 2), 스레드 스택 메모리가 극적으로 줄어듭니다.
Netty의 약점 — Blocking I/O와 JDBC
협력적 스케줄링의 전제 조건(“핸들러가 빠르게 반환한다”)이 깨지면 어떻게 될까요.
EventLoop thread를 절대 block하면 안 됩니다.
EventLoop 하나가 수십~수백 개의 커넥션을 관리합니다. 핸들러가 blocking call을 만나면, OS는 해당 스레드를 sleep 상태로 전환합니다. 그 순간 이 스레드는 epoll 폴링도, 다른 핸들러 실행도 할 수 없습니다. 같은 EventLoop가 관리하는 모든 커넥션이 멈춥니다.
JDBC는 태생적으로 blocking입니다. connection.prepareStatement(sql).executeQuery()는 DB 응답이 올 때까지 스레드를 block합니다.
따라서 Netty + JDBC 조합에서는 DB 작업을 반드시 별도 executor (worker thread pool)로 offload해야 합니다.
// Micronaut에서 blocking I/O를 별도 스레드로 offload
@Get("/orders/{id}")
@ExecuteOn(TaskExecutors.BLOCKING) // BLOCKING executor로 offload
public Order getOrder(Long id) {
return orderRepository.findById(id); // JDBC (blocking)
}
문제는 이 offload 자체가 새로운 병목을 만든다는 점입니다. worker thread pool 크기가 동시 DB 처리 한계를 결정합니다. 기본값이 200개 스레드라면 동시에 처리할 수 있는 JDBC 쿼리는 최대 200개입니다.
결과적으로: Netty 기반이라도 JDBC를 쓰면 결국 thread-per-request와 유사한 구조로 돌아갑니다. EventLoop의 I/O 처리 이점은 있지만, DB 처리 병목은 스레드 수에 의존하게 됩니다.
Virtual Thread의 등장 — 구도 재정의
JDK 21에서 정식 도입된 Virtual Thread(가상 스레드)는 이 구도를 근본적으로 바꿉니다.
Virtual Thread가 처음이라면 — 이 글은 VT의 동작 원리가 어느 정도 익숙한 독자를 전제합니다. 개념이 낯설다면 아래 자료를 먼저 읽어보세요.
Virtual Thread의 핵심
Virtual Thread의 핵심 특성은 하나입니다: blocking call이 발생해도 OS thread를 점유하지 않습니다.
JVM이 Virtual Thread를 소수의 OS thread(carrier thread) 위에서 실행합니다. Virtual Thread가 socket.read(), connection.executeQuery() 같은 blocking call을 만나면 JVM이 해당 Virtual Thread를 park 상태로 전환하고, carrier thread를 다른 Virtual Thread 실행에 씁니다. I/O가 완료되면 Virtual Thread를 다시 unpark하여 실행을 재개합니다.
기존 OS thread의 blocking:
OS thread: [====작업====][====JDBC 대기====][====작업====]
^ OS thread 점유
Virtual Thread의 blocking:
VThread: [====작업====][park][====작업====]
Carrier: [=VT 실행=][=다른 VT 실행=][=VT 재개=]
^ OS thread 재활용
JDBC + Virtual Thread
Virtual Thread와 함께라면 JDBC를 blocking으로 쓰면서도 OS thread를 낭비할 필요가 없습니다. Virtual Thread를 수만 개 만들어도 OS thread는 CPU 코어 수 정도만 실제로 사용됩니다.
덕분에 복잡한 reactive 코드 없이 일반적인 blocking 스타일로 작성해도 높은 동시성을 달성할 수 있습니다.
Pinning — JDK 버전이 중요한 이유
Virtual Thread의 park/unpark가 동작하려면 한 가지 전제가 있습니다: VT가 blocking call을 만났을 때 carrier thread를 다른 VT에 내어줄 수 있어야 합니다.
synchronized 블록 안에서 blocking call이 발생하면 이 전제가 깨집니다. JVM은 synchronized의 모니터 락을 OS thread에 귀속시키기 때문에, VT가 synchronized 안에 있는 동안 carrier thread를 다른 VT에 양보할 수 없습니다. 이를 pinning이라고 합니다.
synchronized 블록 안에서의 blocking:
VThread: [=작업=][synchronized 진입][====JDBC 대기====][synchronized 해제]
Carrier: [=VT 실행=][=============== carrier thread 점유 ===============]
^ park 불가 — carrier thread가 묶임
문제는 Java 생태계에 synchronized가 광범위하게 사용된다는 점입니다. 구형 JDBC 드라이버는 물론, HikariCP 같은 널리 쓰이는 커넥션 풀도 일부 경로에서 synchronized를 사용합니다2. JDK 21에서 Virtual Thread를 활성화하더라도 DB 연결을 얻는 순간 pinning이 발생해 carrier thread가 묶히고, VT의 동시성 이점이 상당 부분 사라집니다.
JDK 24에서 JEP 491: Synchronize Virtual Threads without Pinning이 도입되어 이 문제가 해결되었습니다. synchronized 블록 안에서도 blocking 시 carrier thread를 내어줄 수 있게 되었습니다. 따라서 Virtual Thread의 이점을 온전히 누리려면 JEP 491이 포함된 **JDK 25(현재 LTS)**를 권장합니다.
pinning의 동작 원리와 진단 방법(-Djdk.tracePinnedThreads 옵션 등)은 앞서 소개한 Virtual Thread 참고자료들을 살펴보세요.
웹 서비스의 특성 — 왜 Virtual Thread가 잘 맞는가
Virtual Thread의 이점을 본격적으로 살펴보기 전에 한 가지를 먼저 상기할 필요가 있습니다.
우리가 개발하는 웹 서비스 대부분은 CPU 연산 성능을 크게 필요로 하지 않습니다. 전형적인 패턴은 이렇습니다: 외부 REST API에서 데이터를 가져오고, RDBMS나 NoSQL에서 쿼리를 실행하고, 그 결과를 조합·가공하여 응답합니다. 요청 처리 시간의 대부분은 네트워크와 DB 응답을 기다리는 시간이며, 실제 CPU가 비즈니스 로직을 실행하는 구간은 전체의 극히 일부입니다.
Virtual Thread의 알려진 약점은 CPU-bound 작업에서의 overhead입니다. 이미지 처리, 암호화 연산, 머신러닝 추론처럼 CPU를 쉬지 않고 사용하는 작업에서는 Virtual Thread를 수만 개 생성해도 실제 OS thread를 대체할 이점이 없고, carrier thread 간 스케줄링 overhead만 추가됩니다.
그러나 I/O-bound가 주인 일반적인 웹 서비스에서 이 약점은 유의미한 차이를 만들지 않습니다. 대기 시간이 길수록 Virtual Thread가 park 상태에서 carrier thread를 다른 작업에 내어줄 여지가 많아집니다. 스레드 수에 의존하던 동시성 한계를 사실상 제거하면서, blocking 스타일 코드도 그대로 유지할 수 있습니다.
각 서버의 Virtual Thread 활용
Spring MVC + Virtual Thread: thread-per-request 모델의 스레드 풀을 Virtual Thread로 완전히 대체합니다. 요청마다 Virtual Thread를 할당하면, 스레드 풀의 스레드 한계가 사라지고 수만 개의 동시 요청을 처리할 수 있습니다.
# Spring Boot application.yml
spring:
threads:
virtual:
enabled: true # Tomcat이 Virtual Thread를 사용
Netty + Virtual Thread: offload executor를 Virtual Thread executor로 교체합니다. JDBC 쿼리를 처리하는 blocking thread가 Virtual Thread가 되어 OS thread를 낭비하지 않습니다.
# micronaut application.yml
micronaut:
executors:
blocking:
virtual: true # executor 를 Virtual Thread로 교체
Micronaut 4는 JDK 21 이상에서
BLOCKINGexecutor를 자동으로 Virtual Thread executor로 구성합니다3. 따라서 위 설정은 JDK 21+에서는 생략 가능하지만, 의도를 명시적으로 드러내기 위해 선언하는 것도 유효합니다.
// Micronaut: BLOCKING executor로 offload
@ExecuteOn(TaskExecutors.BLOCKING) // 위와 같이 환경설정에서 VT executor로 구성 가능
세 가지 선택지 비교 (JDK 21+ 기준)
Virtual Thread 시대에 실질적인 선택지는 세 가지입니다.
| 항목 | Spring MVC + VT | Spring WebFlux | Micronaut |
|---|---|---|---|
| HTTP 모델 | thread-per-request (VT) | EventLoop + reactive | EventLoop + (VT offload) |
| blocking 코드 | 자연스러움 | 반드시 offload | 반드시 offload |
| 코드 단순성 | 가장 단순 | 가장 복잡 | 중간 |
| I/O-bound 고동시성 | 충분 | 최고 | 최고 |
| 초저지연 / 커스텀 프로토콜 | 어려움 | 유리 | 유리 |
| cold start / 메모리 | 보통 | 보통 | 가장 우수 (AOT) |
| GraalVM Native Image | 추가 힌트 필요 | 추가 힌트 필요 | reflection-free 설계 |
Spring MVC + Virtual Thread
Virtual Thread 도입 이후 일반 업무용 REST API에서 다시 유력한 선택지로 부상했습니다.
- 코드가 단순합니다.
@Service,@Transactional,repository.findById(id)— 모두 blocking style 그대로 씁니다. - Virtual Thread가 동시성을 담당하므로 reactive 패러다임 전환 없이 높은 처리량을 달성합니다.
- Spring 생태계 전체를 그대로 활용합니다.
다만 MVC는 EventLoop가 아닌 thread-per-request 모델이므로, 초고동시성(수만 개 이상의 동시 커넥션)과 초저지연이 동시에 요구되는 환경에서는 한계가 있습니다.
Spring WebFlux
I/O bound 초고동시성, WebSocket 서버, 커스텀 프로토콜 서버에서 여전히 차별화됩니다.
- EventLoop + Reactor 파이프라인으로 CPU 코어 수 수준의 스레드로 수만 개의 커넥션을 처리합니다.
- Netty의 cache locality 이점을 최대한 활용합니다.
- 그러나 코드가 복잡합니다.
Mono,Flux,flatMap,switchIfEmpty— reactive 파이프라인에 익숙해지는 데 상당한 학습 비용이 있습니다. - blocking 코드가 하나라도 EventLoop에 들어오면 전체 동시성이 무너집니다. 팀 전체가 이 원칙을 지켜야 합니다.
Micronaut
AOT 고유 이점(startup, 메모리, Native Image)이 Virtual Thread와는 독립적으로 여전히 유효합니다.
- EventLoop 기반이므로 I/O bound 고동시성은 WebFlux와 동일한 이점입니다.
- JDBC 사용 시
@ExecuteOn(TaskExecutors.BLOCKING)offload가 필요하지만, 이 executor를 Virtual Thread executor로 구성하면 OS thread 낭비 없이 높은 동시성을 유지합니다. - startup time, 메모리 사용량, GraalVM Native Image 호환성은 Spring 계열 대비 명확한 이점입니다.
WebFlux와 비교했을 때 Micronaut의 결정적 차이점은 코드 스타일입니다. WebFlux에서 EventLoop 이점을 얻으려면 Mono, Flux 등의 reactive 인터페이스로 파이프라인을 직접 조합해야 합니다. Micronaut에서는 @ExecuteOn 어노테이션 하나로 스케줄링을 프레임워크에 위임하고, 비즈니스 로직은 평범한 동기식 코드로 작성합니다.
// WebFlux — reactive 파이프라인을 직접 조합해야 함
@GetMapping("/orders/{id}")
public Mono<Order> getOrder(@PathVariable Long id) {
return Mono.fromCallable(() -> orderRepository.findById(id))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(order ->
Mono.fromCallable(() -> userRepository.findById(order.getUserId()))
.subscribeOn(Schedulers.boundedElastic())
.map(user -> enrichOrder(order, user))
);
}
// Micronaut — @ExecuteOn 하나로 offload, 나머지는 동기식 코드
@Get("/orders/{id}")
@ExecuteOn(TaskExecutors.BLOCKING)
public Order getOrder(Long id) {
Order order = orderRepository.findById(id);
User user = userRepository.findById(order.getUserId());
return enrichOrder(order, user);
}
JavaScript 생태계에서 Promise 체인을 async/await으로 감싸 동기식 스타일로 작성할 수 있는 것과 같은 원리입니다. async/await가 런타임의 비동기 스케줄링을 감추듯, @ExecuteOn은 스레드 전환을 프레임워크가 처리하게 합니다. EventLoop 기반 아키텍처의 이점을 유지하면서 reactive 패러다임 전환 없이 팀이 이미 익숙한 코딩 스타일을 그대로 씁니다.
DB 접근 모델 — JDBC+VT vs R2DBC
JDBC가 blocking인 이유 — 역사적 배경
JDBC(Java Database Connectivity)는 Java 1.1(1997년)과 함께 등장했습니다. 당시 Java에는 비동기 프로그래밍을 위한 표준 추상화가 존재하지 않았습니다.
| 시점 | 사건 |
|---|---|
| 1997년 | Java 1.1, JDBC 1.0 출시 — 모든 DB 호출이 동기 |
| 2004년 | Java 5, Future<V> 인터페이스 도입 |
| 2014년 | Java 8, CompletableFuture 도입 |
| 2018년 | R2DBC 프로젝트 시작 — JDBC의 reactive 대안 |
JDBC API는 비동기 추상화가 언어에 존재하기도 전에 설계되었습니다. 호출 결과를 기다리는 것이 유일한 방법이었고, 그 설계는 지금까지 이어집니다.
// JDBC — 모든 단계가 blocking
Connection conn = dataSource.getConnection(); // blocking
PreparedStatement ps = conn.prepareStatement(sql); // blocking
ResultSet rs = ps.executeQuery(); // blocking: DB 응답까지 스레드 점유
이것이 언어 설계부터 비동기를 택한 Node.js(2009년)와 대비됩니다. Node.js는 싱글스레드 EventLoop를 런타임의 핵심으로 채택했기 때문에, 주요 DB 드라이버들이 처음부터 비동기 인터페이스로 설계되었습니다.
// node-postgres(pg), mysql2 등 Node.js 주요 드라이버 — Promise/async 기반
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
Java 생태계에서 JDBC의 reactive 대안을 만들려는 시도가 R2DBC입니다. 그러나 20년 이상의 역사를 가진 JDBC 생태계와의 성숙도 격차는 단기간에 좁히기 어렵습니다.
Virtual Thread 시대의 선택 기준
Virtual Thread 시대에 DB 접근 방식을 어떻게 선택해야 할까요.
| 조합 | EventLoop offload 필요 | 코드 복잡도 | R2DBC 성숙도 영향 |
|---|---|---|---|
| Spring MVC + JDBC + VT | 없음 | 낮음 | 무관 |
| WebFlux + JDBC | 필수 | 높음 | 무관 |
| WebFlux + R2DBC | 없음 | 매우 높음 | 직접 영향 |
| Micronaut + JDBC | 필수 (@ExecuteOn) | 중간 | 무관 |
| Micronaut Data R2DBC | 없음 | 중간~높음 | 직접 영향 |
R2DBC의 현실
R2DBC는 EventLoop thread를 block하지 않고 DB 쿼리를 처리한다는 목표로 등장했습니다. 그러나 2026년 현재 R2DBC는 “JDBC를 대체하는 메인스트림”이 아닙니다.
성숙도와 생태계 격차가 가장 큰 이유입니다. JPA, Hibernate, MyBatis 등 JDBC 기반 ORM과 쿼리 빌더는 수십 년의 역사로 기능과 안정성이 검증되어 있습니다. R2DBC 기반 도구는 기능과 문서가 아직 부족하고, PostgreSQL을 제외한 일부 DB는 드라이버 품질이 균일하지 않습니다.
성능에 관해서는 단순한 결론이 없습니다. 실측 벤치마크45를 종합하면 다음과 같습니다.
- 저지연 환경(localhost 등): JDBC가 R2DBC보다 약 27% 빠릅니다. Mono/Flux 생성과 GC 압력이 overhead로 작용합니다.
- 실제 DB 레이턴시가 있는 환경: 격차가 1% 내외로 수렴합니다. 네트워크 대기 시간이 reactive overhead를 압도합니다.
- 고동시성 환경: WebFlux + R2DBC가 Spring MVC + JDBC + VT를 오히려 앞섭니다5. 800 동시 사용자 기준 WebFlux 653 req/s vs Virtual Threads 615 req/s.
즉, R2DBC가 항상 느리다는 주장은 사실이 아닙니다. EventLoop 모델의 이점은 충분히 높은 동시성에서 발현됩니다.
결론: JDBC + Virtual Thread가 대부분 상황에서 충분합니다
R2DBC의 이점이 발현되는 조건(고동시성, 높은 DB 레이턴시, 팀의 reactive 숙련도)이 모두 갖춰진다면 R2DBC는 유효한 선택입니다.
그러나 Virtual Thread 이전에 “EventLoop에서 JDBC를 쓰려면 어쩔 수 없이 R2DBC를 써야 한다”는 강제성이 사라졌습니다. 성능 우위가 조건부이고 생태계 성숙도 격차가 실재하므로, R2DBC 선택에는 막연한 “비동기니까 더 좋다”가 아닌 구체적인 근거가 필요합니다.
Micronaut의 위치 정리
이 맥락에서 Micronaut의 포지션을 다시 정리합니다.
Micronaut이 유리한 상황:
- cold start가 중요한 환경: 서버리스, Lambda, 빈번한 재시작이 필요한 서비스. AOT 덕분에 JVM 기준 수백 ms, Native Image 기준 수십 ms 수준의 시작 시간을 달성합니다6.
- 메모리 비용이 중요한 환경: 수십~수백 개의 마이크로서비스를 운영할 때, 각 서비스의 메모리가 줄어드는 것이 비용에 직결됩니다.
- GraalVM Native Image 목표: reflection-free 설계로 Native Image 빌드가 Spring 대비 훨씬 수월합니다.
- Spring 생태계 의존성이 없는 새 프로젝트: 생태계 의존성 없이 시작한다면 Micronaut이 충분한 기능을 제공합니다.
주의할 점:
- JDBC 사용 시
@ExecuteOn(TaskExecutors.BLOCKING)명시가 필요합니다. 실수로 EventLoop thread에서 blocking 호출을 하면 EventLoop에서 JDBC 쿼리가 수행되기에 동시성이 무너집니다. 어노테이션을 사용하고 싶지 않다면 reactive 프레임워크등을 사용하여 명시적으로 다른 스레드 풀에 위임해야합니다. (ex:Mono.fromCallable(() -> {return repository.findById(id));}).subscribeOn(Schedulers.boundedElastic());) - Spring 생태계(Spring Batch, Spring Cloud, Spring Security 고급 기능)에 의존적인 프로젝트라면 Micronaut 전환은 적합하지 않습니다.
결론 — 상황별 선택 기준
업무용 CRUD + RDB → Spring MVC + JDBC + Virtual Thread
대부분의 REST API 서비스에 해당합니다. Virtual Thread 덕분에 코드 단순성을 유지하면서 충분한 동시성을 확보합니다. Spring 생태계를 그대로 활용하고, JDBC 기반 ORM의 성숙도도 누립니다.
I/O 중심 초고동시성 + 지연 민감 → WebFlux 또는 Micronaut + R2DBC (신중히)
수만 개의 동시 커넥션, WebSocket 서버, 또는 커스텀 프로토콜 처리가 필요한 경우입니다. Reactive 파이프라인의 학습 비용과 코드 복잡성을 감수할 수 있고, 팀이 이 패러다임에 익숙해야 합니다. R2DBC는 워크로드 특성에 맞는 벤치마크를 먼저 수행하세요.
Cold start · 메모리 · Native Image → Micronaut
서버리스, Lambda, 메모리 효율이 중요한 마이크로서비스 환경입니다. Spring 생태계 의존이 없다면 Micronaut의 AOT 이점을 최대한 활용할 수 있습니다.
이 시리즈는 Micronaut의 역사적 맥락에서 시작하여 실습, 내부 동작, Spring과의 비교, 그리고 HTTP 서버 모델과 Virtual Thread까지 다뤘습니다. Java 생태계에서 프레임워크 선택은 단순히 “무엇이 더 빠른가”가 아니라 “어떤 실행 환경에서, 어떤 트레이드오프를 받아들이는가”의 문제입니다. 이 시리즈가 그 판단에 도움이 되길 바랍니다.
이전 편: Spring과 Micronaut 비교 — 무엇을 선택할까
시리즈 처음으로: AOT의 시대를 열다 — Micronaut 소개와 역사적 맥락
Footnotes
-
JVM의
-Xss옵션으로 스레드 스택 크기를 조정할 수 있으며, 기본값은 플랫폼에 따라 다릅니다. Oracle Java SE 도구 참고를 참조하세요. ↩ -
HikariCP issue #1463에서 이 문제가 제기되었으며,
ReentrantLock으로 교체하려는 PR #2027이 제출됐으나 닫혔습니다. HikariCP는 라이브러리 차원에서synchronized유지를 결정했고, JEP 491이 이 문제의 근본적인 해결책입니다. ↩ -
Micronaut Framework 4.0은 JDK 19+의 Virtual Thread를 감지하여
TaskExecutors.BLOCKINGexecutor에 자동 적용합니다. Micronaut Framework 4.0.0 릴리스 노트 참조. ↩ -
Spring Data R2DBC GitHub issue #203 Full code — 단순 SELECT 쿼리에서 PGJDBC ~15,000 q/s vs R2DBC ~11,000 q/s. DB 레이턴시 도입 시 차이가 ~1%로 수렴. Windows 10 Pro, i7–4710HQ, 12 GB RAM 에서 수행 ↩
-
Vincenzo Racca, Virtual Threads vs WebFlux — Spring Boot 기준 WebFlux+R2DBC vs Spring MVC+VT+JDBC 처리량 비교. ↩ ↩2
-
시작 시간은 하드웨어, 의존성 구성, JVM 옵션에 따라 크게 달라집니다. Java 25 환경의 참고 측정치로 gillius.org의 Java 25 Startup Performance for Spring Boot, Quarkus, and Micronaut를 참조하세요. ↩
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...