Skip to Content

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 지연플랫폼 스레드
풀 501,074 ms466 req/s614 ms1,055 ms68
풀 200345 ms1,449 req/s214 ms317 ms218
풀 1,000201 ms2,488 req/s142 ms173 ms518
가상 스레드215 ms2,326 req/s132 ms147 ms19

✅ 실측 (같은 환경, 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는 오히려 더 좋았고요. 모델을 바꾼 게 아니라 스레드의 정체를 바꾼 겁니다.

생각해볼 질문

  1. 이 장의 실험은 100ms가 통째로 대기인 작업이었습니다. “코어 수 × 2”(이 기계면 16)를 풀 크기로 잡았다면 처리량이 얼마쯤 나왔을까요? 그 처방은 어떤 작업에서 맞는 처방일까요?
  2. 다운스트림이 100ms에서 1초로 느려지면, 같은 풀 크기에서 처리량은 어떻게 될까요? 그때 스레드를 늘리는 게 답일까요?
  3. 위 서버는 요청 하나가 다운스트림을 한 번 부릅니다. 세 곳을 순서대로 불러야 한다면 스레드는 몇 번 잠들까요? 그 셋을 동시에 부르려면 코드가 어떻게 생겨야 할까요?

3번이 다음 장의 질문입니다. 자바가 그 답으로 내놓은 첫 도구가 Future였어요. 그런데 Future에는 이상한 구멍이 하나 있습니다 — 결과를 꺼내려면 결국 누군가 기다려야 합니다.

10장. Future의 한계 — 결과를 꺼내려면 누군가는 기다려야 한다

Last updated on