이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- pool 고갈은 DB pool 말고 어떤 자원에서 발생하는가?
- pending queue, timeout, retry, downstream 지연을 어떻게 한 장애 흐름으로 연결하는가?
- pool size를 키우는 것이 언제 완화이고 언제 장애 증폭인가?
개요
Pool 고갈은 “공유 자원을 빌려 쓰려는 요청이 자원 수보다 많아져 대기열이 쌓이는 상태”다. DB connection pool이 대표적이지만 HTTP client connection pool, thread pool, Redis connection pool, proxy upstream connection, NAT port, Linux conntrack도 같은 방식으로 고갈될 수 있다.
고갈 장애의 무서운 점은 시작이 조용하다는 것이다. 평균 latency는 괜찮아 보이는데 p95, p99, pending queue, acquire duration이 먼저 올라간다. 이후 timeout과 retry가 늘어나면 같은 pool을 더 오래 점유하고, 장애가 사용자 API 전체로 번진다.
왜 백엔드 개발자가 알아야 하는가
백엔드 서비스는 대부분 여러 pool을 직렬로 사용한다.
request thread
→ DB connection pool
→ HTTP client pool
→ Redis connection pool
→ downstream service어느 하나가 느려지면 앞쪽 자원이 같이 붙잡힌다. 예를 들어 외부 API read timeout이 10초이고 request thread가 그동안 대기하면, 외부 API pool뿐 아니라 servlet thread도 같이 소모된다. retry가 붙으면 같은 요청이 pool을 여러 번 점유한다.
Pool 고갈을 이해하지 못하면 “pool size를 늘리자”가 기본 대응이 된다. 하지만 downstream이 느린 상황에서 pool만 키우면 더 많은 요청을 downstream에 밀어 넣어 장애를 키울 수 있다.
요청 흐름에서의 위치
Pool 고갈은 요청 경로의 여러 곳에서 발생한다.
| 위치 | pool 예시 | 사용자 증상 |
|---|---|---|
| Inbound | server connection, worker thread | 전체 API 지연, 503/504 |
| Application | executor, coroutine dispatcher | 특정 기능 지연, queue 증가 |
| DB | HikariCP 등 DB connection pool | query 시작 전 대기, transaction 지연 |
| Outbound HTTP | WebClient/OkHttp/Apache HC pool | 외부 API 호출 대기, pool acquire timeout |
| Cache/Queue | Redis/Kafka client pool | 캐시 접근 지연, publish 지연 |
| Network edge | NAT port, conntrack table | outbound connection 실패, 간헐 timeout |
같은 504라도 실제 원인은 proxy timeout, app thread 고갈, DB pool 대기, 외부 API read timeout일 수 있다.
원리
Pool은 처리량을 무한히 늘리는 장치가 아니라 concurrency를 제한하는 장치다. 너무 작으면 불필요하게 대기하고, 너무 크면 downstream을 압박한다.
간단히 보면 동시 점유량은 요청률과 점유 시간의 곱에 비례한다. 같은 traffic이라도 downstream latency가 100ms에서 2초로 늘면 필요한 동시 connection 수가 크게 증가한다. 그래서 pool 고갈은 traffic spike뿐 아니라 latency spike에서도 발생한다.
| 신호 | 의미 |
|---|---|
| active가 max에 붙음 | 모든 자원이 사용 중 |
| idle이 0에 가까움 | 여유 자원이 없음 |
| pending queue 증가 | 새 요청이 자원을 기다림 |
| acquire timeout 증가 | 요청이 시작도 못 하고 실패 |
| request thread WAITING 증가 | pool 또는 downstream 대기 |
| retry count 증가 | 장애 증폭 가능성 |
고갈의 핵심 원인은 “pool 크기 부족”이 아니라 “점유 시간이 길어졌거나 유입량이 늘었거나 반환이 누락된 것”이다.
실무에서 자주 만나는 문제
- read timeout이 길어 실패 요청이 pool을 오래 점유한다.
- downstream이 느릴수록 active connection이 max에 붙고 pending이 증가한다.
- timeout을 사용자 경험과 downstream 보호 기준으로 줄인다.
- retry가 고갈을 증폭한다.
- 실패한 요청이 pool을 점유한 뒤 다시 같은 dependency를 호출한다.
- retry budget, exponential backoff, jitter, circuit breaker를 둔다.
- 모든 dependency가 하나의 pool을 공유한다.
- 중요하지 않은 API 장애가 핵심 주문 API의 outbound pool까지 막는다.
- dependency별 pool, bulkhead, timeout을 분리한다.
- connection leak 또는 반환 누락이 있다.
- active가 계속 증가하고 idle이 회복되지 않는다.
- try-with-resources, response body consume, transaction boundary를 확인한다.
- NAT port나 conntrack 고갈을 애플리케이션 pool 문제로만 본다.
- outbound connection이 많고 짧은 연결이 반복되면 인프라 자원이 먼저 찰 수 있다.
- egress IP, NAT gateway metric, conntrack table을 확인한다.
디버깅 방법
가장 먼저 pool별 상태를 같은 시간축에 놓는다.
사용자 API p95/p99
request thread busy/queued
DB pool active/idle/pending
HTTP client pool active/idle/pending
external API latency/error
retry count
circuit breaker state
NAT/conntrack metricJVM 서비스에서는 thread dump와 actuator metric을 같이 본다.
# thread dump를 여러 장 떠서 같은 대기 stack이 반복되는지 본다.
jcmd <pid> Thread.print > thread-dump-1.txt
sleep 10
jcmd <pid> Thread.print > thread-dump-2.txt
# Spring Actuator가 켜져 있다면 pool 관련 metric을 확인한다.
curl -s http://localhost:8080/actuator/metrics | jq '.names[]' | grep -E 'hikaricp|http|tomcat|executor'Linux 네트워크 자원도 확인한다.
ss -s
ss -tan state time-wait | wc -l
ss -tan state established | wc -l관찰 결과를 이렇게 해석한다.
- DB pool pending만 증가하면 DB latency, lock, transaction leak을 먼저 본다.
- HTTP client pending과 external latency가 함께 증가하면 downstream 지연이나 retry storm을 본다.
- request thread는 max인데 DB/HTTP pool이 여유롭다면 app 내부 lock, CPU, synchronized block, serialization을 의심한다.
- outbound connection 실패와 TIME-WAIT/NAT metric이 같이 증가하면 짧은 연결 폭증이나 NAT port를 본다.
코드로 확인하기
고갈을 줄이는 기본 전략은 dependency별 bulkhead와 timeout을 분리하는 것이다.
BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
.maxConcurrentCalls(30)
.maxWaitDuration(Duration.ofMillis(100))
.build();
TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(2))
.cancelRunningFuture(true)
.build();
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slowCallRateThreshold(50)
.slowCallDurationThreshold(Duration.ofSeconds(1))
.slidingWindowSize(50)
.waitDurationInOpenState(Duration.ofSeconds(10))
.build();이 코드는 값 자체보다 의도를 봐야 한다.
Bulkhead는 특정 dependency가 전체 동시성을 잡아먹지 못하게 한다.maxWaitDuration은 대기열을 무한정 허용하지 않는다.TimeLimiter는 점유 시간을 제한한다.CircuitBreaker는 이미 느려진 dependency에 계속 요청을 보내는 것을 줄인다.
단, 결제/주문/예약 같은 요청에는 circuit breaker와 retry를 넣기 전에 idempotency key, 중복 방지, 보상 트랜잭션을 먼저 설계해야 한다.
부하 테스트는 “정상 downstream”뿐 아니라 “느린 downstream”을 반드시 포함한다.
# 외부 API mock이 2초 지연되는 상황에서 caller의 pending queue와 p99를 본다.
wrk -t4 -c100 -d60s http://localhost:8080/orders테스트가 성공하려면 HTTP 200 비율만 볼 것이 아니라 pool pending, retry count, thread busy, downstream error를 같이 봐야 한다.
주니어가 자주 하는 오해
- pool 고갈은 pool size가 작아서 생긴다고만 생각한다.
- 실제로는 downstream latency 증가, 반환 누락, retry, lock, timeout 과다가 더 흔한 원인일 수 있다.
- pool을 키우면 사용자가 덜 기다릴 것이라고 생각한다.
- downstream이 병목이면 더 많은 요청을 동시에 보내 장애를 키울 수 있다.
- 평균 latency가 괜찮으면 문제가 없다고 본다.
- 고갈은 p95/p99, pending, acquire timeout에서 먼저 보인다.
- timeout을 길게 하면 실패가 줄어든다고 생각한다.
- 실패는 늦어질 뿐이고 자원 점유 시간이 늘어 전체 장애가 커질 수 있다.
시니어의 설계 판단 기준
- pool은 dependency별로 격리하고, 중요도에 따라 다른 bulkhead를 둔다.
- pool size는 instance 수를 곱한 전체 downstream 동시성으로 계산한다.
- timeout, retry, circuit breaker, queue length는 하나의 장애 제어 정책으로 다룬다.
- 빠른 실패가 사용자 경험과 시스템 보호에 더 나은 경우를 명확히 정한다.
- 고갈 대응은 scale out, pool 증설, timeout 축소, traffic shedding, feature degrade 중 무엇이 맞는지 상황별로 고른다.
인프라 협업 포인트
- NAT gateway, firewall, conntrack, load balancer connection metric을 앱 pool metric과 같은 시간대로 요청한다.
- Kubernetes에서는 pod 수 증가가 downstream connection 수와 NAT port 사용량을 얼마나 늘리는지 함께 계산한다.
- DB팀이나 인프라팀에는 “DB가 느립니다”보다 pool active/pending, query latency, lock wait, instance 수, 발생 시각을 전달한다.
- 외부 API 담당자에게는 request id, dependency endpoint, timeout 종류, retry attempt, error body를 제공한다.
실전 팁
- dashboard는 pool별 active, idle, pending, acquire duration, timeout count를 기본으로 둔다.
- 모든 pool에는 “대기 최대 시간”을 둔다. 무한 대기는 장애를 조용히 키운다.
- pool 증설은 downstream capacity 확인과 함께 한다.
- 장애가 난 dependency는 bulkhead와 circuit breaker로 격리하고, 사용자에게 줄 degraded response를 미리 정한다.
- 개인 프로젝트에서도 DB pool과 HTTP client timeout을 명시하고, 외부 API mock delay 테스트를 한 번은 해 본다.
위험 신호!
- pool pending metric이 없다.
- 모든 HTTP client가 하나의 전역 pool을 공유한다.
- retry는 있지만 retry budget, backoff, jitter가 없다.
- timeout이 길어서 장애가 사용자 timeout으로만 드러난다.
- scale out 뒤 DB connection, NAT port, downstream rate limit이 함께 증가하는지 계산하지 않는다.
확인 질문
확인 질문
- pool 고갈의 직접 신호는 무엇인가?
- active가 max에 붙고 idle이 줄며 pending queue와 acquire timeout이 증가하는 것이다.
- pool size를 키우는 것이 위험할 수 있는 이유는 무엇인가?
- downstream이 병목인 상황에서는 더 많은 동시 요청을 밀어 넣어 latency와 error를 키울 수 있기 때문이다.
- retry와 pool 고갈은 어떤 관계가 있는가?
- 실패한 요청이 자원을 점유한 뒤 다시 같은 dependency를 호출하므로 pending queue와 downstream 부하를 동시에 증가시킬 수 있다.