Skip to Content

18장. 무엇을 언제 쓰나 — 결정표와 마이그레이션 판단

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

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

풀 크기 200은 성능 설정이 아니라 동시 실행 상한이었습니다. 가상 스레드로 옮기면 그 상한이 사라집니다 — 처리량은 그대로인 채로요.

면접 실전 질문: ① 가상 스레드로 바꾸면 처리량이 늘어나나요? ② 스레드 풀 크기를 없애면 무엇을 잃나요? ③ WebFlux를 쓰던 서비스를 가상 스레드로 되돌릴 판단 기준은 무엇인가요?


배경 — 16장이 남긴 하나

16장 끝에서 이렇게 예고했습니다. “4부의 넷 중 셋은 같은 처방으로 풀립니다. 전제를 되사오는 것 — … 딱 하나만은 스레드가 아무리 싸져도 그대로 남습니다.”

그 하나의 이름은 백프레셔입니다. 4장에서 이름을 붙였고, 16장에서는 큐를 두고 쟀죠. 이 장은 스레드를 두고 잽니다.

스토리 — 처리량은 그대로고, 사라지는 건 상한이다

요청 20,000건이 한꺼번에 도착합니다. 각 요청은 하류(용량 50짜리 커넥션 풀이라고 칩시다)를 20ms 점유해요. 이론상 최소 소요는 20,000 ÷ 50 × 20ms = 8,000ms입니다. 스레드를 어떻게 주든 이 벽은 못 넘어요.

여기서 병목은 하류입니다. 표를 보기 전에 이걸 먼저 정해두는 게 중요해요. 아래 결과는 “가상 스레드는 안 빠르다”가 아니라 “병목이 하류일 때는 스레드 모델이 처리량을 못 바꾼다”는 이야기니까요.

같은 워크로드를 다섯 가지 방식으로 처리합니다.

방식처리량p50p99동시 in-flight 최대힙 사용 최대(GC 전)
고정 풀 2001,895 req/s5,264 ms10,406 ms200개10 MB
고정 풀 20001,903 req/s5,250 ms10,401 ms2,000개11 MB
가상 스레드1,818 req/s5,518 ms10,916 ms19,900개53 MB
가상 + 입장 제한 2001,836 req/s5,418 ms10,801 ms200개10 MB
가상 + 즉시 거절2,020 req/s (200건만)69 ms99 ms200개4 MB

✅ 실측 (JDK 25 / Apple M1 8코어 / 램 16GB / macOS 26.5 / -Xmx2g, 2026-08. 3회 반복 시 처리량 1,801~1,910 req/s 범위. 지연은 20,000건이 t=0에 전부 도착했다는 전제로 t=0부터 측정. 전체 소스: docs/book/code/async/Ch18Choosing.java)

여기서 동시 in-flight는 스레드가 배정돼 살아 있는 요청 수입니다. 실제로 하류를 점유하는 건 다섯 방식 모두 언제나 50개예요. 그래서 처리량이 같은 겁니다. (19,900이 20,000이 아닌 건 마지막 백여 건이 제출될 즈음 앞엣것들이 이미 끝났기 때문입니다.)

처리량이 다 같습니다. 1,818부터 1,903까지, 반복 측정의 산포 안이에요. 스레드를 200개 주든 20,000개 주든 결과가 같은 이유는 명백합니다 — 병목이 스레드가 아니라 하류니까요. 9장에서 본 리틀의 법칙 그대로입니다. 하류 용량 50 ÷ 점유 20ms = 초당 2,500건이 천장이고, 실측 1,900은 그 **76%**예요. 나머지 24%는 Thread.sleep(20)이 실제로는 20ms보다 길게 자고 세마포어 인계에도 시간이 들기 때문입니다. 정확한 값이 아니라 벽의 존재를 보려고 만든 모형이니 이 정도면 됩니다.

1장 마지막에 이걸 물었죠 — 스레드 풀 크기를 200에서 2,000으로 늘리면 처리량은 10배가 될까요? 표 1행과 2행이 답입니다. 1,895 → 1,903. 0.4%예요.

이건 가상 스레드에 대한 가장 흔한 기대도 정면으로 반박합니다. “가상 스레드로 바꾸면 처리량이 는다”는 병목이 스레드 개수일 때만 참입니다. 대부분의 CRUD 서버에서 병목은 DB고, 그러면 아무것도 안 늘어나요.

그럼 뭐가 달라졌냐 — 동시 in-flight가 200에서 19,900으로 갔습니다. 그리고 힙이 5배가 됐죠.

