[업무일지] 컨슈머 6개에 스레드 5개, 조용히 멈춘 회수 배치
한 외부 연동의 회수만 전면 중단됐습니다. 다른 연동은 멀쩡했고, 에러 로그도 경보도 없었습니다. 원인은 공유 스레드풀 starvation이었습니다. corePoolSize가 컨슈머 수보다 작아, 한 컨슈머가 스레드를 못 잡고 조용히 멈춘 겁니다. 큐 용량이 1000이라 maxPool은 애초에 도달할 수 없었습니다. corePoolSize만 실효 동시성이었던 거죠.
Java 21, Spring Boot 3.x, Redisson 기반 스케줄러 서버입니다. 외부 연동별로 Redis 큐를 두고 이벤트 컨슈머가 소비하는 구조입니다. 이 글의 클래스명과 연동 이름은 예시로 바꿨습니다.
특정 회수만 전면 중단됐다
제보는 단순했습니다. 한 외부 연동의 출금 회수가 어느 시각 이후로 전혀 안 된다는 것이었습니다.
특이한 건 범위였습니다. 그 셀러 한 명이 아니라 그 연동으로 도는 회수 전체가 멈췄습니다. 다른 연동 컨슈머는 정상이었고요.
로그를 보니 회수 대상 자체는 5분마다 정상적으로 새로 생성되고 있는데, 정작 그걸 받아 실제 출금을 실행해야 할 회수 상세는 하루 종일 0건이었습니다. 배치는 5분마다 돌면서 “이미 실행 중입니다”만 찍고 즉시 끝났고요. 그런데 에러도, 인터럽트도, 종료 로그도 없었습니다. 그냥 조용히 아무 일도 안 일어난 거죠.
에러 없이 특정 토픽만 소비가 0이면, 그 컨슈머 스레드가 살아 있는지부터 의심해야 합니다.
컨슈머 하나가 스레드 하나를 영구 점유한다
이벤트 컨슈머는 이렇게 돕니다.
1
2
3
4
5
6
7
8
9
// 컨슈머 하나가 공유 풀의 스레드 하나를 무한 루프로 영구 점유한다
executor.execute(() -> {
while (!shutdown) {
String event = queue.poll(1, TimeUnit.SECONDS);
if (event != null) {
handle(event);
}
}
});
executor.execute(...)에 넘긴 작업이 while 무한 루프입니다. 한 번 스레드를 잡으면 shutdown 전까지 안 놓습니다. 즉 컨슈머 1개 = 공유 풀 스레드 1개 영구 점유입니다.
그동안 이 공유 풀을 쓰는 컨슈머가 6개로 늘어 있었습니다. 자동지급, 신용평가, 입금, 정산금 조회, 그리고 출금 회수 계열까지 이관되면서요. 컨슈머가 6개면 그 6개가 각자 스레드를 하나씩 붙들고 있어야 정상이죠.
큐가 1000이라 maxPool엔 영영 안 갔다
풀 설정은 이랬습니다.
1
2
3
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(1000); // 이게 함정이었다
corePoolSize 5에 maxPoolSize 10이면, 부하가 몰릴 때 스레드가 알아서 10개까지 늘어나 버텨줄 거라고 읽기 쉽습니다. 저도 처음엔 그렇게 읽었고요. 그런데 JDK ThreadPoolExecutor의 성장 규칙은 직관과 다릅니다.
- 코어 스레드가 다 차면, 새 작업은 먼저 큐에 쌓입니다.
- 큐가 가득 차야 그제서야 maxPoolSize까지 스레드를 늘립니다.
- 큐가 안 차면 maxPool은 영영 안 옵니다.
큐 용량이 1000입니다. 컨슈머는 6개뿐이고요. 큐에 1000개가 쌓일 일이 없으니 maxPoolSize(10)는 도달 불가였습니다. 실효 동시성은 corePoolSize인 5. 그런데 스레드를 영구 점유할 컨슈머는 6개입니다.
6개 컨슈머가 5개 스레드를 두고 경쟁하면, 한 컨슈머는 스레드를 못 잡습니다. 사실 컨슈머가 6개가 된 순간부터, 재배포할 때마다 그중 하나는 스레드를 못 잡고 큐에 갇혀 있었습니다. 그동안은 덜 눈에 띄는 컨슈머가 걸려 지나갔을 뿐이고요. 이번 재배포에서 하필 그 회수 컨슈머가 밀렸습니다. 폴링 루프를 시작조차 못 한 채로요. 에러도 없이 조용히.
flowchart TD
subgraph pool["consumerThreadExecutor · core=5"]
T1["스레드1<br/>자동지급"]
T2["스레드2<br/>신용평가"]
T3["스레드3<br/>입금"]
T4["스레드4<br/>정산금"]
T5["스레드5<br/>타 연동 회수"]
end
C6["출금 회수 컨슈머<br/>(6번째)"] -->|"스레드 못 잡음<br/>큐 1000이라 maxPool 도달 불가"| WAIT(["대기큐에 갇혀<br/>영구 정지"])
classDef bad fill:#c62828,color:#fff,stroke:#b71c1c
class WAIT bad
DB 플래그만 내려선 안 풀렸다
여기서 곁가지 함정이 하나 있었습니다. 배치에는 “실행 중” 플래그를 두는 잡 상태 테이블이 있었는데, 그게 계속 실행 중(ING)으로 고착돼 있었습니다.
그래서 그 플래그를 STOP으로 내리면 풀릴 것 같습니다. 실제로는 안 풀립니다. 플래그를 내리면 producer가 이벤트를 다시 발행하긴 하는데, 정작 그걸 소비할 컨슈머가 죽어 있으니 소비는 여전히 0이거든요. 플래그 고착은 원인이 아니라 증상이었습니다. 컨슈머가 큐를 못 비워서 플래그를 STOP으로 되돌리는 코드가 안 불린 것뿐입니다.
증상을 원인으로 착각하면 여기서 시간을 버립니다. 근본은 죽은 컨슈머고, 그건 DB에 없습니다.
corePoolSize를 올리고 감지 수단을 남겼다
당장의 조치는 풀을 키우는 것이었습니다.
1
2
executor.setCorePoolSize(10); // 컨슈머 6개 + 향후 여유
executor.setMaxPoolSize(16);
corePoolSize를 컨슈머 수보다 넉넉히 잡으면, 여섯 컨슈머가 저마다 자기 스레드를 하나씩 확보하고 시작하니 스레드를 두고 경쟁하다 누군가 밀려나는 일 자체가 사라집니다. 계산은 단순하죠.
감지 수단도 하나 심었습니다. 컨슈머가 폴링 루프에 실제로 진입한 뒤에만 로그를 한 줄 찍게 했습니다.
1
log.info("Started polling loop for topic {}", topic);
이 로그는 풀 스레드를 실제로 확보해야 찍힙니다. 그래서 배포 직후 “이 로그 수 = 컨슈머 수”가 맞는지만 보면, 스레드를 못 잡고 굶은 컨슈머가 있는지 바로 압니다. 빈 초기화 시점에 찍는 구독 로그로는 이걸 못 잡습니다. 스레드를 못 잡아도 구독 로그는 찍히거든요.
맺음말
corePoolSize를 올려 급한 불은 껐습니다. 굶어 죽던 컨슈머가 전용 스레드를 잡고 회수도 재개됐습니다.
그런데 이건 근본 해결이 아닙니다. 공유 풀을 그대로 두는 한, 컨슈머를 하나 더 추가할 때마다 같은 함정이 되돌아옵니다. “corePoolSize ≥ 컨슈머 수 + 여유”를 누군가 기억하고 지켜야 하는데, 코드 어디에도 그걸 강제하는 장치가 없습니다. 컨슈머별 전용 풀로 쪼개는 게 더 안전하지만, 그건 스레드 모델을 바꾸는 일이라 이번 범위 밖으로 뒀습니다.
더 아픈 건 조용히 멈췄다는 점입니다. 컨테이너가 죽은 것도, 에러가 난 것도 아니라서 아무 경보도 안 울렸습니다. 결국 셀러 문의로 알았습니다. 폴링 루프가 예기치 않게 끝나면 스스로 되살아나거나 최소한 경보라도 울리게 하는 것, 그게 남은 진짜 숙제입니다. 이번엔 풀을 키워 재발 확률만 낮췄을 뿐입니다.