Contents
see List왜 스레드풀 포화가 장애로 번지는가
Java 서버에서 외부 API 호출, 파일 처리, 알림 발송, 배치성 집계 같은 작업을 모두 비동기로 넘기면 응답 시간이 좋아 보일 수 있습니다. 하지만 스레드풀의 큐가 무한히 쌓이거나 거절 정책이 기본값 그대로 방치되면 장애는 더 늦게, 더 크게 나타납니다. 요청은 성공처럼 접수되지만 실제 작업은 수 분 뒤 실행되고, 메모리는 큐에 쌓인 작업 객체 때문에 증가하며, 재시도 로직은 같은 작업을 다시 밀어 넣습니다. 결국 CPU보다 먼저 지연 시간이 무너지고, 그 다음에는 힙과 커넥션 풀이 함께 포화됩니다.
실무에서는 스레드 수를 크게 늘리는 방식보다 작업의 성격을 분리하고, 큐 길이를 제한하고, 포화 시 호출자에게 즉시 신호를 주는 설계가 더 안전합니다. 스레드풀은 성능 튜닝 장치가 아니라 용량 제한 장치로 다루어야 합니다. 처리량을 높이려면 먼저 병목이 CPU인지, 네트워크 대기인지, 데이터베이스 커넥션인지 확인하고 그 한계 안에서 동시성을 정해야 합니다.
풀을 나누는 기준
하나의 공용 Executor에 모든 작업을 넣으면 장애 원인을 분리하기 어렵습니다. 메일 발송 지연이 주문 후처리를 막고, 리포트 생성이 웹훅 처리를 밀어내는 식의 간섭이 발생합니다. 최소한 CPU 계산 작업, 외부 I/O 작업, 사용자 요청 흐름에 직접 연결된 짧은 비동기 작업은 분리하는 것이 좋습니다. 각 풀은 이름, 스레드 수, 큐 크기, 거절 정책, 모니터링 지표를 따로 가져야 합니다.
- CPU 중심 작업은 코어 수에 가깝게 제한하고 큐를 짧게 둡니다.
- 외부 I/O 작업은 평균 응답 시간과 커넥션 풀 크기를 기준으로 동시성을 정합니다.
- 사용자 요청과 연결된 작업은 오래 기다리지 않게 타임아웃과 거절 응답을 명확히 합니다.
- 배치 작업은 업무 피크 시간대의 온라인 트래픽과 같은 풀을 쓰지 않습니다.
안전한 ThreadPoolExecutor 기본형
아래 예시는 큐 크기를 제한하고, 스레드 이름을 남기며, 포화 시 호출자 스레드에서 실행하는 대신 명시적인 예외를 던지도록 만든 구성입니다. CallerRunsPolicy는 순간적인 완충에는 유용하지만 웹 요청 스레드에서 오래 걸리는 작업을 실행하게 만들어 전체 응답 지연을 키울 수 있습니다. 온라인 API에서는 실패를 빠르게 알리고 상위 계층에서 429 또는 503으로 변환하는 편이 관측과 복구에 유리합니다.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public final class ExecutorsConfig {
public static ThreadPoolExecutor externalApiExecutor() {
AtomicInteger sequence = new AtomicInteger();
ThreadFactory threadFactory = task -> {
Thread thread = new Thread(task);
thread.setName("external-api-" + sequence.incrementAndGet());
thread.setDaemon(false);
return thread;
};
return new ThreadPoolExecutor(
8,
16,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
threadFactory,
(task, executor) -> {
throw new RejectedExecutionException("external API executor is saturated");
}
);
}
}
핵심은 숫자 자체보다 숫자를 정한 이유입니다. 예를 들어 외부 API의 평균 응답 시간이 300ms이고 안정적으로 허용되는 초당 요청 수가 40개라면 동시 실행 수는 대략 12개 안팎에서 시작할 수 있습니다. 여기에 네트워크 변동과 순간 피크를 고려해 최대 스레드 16개, 큐 200개처럼 제한을 둡니다. 큐가 200개를 넘는 상황은 이미 정상 처리 속도보다 유입 속도가 빠르다는 뜻이므로, 더 많이 받아 주는 것이 아니라 상위 계층으로 압력을 되돌려야 합니다.
백프레셔를 API 응답으로 연결하기
거절 예외를 로그만 남기고 삼키면 시스템은 바쁘다는 사실을 사용자와 호출자에게 숨기게 됩니다. 내부 이벤트 처리라면 재시도 가능한 상태로 저장하고, HTTP 요청이라면 상태 코드를 통해 명확히 알려야 합니다. 중요한 것은 실패를 빠르게 만드는 것입니다. 느린 성공보다 빠른 거절이 장애 전파를 줄입니다.
- 사용자 요청에서 즉시 필요한 작업이면 제한 시간 안에 처리하지 못할 때 503을 반환합니다.
- 나중에 처리해도 되는 작업이면 데이터베이스에 PENDING 상태로 저장한 뒤 별도 워커가 가져가게 합니다.
- 외부 시스템 호출 실패와 내부 스레드풀 포화는 로그 필드와 지표 이름을 분리합니다.
- 재시도는 고정 간격보다 지수 백오프와 최대 시도 횟수를 둡니다.
운영 지표로 봐야 할 값
스레드풀 장애는 애플리케이션 로그보다 지표에서 먼저 보입니다. 최소한 active count, pool size, queue size, completed task count, rejected count를 수집해야 합니다. 큐 사용률이 오랫동안 70%를 넘거나 rejected count가 증가하면 작업 지연이 이미 발생하고 있다고 봐야 합니다. 단순히 CPU 사용률이 낮다는 이유로 괜찮다고 판단하면 안 됩니다. I/O 대기형 장애는 CPU가 낮은 상태에서도 큐와 응답 시간이 동시에 증가합니다.
알림 기준은 서비스 성격에 맞춰야 합니다. 실시간 주문 처리라면 거절 1건도 확인 대상일 수 있고, 비중요 알림 발송이라면 일정 비율까지 허용할 수 있습니다. 다만 모든 경우에 공통으로 필요한 것은 대시보드에서 풀 이름별로 지표가 분리되어 보이는 것입니다. 공용 지표 하나만 있으면 어떤 작업군이 포화를 일으켰는지 늦게 알게 됩니다.
점검 체크리스트
- 무제한 큐 LinkedBlockingQueue를 기본값으로 사용하고 있지 않은지 확인합니다.
- 비동기 작업을 성격별 Executor로 분리했는지 확인합니다.
- 거절 정책이 서비스 응답, 재시도, 저장 후 처리 중 하나로 명확히 연결되는지 확인합니다.
- 큐 길이와 거절 횟수를 풀 이름별 지표로 수집하는지 확인합니다.
- 스레드 수가 데이터베이스 커넥션 풀이나 외부 API 허용량보다 과도하게 크지 않은지 확인합니다.
- 장애 상황에서 큐를 더 키우기 전에 유입 제한, 빠른 실패, 재처리 구조를 먼저 검토합니다.