Skip to Content

12장. 이벤트 루프를 프레임워크로 — Netty와 WebFlux

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

핸들러 하나가 100ms를 잤더니 500개 전체의 p50이 36ms에서 119ms로 밀렸습니다. 루프는 하나였으니까요.

면접 실전 질문: ① Netty의 EventLoopGroup은 몇 개의 스레드를 쓰나요? ② boss 그룹과 worker 그룹은 왜 나누나요? ③ WebFlux에서 블로킹 코드를 쓰면 정확히 무슨 일이 벌어지나요?


배경 — 자바에도 그 루프가 있다

11장 마지막 질문입니다.

HttpClient.sendAsync가 응답을 받아 complete()를 부르기까지, 어느 스레드가 무엇을 하고 있었을까요?”

답은 7장에 이미 있습니다. 누군가 epoll_wait를 돌고 있었어요. 이 장 끝에서 그 “누군가”에게 이름을 붙여줍니다. 7장에서는 C로 직접 만들었죠. 자바에도 똑같은 게 있습니다 — java.nio.channels.Selector입니다.

4장에서 봤듯 자바는 이 도구를 2002년(Java 1.4)에 이미 가졌습니다. 그런데 아무도 직접 쓰지 않았어요. 너무 어려웠거든요. 그래서 Netty가 나왔고, 오늘날 자바 비동기 스택 대부분이 그 위에 서 있습니다.

이 장은 두 가지를 합니다. 먼저 Selector 루프를 자바로 직접 짜서 7장의 C 코드와 같은 물건임을 확인하고, 그다음 그 루프가 무너지는 지점을 잽니다.

스토리 — Netty가 감싼 것과 더한 것

감싼 것: Selector 루프

Netty의 EventLoop를 벗기면 안에 이게 있습니다.

