이 글의 위치: 기술 블로그 → Backend · 운영 판단. 작성·검증 방식은 Editorial Policy 참조.
커넥션 풀 운영 시리즈 3편이자 비교 학습의 백미. 1편 지표·2편 timeout/lifetime에 이어, 이번엔 한쪽엔 있고 한쪽엔 없는 기능 — 커넥션 누수 추적 — 을 다룬다.
누수: 풀이 조용히 말라죽는 방식
커넥션을 빌리고(acquire) 반납(release)을 안 하면 그 슬롯은 영영 막힌다. 코드 어딘가에서 close()를 빠뜨리거나, 예외 경로에서 반납이 누락되거나, try-finally를 안 감싼 한 줄이면 충분하다.
증상은 1편의 진단표에 있던 그것이다 — 시간이 지나도 active가 안 줄어든다. 부하가 멎었는데도 active가 우상향으로 누적되다가, 결국 풀이 고갈돼 모든 요청이 timeout으로 떨어진다. 트래픽이 원인인 고갈은 부하가 빠지면 회복되지만, 누수성 고갈은 재시작 전까지 회복되지 않는다는 점이 다르다.
풀이 해줄 수 있는 일은 분명하다: 이 커넥션을 누가, 언제, 어디서(stacktrace) 빌려갔는지 기억해뒀다가, 임계 시간을 넘겨도 안 돌아오면 신고하는 것.
두 진영 — 옵션 한 줄 vs 직접 구현
| Hikari | pgxpool | |
|---|---|---|
| 설정 | leakDetectionThreshold = 2000 |
없음 — 직접 wrapping |
| 스택트레이스 | 자동 캡처 → 로그 출력 | 직접 runtime.Stack(...) |
| 코드 위치 | 설정 한 줄 | internal/observability/leak.go (~120줄) |
Hikari — 설정 한 줄, 그리고 숨은 가드
spring:
datasource:
hikari:
leak-detection-threshold: 2000 # ms
이 한 줄이면 끝이다. 임계(2000ms)를 넘겨도 반납 안 된 커넥션이 있으면, Hikari가 그 커넥션을 빌려간 지점의 stacktrace를 자동으로 로그에 찍는다(Apparent connection leak detected). 범인 코드 줄까지 바로 나온다.
단, 알아둘 가드가 하나 있다. leakDetectionThreshold가 2000ms 미만이면 Hikari가 안전장치로 자동 비활성화한다(로그: leakDetectionThreshold is less than 2000ms ... disabling it). 짧게 잡아 정상적인 느린 쿼리까지 누수로 오인하는 걸 막기 위해서다. 그래서 "왜 누수 로그가 안 떠?"의 흔한 원인이 바로 임계를 1000 같은 값으로 둔 것이다 — 반드시 2000 이상이어야 한다.
Go pgxpool — 같은 UX를 손으로
pgxpool엔 그런 옵션이 없다. 그래서 acquire/release를 감싸는 추적기를 직접 만든다.
// internal/observability/leak.go
func (w *LeakWatcher) Begin(pool, label string) int64 {
id := w.nextID++
buf := make([]byte, 4096)
n := runtime.Stack(buf, false) // ← 스택트레이스 캡처
w.inFlight[id] = &LeakEntry{
AcquiredAt: time.Now().UnixMilli(),
Stack: string(buf[:n]),
}
return id
}
// loop(): 0.5s마다 in-flight 전체를 훑어 threshold 초과한 것을 신고
Begin에서 runtime.Stack으로 호출 지점을 캡처하고 시각을 기록한 뒤, 백그라운드 루프가 0.5초마다 in-flight 목록을 훑어 임계를 넘긴 항목을 신고 목록으로 옮긴다. 반납 시 End(id)로 in-flight에서 지운다. Hikari가 옵션 한 줄로 주던 것과 화면상 똑같은 결과 — 임계 설정, 누수 감지, stacktrace — 를 약 120줄로 재현한 셈이다.
추상화의 값, 그리고 자동의 비용
이 트랙의 메시지는 "Go가 불편하다"가 아니다. 프레임워크가 공짜로 해주던 게 정확히 무엇인지를 직접 만들어 보면 알게 된다는 것이다. Hikari의 한 줄 뒤에는 스택 캡처, in-flight 추적, 백그라운드 스캐너, 오탐 방지 가드가 다 들어 있다. 추상화의 값이란 곧 프레임워크가 흡수해 둔 복잡도다.
반대로 자동에도 비용은 있다. 스택트레이스 캡처는 모든 acquire에 얹히는 오버헤드라, Hikari도 운영에서 임계를 무한정 낮게 두진 않는다(그래서 2000ms 가드가 있다). Go처럼 직접 만들면 그 비용 지점을 내가 제어할 수 있다 — 캡처 깊이, 스캔 주기, 신고 보관량까지. 마법의 편리함 vs 명시의 제어권이라는, 이 시리즈에 반복되는 트레이드오프가 여기서도 나타난다.
그리고 둘 다 잊지 말 점: 감지는 안전망일 뿐, 근본 해결은 코드에서 반납을 보장하는 것이다. Java라면 try-with-resources, Go라면 defer rows.Close() / defer conn.Release(). 누수 추적기는 "이미 새는 곳을 빨리 찾는" 도구이지, 새지 않게 만드는 도구가 아니다.
정리
- 누수 = 반납 안 된 커넥션의 누적. 트래픽성 고갈과 달리 재시작 전까지 회복 안 됨.
- Hikari
leakDetectionThreshold는 한 줄로 stacktrace까지. 단 2000ms 미만이면 자동 비활성(흔한 함정). - pgxpool은 같은 UX를
runtime.Stack+ 백그라운드 스캐너로 직접 구현 → 추상화가 흡수하던 복잡도가 드러난다. - 자동(편리)이냐 직접(제어)이냐의 트레이드오프. 어느 쪽이든 근본 해결은 코드에서 close 보장.
다음 편은 풀을 나누는 이야기 — 긴 작업이 짧은 요청을 잡아먹지 못하게 하는 풀 격리와 읽기/쓰기 분리.
참고
- HikariCP —
leakDetectionThreshold("Apparent connection leak") · github.com/brettwooldridge/HikariCP - Go
runtime.Stack· pkg.go.dev/runtime#Stack - pgx ·
pgxpool.Pool.Acquire/Conn.Release· pkg.go.dev/github.com/jackc/pgx/v5/pgxpool - 같은 시리즈 — 1편 지표 읽는 법 · 2편 timeout/lifetime
답글 남기기