예전에 ThreadPoolTaskExecutor 글에서 core/queue/max 와 RejectedExecutionHandler 를 정리한 적이 있습니다. 이번에는 그 지식을 실제로 써먹어야 했던 상황을 정리합니다.
느린 API 하나가 공유 스레드풀을 점유하면서, 전혀 무관한 다른 API까지 500을 뿜는 장애를 분석하고 개선하면서 알게 된 내용입니다. 단순히 “풀을 나눴다”로 끝나는 게 아니라, CallerRunsPolicy 를 쓰는 한 풀을 나눠도 격리가 완전하지 않다는 점이 핵심이었습니다.
상황: 피해자와 주범이 다르다
하나의 애플리케이션이 여러 종류의 요청을 단일 공유 스레드풀로 처리하고 있었습니다. 모든 컨트롤러 호출이 AOP를 통해 이 풀에 submit() 되고, 호출 스레드는 future.get(timeout) 으로 결과를 기다리는 구조입니다.
어느 날 특정 API(A라고 하겠습니다)가 500 에러를 버스트로 뿜었는데, 분석해보니 A는 피해자였고 주범은 따로 있었습니다.
- 주범은 평소 응답이 긴 경로 계산 류 API(B).
- B는 전체 트래픽의 5% 남짓에 불과했지만, 응답이 느려서 스레드-시간 기준으로는 전체의 약 24% 를 소비하고 있었습니다.
- 트래픽 버스트가 겹치자 B가 공유 풀을 포화시켰고, 풀에 자리가 없어진 A가 다운스트림 호출 타임아웃으로 500이 난 것이었습니다.
CPU, GC, 다운스트림 지연은 모두 정상이었습니다. 순수하게 스레드풀 포화가 원인이었습니다.
짚고 넘어갈 오해: core 다음은 max가 아니다
개선을 설계하기 전에 팀과 공유하려고 스레드풀 동작을 다시 정리했는데, 생각보다 많이들 착각하는 지점이 있었습니다.
작업이 도착하면 아래 순서로 처리됩니다.
| 순서 | 조건 | 동작 |
|---|---|---|
| ① | 현재 스레드 수 < core | 새 스레드를 만들어 즉시 실행 |
| ② | core 가득 참 | queue에 적재 (← max보다 큐가 먼저) |
| ③ | queue까지 가득 참 | 스레드를 max까지 추가 생성해 실행 |
| ④ | max + queue 모두 가득 참 | 거부 정책(RejectedExecutionHandler) 발동 |
⚠️ 자주 하는 오해: “core가 다 차면 곧바로 max까지 스레드를 늘린다”는 틀립니다. core가 차면 큐부터 채우고, 큐가 꽉 차야 비로소 max까지 스레드를 늘립니다. 따라서 큐가 크면 max 스레드는 늦게 늘어납니다.
예를 들어 core 45 / max 90 / queue 90 인 풀이라면, 동시 작업 수에 따라 이렇게 전이합니다.
| 동시 작업 수 | 단계 | 스레드 | 큐 |
|---|---|---|---|
| 1 ~ 45 | ① core | 1~45 | 0 |
| 46 ~ 135 | ② queue | 45 | 1~90 |
| 136 ~ 180 | ③ max | 46~90 | 90 |
| 181 ~ | ④ reject | 90 | 90 |
즉 큐가 90이나 되기 때문에, 작업이 46개부터 135개까지 늘어나는 동안 스레드는 45개에서 전혀 늘지 않고 큐만 쌓입니다. “왜 max를 90으로 줬는데 스레드가 45개밖에 안 뜨지?” 하는 혼란이 여기서 나옵니다.
거부 정책: CallerRunsPolicy 와 Tomcat 스레드의 관계
④ 단계(포화)에서 무엇을 하느냐가 RejectedExecutionHandler 입니다. 지난 글에서도 정리했지만, 이번 케이스에서 중요했던 건 CallerRunsPolicy 였습니다.
| 정책 | 포화 시 동작 | 결과 |
|---|---|---|
| CallerRunsPolicy | submit을 호출한 스레드가 그 작업을 직접 실행 | 작업은 처리되나 호출 스레드가 묶임 |
| AbortPolicy | RejectedExecutionException 던지고 작업 버림 |
빠른 실패(429/503), 호출 스레드는 바로 풀림 |
| DiscardPolicy | 조용히 작업 폐기 | 유실 |
| DiscardOldestPolicy | 큐의 가장 오래된 작업을 버리고 새 작업 적재 | 유실 |
여기서 핵심은 “호출(caller) 스레드가 누구냐” 입니다. 웹 애플리케이션에서 컨트롤러 작업을 풀에 넘기는 주체는 보통 Tomcat 워커 스레드입니다. 그래서 컨트롤러 풀이 포화되어 CallerRunsPolicy 가 발동하면, 그 느린 작업을 실행하는 동안 Tomcat 스레드 1개가 통째로 묶입니다.
Spring Boot 기본값 기준으로 Tomcat 풀은 대략 이렇습니다.
| 설정 | 기본값 |
|---|---|
server.tomcat.threads.max |
200 |
server.tomcat.threads.min-spare |
10 |
server.tomcat.max-connections |
8192 |
server.tomcat.accept-count |
100 |
그러니까 CallerRunsPolicy 는 “작업을 버리지 않는다”는 장점이 있지만, 그 대가로 앞단 Tomcat 풀을 2차로 갉아먹을 수 있다는 함정이 있습니다. 작업 풀이 포화된 상태에서 요청이 계속 들어오면 Tomcat 스레드가 하나씩 작업 실행에 묶이고, 그러다 Tomcat 풀(max 200)까지 고갈되면 그때는 풀을 나눠놔도 못 막습니다.
개선: 느린 API를 전용 풀로 격리
목표는 간단했습니다. 느린 B 때문에 멀쩡한 A가 죽는 것을 막자. 그래서 B 계열을 전용 스레드풀로 떼어내고, 나머지는 공유 풀로 유지했습니다.
공유 풀 (B 제외 전부) : core 30 / max 60 / queue 60
B 전용 풀 : core 45 / max 90 / queue 90
풀 크기는 감으로 정하지 않고 Little’s Law 로 산정했습니다.
동시 실행 스레드 수 = 요청률(req/s) × 평균 응답시간(s)
- 평균 응답시간과 피크 요청률(pod 당)을 넣어 각 풀의 동시 점유 스레드 수를 추정했습니다.
- B는 평균 기준 동시 점유는 낮았지만(한 자릿수), p95 버스트 시 수십 개까지 치솟는 특성이 있어 전용 풀의 max 를 넉넉히 잡았습니다.
- 반대로 나머지(공유 풀)는 응답이 짧아 p95 에서도 동시 점유가 크지 않아, 그만큼만 할당했습니다.
트래픽 비중이 아니라 스레드-시간 기여(= 요청률 × 응답시간) 로 사이징을 해야 한다는 게 이 과정의 교훈이었습니다. 트래픽 5%짜리가 스레드-시간 24%를 먹고 있었던 것처럼요.
함정: 풀을 나눠도 CallerRunsPolicy면 격리가 샌다
여기서 끝나면 깔끔했겠지만, CallerRunsPolicy 를 유지하는 한 격리가 완전하지 않습니다.
풀을 나눈 목적은 “B가 포화돼도 A는 멀쩡하게”였는데, B가 ④ 단계에 도달해 CallerRunsPolicy 가 발동하면 B를 호출하던 Tomcat 스레드가 묶입니다. 이게 B 전용 풀과는 별개로 공용 자원인 Tomcat 풀을 소모합니다.
- B 요청이 Tomcat max(200)보다 적게 몰릴 때: Tomcat 스레드에 여유가 있으니, 묶이지 않은 나머지 스레드 + 공유 풀로 A는 정상 처리 → 격리 효과 성립.
- B 요청이 200개 이상 몰려 Tomcat 스레드를 전부 묶을 때: Tomcat 자체가 고갈되어 A도 받지 못함 → 풀 분리로도 못 막음.
그래서 “풀 분리”는 근본 해결이 아니라 버스트를 완충하는 buffer 로 이해해야 했습니다. 근본적으로는 느린 B가 요청당 스레드를 과점유하는 구조(예: 내부에서 자기 자신을 다시 HTTP로 호출해 스레드를 2배로 쓰는 류) 자체를 걷어내야 합니다.
그렇다고 B 전용 풀을 AbortPolicy 로 바꿔 빠르게 끊어내면 되지 않느냐 — 이건 포화 시 429/503 을 뱉게 되어 응답 계약이 바뀌는 문제가 있어 간단히 결정할 일이 아니었습니다. (백프레셔는 강해지지만 사용자 체감 에러가 생김) 결국 정책 전환은 별도 과제로 남겼습니다.
덤: 타임아웃이 재는 시간은 “실행 시간”이 아니다
이 구조에서 호출 스레드는 future.get(105000ms) 처럼 타임아웃을 걸고 결과를 기다립니다. 이때 착각하기 쉬운 게, 이 타임아웃이 작업의 실제 실행 시간을 재는 거라고 생각하는 겁니다.
실제로는 submit() 하는 순간부터 카운트가 시작되므로, 큐 대기 시간 + 실제 실행 시간의 합입니다. 풀이 ②~③ 단계라 큐에서 오래 대기하면, 아직 실행도 시작 안 했는데 타임아웃이 소모됩니다.
| 구분 | 동작 |
|---|---|
| 타임아웃 내 완료 | future.get 이 결과를 받아 정상 응답 |
| 타임아웃 초과 | TimeoutException → future.cancel(true) → 에러 응답(504 등) |
| 포함 범위 | 큐 대기 + 실제 실행의 합 (submit 시점부터) |
⚠️
future.cancel(true)는 실행 중 스레드에 인터럽트 신호를 보낼 뿐입니다. 인터럽트에 반응하지 않는 블로킹(소켓 read 등) 작업은 즉시 멈추지 않을 수 있어서, 504를 응답해도 백그라운드 작업이 당장 종료된다는 보장은 없습니다.
또 하나: graceful shutdown 과 타임아웃의 관계
배포/종료(SIGTERM) 시 풀 동작도 함께 봐야 했습니다. ThreadPoolTaskExecutor 는 아래 두 설정으로 종료 동작을 정합니다.
| 설정 | 의미 |
|---|---|
setWaitForTasksToCompleteOnShutdown(true) |
종료 신호를 받아도 새 작업만 거부하고, 실행 중/큐에 있는 작업은 끝까지 수행(graceful). false 면 shutdownNow() 로 즉시 인터럽트 |
setAwaitTerminationSeconds(n) |
위 graceful 종료를 최대 몇 초까지 기다릴지 상한 |
여기서 놓치기 쉬운 지점 — graceful 대기 시간이 작업 타임아웃보다 짧으면, 종료 시 느린 작업은 완료를 못 기다리고 강제 종료됩니다. 예를 들어 작업 타임아웃이 105초인데 awaitTerminationSeconds 가 100초면, 막판에 들어온 느린 작업은 완료 전에 잘립니다. 추가로 Kubernetes terminationGracePeriodSeconds 가 이보다 짧으면 파드가 그전에 SIGKILL 되므로, 이 세 값을 함께 맞춰야 합니다.
정리
- core 다음은 max가 아니라 queue가 먼저다. 큐가 크면 max 스레드는 늦게 뜬다.
- 느린 API는 트래픽 비중이 작아도 스레드-시간을 독점할 수 있다. 사이징은 비중이 아니라
요청률 × 응답시간으로. CallerRunsPolicy는 작업을 버리지 않는 대신 호출(Tomcat) 스레드를 묶는다. 풀을 나눠도 공용 Tomcat 풀을 통해 격리가 샐 수 있다. 풀 분리는 근본 해결이 아니라 완충재.- 타임아웃은 큐 대기 + 실행의 합을 재고,
future.cancel(true)는 인터럽트 신호일 뿐이다. - graceful shutdown 대기 시간은 작업 타임아웃보다 길게 잡아야 종료 시 작업이 잘리지 않는다.
지난 글에서 “서비스 유형에 따라 적절한 RejectedExecutionHandler 정책을 설정하면 좋겠다”고 적었는데, 실제로 그 선택이 생각보다 훨씬 복잡한 트레이드오프라는 걸 체감한 작업이었습니다.
그럼 이만. 🥕👋🏼🖐🏼