Zettelkasten

minimumIdle을 maximumPoolSize와 같게 둔 고정 풀은 버스트 때 커넥션 생성 지연을 없앤다

·수정 2

요약

  • HikariCP 기본값은 minimumIdle = maximumPoolSize(고정 풀)다. minimumIdle을 낮추면 idle 커넥션을 반납해 DB 자원을 아끼지만, 버스트 수요가 idle 수를 넘는 순간 커넥션을 새로 맺느라(TCP+TLS+인증 핸드셰이크, 수십 ms) 그 지연을 버스트를 유발한 요청이 직접 떠안는다 — 제일 바쁜 순간에 느려진다.
  • 단 멀티프로세스(gunicorn worker × 인스턴스)에선 고정 풀이 항상 프로세스 수 × 풀 크기만큼 DB 커넥션을 상시 점유한다. 그래서 풀 자체를 작게 역산한 뒤 그 작은 풀을 고정 풀로 채우는 게 절충이다.

본문

풀의 두 노브 maximumPoolSize는 풀이 가질 수 있는 커넥션 상한, minimumIdle은 놀고 있어도 유지할 최소 idle 커넥션 수다. 이 둘을 같게 두면 풀 크기가 고정된다(고정 풀). HikariCP는 기본값이 둘을 같게 잡고, 제작자는 minimumIdle을 따로 설정하지 말라고 권장한다.

minimumIdle < max로 두면 생기는 일 평소엔 idle 커넥션을 적게 유지하다가, 트래픽 버스트가 와서 동시 수요가 idle 수를 넘으면 HikariCP가 그 자리에서 커넥션을 새로 만든다. 커넥션 생성은 TCP 연결 + TLS + DB 인증 핸드셰이크라 수십 ms가 든다. 그리고 그 생성 지연은 버스트를 유발한 바로 그 요청이 지불한다. 즉 부하가 가장 몰리는 순간에, 하필 커넥션 만드느라 응답이 느려지는 역설이 생긴다.

그래서 고정 풀을 권장하는 이유 minimumIdle을 낮춰서 얻는 이득은 "idle 커넥션을 반납해 DB 자원을 아끼는 것"인데, 이 이득이 위의 버스트 응답시간 손해보다 대부분 작다는 판단이다. 미리 다 띄워두면 버스트가 와도 기다릴 필요 없이 즉시 꺼내 쓴다. 풀 크기가 고정이라 동작도 예측 가능해진다.

고정 풀 (min=max) 탄력 풀 (min<max)
버스트 응답성 스파이크 없음 (이미 떠 있음) 생성 지연 스파이크
DB 자원 항상 max 점유 idle 시 반납
예측 가능성 높음 낮음

멀티프로세스에선 전제가 깨질 수 있다 — 핵심 주의점 HikariCP의 "idle 반납 이득은 작다"는 전제는 "앱 한 프로세스에 풀 하나" 같은 단순 환경을 가정한다. 그런데 gunicorn 워커·다중 인스턴스 환경에선 풀이 프로세스마다 따로 뜬다.

상시 점유 = 인스턴스 수 × 워커 수 × 워커당 풀 크기
예: 10 × 4 × 100 = 4,000 커넥션을 항상 점유

워커당 풀에 고정 풀을 그대로 적용하면 idle 커넥션이 N배로 증폭돼 DB의 max_connections를 압박하고, MySQL이면 커넥션마다 per-thread 버퍼 메모리까지 먹는다. 이 환경에서 "워커당 100을 고정 풀"은 위험하다.

절충: 풀을 작게 역산한 뒤 그 작은 풀을 고정 풀로 풀 크기 자체를 DB max_connections 여유분 ÷ (인스턴스 × 워커)로 역산해 작게(예: 워커당 30~40) 잡으면 증폭 문제가 사라진다. 그렇게 작아진 풀에 대해서는 다시 고정 풀(full pre-warm)을 적용하는 게 자연스럽다 — 어차피 작으니 미리 다 띄워도 DB 부담이 적고, 버스트 레이턴시 스파이크는 없앨 수 있다. "풀을 작게 만들어 증폭을 잡고, 그 작은 풀은 꽉 채워 응답성을 확보"하는 두 단계다.

Python에는 minimumIdle 등가가 없다 HikariCP는 Java(JDBC) 라이브러리라 Python에선 쓰지 않는다. SQLAlchemy QueuePool은 커넥션을 요청 올 때마다 lazy하게 pool_size까지 만들 뿐, "미리 N개 띄워 유지"하는 minimumIdle 기능이 기본엔 없다. Python에서 pre-warm을 원하면 앱 시작 시 커넥션을 명시적으로 미리 여는 코드를 직접 넣어야 한다(gevent라면 monkey-patch가 import보다 먼저 실행돼야 함도 별개 전제).

관련 노트

참고