while (!shutdown) { selector.select(timeout); // ← epoll_wait / kevent for (SelectionKey k : selector.selectedKeys()) { if (k.isReadable()) channel.read(); // 커널 버퍼 → 유저 버퍼 // 파이프라인 핸들러 실행 = 콜백 호출 ← 이 스레드가 직접 부른다 } runAllTasks(); // 다른 스레드가 넣어둔 작업 큐 }

7장의 C 코드와 구조가 같습니다. 물어보고, 돌려받은 것마다 내 함수를 부르고, 다시 물어봅니다. 커널이 콜백을 부르지 않는다는 7장의 결론이 여기서도 그대로예요.

더한 것 ①: 스레드 그룹을 둘로 나눴다

Netty를 쓰면 처음 만나는 게 이 두 줄입니다.

EventLoopGroup boss = new NioEventLoopGroup(1); // 연결을 받는다(accept) EventLoopGroup worker = new NioEventLoopGroup(); // 읽고 쓰고 처리한다

Netty 4.2부터 NioEventLoopGroupMultiThreadIoEventLoopGroup(n, NioIoHandler.newFactory())로 대체됐습니다. 안에 도는 루프는 같아요. 📄 문서 기반 (미검증)

왜 나눌까요? 이 장 후반부에서 나누지 않은 버전을 돌려봅니다.

더한 것 ②: 기본 스레드 수

NioEventLoopGroup()을 인자 없이 만들면 스레드가 몇 개일까요? 가용 코어 × 2입니다(MultithreadEventLoopGroup.DEFAULT_EVENT_LOOP_THREADS, 시스템 프로퍼티 io.netty.eventLoopThreads로 조정). Spring WebFlux가 쓰는 Reactor Netty는 조금 달라서 max(4, 가용 코어)예요(LoopResources.DEFAULT_IO_WORKER_COUNT). 📄 문서 기반 (미검증)

7장 첫 질문이 이거였습니다 — “코어가 8개인 기계에서는 루프를 몇 개 돌려야 할까요?” 프레임워크들의 답이 이겁니다. 코어 수 언저리. 루프 스레드는 자면 안 되니 코어보다 훨씬 많이 둘 이유가 없어요.

그리고 이 숫자가 하나로 줄면 무슨 일이 생기는지가 이 장 후반부입니다.

그래서 WebFlux는 어디에 있나

층을 쌓아보면 이렇습니다.

정체도는 스레드
@RestController (WebFlux)내 핸들러reactor-http-nio-N
Reactor Netty HttpServerHTTP 디코딩·라우팅위와 같음
Netty EventLoopselect → 핸들러 → runAllTasks위와 같음
java.nio.SelectorJDK 창구위와 같음
kqueue / epoll커널 (2장)

오른쪽 열이 전부 같습니다. 여러분이 @RestController에 쓴 코드는 이벤트 루프 스레드에서 직접 돕니다. 중간에 스레드를 갈아타는 층이 없어요.

그래서 그 핸들러에서 블로킹을 하면 어떻게 되는지가 이 장의 나머지입니다.

핵심 — 자바로 만든 루프, 그리고 그것이 무너지는 지점

7장의 C 이벤트 루프를 자바로 옮겼습니다. Selector 하나, 스레드 하나입니다.

측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch12SelectorLoop.java에 있습니다. 연결 2,000개는 SocketChannel이라 fd를 많이 쓰므로 ulimit -n 20000으로 올리고 돌렸습니다.

while (handled < n) { sel.select(100); for (SelectionKey k : sel.selectedKeys()) { if (k.isAcceptable()) { /* 새 연결을 등록 */ } else if (k.isReadable()) { /* 읽고 응답 */ } } }

① 잘 돌 때

논블로킹 | 연결 500 | 루프 스레드 1개 | 전체 87 ms | p50 36 ms | p99 63 ms

✅ 실측 (같은 환경, 2026-08. 첫 회차는 소스파일 모드 컴파일과 JIT 워밍업 때문에 140ms가 나옵니다 — 아래는 워밍업 후 값이에요. 플랫폼 스레드는 17개였습니다.)

500개 연결을 스레드 하나가 87ms에 처리했습니다. 플랫폼 스레드 17개 — 9장에서 풀 1,000이 518개를 쓴 것과 비교해보세요. 7장의 C 버전과 같은 그림이에요. 자바에서도 이벤트 루프는 유저 공간 while문이고, 잘 돌 때는 아주 잘 돕니다.

② 핸들러 하나가 100ms를 자면

이제 핸들러 딱 하나에 100ms 블로킹을 넣었습니다. 500개 중 하나예요.

if (blockOnce && !slept) { slept = true; Thread.sleep(100); } // 딱 한 번만
전체p50p99
논블로킹87 ms36 ms63 ms
핸들러 하나만 100ms 블로킹196 ms119 ms185 ms

✅ 실측 (같은 환경, 2026-08. 연결 500개, 루프 스레드 1개. 4회 재실행에서 전체 178243ms, p50 122142ms)

한 번의 100ms가 전체 p50을 83ms 밀어 올렸습니다.

산술이 이상해 보이나요? 500개가 동시에 진행 중인 상태에서 루프가 초반에 100ms 얼어붙으면, 그 시점 이후 완료될 예정이던 요청 전부가 통째로 +100ms 밀립니다. 논블로킹 p50이 36ms고 전체가 87ms에 끝나니 대부분이 해당돼요.

느려진 게 그 요청 하나가 아니라는 점이 중요합니다. 500개가 다 같이 느려졌어요. 루프가 Thread.sleep에 붙잡혀 있는 동안 select()로 돌아가지 못하니, 그동안 준비된 연결들은 아무도 봐주지 않았습니다.

9장의 요청당 스레드 서버와 비교해보세요. 거기서는 느린 요청 하나가 스레드 하나만 잡아먹었습니다. 9장 풀 200 행을 떠올려보세요 — 나머지 199개 스레드는 멀쩡히 일했습니다. 여기서는 느린 요청 하나가 그 루프가 맡은 전부를 멈춥니다.

이게 WebFlux에서 JdbcTemplate을 부르면 안 되는 이유입니다. 정확히 이 구조예요. 그리고 실제 서비스에서는 루프가 몇 개 안 됩니다. 8코어 기계라면 Reactor Netty는 루프 8개예요. 그중 하나가 100ms 막히면 그 100ms 동안 처리 능력의 8분의 1이 사라지고, 그 루프에 붙은 연결들은 통째로 멈춥니다. 15장에서 이 장면을 WebFlux로 다시 봅니다.

③ 연결 2,000개 — 결과가 두 갈래로 갈린다

같은 코드로 연결 수만 2,000개로 올렸습니다. 그런데 여기서는 한 번 재고 표를 만들 수가 없었습니다.

15회 돌린 전체 소요를 정렬하면 이렇습니다.

126 127 141 157 157 167 189 | 2,069 3,992 4,016 4,047 5,935 5,970 | 30,045 30,084 빠른 쪽 7회 느린 쪽 6회 30초 안에 못 끝낸 2회

✅ 실측 (같은 환경, 2026-08. 15회 전량. 30초는 제가 건 안전장치라, 그 두 회는 시간 안에 2,000개를 다 처리하지 못했다는 뜻입니다.)

이봉분포입니다. 빠르면 130ms 언저리, 아니면 2~6초, 최악은 30초에도 못 끝냅니다. 같은 코드, 같은 조건인데요.

그리고 느린 쪽 값들을 보세요. 2.0초 / 4.0초 / 6.0초에 뭉쳐 있습니다. 경합이라면 지연이 연속적으로 퍼져야 하는데, 초 단위 눈금에 계단으로 붙어 있어요. 이건 TCP 재전송 타이머의 서명입니다.

원인을 확인해봤습니다.

$ sysctl kern.ipc.somaxconn kern.ipc.somaxconn: 128

코드는 server.bind(addr, 4096)으로 백로그 4,096을 요청하지만, macOS가 조용히 128로 깎습니다. 2,000개가 동시에 문을 두드리는데 단일 루프가 accept를 충분히 빨리 빼가지 못하면 백로그가 넘치고, 넘친 SYN은 그냥 버려집니다. 그럼 클라이언트가 1초·2초 뒤에 재전송해요. 그 재전송 주기가 위 계단값입니다.

원인을 이것 하나로 단정하지는 않습니다(추정). 클라이언트 2,000개를 같은 JVM에서 띄운 부하도 섞여 있어요. 다만 지연이 초 단위 눈금에 뭉친다는 것백로그 상한이 128이라는 것은 확인된 사실입니다.

핵심은 평균이 아니라 꼬리입니다. 이벤트 루프가 무너지는 건 대개 평균이 나빠져서가 아니라, 어느 순간 꼬리가 초 단위로 튀기 때문이에요.

이제 Netty가 왜 그룹을 나누는지 보이시나요?

EventLoopGroup boss = new NioEventLoopGroup(1); // accept 전담 EventLoopGroup worker = new NioEventLoopGroup(); // read/write 전담

accept를 전담시킨다는 건, 백로그를 비우는 일을 아무에게도 안 뺏긴다는 뜻입니다.

정확히 하자면 제 측정은 “그룹을 안 나눈 버전”이 아니라 루프가 아예 하나인 극단입니다. Netty에서 그룹 하나만 줘도 accept는 그중 한 루프에만 붙고 나머지는 read를 계속하거든요. 그래도 그룹을 나누는 건, accept 처리 속도를 워커의 I/O 부하와 독립적으로 통제하려는 겁니다.

7장의 270ms와 나란히 놓지 마세요

7장에서 C 루프가 8,000 연결을 270ms에 처리했죠. 방금 자바 루프는 2,000개에서 초 단위로 튀었고요. 두 숫자는 다른 걸 잽니다.

7장12장
연결 종류socketpair (프로세스 내부)진짜 TCP — connect·핸드셰이크·백로그 전부 있음
재는 것이벤트 5만 건 처리 시간연결 2,000건의 접속~응답 지연
연결 생성측정 (미리 다 만듦)측정 (동시 접속)

7장은 이벤트 처리량이고 12장은 접속 지연입니다. “자바 루프가 C보다 20배 느리다”로 읽으면 안 돼요. 7장이 비워둔 자리 — 연결을 만드는 비용 — 가 정확히 이 장에서 청구된 겁니다.

그 “누군가”의 이름

이 장을 열면서 11장의 질문을 가져왔죠. HttpClient.sendAsynccomplete()를 부르기까지 어느 스레드가 무엇을 하고 있었나.” 7장 이후로 이 책은 그를 계속 “누군가”라고만 불렀습니다. 이제 이름을 붙일 차례예요.

HttpClient로 요청 하나를 보내고, 살아있는 스레드 이름을 찍어봤습니다.

complete() 를 부른 스레드: ForkJoinPool.commonPool-worker-1 살아있는 스레드 중 HttpClient 것: HttpClient-1-SelectorManager HttpClient-1-Worker-0 HttpClient-1-Worker-1 HttpClient-1-Worker-2

✅ 실측 (같은 환경, 2026-08. 전체 소스: docs/book/code/async/Ch12HttpClientThreads.java)

HttpClient-1-SelectorManager. 이름 그대로 Selector를 관리하는 스레드고, 이 장에서 우리가 직접 짠 그 루프를 돌고 있습니다. 7장의 C 루프 → 12장의 자바 루프 → JDK HttpClient 안의 루프. 셋이 같은 물건이에요.

그리고 complete()를 부른 건 commonPool-worker-1이었습니다. 11장에서 “완료시킨 스레드가 콜백을 실행한다”고 했던 그 자리에, 이제 실제 이름이 들어갔습니다.

그래서 프레임워크가 파는 것

Netty와 WebFlux가 여러분 대신 해주는 일을 정리하면 이렇습니다.

직접 하면프레임워크가 해주는 것
Selector 루프를 손으로 짠다EventLoop가 대신 돈다
accept와 read가 한 루프에서 경합한다boss/worker 그룹 분리
루프 개수를 고민한다기본값 = 코어 × 2 (Reactor Netty는 max(4, 코어))
부분 읽기·버퍼 관리를 손으로ByteBuf와 파이프라인

하지만 팔지 않는 게 하나 있습니다. 루프를 막지 않을 책임이요. 그건 끝까지 여러분 몫입니다. ②에서 본 83ms가 그 값이고, 루프가 8개뿐인 실제 서버에서는 훨씬 아픕니다.

정리

  • Netty의 EventLoop를 벗기면 Selector 루프입니다. 7장에서 C로 만든 것과 구조가 같아요. 커널이 콜백을 부르지 않는다는 결론도 그대로입니다.
  • 실측: Selector 스레드 하나가 500 연결을 87ms에 처리했습니다. 자바에서도 이벤트 루프는 잘 돌 때 아주 잘 돕니다.
  • 실측: 핸들러 하나가 100ms를 자자 500개 전체의 p50이 36ms → 119ms로 밀렸습니다. 요청당 스레드였다면 그 요청 하나만 느렸을 텐데, 이벤트 루프에서는 루프가 맡은 전부가 멈춥니다.
  • 실측: 연결 2,000개 동시 접속은 결과가 두 갈래로 갈렸습니다. 15회 중 7회는 130ms대, 6회는 2~6초, 2회는 30초에도 못 끝냈어요. 느린 값들이 초 단위 눈금에 뭉치는 걸 보면 백로그 오버플로와 SYN 재전송으로 보입니다(추정, somaxconn은 128이었습니다). Netty가 accept를 전담 그룹에 맡기는 이유예요.
  • 11장의 “누군가”는 HttpClient-1-SelectorManager였습니다. 7장의 C 루프, 12장의 자바 루프, JDK HttpClient 내부 루프 — 셋이 같은 물건입니다.
  • 기본 루프 수는 코어 수 언저리입니다. Netty는 코어 × 2, Reactor Netty는 max(4, 코어). 루프 스레드는 자면 안 되니 많이 둘 이유가 없습니다.
  • 프레임워크가 팔지 않는 것 하나 — 루프를 막지 않을 책임. 끝까지 여러분 몫입니다.

생각해볼 질문

  1. 8코어 기계의 Reactor Netty는 루프가 8개입니다. 핸들러 하나가 1초 블로킹하면 그 1초 동안 처리 능력의 몇 퍼센트가 사라질까요? 그게 요청 1,000개 중 하나여도 그럴까요?
  2. “루프를 막지 마라”가 규칙이라면, 어쩔 수 없이 블로킹 라이브러리(JDBC 같은)를 써야 할 때는 어떻게 해야 할까요? 3장의 Node는 같은 문제를 어떻게 처리했나요?
  3. 3부에서 본 세 가지(요청당 스레드, CompletableFuture, 이벤트 루프)는 전부 “스택에 기대던 것”을 조금씩 포기했습니다. 스택 트레이스 말고 또 무엇이 스택 위에 얹혀 있었을까요?

3부는 여기서 끝납니다. 9장에서 요청당 스레드의 처리량 상한을 봤고, 10장에서 Future가 왜 부족했는지, 11장에서 CompletableFuture가 무엇을 더했는지, 12장에서 이벤트 루프가 프레임워크가 되면 어떤 모습인지 봤어요.

전부 잘 돌아가는 이야기였습니다. 4부는 반대입니다. 3번 질문이 그 시작이에요 — 실행 흐름이 스택을 떠나면서 함께 깨진 것들을 하나씩 봅니다.

13장. 사라진 로그 — ThreadLocal이 스레드를 갈아탈 때

Last updated on