여기서 이 책의 결론 하나가 나옵니다.

newFixedThreadPool(200)의 200은 성능 설정처럼 생겼지만, 실제로 하던 일은 동시 실행 수의 상한이었습니다. 하류에 동시에 200개 넘게 못 가게 막는 밸브요. 가상 스레드는 그 밸브를 뽑아버립니다.

정확히 하고 넘어갑시다. 고정 풀이 상한을 건 건 동시 실행 수뿐입니다. 큐에는 상한이 없었어요. newFixedThreadPool의 큐는 무한이라(16장) 나머지 19,800건은 그냥 큐에 쌓였습니다.

다만 p99 10.4초의 범인은 큐가 아닙니다. 큐가 아예 없는 3·4행도 p99가 10.9초예요. 지연을 정하는 건 하류 용량 50이고, 큐 정책은 그걸 못 바꿉니다. 아무도 안 버리는 한, 마지막 요청은 반드시 10초를 기다려요. 큐가 정하는 건 다른 겁니다 — 밀린 것들이 어디에 무엇으로 남아 있는가.

그래서 고정 풀은 백프레셔의 절반만 갖고 있었던 셈입니다. 하류는 보호했지만 자기 메모리는 아니었어요. 다만 이 실측만 보면 반대로 보입니다 — 큐에 쌓인 고정 풀이 10MB, 가상 스레드가 53MB니까요. 여기 작업 객체가 가볍기 때문입니다. 16장처럼 큐에 든 작업이 요청 데이터를 붙들고 있으면 그 큐가 곧 힙입니다(GC 후에도 180MB가 살아 있었죠). 무한 큐의 청구서는 여기가 아니라 거기서 나왔습니다.

이 실측에서 아무것도 안 터진 건 제가 하류를 Semaphore(50)으로 모델링했기 때문이기도 합니다. 실제 하류가 스스로를 지켜준 셈이죠. 하류가 그런 밸브를 안 갖고 있다면 — 커넥션 풀이 아니라 사내 API 서버라면 — 19,900개가 동시에 두드립니다.

밸브는 되찾을 수 있다. 다만 손으로.

네 번째 줄이 처방입니다. 제출 전에 밸브를 손으로 달았어요.

Semaphore gate = new Semaphore(200); // ← 되찾은 밸브 for (Request r : requests) { gate.acquire(); // 200개가 차면 여기서 막힌다 = 백프레셔 executor.submit(() -> { try { handle(r); } finally { gate.release(); } }); }

(소스에서는 즉시 거절과 코드를 공유하느라 tryAcquire(60초)로 돼 있습니다. 실질은 acquire()와 같아요.)

처리량은 그대로(1,836 req/s), 동시 in-flight는 200, 힙은 10MB. 가상 스레드의 코드 스타일을 지키면서 고정 풀의 밸브를 되찾았습니다. 치르는 값은 gate.acquire()에서 제출자가 막히는 것 — 16장에서 백프레셔를 정의한 바로 그 방식이에요.

다섯 번째 줄은 세 번째 운영점입니다. 밸브가 안 열리면 기다리지 않고 즉시 거절하면, 처리한 200건의 p99가 99ms로 떨어지고 힙은 4MB입니다. 대신 19,800건을 버렸어요. 재밌는 건 99%를 버렸는데 처리량(2,020 req/s)은 똑같다는 겁니다. 그 값은 하류가 정하니까요.

세 운영점을 나란히 놓으면 이렇습니다.

무엇이 무한한가p99값을 치르는 쪽
상한 없음동시 실행 수·메모리10.9초힙 (53MB)
입장 제한10.8초제출자의 시간
즉시 거절0.1초버려진 요청 99%

16장과 다른 점이 하나 있습니다. 거기선 상한을 걸자 지연이 3.6초 → 465ms로 떨어졌죠. 여기선 안 떨어집니다 — 20,000건이 이미 다 도착했고 아무도 안 버리니까요. 입장 제한이 산 건 지연이 아니라 동시 실행 수와 힙입니다. 지연까지 사려면 세 번째 줄, 버려야 해요.

공짜는 없고, 안 고르면 “상한 없음”이 기본값입니다. 가상 스레드에서는 특히 그래요. 예전엔 풀 크기를 적는 자리가 코드에 있었지만, 이제는 그 자리 자체가 없어졌거든요.

그럼 CPU는요? 거기선 밸브를 뽑아도 아무 일이 안 일어납니다. 좋은 뜻으로가 아니라요.

