Thread Pool Size를 CPU 코어 수 × 2로 잡으면 될까?
서버 개발을 하다 보면 자주 듣는 말이 있다.
“스레드 풀은 CPU 코어 수의 2배 정도로 잡으면 된다.”
틀린 말은 아니다.
하지만 실무에서는 이 말만 믿고 corePoolSize, maxPoolSize를 정하면 위험하다.
스레드 풀의 크기는 단순히 CPU 코어 수만 보고 정하는 값이 아니다.
작업의 성격, I/O 대기 시간, DB 커넥션 풀, 외부 API 처리량, 큐 적체량, 응답 시간, 장애 시 영향 범위까지 같이 봐야 한다.
이 글에서는 백엔드 서버 운영 관점에서 스레드 풀 개수를 어떻게 정해야 하는지 정리한다.
1. 먼저 CPU 바운드와 I/O 바운드를 구분해야 한다
스레드 풀 크기를 정할 때 가장 먼저 봐야 하는 것은 작업의 성격이다.
크게 두 가지로 나눌 수 있다.
CPU 바운드 작업
- 계산
- 암호화
- 압축
- 대량 정렬
- 복잡한 파싱
- 좌표 계산
- 거리 계산
- 룰 평가
I/O 바운드 작업
- DB 조회/저장
- 외부 API 호출
- Redis 호출
- 파일 읽기/쓰기
- 메시지 브로커 송수신
- HTTP 통신
CPU 바운드 작업은 스레드가 CPU를 계속 사용한다.
반면 I/O 바운드 작업은 대부분의 시간을 외부 응답을 기다리며 보낸다.
이 차이가 스레드 풀 크기를 결정하는 핵심이다.
2. CPU 바운드 작업은 코어 수 근처가 적절하다
CPU 연산이 많은 작업은 스레드를 많이 늘린다고 처리량이 계속 증가하지 않는다.
예를 들어 8코어 16스레드 서버에서 무거운 계산 작업을 100개 스레드로 돌린다고 해보자.
실제로 동시에 CPU에서 실행될 수 있는 작업은 제한적이다.
나머지 스레드는 스케줄링 대기 상태가 된다.
스레드가 너무 많아지면 다음 비용이 증가한다.
- 컨텍스트 스위칭 비용 증가
- CPU 캐시 효율 저하
- 스케줄링 비용 증가
- 메모리 사용량 증가
- 전체 처리량 저하
따라서 CPU 바운드 작업의 스레드 풀은 보통 다음 범위에서 시작하는 것이 적절하다.
corePoolSize = 물리 코어 수
maxPoolSize = 논리 코어 수
예를 들어 서버가 8코어 16스레드라면 다음과 같이 시작할 수 있다.
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(1000); //기준시작점: 초당 처리 가능 작업 수 × 허용 가능한 최대 대기 시간 × MaxPoolSize
executor.setThreadNamePrefix("cpu-worker-");
여기서 중요한 점은 maxPoolSize를 50, 100으로 크게 잡는다고 해서 CPU 계산이 그만큼 빨라지는 것이 아니라는 점이다.
CPU 바운드 작업에서는 스레드 수를 늘리는 것보다 알고리즘 개선, 캐싱, 데이터 구조 개선, 불필요한 반복 제거가 더 효과적일 때가 많다.
3. I/O 바운드 작업은 코어 수보다 크게 잡을 수 있다
반대로 I/O 바운드 작업은 CPU를 계속 사용하는 것이 아니라 대부분 외부 응답을 기다린다.
예를 들어 하나의 작업이 다음과 같다고 해보자.
CPU 계산 시간: 10ms
DB/API 대기 시간: 90ms
전체 작업 시간: 100ms
이 작업은 전체 시간 중 90%를 기다리는 데 사용한다.
이 경우 스레드를 CPU 코어 수보다 더 많이 두어도 의미가 있다.
자주 사용되는 계산식은 다음과 같다.
적정 스레드 수 = CPU 코어 수 × (1 + 대기 시간 / 계산 시간)
예를 들어 8코어 서버에서 계산 10ms, 대기 90ms인 작업이라면 다음과 같다.
8 × (1 + 90 / 10)
= 8 × 10
= 80
이론적으로는 80개 정도의 스레드도 의미가 있을 수 있다.
하지만 이 계산식은 어디까지나 출발점이다.
실무에서는 이론값보다 더 중요한 제한 조건이 있다.
4. 진짜 상한은 downstream 자원이 결정한다
스레드 풀 크기를 정할 때 가장 많이 하는 실수가 있다.
“I/O 작업이니까 스레드를 크게 잡으면 처리량이 늘겠지.”
항상 그렇지 않다.
예를 들어 Async Executor를 100개로 잡았다고 해보자.
executor.setCorePoolSize(30);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(1000);
그런데 DB 커넥션 풀이 20개라면 어떻게 될까?
동시에 DB를 사용할 수 있는 작업: 20개
나머지 작업: DB 커넥션 대기
결국 스레드 100개 중 상당수는 DB 커넥션을 기다리는 상태가 된다.
이 경우 처리량이 늘어나는 것이 아니라 대기 스레드와 메모리 사용량만 증가한다.
따라서 I/O 바운드 작업의 스레드 풀 상한은 다음 자원과 함께 봐야 한다.
- DB 커넥션 풀 크기
- HTTP connection pool 크기
- Redis connection pool
- RabbitMQ consumer/channel 수
- 외부 API rate limit
- 파일 시스템 처리량
- 메시지 브로커 처리량
- 서버 메모리
특히 DB 중심 작업이라면 특정 Executor가 DB 커넥션 풀 전체를 독점하지 않도록 해야 한다.
예를 들어 전체 DB 커넥션 풀이 30개라면 특정 Async Executor의 maxPoolSize를 30 이상으로 잡는 것은 신중해야 한다.
DB connection pool: 30
asyncExecutor maxPoolSize: 10~20
이렇게 특정 작업군이 전체 DB 커넥션을 고갈시키지 않도록 여유를 두는 것이 안전하다.
5. Spring @Async는 논블로킹이 아니다
Spring에서 @Async를 사용하면 호출한 스레드가 직접 작업을 수행하지 않고, 별도의 Executor 스레드 풀에 작업을 위임한다.
@Async("taskExecutor")
public CompletableFuture<Void> processAsync() {
repository.save(...);
return CompletableFuture.completedFuture(null);
}
호출한 요청 스레드는 바로 반환된다.
하지만 repository.save()는 일반적으로 JDBC 기반 블로킹 I/O다.
즉, 요청 스레드는 막히지 않지만 Async worker thread는 DB 응답을 기다리며 막힌다.
Request Thread : 안 기다림
Async Worker Thread : DB 응답 대기
따라서 이것은 비동기 실행이지만 논블로킹 I/O는 아니다.
@Async = 작업을 다른 스레드로 넘기는 비동기 실행
Non-blocking I/O = I/O 대기 중에도 스레드를 점유하지 않는 방식
이 차이를 이해하지 못하면 @Async를 붙였으니 성능이 좋아질 것이라고 착각하기 쉽다.
@Async는 요청 흐름을 분리하는 데 유용하지만, 내부 작업이 블로킹이면 결국 worker thread는 대기한다.
따라서 @Async를 사용할수록 Executor 크기, 큐 용량, reject 정책, DB 커넥션 풀을 함께 설계해야 한다.
6. ThreadPoolTaskExecutor의 동작 순서를 이해해야 한다
Spring에서 자주 사용하는 ThreadPoolTaskExecutor는 대략 다음 순서로 동작한다.
1. corePoolSize까지 스레드를 생성해서 작업 처리
2. corePoolSize가 모두 사용 중이면 queueCapacity에 작업 적재
3. queue가 가득 차면 maxPoolSize까지 스레드 증가
4. maxPoolSize까지 찼고 queue도 가득 차면 reject 정책 동작
예를 들어 다음 설정이 있다고 하자.
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(1000);
동작은 다음과 같다.
동시 작업 1~10개 → core thread로 처리
그 이후 작업 → queue에 최대 1000개 적재
queue가 가득 참 → maxPoolSize 30까지 증가
30개도 모두 사용 중 → reject 정책 동작
여기서 중요한 점이 있다.
queueCapacity가 너무 크면 maxPoolSize까지 스레드가 잘 늘어나지 않는다.
core thread가 모두 사용 중이어도 queue에 공간이 있으면 작업은 먼저 queue에 쌓인다.
따라서 다음과 같은 설정은 기대와 다르게 동작할 수 있다.
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(100000);
이 경우 작업이 몰려도 스레드가 100개까지 늘어나기 전에 큐에 계속 쌓일 수 있다.
그 결과 응답 시간이 길어지고, 장애 상황에서 대량의 작업이 메모리에 적재될 수 있다.
즉, maxPoolSize만 볼 것이 아니라 queueCapacity까지 같이 봐야 한다.
7. 큐를 무작정 크게 잡으면 장애가 늦게 드러난다
큐가 크면 순간적인 트래픽 피크를 흡수할 수 있다.
하지만 너무 큰 큐는 문제를 숨긴다.
예를 들어 처리량은 초당 100건인데 요청이 초당 1000건 들어온다고 해보자.
큐가 크면 당장은 에러가 나지 않는다.
하지만 내부에서는 작업이 계속 밀린다.
처리량 < 유입량
→ 큐 적체
→ 응답 지연 증가
→ 메모리 사용량 증가
→ 장애 전파
큰 큐는 장애를 막는 것이 아니라 장애를 늦게 보이게 만들 수 있다.
따라서 큐 용량은 다음 기준으로 정해야 한다.
- 짧은 피크를 흡수할 만큼은 허용
- 지속적인 과부하는 빠르게 감지
- reject 정책으로 시스템 보호
- 모니터링으로 큐 적체를 알림
실무에서는 다음 지표를 반드시 봐야 한다.
- active thread count
- pool size
- queue size
- completed task count
- rejected task count
- task execution time
- task waiting time
스레드 풀이 꽉 찬 상태에서 큐가 계속 증가한다면 스레드를 늘리는 것이 아니라 병목이 어디인지 먼저 봐야 한다.
8. reject 정책은 반드시 의도적으로 정해야 한다
스레드와 큐가 모두 가득 차면 작업은 거부된다.
이때 어떤 방식으로 처리할지 정하는 것이 reject 정책이다.
대표적으로 다음 정책들이 있다.
AbortPolicy
- 기본 정책
- RejectedExecutionException 발생
CallerRunsPolicy
- 호출한 스레드가 직접 작업 실행
- 유입 속도를 늦추는 back pressure 효과
DiscardPolicy
- 작업을 조용히 버림
DiscardOldestPolicy
- 큐에서 가장 오래된 작업을 버리고 새 작업 적재
실무에서는 기본값인 AbortPolicy를 그대로 둘 수도 있지만, 상황에 따라 CallerRunsPolicy가 더 안전할 때도 있다.
예를 들어 비동기 로그 저장이나 알림 전송처럼 순간 피크를 완화하고 싶다면 CallerRunsPolicy를 고려할 수 있다.
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
이 정책은 큐가 가득 찼을 때 호출한 스레드가 직접 작업을 수행한다.
그러면 호출 흐름이 느려지면서 자연스럽게 유입 속도가 줄어드는 효과가 있다.
하지만 이 역시 모든 상황에서 정답은 아니다.
요청 스레드가 직접 무거운 작업을 수행하게 되면 API 응답 시간이 증가할 수 있다.
중요한 것은 reject가 발생했을 때의 동작을 의도적으로 설계하는 것이다.
9. 작업 성격별로 Executor를 분리해야 한다
하나의 Executor에 모든 비동기 작업을 넣는 것은 위험하다.
예를 들어 다음 작업들이 모두 같은 Executor를 사용한다고 해보자.
- 좌표 계산
- DB 저장
- 외부 API 호출
- 알림 전송
- 파일 생성
외부 API 지연이 발생하면 어떻게 될까?
외부 API 호출 작업이 worker thread를 오래 점유한다.
그러면 좌표 계산이나 내부 알림 같은 다른 작업까지 밀릴 수 있다.
이를 thread pool contamination이라고 볼 수 있다.
한 작업군의 지연이 다른 작업군까지 오염시키는 것이다.
실무에서는 작업 성격에 따라 Executor를 분리하는 것이 좋다.
cpuExecutor
- 계산
- 파싱
- 룰 평가
ioExecutor
- 외부 API 호출
- 파일 I/O
- Redis 호출
dbExecutor
- DB 저장/조회 보조 작업
notificationExecutor
- JMS/RabbitMQ/Kafka publish
- 알림 전송
예시는 다음과 같다.
@Bean(name = "evaluationExecutor")
public Executor evaluationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("evaluation-");
executor.initialize();
return executor;
}
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notification-");
executor.initialize();
return executor;
}
그리고 사용처에서 명시적으로 지정한다.
@Async("evaluationExecutor")
public CompletableFuture<EvaluationResult> evaluateAsync(Position position) {
EvaluationResult result = evaluator.evaluate(position);
return CompletableFuture.completedFuture(result);
}
@Async("notificationExecutor")
public CompletableFuture<Void> sendNotificationAsync(Event event) {
notificationSender.send(event);
return CompletableFuture.completedFuture(null);
}
이렇게 하면 계산 작업과 알림 전송 작업이 서로 영향을 덜 준다.
10. 실무적인 시작값
정답은 아니지만, 실무에서 출발점으로 사용할 수 있는 기준은 있다.
CPU 바운드 Executor
물리 8코어 / 논리 16스레드 서버 기준
corePoolSize: 8
maxPoolSize : 16
queueCapacity: 500~1000
근거는 CPU 계산 작업은 코어 수 이상으로 늘려도 병렬성이 크게 증가하지 않기 때문이다.
I/O 바운드 Executor
corePoolSize: 10~20
maxPoolSize : 30~50
queueCapacity: 500~1000
단, DB 작업이라면 DB 커넥션 풀보다 과도하게 크게 잡지 않는다.
DB connection pool: 30
특정 dbExecutor maxPoolSize: 10~20
외부 API 호출 Executor
corePoolSize: 10
maxPoolSize : 30
queueCapacity: 300~500
timeout: 반드시 설정
circuit breaker: 필요 시 적용
외부 API는 내 서버에서 스레드를 늘린다고 상대 서버 처리량이 늘어나지 않는다.
오히려 장애 시 스레드가 대량으로 묶일 수 있으므로 timeout, bulkhead, circuit breaker가 중요하다.
메시지 Consumer
메시지 브로커는 단순히 스레드 풀만 볼 것이 아니라 consumer concurrency와 prefetch를 같이 봐야 한다.
consumer concurrency: 동시에 처리할 consumer 수
prefetch: consumer가 broker에서 미리 가져올 메시지 수
예를 들어 RabbitMQ에서는 다음과 같은 형태로 처리량과 안정성을 조정할 수 있다.
concurrency = 10
prefetch = 250
consumer 수를 늘리면 병렬 처리량은 증가할 수 있다.
하지만 downstream DB나 외부 API가 받쳐주지 못하면 큐 적체 위치가 브로커에서 애플리케이션 또는 DB로 이동할 뿐이다.
따라서 consumer concurrency, prefetch, DB pool, 처리 시간, 큐 적체량을 함께 봐야 한다.
11. maxPoolSize를 정하는 기준
면접이나 코드 리뷰에서 자주 나오는 질문이 있다.
“maxPoolSize는 몇 개가 이상적인가요?”
내 답은 다음과 같다.
“고정값은 없고, 작업 성격과 downstream 병목 자원보다 조금 낮게 잡는 것이 원칙입니다.”
구체적으로는 다음 기준을 본다.
CPU 바운드
- CPU 코어 수 또는 논리 코어 수 근처
DB 바운드
- DB 커넥션 풀보다 작게
- 특정 Executor가 전체 커넥션을 독점하지 않게
외부 API 바운드
- 외부 API rate limit 이하
- timeout 필수
- 장애 시 빠르게 실패하도록 구성
메시지 처리
- consumer concurrency
- prefetch
- ack 처리 방식
- downstream 처리량
파일 I/O
- 디스크 처리량
- 파일 크기
- 동시 파일 핸들 수
즉, maxPoolSize의 근거는 단순히 CPU 코어 수가 아니라 다음 네 가지다.
1. CPU 코어 수
2. 작업의 CPU/I/O 비율
3. downstream 자원의 처리 한계
4. 운영 지표
12. 모니터링 없이 정한 스레드 풀 크기는 추측일 뿐이다
스레드 풀 크기는 책상에서 완벽하게 정할 수 없다.
초기값은 설계할 수 있지만, 최종값은 운영 지표를 보고 조정해야 한다.
반드시 봐야 하는 지표는 다음과 같다.
Thread Pool
- active thread count
- pool size
- queue size
- completed task count
- rejected task count
Application
- API response time
- task execution time
- task waiting time
- error rate
- timeout count
System
- CPU usage
- load average
- memory usage
- GC pause
- context switching
Database
- active connection
- idle connection
- connection wait time
- slow query
- lock wait
Message Broker
- queue depth
- publish rate
- consume rate
- ack rate
- redelivery count
예를 들어 다음 상황이라면 스레드를 늘리는 것이 답이 아닐 수 있다.
CPU 사용률 90% 이상
queue 증가
응답 시간 증가
이 경우는 CPU가 병목일 가능성이 높다.
스레드를 늘리면 컨텍스트 스위칭만 증가할 수 있다.
반대로 다음 상황이라면 I/O 대기가 병목일 수 있다.
CPU 사용률 낮음
active thread 높음
DB connection wait 증가
응답 시간 증가
이 경우는 스레드 풀이 아니라 DB 커넥션 풀, 쿼리 성능, 외부 시스템 지연을 봐야 한다.
13. 실무에서 추천하는 설정 예시
아래는 정답이 아니라 출발점이다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "cpuTaskExecutor")
public Executor cpuTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("cpu-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean(name = "ioTaskExecutor")
public Executor ioTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(40);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("io-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notification-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
사용 예시는 다음과 같다.
@Service
@RequiredArgsConstructor
public class EvaluationService {
@Async("cpuTaskExecutor")
public CompletableFuture<EvaluationResult> evaluateAsync(Position position) {
EvaluationResult result = evaluate(position);
return CompletableFuture.completedFuture(result);
}
private EvaluationResult evaluate(Position position) {
// CPU 중심 계산 로직
return new EvaluationResult();
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
@Async("notificationExecutor")
public CompletableFuture<Void> notifyAsync(Event event) {
// JMS, RabbitMQ, 외부 API 등 I/O 중심 작업
send(event);
return CompletableFuture.completedFuture(null);
}
private void send(Event event) {
// notification send
}
}
이렇게 작업 성격에 따라 Executor를 분리하면 한 작업군의 지연이 전체 비동기 처리 구조를 망가뜨리는 것을 줄일 수 있다.
14. 흔한 실수
실수 1. maxPoolSize만 크게 잡는다
executor.setCorePoolSize(10);
executor.setMaxPoolSize(200);
executor.setQueueCapacity(100000);
이 설정은 보기에는 처리량이 높아 보이지만, 실제로는 큐가 너무 커서 장애가 늦게 드러날 수 있다.
실수 2. DB 커넥션 풀보다 worker thread를 과도하게 크게 잡는다
DB 커넥션 풀이 20개인데 DB 작업 Executor를 100개로 잡으면 대부분의 스레드는 커넥션을 기다린다.
실수 3. 모든 비동기 작업을 하나의 Executor에 넣는다
외부 API 장애가 발생했을 때 계산 작업, 알림 작업, DB 작업까지 같이 밀릴 수 있다.
실수 4. timeout 없이 외부 API를 호출한다
외부 API 지연은 worker thread 고갈로 이어질 수 있다.
비동기 처리에서도 timeout은 필수다.
실수 5. 모니터링 없이 감으로 튜닝한다
스레드 풀 튜닝은 감이 아니라 지표 기반으로 해야 한다.
15. 결론
스레드 풀 크기는 하나의 공식으로 정할 수 없다.
다만 실무에서 사용할 수 있는 원칙은 명확하다.
CPU 바운드 작업
→ 코어 수 또는 논리 코어 수 근처
I/O 바운드 작업
→ 코어 수보다 크게 가능
DB 중심 작업
→ DB 커넥션 풀보다 과도하게 크게 잡지 않기
외부 API 작업
→ timeout, rate limit, circuit breaker 고려
메시지 처리
→ consumer concurrency, prefetch, downstream 처리량 함께 고려
공통
→ queueCapacity, reject 정책, 모니터링 필수
내가 생각하는 가장 중요한 기준은 이것이다.
스레드 풀의 크기는 CPU 코어 수가 아니라, 작업이 CPU를 쓰는 시간과 기다리는 시간의 비율, 그리고 downstream 자원의 처리 한계로 결정해야 한다.
결국 좋은 스레드 풀 설정은 큰 숫자를 넣는 것이 아니다.
시스템이 감당할 수 있는 만큼만 병렬성을 열어두고, 과부하 상황에서는 큐와 reject 정책으로 안전하게 버티며, 운영 지표를 보고 지속적으로 조정하는 것이다.
'나의 주니어 개발 일기 > 트러블슈팅' 카테고리의 다른 글
| 실무에서 DB Connection Pool Size는 어떻게 정해야 할까? (0) | 2026.07.06 |
|---|---|
| 주변 지오펜스 조회 성능 개선 (like 주변 맛집 검색) (0) | 2026.06.25 |
| 로컬 LLM 기반 AI Code Reviewer 구축기 - 단순 코드 리뷰를 넘어 아키텍처 리뷰까지 (0) | 2026.06.11 |
| PolyCARP를 활용한 드론 Geofence/Area 공간 판단 구현기 - 좌표계, 경계선 그리고 수많은 삽질들 (0) | 2026.06.04 |
| 드론 안전 제어 시스템을 설계하며 고민했던 것들 (0) | 2026.06.04 |
