스레드 풀 크기, 그 공식은 왜 늘 틀리는가
"코어 수 + 1" 이라는 공식의 함정
스레드 풀 크기를 얼마로 잡아야 하냐는 질문에 흔히 따라오는 답이 있습니다. CPU 바운드 작업이면 코어 수 + 1, I/O 바운드 작업이면 코어 수 × (1 + 대기시간/계산시간). 이 공식들은 『Java Concurrency in Practice』 같은 책에 실린 뒤로 거의 성경처럼 인용됩니다.
문제는 실무 코드가 CPU 바운드도 I/O 바운드도 아닌, 이 둘이 섞인 작업이라는 겁니다. 요청 하나 안에 DB 조회(I/O), JSON 직렬화(CPU), 외부 API 호출(I/O), 비즈니스 로직 계산(CPU)이 다 들어있습니다. 대기시간과 계산시간의 비율은 요청 종류마다 다르고, 트래픽 패턴에 따라 시시각각 바뀝니다. 공식에 넣을 숫자 자체가 안정적이지 않다는 뜻입니다.
Executor 로 스레드 풀 다루기
자바에서 스레드 풀은 보통 ExecutorService 로 씁니다.
ExecutorService fixedPool = Executors.newFixedThreadPool(50);
ExecutorService cachedPool = Executors.newCachedThreadPool();
fixedPool.submit(() -> {
processOrder(orderId);
});newFixedThreadPool 은 스레드 수를 고정하고, 그 이상 들어오는 작업은 무제한 큐에 쌓입니다. newCachedThreadPool 은 필요할 때마다 스레드를 만들고 60초간 안 쓰면 회수하는데, 트래픽이 갑자기 튀면 스레드가 통제 없이 늘어날 수 있어 운영 환경에서는 잘 쓰지 않습니다. 실무에서는 대개 ThreadPoolExecutor 를 직접 생성해서 큐 크기와 거부 정책까지 명시합니다.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
20, // corePoolSize
100, // maximumPoolSize
60L, TimeUnit.SECONDS, // 유휴 스레드 회수 시간
new LinkedBlockingQueue<>(500), // 대기 큐 크기
new ThreadPoolExecutor.CallerRunsPolicy() // 큐까지 찼을 때 정책
);이 설정 하나하나가 실제로는 튜닝 대상입니다. 큐를 무제한으로 두면 메모리 부족으로 서버가 죽고, 큐를 너무 작게 두면 정상 트래픽도 거부됩니다. CallerRunsPolicy 는 호출한 스레드가 직접 작업을 실행하게 해서 자연스럽게 속도를 늦추는 백프레셔 역할을 하지만, 이 역시 상황에 따라 적절한 선택이 아닐 수 있습니다.
숫자를 늘려도 처리량이 늘지 않는 지점
스레드 풀 크기를 200 에서 500 으로 늘렸는데 처리량이 오히려 떨어지는 경우를 실무에서 종종 봅니다. 원인은 대개 둘 중 하나입니다.
첫째, 컨텍스트 스위칭 비용이 늘어난 작업량을 넘어섭니다. 코어 수는 그대로인데 스레드만 늘리면, 스케줄러가 스레드를 돌아가며 실행하는 오버헤드가 커집니다.
둘째, 병목이 애초에 스레드 풀이 아니라 다른 곳에 있습니다. DB 커넥션 풀이 20개인데 애플리케이션 스레드를 500개로 늘려봤자, 그 이상의 요청은 결국 DB 커넥션을 기다리며 블록됩니다. 스레드는 늘었지만 실질적인 동시 처리량은 커넥션 풀 크기에 여전히 묶여 있는 겁니다.
flowchart LR A["요청 500개 동시 도착"] --> B["애플리케이션 스레드 풀 500"] B --> C["DB 커넥션 풀 20"] C --> D["실제 동시 처리량은 20"]
이 그림이 보여주는 게 실무에서 가장 흔한 오진입니다. "느리니까 스레드를 늘리자"는 반응은 병목이 스레드 자체가 아니라면 아무 효과가 없거나 오히려 상황을 악화시킵니다.
이 장이 왜 필요했는가
가상 스레드를 다루기 전에 플랫폼 스레드 풀의 한계를 짚은 이유가 있습니다. 가상 스레드는 "스레드 풀 크기를 신경 쓸 필요가 없는" 세계를 약속합니다. 실제로 상당 부분 맞는 얘기입니다 — I/O 바운드 작업이라면요. 하지만 위에서 본 DB 커넥션 풀 같은 다른 병목은 가상 스레드로 바꾼다고 사라지지 않습니다. 오히려 가상 스레드로 동시 요청 수가 늘어나면 그 다른 병목이 더 빨리, 더 세게 드러납니다. 이 관계는 "가상 스레드를 쓰지 말아야 할 때" 장에서 구체적인 사례로 다시 다룹니다.