핵심 — 무엇을 언제 쓰나

CPU 바운드에는 여전히 이득이 없다

4장에서 가상 스레드의 한계로 “CPU 바운드에는 이득이 없다”를 꼽았습니다. 소수 세기 2,000건을 세 가지 방식으로 돌립니다.

방식중앙값범위
고정 풀 = 코어 수(8)142 ms125~150
고정 풀 200142 ms131~165
가상 스레드148 ms137~169

✅ 실측 (같은 환경, 2026-08, JIT 예열 200회 후. 프로세스 1회 실행이 조건당 1샘플이라 프로세스를 10번 띄워 중앙값을 냈습니다)

셋 다 같은 자리에 있습니다. 가상 스레드가 4%쯤 느린데, 캐리어 위의 스케줄링 오버헤드로 보입니다(추정). 어느 쪽이든 스레드를 25배 늘려서 얻은 게 0이라는 결론은 같아요.

17장에서 본 이유 그대로입니다 — 가상 스레드가 아무리 많아도 실제로 도는 건 코어 수만큼의 캐리어예요. 계산은 기다림이 아니니까 언마운트할 일도 없고, 그래서 겹쳐 돌 수가 없습니다.

기억해둘 문장 하나: 가상 스레드는 기다림을 싸게 만들지, 계산을 빠르게 만들지 않습니다.

세마포어로 되는데 왜 리액티브인가

방금 Semaphore 다섯 줄로 백프레셔를 되찾아 보였습니다. 그럼 리액티브의 백프레셔는 뭐가 다를까요.

세마포어는 입구 하나를 잠급니다. 리액티브의 request(n)은 소비자의 여유를 사슬 전체로 거슬러 올려 보내요. 단계가 하나면 둘이 같습니다. 그런데 소스 → 변환 → 배치 → 싱크처럼 여러 단이면, 세마포어는 자기 앞단만 세울 뿐 중간 단이 밀리는 걸 못 봅니다. 16장에서 “압력은 없어지는 게 아니라 위로 밀려 올라간다”고 했던 그 사슬 이야기예요.

그래서 소스가 Kafka나 소켓처럼 스스로 pull을 늦출 수 있는 경우에 차이가 납니다. 압력이 사슬 끝까지 올라가 소비 속도가 생산 속도를 실제로 제어하거든요.

결정표

이제 책 전체를 한 장에 담습니다. 먼저 물어야 할 건 항상 같아요 — 무엇이 병목인가.

상황선택
병목이 하류(DB·외부 API)다아무거나. 대신 입장 제한을 걸어라스레드 모델을 바꿔도 처리량은 안 변합니다 (이 장)
병목이 스레드 개수다 (동시 연결 수천 이상)가상 스레드천장이 4,068 → 140만 이상 (1·17장)
계산이 병목이다코어 수만큼의 고정 풀늘려도 안 빨라지고, 늘리면 8장의 세금만 붙습니다 (이 장·8장)
하류 여러 곳을 동시에 부른다가상 스레드에 셋 다 던지고 기다려라어차피 제일 느린 호출을 기다리므로 소요가 안 줄어듭니다(10장). 대신 스택 트레이스가 남아요(4장)
결과를 변환·조합해야 한다CompletableFuture + 전용 풀조합이 목적일 때. commonPool은 피하세요 (11·14장)
생산자가 소비자보다 빠르다 (스트리밍·파이프라인)리액티브압력을 사슬 전체로 올려보냅니다 (4·16장)
흐름 조립이 복잡하다 (retry·window·merge)리액티브연산자로 표현됩니다. 블로킹 코드면 손으로 짜야 해요 (4장)
그 외 대부분의 CRUD 서버블로킹 스타일 + 가상 스레드스레드를 안 갈아타면 스택 트레이스와 ThreadLocal이 그대로 삽니다 (4·13장). 갈아타는 순간 13장이 그대로 돌아와요

각 선택이 가져오는 청구서도 한자리에 모아둡시다.

선택어디서 봤나
플랫폼 스레드 풀개수 천장 4,068개, 자는 스레드가 깨우기 비용 22.5배1·8장
가상 스레드입장 제한을 직접 걸어야 함, ThreadLocal 10개면 값 두 배, 네이티브 프레임 pin이 장·17장
CompletableFuture스택 트레이스에 내 코드 없음, commonPool 오염4·11·14장
리액티브루프 블로킹 한 줄이 전체를 멈춤, 컨텍스트 유실, 학습·디버깅 비용4·12·13·15장

