이 글의 위치: 기술 블로그 → 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처럼. 같은 "풀이 막혔다"를 모니터링으로 옮길 때 한쪽은 게이지 임계, 한쪽은 카운터 미분이 되는 것이다.
점검 순서 — 무엇부터 볼까
증상이 보이면 이 순서로 좁힌다.
- awaiting / EmptyAcquireCount — 풀이 막혔나? (막혔으면 고갈 라인으로)
- active vs max — 한계에 붙었나, 여유가 있나?
- p99 vs p50 — 전체가 느린가, 꼬리만 느린가?
- Active Queries의 held — 느리면 그 범인 SQL이 뭔가?
- errors — 결국 사용자에게 실패로 나갔나?
"풀을 키운다"는 이 순서의 마지막에 온다. 대개는 그 전에 진짜 원인 — 느린 쿼리, 안 끝나는 트랜잭션, 격리되지 않은 작업, 반납 안 된 누수 — 이 잡힌다.
정리
- 풀 상태는
active + idle = total ≤ max, 그리고 awaiting은 별개.active = max+awaiting > 0= 고갈. - Latency는 p50과 p99의 간격으로 읽는다. 풀 문제는 꼬리에서 먼저 보인다.
- 같은 고갈도 Hikari는 게이지(awaiting), pgxpool은 누적 카운터(EmptyAcquireCount)로 다르게 드러난다 → 모니터링 임계 설계가 달라진다.
- 기억할 한 줄: 풀을 키우는 건 마지막 카드. 숫자로 진짜 범인을 먼저 좁힌다.
다음 편에서는 "얼마나 기다리고, 얼마나 오래 살릴 것인가" — connectionTimeout과 maxLifetime을 두 진영이 어떻게 다르게 다루는지, 그리고 DB가 먼저 끊은 죽은 커넥션을 풀이 빌려주는 함정을 다룬다.
참고
- HikariCP —
HikariPoolMXBean(getThreadsAwaitingConnection) · github.com/brettwooldridge/HikariCP - pgx ·
pgxpool.Stat(EmptyAcquireCount·AcquireDuration·CanceledAcquireCount) · pkg.go.dev/github.com/jackc/pgx/v5/pgxpool - 같은 시리즈 — 커넥션 풀 크기는 무엇으로 정하는가 (사이징 공식·Little’s Law)
답글 남기기