,

[백엔드 운영] 커넥션 풀이 막히는 순간을 숫자로 읽는 법 — Hikari와 pgxpool은 같은 고갈을 다르게 보여준다

이 글의 위치: 기술 블로그 → Backend · 운영 판단. 작성·검증 방식은 Editorial Policy 참조.
본 글은 Spring Boot + HikariCP 와 Go + pgxpool 을 같은 부하로 나란히 돌리는 학습 환경에서, 커넥션 풀 대시보드의 숫자를 어떻게 읽는지 정리한 커넥션 풀 운영 시리즈 1편이다. 풀 크기를 정하는 공식은 별도 글 커넥션 풀 크기는 무엇으로 정하는가에서 다뤘고, 이 글은 그 풀을 띄운 무엇을 보고 판단하는지를 다룬다.


풀 문제는 항상 "숫자가 이상해진다"로 시작한다

서비스가 느려졌다. 로그엔 connection timeout이 찍히고, 대시보드의 어떤 선은 천장에 붙어 있고 어떤 선은 바닥에서 들썩인다. 이때 풀을 키울지, 쿼리를 고칠지, 작업을 격리할지를 가르는 건 감이 아니라 그 숫자들이 무엇을 말하는지 읽는 능력이다.

커넥션 풀 대시보드는 보통 세 묶음을 보여준다 — 풀 상태(active·idle·awaiting·total·max), Latency(p50·p95·p99·errors), Active Queries(지금 어떤 SQL이 커넥션을 잡고 있나). 이 글은 이 숫자들을 한 장의 멘탈 모델로 묶고, 마지막에 HikariCP와 pgxpool이 같은 고갈을 어떻게 다르게 보여주는지까지 정리한다.


한 장짜리 멘탈 모델

커넥션 풀은 "이미 열어둔 DB 커넥션"을 빌려주고 돌려받는 큐다. 요청이 오면 풀에서 커넥션을 acquire(빌림) → SQL 실행 → release(반납) 한다.

요청 ── acquire() ─┬─ idle 커넥션 있음        → 즉시 빌려줌
                  ├─ 없지만 total < max     → 새로 하나 만들어 빌려줌
                  └─ 없고 total == max      → 줄 서서 기다림(awaiting)
                                              │
                                  timeout 안에 못 빌리면 → 에러

여기서 외워야 할 항등식은 두 개다.

active + idle = total
total ≤ max
  • active: 지금 SQL을 실행 중인 = 빌려나간 커넥션 수
  • idle: 놀고 있어 즉시 빌려줄 수 있는 커넥션 수
  • total: 풀이 지금 들고 있는 실제 커넥션 수 (= active + idle)
  • max: 상한선 (maximumPoolSize / MaxConns)
  • awaiting: total이 max에 닿은 뒤에도 요청이 더 와서 줄 서서 기다리는 요청 수

핵심은 awaiting은 total과 별개라는 점이다. total은 max를 넘을 수 없지만, 요청은 얼마든지 더 올 수 있다. 그 초과분이 awaiting에 쌓인다. "풀이 막혔다"의 정의가 바로 이것active = max 이면서 awaiting > 0.


풀 상태 다섯 숫자 — 건강한 모양과 이상 신호

지표 의미 건강한 모양 이상 신호
active 실행 중인 커넥션 부하 따라 오르내림, max 아래 여유 계속 max에 붙어 있음 → 포화
idle 즉시 빌려줄 수 있는 여유 0보다 큼 0에 오래 머묾 → 곧 대기 발생
awaiting 못 빌려 대기 중인 요청 0 0보다 큼 → 풀 고갈. 가장 직접적 위험
total 실제 보유 커넥션 min↔max 사이 변동 max에 붙은 채 active도 max → 한계
max 상한(설정값) 고정선

차트를 읽는 한 줄 규칙: active(실행 중)가 max에 닿고 awaiting이 바닥에서 뜨기 시작하면, 그 순간이 풀이 부족해지는 지점이다.


Latency — p50과 p99의 간격이 진단이다

풀 상태가 "공급"이라면 Latency는 그 결과로 사용자가 느끼는 "체감"이다. 분위수(percentile)로 본다.

  • p50(중앙값): 보통의 사용자가 느끼는 속도
  • p95 / p99: 상위 5% / 1%의 느린 꼬리(tail)
  • errors: 실패한 요청 — 대부분 acquire timeout(풀에서 커넥션을 못 빌림)

진단의 핵심은 p50과 p99의 간격이다.

  • p50·p99 둘 다 낮음 → 건강
  • p50은 낮은데 p99만 치솟음 → 대부분 빠른데 일부가 풀에서 대기하거나 슬로우 SQL에 걸림 (꼬리 지연)
  • p50까지 같이 오름 → 시스템 전체가 느려짐 (포화 / DB 병목)

"평균 응답시간"만 보면 이 구분이 사라진다. 풀 문제는 거의 항상 꼬리에서 먼저 드러나기 때문에 p99를 따로 봐야 한다.


★ 증상 → 진단 → 조치

대시보드의 숫자가 이렇게 움직이면, 무슨 일이고, 무엇을 먼저 고려해야 하는가. 이 표가 풀 운영의 요약이다.