뒤집을 것인가, 둘 것인가

이미 WebFlux로 짜인 서비스를 가상 스레드로 되돌릴 가치가 있을까요. 이건 성능 질문이 아니라 왜 리액티브를 골랐었나 하는 질문입니다.

“스레드를 아끼려고” 골랐다면 — 되돌릴 이유가 생겼습니다. 그 이유가 4,068개였는데 이제 140만 개니까요(17장). 되돌리면 스택 트레이스와 ThreadLocal이 돌아오고, 팀의 디버깅 비용이 내려갑니다.

백프레셔나 선언적 조합 때문에 골랐다면 — 두세요. 세마포어는 입구 하나만 잠그지, 사슬 전체를 제어하지 못합니다.

그래서 새로 짠다면, 기본값은 블로킹 스타일 + 가상 스레드입니다. 리액티브는 위 두 이유 중 하나가 실제로 있을 때 고르세요. “빠를 것 같아서”는 이유가 아닙니다 — 이 장의 첫 표가 그걸 말합니다.

옮기기로 했다면 순서는 이렇습니다.

  1. 입장 제한부터 건다. 풀 크기가 하던 일을 명시적으로 옮겨 적는 겁니다. 예전 풀 크기가 출발점이에요.
  2. 네이티브 프레임을 쓰는 라이브러리를 찾는다. JNI 기반 드라이버·압축·암호화. jdk.VirtualThreadPinned가 0건이어도 무죄가 아닙니다(17장).
  3. ThreadLocal 개수를 센다. TransactionSynchronizationManager 하나가 여섯 개예요(17장).
  4. 타임아웃을 확인한다. 스레드가 싸다는 건 새는 요청이 오래 살아남는다는 뜻이기도 합니다(16장).

정리

  • 처리량은 스레드 모델이 정하지 않습니다. 실측: 같은 워크로드에서 고정 풀 200 / 고정 풀 2000 / 가상 스레드가 1,818~1,903 req/s로 사실상 동일. 병목이 하류였기 때문입니다. 1장의 질문(“풀을 200에서 2,000으로 늘리면 10배?”)에 대한 답은 0.4%였습니다.
  • 풀 크기는 성능 설정이 아니라 동시 실행 상한이었습니다. 실측: 동시 in-flight 최대가 200 → 19,900개, 힙 10MB → 53MB. 처리량은 그대로예요. 가상 스레드는 그 밸브를 뽑습니다.
  • 다만 p99를 정하는 건 큐가 아니라 하류입니다. 큐가 없는 방식도 p99가 10.9초였어요. 큐 정책이 정하는 건 지연이 아니라 밀린 것들이 어디에 무엇으로 남아 있는가입니다.
  • 밸브는 손으로 되찾을 수 있습니다. 실측: Semaphore(200)을 제출 앞에 두면 처리량 1,836 req/s를 유지하면서 동시 in-flight 200, 힙 10MB. 다만 지연은 안 삽니다 — 지연까지 사려면 버려야 하고, 즉시 거절은 p99를 99ms로 내리는 대신 99%를 버렸습니다.
  • CPU 바운드에는 이득이 없습니다. 실측(n=10 중앙값): 142 / 142 / 148ms. 가상 스레드가 오히려 4% 느려요. 스레드를 25배 늘려서 얻은 게 0입니다.
  • 결정은 항상 같은 질문에서 시작합니다. 무엇이 병목인가. 그 답이 하류라면 스레드 모델을 바꿔봐야 숫자가 안 움직입니다.

생각해볼 질문

  1. 여러분의 서비스에서 newFixedThreadPool의 숫자는 누가 언제 정했나요? 그 숫자가 지금 무엇을 막고 있는지 설명할 수 있나요?
  2. 가상 스레드로 옮기면서 입장 제한을 안 걸었다면, 장애는 어디서 먼저 보일까요? 애플리케이션 로그일까요, 하류의 지표일까요?
  3. 4장에서 Loom이 되물었던 질문으로 돌아가봅시다. “스레드가 싸지면 코드를 뒤집을 이유가 남나요?” 이제 답할 수 있나요? 답에 조건이 몇 개 붙나요?

여기까지가 답입니다. 그런데 답을 다 적고 나니 하나가 남네요. 이 책이 열여덟 장 내내 재온 건 사실 스레드가 아니었습니다. 기다림이었어요.

닫는 글 · 다시, 기다림 앞에서

Last updated on