왜 커넥션 풀 고갈이 문제인가
대부분의 HTTP 클라이언트는 매 요청마다 TCP 연결을 새로 맺지 않고, 미리 만들어 둔 연결을 재사용하기 위해 커넥션 풀을 둔다. 풀 크기는 유한하므로, 응답이 느린 다운스트림이 하나라도 끼면 연결이 반납되지 않고 대기 큐가 쌓인다. 결국 애플리케이션 스레드는 "연결을 빌리려고 대기"하는 상태로 멈추고, 겉으로는 CPU도 한가한데 지연시간(latency)만 폭증하는 전형적인 장애가 발생한다.
특히 위험한 건 전파성이다. A 서비스의 풀이 고갈되면 A를 호출하는 B의 스레드도 묶이고, 다시 B의 풀이 고갈된다. 한 다운스트림의 미세한 지연이 시스템 전체로 번지는 캐스케이딩 장애의 시작점이 된다.
대표적인 증상 신호
- p99 지연은 치솟는데 CPU·메모리 사용률은 낮음
- 스레드 덤프에서 다수 스레드가 커넥션 획득 대기 지점에 멈춰 있음
TimeoutException: Timeout waiting for connection from pool류의 예외ESTABLISHED또는CLOSE_WAIT소켓 수가 풀 상한에 붙어 유지됨
먼저 현상을 관측한다
추측하지 말고 소켓 상태부터 세어라. 대기 중인 연결이 상한에 딱 붙어 있는지, CLOSE_WAIT이 쌓이는지를 보면 원인 방향이 갈린다.
# 프로세스별 소켓 상태 집계 (대상 포트 8080)
ss -tan state established '( dport = :8080 or sport = :8080 )' | wc -l
# 상태별 분포 확인 — CLOSE_WAIT 급증은 "응답 후 close 누락" 신호
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
CLOSE_WAIT이 쌓인다면 서버가 연결을 닫았는데 클라이언트가 소켓을 반납/close 하지 않은 것이다. 응답 본문(body)을 끝까지 읽거나 닫지 않는 코드가 주범인 경우가 많다.
가장 흔한 근본 원인
실무에서 마주치는 원인은 몇 가지로 수렴한다.
| 원인 | 메커니즘 | 확인 포인트 |
|---|---|---|
| 응답 미소비/미close | 연결이 풀로 반납 안 됨 | CLOSE_WAIT 증가 |
| read timeout 미설정 | 느린 응답이 연결 무기한 점유 | 스레드 무한 대기 |
| 풀 크기 < 동시성 | 구조적 대기 큐 형성 | acquire 대기시간 상승 |
| 클라이언트 매 요청 생성 | 풀 자체가 재사용 안 됨 | ESTABLISHED 폭증 |
코드로 바로잡기
핵심은 세 가지다. 클라이언트를 싱글턴으로 재사용하고, 모든 타임아웃을 명시하며, 응답을 반드시 닫는다. 파이썬 requests 기준 예시다.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# 애플리케이션 전역에서 1개만 생성해 재사용한다
session = requests.Session()
adapter = HTTPAdapter(
pool_connections=20, # 호스트 풀 개수
pool_maxsize=50, # 호스트당 최대 커넥션
max_retries=Retry(total=2, backoff_factor=0.2,
status_forcelist=[502, 503, 504]),
)
session.mount("https://", adapter)
session.mount("http://", adapter)
def fetch(url):
# connect=3s, read=5s 로 분리 지정 — read timeout 누락이 최대 함정
with session.get(url, timeout=(3, 5)) as resp:
resp.raise_for_status()
return resp.json() # with 블록이 연결을 확실히 반납한다
pool_maxsize는 무작정 키우지 말고 동시 실행 워커 수 × 안전계수 정도로 잡는다. 풀이 스레드 수보다 작으면 구조적 대기가 발생하고, 너무 크면 다운스트림에 부하를 그대로 전가한다.
타임아웃과 서킷브레이커로 전파 차단
타임아웃은 방어의 1차선이다. connect와 read 타임아웃을 반드시 분리해 지정하고, 그 위에 서킷브레이커를 얹어 느린 다운스트림을 빠르게 격리한다. 야멀 설정 예시(리졸리언스 계층)다.
resilience:
http-client:
connect-timeout: 3s
read-timeout: 5s
pool:
max-size: 50
acquire-timeout: 1s # 연결 못 빌리면 1초 뒤 즉시 실패
circuit-breaker:
failure-rate-threshold: 50 # 실패율 50% 초과 시 open
slow-call-duration: 4s
wait-duration-in-open-state: 10s
acquire-timeout이 핵심이다. 연결을 무한정 기다리지 않고 짧게 실패시켜야, 대기 큐가 스레드를 삼키는 캐스케이딩을 끊을 수 있다.
주의점
- 풀 확대는 임시방편이다. 진짜 원인이 느린 다운스트림이면 풀만 키워도 부하를 뒤로 미룰 뿐이다.
- 재시도는 폭증을 부른다. 타임아웃 없는 재시도는 이미 힘든 다운스트림에 트래픽을 배가시킨다. 반드시 백오프와 상한을 둔다.
- 클라이언트 인스턴스를 요청마다 만들지 마라. 풀 재사용이 무력화되고 소켓이 누적된다.
- 지표를 상시 노출하라. 풀 사용률, acquire 대기시간, active/idle 커넥션 수를 대시보드에 두면 장애가 터지기 전에 포화 추세가 보인다.