증상(지표 변화) 진단 조치 (우선순위)
awaiting > 0 지속 + active = max 풀 고갈. 들어오는 속도 > 비는 속도 ① 쿼리를 더 빠르게(인덱스·튜닝)가 1순위 ② 그래도 부족하면 size↑ ③ 단, DB max_connections 한도 안에서
active = max 인데 awaiting = 0 포화 직전 — 여유가 없을 뿐 아직 안 막힘 헤드룸이 없음. 부하가 조금만 더 늘면 대기 시작 → size 약간↑ 검토
p50 낮은데 p99만 급등 꼬리 지연 — 일부가 대기 또는 슬로우 SQL Active Queries에서 held 긴 SQL 확인 → 슬로우면 SQL 튜닝, 대기면 격리 검토
errors 증가(주로 timeout) timeout 안에 커넥션을 못 빌림 = 고갈의 결과 timeout을 늘리면 에러는 줄지만 p99가 오름(트레이드오프). 근본 원인부터
긴 작업 들어오자 짧은 요청 p95도 악화 한 풀을 긴/짧은 작업이 공유 → 긴 작업이 독점 풀 격리(short/long 분리)
누수 reported↑ / 시간 지나도 active 안 줄어듦 빌리고 release를 안 함(코드 버그) 누수 추적기로 stacktrace, 결국 코드에서 close 보장

이 표에서 반복되는 결론이 하나 있다 — "풀을 키운다"는 거의 항상 마지막 카드다. 대개는 느린 SQL을 빠르게 하거나(원인 제거) 작업을 격리하는 것이 먼저다. DB의 max_connections라는 천장이 있기 때문이다. (이 결론의 근거는 사이징 글의 "왜 작은 풀이 더 빠른가"와 이어진다.)


같은 고갈, 다른 화면 — Hikari 게이지 vs pgxpool 카운터

여기까지는 두 진영 공통이다. 그런데 똑같이 풀이 고갈됐을 때 화면에 뜨는 숫자가 다르다. 이게 HikariCP와 pgxpool을 나란히 놓고 봤을 때 가장 인상적인 차이였다.

HikariCP는 getThreadsAwaitingConnection()이라는 게이지(현재값)를 직접 준다. 풀이 막히면 awaiting 막대가 즉시 빨갛게 솟는다. 반면 pgxpool에는 그 게이지가 없다. 대신 누적 카운터로 같은 상황을 추정한다. 그래서 Go 쪽 awaiting은 항상 0이고, 아래 숫자들을 봐야 한다.

pgxpool 카운터 의미 이게 늘면
EmptyAcquireCount 빌리려는데 idle이 없어 기다린 횟수(누적) Hikari awaiting의 대용. 빠르게 늘면 풀 부족
AcquireDuration 커넥션 빌리는 데 걸린 평균 시간 오르면 빌리기까지 대기 발생
CanceledAcquireCount 기다리다 ctx timeout으로 취소된 횟수 = Hikari의 errors(timeout)에 해당
ConstructingConns 지금 새로 만드는 중인 커넥션 수 스파이크 시 잠깐 오름(정상)

핵심은 이렇다. 같은 부하에서 Hikari는 awaiting 막대가 빨갛게 솟지만, Go는 awaiting=0이라 "안 막힌 것처럼" 보인다. 실제로는 EmptyAcquireCount가 빠르게 오르고 있는 것 — 게이지(현재값) vs 카운터(누적값)의 관측 패러다임 차이다.

이 차이는 단순한 트리비아가 아니라 알림 설계를 바꾼다. Hikari는 "awaiting > 0이 N초 지속" 같은 현재값 임계로 경보를 걸 수 있다. pgxpool은 누적 카운터라 그대로 임계를 걸 수 없고, 단위 시간당 증가율(rate)로 바꿔서 봐야 한다 — rate(empty_acquire_count[1m]) > 0처럼. 같은 "풀이 막혔다"를 모니터링으로 옮길 때 한쪽은 게이지 임계, 한쪽은 카운터 미분이 되는 것이다.


점검 순서 — 무엇부터 볼까

증상이 보이면 이 순서로 좁힌다.

  1. awaiting / EmptyAcquireCount — 풀이 막혔나? (막혔으면 고갈 라인으로)
  2. active vs max — 한계에 붙었나, 여유가 있나?
  3. p99 vs p50 — 전체가 느린가, 꼬리만 느린가?
  4. Active Queries의 held — 느리면 그 범인 SQL이 뭔가?
  5. errors — 결국 사용자에게 실패로 나갔나?

"풀을 키운다"는 이 순서의 마지막에 온다. 대개는 그 전에 진짜 원인 — 느린 쿼리, 안 끝나는 트랜잭션, 격리되지 않은 작업, 반납 안 된 누수 — 이 잡힌다.


정리

  • 풀 상태는 active + idle = total ≤ max, 그리고 awaiting은 별개. active = max + awaiting > 0 = 고갈.
  • Latency는 p50과 p99의 간격으로 읽는다. 풀 문제는 꼬리에서 먼저 보인다.
  • 같은 고갈도 Hikari는 게이지(awaiting), pgxpool은 누적 카운터(EmptyAcquireCount)로 다르게 드러난다 → 모니터링 임계 설계가 달라진다.
  • 기억할 한 줄: 풀을 키우는 건 마지막 카드. 숫자로 진짜 범인을 먼저 좁힌다.

다음 편에서는 "얼마나 기다리고, 얼마나 오래 살릴 것인가" — connectionTimeoutmaxLifetime을 두 진영이 어떻게 다르게 다루는지, 그리고 DB가 먼저 끊은 죽은 커넥션을 풀이 빌려주는 함정을 다룬다.


참고

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다