9장. 연결마다 스레드를 주던 시절 — Thread와 스레드 풀

만화로 보는 요약 — 먼저 읽어보세요
풀 50에 100ms 작업. 계산상 상한은 초당 500건이고, 실제로 재보니 466건이었습니다. 이 7% 차이 안에 이 장의 전부가 들어 있어요.
면접 실전 질문: ① 스레드 풀 크기는 어떻게 정하나요? ② 풀 크기를 늘리면 지연도 줄어드나요? ③ 연결당 스레드와 요청당 스레드는 무엇이 다른가요?
배경 — 이제 자바로 올라간다
2부에서 커널을 봤습니다. read()가 잠드는 순간(5장), 재우지 말라는 플래그(6장), 그 위에 얹힌 유저 공간 루프(7장), 그리고 그 모든 비용의 근원이 OS 스레드 개수라는 것(8장)까지요.
3부는 같은 기계를 자바 API의 층에서 다시 봅니다. 8장 마지막 질문이 출발점이에요.
“자바에서 ‘연결마다 스레드 하나’를 처음 쓴 코드는 어떻게 생겼을까요? 그리고 그 코드는 이 장의 어느 숫자에서 멈출까요?”
먼저 용어 하나를 정리하고 갑시다. 1장에서 **연결당 스레드(thread-per-connection)**라는 이름을 붙였는데, 요즘 웹서버는 정확히는 다르게 동작합니다.
- 연결당 스레드 — 연결이 열려 있는 동안 스레드 하나를 점유합니다. 초창기 아파치 방식이에요.
- 요청당 스레드(thread-per-request) — 연결은 재사용하고, 요청을 처리하는 동안만 풀에서 스레드를 빌립니다. 오늘날 톰캣·서블릿 방식입니다.
요청당 스레드가 낫습니다. Keep-Alive로 연결을 열어둔 채 아무 요청도 안 보내는 클라이언트가 스레드를 잡아먹지 않으니까요. 하지만 “대기를 스레드로 표현한다”는 구조는 똑같습니다. 요청 하나가 다운스트림을 100ms 기다리면, 그 100ms 동안 스레드 하나가 잠들어 있어요.
그래서 이 장의 질문은 이겁니다. 그 구조의 처리량 상한은 얼마인가?
스토리 — 리틀의 법칙, 그리고 풀 크기라는 오래된 논쟁
답은 사실 계산으로 나옵니다.
처리량 = 동시 처리 개수 ÷ 건당 서비스 시간
큐잉 이론에서 **리틀의 법칙(Little’s Law)**이라 부르는 관계예요. 요청당 스레드 서버에 대입하면 이렇게 됩니다.
처리량 상한 = 풀 크기 ÷ 요청당 서비스 시간
분모가 지연이 아니라 서비스 시간이라는 게 중요합니다. 스레드를 붙잡고 있는 시간만 세요. 큐에서 순서를 기다린 시간은 스레드를 안 잡고 있으니 여기 안 들어갑니다. 잠시 뒤 나올 614ms를 이 자리에 넣으면 안 돼요.
풀이 200이고 서비스 시간이 100ms면 초당 2,000건이 천장입니다. CPU가 얼마나 남아도는지는 상관없습니다. 1장의 그 화면이 이 공식의 다른 얼굴이에요. CPU 3%인데 요청을 못 받던 그 장면이요.
손잡이는 둘입니다. 스레드를 늘리거나, 서비스 시간을 줄이거나. 그런데 그 100ms는 대개 남의 서버가 정합니다. 그래서 내 손에 남는 건 스레드 개수 하나예요.
여기서 실무의 오래된 논쟁이 시작됩니다. “그럼 풀을 크게 잡으면 되잖아?”
전통적인 처방은 이렇습니다.
- CPU 바운드 작업 — 풀 크기 ≈ 코어 수. 더 늘려봐야 컨텍스트 스위치만 늘어납니다.
- I/O 바운드 작업 — 코어 수보다 훨씬 크게. 대부분 자고 있으니까요.
📄 문서 기반 — 브라이언 괴츠 『Java Concurrency in Practice』의 N × U × (1 + W/C) 공식이 표준 인용처입니다.
두 번째가 문제입니다. “훨씬 크게”가 얼마나요? 톰캣 기본값이 200인 데는 이유가 있고, 1장에서 본 4,068개 천장도 있습니다. 그리고 8장에서 확인했듯 자는 스레드도 값을 치릅니다.
그래서 직접 재보기로 했습니다.
핵심 — 진짜 서버를 세워 재보기
실제 TCP 서버를 띄웠습니다. 요청 하나가 오면 100ms를 자고(다운스트림 호출 흉내) 응답합니다. 동시에 500개 요청을 던지고 풀 크기를 바꿔가며 쟀어요.
측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch09ThreadPerRequest.java에 있습니다.
ExecutorService workers = Executors.newFixedThreadPool(pool); // ← 이 크기를 바꾼다
Socket s = ss.accept(); // accept 는 별도 스레드가
workers.submit(() -> handle(s)); // 처리는 풀의 스레드가 ← 여기서 두 모델이 갈린다
static void handle(Socket s) {
try (Socket sock = s) {
sock.getInputStream().read();
Thread.sleep(100); // ← 다운스트림 대기. 스레드는 여기서 잠든다
sock.getOutputStream().write('y');
} catch (Exception ignored) {}
}이 서버는 구조상 요청당 스레드입니다. accept()는 별도 스레드가 하고, 풀의 스레드는 요청을 처리하는 동안만 붙잡히거든요.
다만 정직하게 적어둡시다. 이번 부하는 연결마다 요청 하나라, 두 모델의 차이가 숫자로 갈리지 않습니다. 그 차이는 Keep-Alive 연결이 idle로 남을 때 드러나는데, 이번 측정은 그걸 재지 않았어요. 여기서 재는 건 두 모델의 공통 구조 하나입니다 — 대기하는 동안 스레드가 잠든다는 것.
결과
| 워커 | 전체 소요 | 처리량 | p50 지연 | p99 지연 | 플랫폼 스레드 |
|---|---|---|---|---|---|
| 풀 50 | 1,074 ms | 466 req/s | 614 ms | 1,055 ms | 68 |
| 풀 200 | 345 ms | 1,449 req/s | 214 ms | 317 ms | 218 |
| 풀 1,000 | 201 ms | 2,488 req/s | 142 ms | 173 ms | 518 |
| 가상 스레드 | 215 ms | 2,326 req/s | 132 ms | 147 ms | 19 |
✅ 실측 (같은 환경, 2026-08. 동시 요청 500개, 서비스 시간 100ms.)
세 가지 단서를 붙입니다.
첫째, 회차 편차. 6회 재실행하면 풀 50은 454468 req/s로 거의 안 흔들리는데, 풀 1,000은 1,7002,600, 가상은 2,300~3,400을 오갑니다. 아래에서 풀 1,000과 가상의 순위는 주장하지 않습니다 — 편차 안이에요. 재미있는 건 이겁니다: 천장에 걸린 조건은 안정적이고, 천장에서 자유로운 조건은 노이즈가 큽니다.
둘째, 측정의 바닥. 클라이언트도 같은 JVM에서 돕니다. 작업 시간을 0으로 두고 재보니 500건에 59ms — 이게 측정 자체의 바닥이에요. 아래 숫자는 그 위에 얹힌 값입니다.
셋째, 플랫폼 스레드 열의 정체. 8장과 같은 방법(Thread.getAllStackTraces())으로 센 JVM 전체 플랫폼 스레드입니다. 베이스라인 18개(메인·acceptor·클라이언트 캐리어·JVM 내부)가 모든 행에 깔려 있어요. 68=50+18, 218=200+18, 518=500+18, 19=18+1입니다.
리틀의 법칙이 눈에 보인다
풀 50 행의 p50이 614ms입니다. 작업 자체는 100ms인데요.
계산해보면 정확히 맞습니다. 요청 500개를 50개씩 나눠 처리하니 10웨이브가 필요하고, 중앙값에 해당하는 요청(정렬해서 250번째)은 5웨이브를 기다린 뒤 자기 차례가 옵니다. 500ms 대기 + 100ms 작업 = 600ms. 측정값 614ms와 거의 같아요.
p99도 맞습니다. 1,055ms — 마지막 웨이브에 실린 요청이에요. 10웨이브 × 100ms = 1,000ms.
작업 시간을 넘는 부분은 전부 큐에서 기다린 시간입니다. 풀 200에서는 214ms(대기 114ms), 풀 1,000에서는 142ms(대기 42ms)로 줄어듭니다.
처리량도 공식대로입니다. 풀 50이면 상한이 50 ÷ 0.1초 = 초당 500건인데, 측정값이 466건이에요. 연결 수립 같은 부대 비용을 감안하면 상한에 거의 닿았습니다.
풀 200에서 1,449 req/s가 나온 건 상한(2,000)보다 낮습니다. 500개 요청이 2.5웨이브밖에 안 돼서 마지막 웨이브가 덜 찬 탓이에요 — 정상 상태(steady state)가 아니라 짧은 버스트를 잰 값입니다. 상한은 지속 부하에서 나오는 숫자입니다.
그런데 풀 1,000을 보세요
풀을 1,000으로 키우니 처리량이 2,488 req/s까지 올랐습니다. “그럼 크게 잡으면 되네?”
플랫폼 스레드 열을 보세요. 518개입니다. (풀 1,000이라고 스레드 1,000개가 뜨는 게 아닙니다. newFixedThreadPool은 일이 올 때 만들어요. 동시 요청이 500이라 500개만 생겼고, 거기에 베이스라인 18을 더한 값입니다.)
참고로 풀 1,000 행의 201ms 중 100ms는 작업이고, 나머지 100ms 남짓은 스레드 500개를 만들고 연결을 수립하는 비용입니다. 작업 시간을 0으로 두고 재보니 132ms가 나왔거든요(가상 스레드는 같은 조건에서 59ms). 스레드를 만드는 값을 실제로 문 셈이에요.
1장에서 이 기계의 천장이 4,068개였죠. 아직 여유가 있어 보이지만, 이건 동시 요청이 500개일 때 얘기입니다. 동시 요청이 5,000개인 서비스라면 풀 1,000으로도 부족하고, 풀 5,000은 만들 수는 있어도 8장에서 본 세금을 물게 됩니다.
그리고 이게 요청당 스레드 모델의 한계입니다. 내 손에 남은 손잡이가 스레드 개수 하나인데, 그 손잡이에 커널이 정한 천장이 있어요.
마지막 행이 3부의 예고편입니다
가상 스레드 행을 보세요.
- 처리량은 풀 1,000과 거의 같습니다 (2,326 vs 2,488 req/s)
- p99 지연은 오히려 더 낫습니다 (147ms vs 173ms)
- 그런데 플랫폼 스레드는 19개입니다. 518개가 아니라요.
워커용 OS 스레드가 아예 안 만들어졌습니다. JVM 전체 플랫폼 스레드가 518개에서 19개로 줄었고, 그 19개는 가상 스레드를 띄우기 전부터 있던 것들이에요. 8장에서 “대기를 OS 스레드로 표현하지 않기 때문”이라고 한 그 결론이, 여기서는 실제 서버의 처리량 표로 나타난 거예요.
코드는 한 줄만 다릅니다.
Executors.newFixedThreadPool(1000) // 플랫폼 스레드 518개
Executors.newVirtualThreadPerTaskExecutor() // 플랫폼 스레드 19개요청당 스레드 모델을 버린 게 아닙니다. 여전히 요청 하나에 스레드 하나예요. 바꾼 건 그 스레드가 OS 스레드가 아니라는 것뿐입니다. 17장에서 이 기계를 뜯어봅니다.
이 모델이 무너지는 자리
공정하게 적어둡시다. 위 표는 요청당 스레드 모델이 잘 도는 구간을 보여줍니다. 무너지는 자리는 따로 있어요.
- 동시 요청이 풀 크기를 크게 넘을 때. 큐가 쌓이고 p50이 작업 시간의 몇 배가 됩니다(풀 50 행의 614ms).
- 다운스트림이 느려질 때. 100ms가 1초가 되면 같은 풀의 처리량이 10분의 1이 됩니다. 내 서버는 멀쩡한데 처리량만 떨어져요.
- 풀을 키워 대응하려 할 때. 1장의 4,068개 천장과 8장의 세금이 기다립니다.
세 번째가 이 책의 주제이고, 첫 번째와 두 번째는 16장(타임아웃과 백프레셔)에서 다룹니다.
정리
- 요청당 스레드(thread-per-request)는 연결당 스레드보다 낫지만 구조는 같습니다. 대기하는 동안 스레드 하나가 잠들어 있어요.
- 처리량 상한 = 풀 크기 ÷ 요청당 서비스 시간. 분모는 지연이 아니라 스레드를 붙잡고 있는 시간입니다. 풀 50에 100ms면 계산 상한 500 req/s, 측정값 466 req/s.
- 작업 시간을 넘는 지연은 전부 큐 대기입니다. 풀 50에서 p50이 614ms였는데, 그중 500ms가 순서를 기다린 시간이었어요(500개를 50개씩 10웨이브).
- 풀을 키우면 처리량이 오르지만, 내 손에 있는 손잡이가 그것뿐입니다. 풀 1,000은 플랫폼 스레드 518개를 썼고, 그 위에는 1장의 4,068개 천장과 8장의 세금이 있습니다.
- 가상 스레드는 같은 처리량을 플랫폼 스레드 19개로 냈습니다. p99는 오히려 더 좋았고요. 모델을 바꾼 게 아니라 스레드의 정체를 바꾼 겁니다.
생각해볼 질문
- 이 장의 실험은 100ms가 통째로 대기인 작업이었습니다. “코어 수 × 2”(이 기계면 16)를 풀 크기로 잡았다면 처리량이 얼마쯤 나왔을까요? 그 처방은 어떤 작업에서 맞는 처방일까요?
- 다운스트림이 100ms에서 1초로 느려지면, 같은 풀 크기에서 처리량은 어떻게 될까요? 그때 스레드를 늘리는 게 답일까요?
- 위 서버는 요청 하나가 다운스트림을 한 번 부릅니다. 세 곳을 순서대로 불러야 한다면 스레드는 몇 번 잠들까요? 그 셋을 동시에 부르려면 코드가 어떻게 생겨야 할까요?
3번이 다음 장의 질문입니다. 자바가 그 답으로 내놓은 첫 도구가 Future였어요. 그런데 Future에는 이상한 구멍이 하나 있습니다 — 결과를 꺼내려면 결국 누군가 기다려야 합니다.