Spring WebFlux 애플리케이션을 띄워 두고 스레드 덤프를 떠 봤더니, 요청을 받아 처리하는 스레드가 reactor-http-nio-1, reactor-http-nio-2 식으로 코어 수 언저리에서 멈춰 있었다. 동시 접속이 수백 개로 올라가도 이 스레드 수는 늘지 않았다. Tomcat 시절엔 요청이 몰리면 http-nio-8080-exec-NNN이 수백 개까지 불어나던 것과 대조적이었다.
처음엔 "스레드가 이것밖에 없는데 어떻게 동시 요청을 받지?"가 의아했고, 그다음엔 한 군데에 Thread.sleep을 넣었더니 전체 처리량이 무너지는 걸 보고 더 의아했다. 이 두 현상은 모두 Reactor Netty가 깔고 있는 EventLoop 모델 하나로 설명된다.
이 글에서 정리하려는 것:
세부 동작은 Reactor Netty와 그 아래 Netty의 공식 문서를 짚으며 확인한 내용이고, 버전별로 미세하게 다를 수 있는 부분은 단정하지 않고 출처로 넘긴다.
EventLoop을 한 문장으로 정리하면 이렇다.
하나의 스레드가 무한 루프를 돌면서, 자신에게 등록된 여러 연결(Channel)의 I/O 이벤트를 순서대로 꺼내 처리하는 구조.
전통적인 thread-per-request 모델은 연결(또는 요청) 하나에 스레드 하나를 붙인다. 직관적이지만, 연결이 1만 개면 스레드도 1만 개에 가까워진다. 스레드는 공짜가 아니다. 기본 스택만 해도 수백 KB씩 잡고, 수가 많아지면 컨텍스트 스위칭 비용과 스케줄링 부담이 커진다. 게다가 대부분의 웹 요청 시간은 CPU 연산이 아니라 소켓에서 다음 바이트가 오기를 기다리는 시간이다. 그 대기 동안 스레드는 그냥 블로킹된 채 메모리만 점유한다.
EventLoop 모델은 발상을 뒤집는다. 연결마다 스레드를 붙이는 대신, 소수의 스레드가 OS의 I/O 멀티플렉싱(epoll/kqueue/selector)에 "준비된 연결이 생기면 알려달라"고 등록해 두고, 실제로 읽거나 쓸 데이터가 생긴 연결만 골라 처리한다. 그래서 연결 수가 스레드 수와 분리된다. 연결이 1만 개여도 그중 지금 당장 처리할 게 있는 것만 깨어나고, 나머지는 커널의 관심 목록에 등록만 되어 있을 뿐 스레드를 묶어 두지 않는다.
Reactor Netty 레퍼런스 문서에 따르면, 이 모델을 직접 구현하지 않고 Netty의 EventLoopGroup을 그대로 가져다 쓴다. Reactor Netty는 LoopResources라는 추상화로 이 그룹을 감싸 관리한다.
서버가 뜨면 Reactor Netty는 LoopResources를 통해 EventLoop 그룹을 만든다. 워커 스레드 개수는 기본적으로 시스템 프로퍼티 reactor.netty.ioWorkerCount로 정해지는데, 지정하지 않으면 사용 가능한 프로세서 수와 4 중 큰 값(대략 max(availableProcessors, 4))을 쓰는 것으로 문서에 설명되어 있다. 앞에서 스레드 수가 코어 수 근처에서 멈춰 있던 게 이 때문이다.
운영체제에 따라 실제 전송 계층(transport)도 달라진다. Linux에서는 네이티브 epoll, macOS/BSD 계열에서는 kqueue, 그 외에는 JDK NIO Selector 기반으로 동작하는 것으로 알려져 있다. 스레드 이름에 붙던 nio는 NIO 전송이 선택됐을 때의 흔적이다.
여기가 이 모델에서 가장 중요한 지점이다.
하나의 Channel(연결)은 생성 시점에 하나의 EventLoop에 배정되고, 그 연결이 닫힐 때까지 같은 EventLoop에서만 처리된다.
관계를 정리하면 이렇다.
| 관계 | 개수 |
|---|---|
| EventLoopGroup : EventLoop | 1 : N |
| EventLoop : Thread | 1 : 1 |
| EventLoop : Channel | 1 : N (한 루프가 여러 연결 담당) |
| Channel : EventLoop | N : 1 (연결은 평생 한 루프에) |
Channel 하나가 항상 같은 단일 스레드에서만 다뤄진다는 것은 큰 의미가 있다. 그 연결의 파이프라인을 따라 흐르는 핸들러들은 여러 스레드가 동시에 건드릴 일이 없다. 그래서 채널 핸들러 안의 상태는 별도의 락이나 synchronized 없이도 안전하다. 동시성 문제를 락으로 푸는 대신 스레드 가둠(thread confinement)으로 아예 발생하지 않게 만드는 셈이다. 이건 thread-per-request에서는 누리기 어려운 성질이다.
EventLoop 스레드가 도는 무한 루프는 대략 세 가지 일을 번갈아 한다고 이해했다.
eventLoop.execute(...)로 넣은 것 등)를 실행한다.세 단계가 같은 한 스레드에서 직렬로 돈다는 점이 핵심이다. 그래서 어느 한 채널의 처리가 길어지면, 같은 EventLoop에 묶인 다른 채널들의 처리도 그만큼 밀린다. 여기서 다음 함정이 나온다.
Tomcat 경험이 있으면 부하가 올라갈 때 스레드가 안 늘어나는 걸 보고 "스레드 풀이 고갈됐나?"로 오해하기 쉽다. 하지만 EventLoop 모델에서 워커 수가 고정인 건 설계 그대로다. 처리량은 스레드를 늘려서가 아니라, 각 스레드가 블로킹 없이 바쁘게 도는 것으로 확보한다. 그래서 워커 수를 무작정 키운다고 좋아지지 않는다 — 오히려 컨텍스트 스위칭만 늘 수 있다.
문제를 가장 작게 재현하면 이렇다. WebFlux 핸들러 안에서 블로킹 호출을 그냥 해 버리는 경우다.
@GetMapping("/bad")
public Mono<String> bad() {
// 이 코드는 onNext가 흘러온 EventLoop 스레드 위에서 그대로 실행된다.
// 여기서 막히면 같은 EventLoop에 묶인 다른 연결들도 전부 멈춘다.
Thread.sleep(2000); // 블로킹 I/O / JDBC / RestTemplate 등도 동일
return Mono.just("done");
}
워커가 코어 수만큼(예: 4개)밖에 없는데 이런 요청 4개가 동시에 들어오면, EventLoop 4개가 전부 2초씩 잠들어 그 시간 동안 서버가 새 I/O 이벤트를 전혀 못 돌본다. 다른 멀쩡한 요청들까지 같이 지연된다. 스레드가 수백 개인 Tomcat이라면 한두 스레드 자는 게 티가 안 나지만, 여기선 치명적이다.
해법은 블로킹 작업을 EventLoop 밖의 전용 스케줄러로 밀어내는 것이다. Reactor에서는 boundedElastic 스케줄러가 이런 블로킹/레거시 호출을 받아 주는 용도로 권장된다고 문서에 설명되어 있다.
@GetMapping("/ok")
public Mono<String> ok() {
return Mono.fromCallable(() -> {
Thread.sleep(2000); // 블로킹 작업 자체는 그대로지만
return "done";
})
// EventLoop이 아니라 별도 스레드 풀에서 실행되게 옮긴다
.subscribeOn(Schedulers.boundedElastic());
}
이렇게 하면 블로킹은 boundedElastic 스레드에서 일어나고, EventLoop 스레드는 곧장 풀려나 다른 채널의 이벤트를 계속 돌본다. 즉 "블로킹을 없앴다"기보다 블로킹의 위치를 EventLoop 밖으로 옮긴 것이다. 리액티브 스택에서 블로킹 라이브러리를 끼워 쓸 때 반복적으로 쓰는 패턴이다.
참고로 어떤 코드가 EventLoop을 막고 있는지 잡아내려면 BlockHound 같은 도구로 블로킹 호출을 감지하는 방법이 알려져 있다. 다만 이건 진단 도구이고, 근본 해법은 위처럼 스케줄러를 분리하는 쪽이다.
핵심을 한 줄로 정리하면:
Reactor Netty는 연결마다 스레드를 붙이는 대신, 소수의 EventLoop 스레드가 여러 연결의 I/O 이벤트를 직렬로 처리한다. 그래서 적은 스레드로 많은 연결을 받지만, 그 스레드를 블로킹하는 순간 모델의 전제가 무너진다.
boundedElastic 같은 별도 스케줄러로 옮긴다.더 파고들 만한 주제:
boundedElastic, parallel 등 Reactor Scheduler 종류별 용도와 스레드 수 기본값