지난 포스팅에서는 무분별한 락(Lock) 사용이 불러오는 참사인 교착 상태(Deadlock)에 대해 알아보았습니다. 동시성 제어의 또 다른 핵심은 ‘스레드를 얼마나 많이 만들 것인가’입니다.
사용자의 요청이 들어올 때마다 새로운 스레드를 무한정 생성한다면 어떻게 될까요? 스레드 생성과 소멸에 드는 CPU 오버헤드는 엄청나며, 결국 메모리 고갈(OutOfMemory)이나 잦은 컨텍스트 스위칭(Context Switching)으로 인해 서버는 완전히 뻗어버리고 맙니다. 이를 방지하기 위해 미리 일정 개수의 스레드를 만들어두고 재사용하는 스레드 풀(Thread Pool)을 사용합니다. 그렇다면 과연 ‘몇 개’의 스레드를 만들어 두는 것이 가장 적절할까요? 이번 글에서는 감이 아닌 공식에 의한 스레드 풀 사이즈 산정법을 알아봅니다.
1. 작업의 성격 파악하기: CPU-Bound vs I/O-Bound
적정 스레드 풀 크기를 구하기 위해서는 먼저 애플리케이션이 주로 수행하는 작업의 성격을 정확히 파악해야 합니다. 작업은 크게 두 가지로 나뉩니다.
- CPU-Bound 작업: 복잡한 암호화 연산, 동영상 인코딩 등 CPU 연산이 주를 이루는 작업입니다. 대기 시간(Wait Time) 없이 스레드가 CPU를 지속적으로 사용합니다.
- I/O-Bound 작업: 외부 시스템의 응답을 기다리는 시간이 긴 작업입니다. 데이터베이스에서 대량의 쿼리 결과 리스트를 조회해와서 JSON 파일 포맷으로 변환 및 저장하거나, 외부 오픈 API를 호출하고 응답을 기다리는 작업 등이 대표적입니다.
2. 브라이언 고츠(Brian Goetz)의 스레드 풀 적정 사이즈 공식
Java 병렬 프로그래밍의 대가인 브라이언 고츠는 그의 저서에서 작업 성격에 따른 명확한 스레드 풀 크기 산정 공식을 제시했습니다.
- CPU-Bound 작업의 경우:
스레드 수 = CPU 코어 수 + 1CPU가 쉬지 않고 일해야 하므로, 물리적인 코어 수보다 스레드가 많아 봐야 컨텍스트 스위칭(Context Switching) 비용만 낭비될 뿐입니다. 결함을 대비한 여분의 1개를 추가하는 것이 가장 이상적입니다. - I/O-Bound 작업의 경우:
스레드 수 = CPU 코어 수 * (1 + 대기 시간 / 서비스 시간)엔터프라이즈 환경의 Java 웹 애플리케이션(Spring Boot 등)은 대부분 I/O-Bound 작업입니다. DB 쿼리나 파일 I/O 처리를 위해 응답을 기다리는(Wait) 동안 CPU는 놀게 됩니다. 따라서 CPU 코어 수보다 훨씬 넉넉한 스레드를 할당하여, 한 스레드가 대기 상태일 때 다른 스레드가 CPU를 점유하도록 해야 합니다.
3. 스레드 풀 크기가 잘못되었을 때 발생하는 치명적 문제
단순히 ‘많을수록 좋겠지’라는 생각으로 스레드 풀을 수백, 수천 개로 설정하면 심각한 부작용이 발생합니다.
- 풀 사이즈가 너무 클 때 (과할당): 스레드 자체도 객체이므로 JVM 메모리를 차지합니다. 수많은 스레드가 한정된 CPU 자원을 얻기 위해 경쟁하면서 컨텍스트 스위칭 오버헤드가 극심해지고, 정작 실제 작업 처리는 못 하는 ‘스레드 스래싱(Thrashing)’ 현상이 발생해 전체 성능이 수직 하락합니다.
- 풀 사이즈가 너무 작을 때 (과소할당): 서버 리소스(CPU, 메모리)는 널널하게 남아도는데, 사용자의 요청을 처리할 스레드가 부족해 모든 요청이 큐(Queue)에 쌓이며 타임아웃(Timeout)이 대량 발생합니다.
4. 리틀의 법칙 (Little’s Law)과 실무 적용
실무에서는 리틀의 법칙을 활용하여 성능 테스트(Load Testing) 결과를 기반으로 튜닝합니다.
L (동시 사용자 수/처리량) = λ (초당 요청 수) * W (응답 시간)
TPS(초당 트랜잭션 처리량) 목표치가 100이고, 평균 DB 처리 및 응답 시간이 0.5초(500ms)라면, 스레드 풀의 적정 크기는 100 * 0.5 = 50개로 산정해 볼 수 있습니다. 이를 기준점으로 삼아 nGrinder나 JMeter 같은 부하 테스트 툴을 이용해 부하를 주면서, CPU 사용률이 70~80% 선을 유지하는 최적의 스레드 개수를 튜닝해 나가는 것이 정석입니다.
결론: 마법의 숫자는 없다, 모니터링이 답이다
인터넷에 떠도는 “Tomcat 기본값인 200개가 좋다”라는 말은 맹신해선 안 됩니다. 시스템마다 처리하는 로직의 I/O 대기 시간 비율이 완전히 다르기 때문입니다. 결국 공식을 통해 도출한 가설을 바탕으로 APM(성능 모니터링 툴)을 통해 지속적으로 튜닝하는 것만이 정답입니다.
스레드의 효율적인 관리에 대해 알아보았으니, 다음 포스팅에서는 멀티 스레드 환경에서 안전하고 빠르게 데이터를 다루기 위한 자바의 핵심 컬렉션, ‘ConcurrentHashMap과 동기화(Synchronization) 메커니즘의 성능 차이’에 대해 분석해 보겠습니다.