이 글의 위치: 기술 블로그 → Backend · 운영 판단. 작성·검증 방식은 Editorial Policy 참조.
커넥션 풀 운영 시리즈 4편. 지금까지 하나의 풀을 다뤘다면(1편~3편), 이번엔 풀을 여러 개로 나누는 격리를 다룬다.
한 풀을 공유하면 생기는 일
커넥션 풀은 본질적으로 FIFO 큐다. 먼저 요청한 쪽이 먼저 커넥션을 받는다. 문제는 긴 작업이 슬롯을 오래 잡고 있을 때다. 무거운 트랜잭션 몇 개가 풀의 커넥션을 점유하면, 그 뒤에 들어온 SELECT 1 같은 짧은 요청은 "내 쿼리는 빠른데도" 큐에서 대기해야 한다.
1편의 진단표에 있던 증상 — "긴 작업이 들어오자 짧은 요청의 p95도 같이 악화" — 가 바로 이것이다. 짧은 요청 자체는 1ms면 끝나는데, 앞의 긴 작업 때문에 acquire 단계에서 수백 ms를 기다린다. 빠른 일과 느린 일이 같은 줄에 서 있는 게 원인이다.
1. short / long 격리 (Track 5)
해결은 풀 분리다. 슬롯이 많은 shortPool과 슬롯이 적은 longPool을 따로 두고, 긴 작업은 명시적으로 long 풀로 보낸다. 그러면 짧은 요청은 전용 풀에서 긴 작업의 영향 없이 처리된다.
실험으로 확인되는 효과: 단일 풀(default)로 mixed 부하를 돌리면 짧은 쿼리 p95가 긴 작업과 함께 악화되지만, 같은 부하를 short 풀(짧은 쿼리)과 long 풀(긴 트랜잭션)로 나누면 short 풀의 p95가 단일 풀 때보다 훨씬 안정적이다. 긴 작업이 아무리 밀려도 짧은 요청의 전용 슬롯을 침범하지 못하기 때문이다.
| Hikari / Spring | pgxpool / Go | |
|---|---|---|
| 다중 풀 | @Bean 두 개 + @Qualifier |
*pgxpool.Pool 두 변수 — 명시 호출 |
| 함정 | 트랜잭션 매니저도 분리 필요 (@Transactional("longTx")) |
없음 (트랜잭션이 메서드가 아닌 conn 단위) |
여기서 Spring의 함정이 흥미롭다. DataSource를 둘로 나누는 것만으로는 부족하고, 각 풀에 대응하는 PlatformTransactionManager까지 따로 만들어 @Transactional("longTxManager")로 지정해야 한다. Spring의 트랜잭션이 메서드 경계에 묶여 있기 때문이다. Go는 트랜잭션이 커넥션 단위라 풀 변수만 골라 쓰면 끝 — 분리할 게 풀 하나뿐이다.
2. 읽기 / 쓰기 분리 (Track 6) — 같은 발상의 응용
격리의 가장 흔한 실전 형태가 reader/writer 분리다. 읽기는 대개 idempotent하고 트랜잭션이 짧아 어느 replica에서 처리해도 되지만, 쓰기는 primary에서 트랜잭션이 길어지고 락 경합 위험이 있다. 같은 풀에 섞으면 무거운 쓰기 트랜잭션이 가벼운 SELECT를 막는다 — Track 5와 정확히 같은 구조다.
| Hikari / Spring | pgxpool / Go | |
|---|---|---|
| 라우팅 | AbstractRoutingDataSource + 라우팅 키(현재 트랜잭션이 readonly?) |
호출자가 명시 — readerPool / writerPool |
| 트레이드오프 | 마법: 코드 한 곳에서 라우팅, 대신 디버깅이 어려움 | 명시: 매번 손으로, 잊으면 잘못된 풀로 감 |
Spring의 AbstractRoutingDataSource는 "현재 트랜잭션이 read-only인가"를 라우팅 키로 삼아 코드 한 줄 없이 reader/writer를 자동 선택한다. 편하지만, 어떤 쿼리가 어느 DB로 갔는지 추적하기 어렵다는 마법의 비용이 있다. Go는 호출부에서 readerPool.Query(...) / writerPool.Exec(...)를 직접 고른다. 명시적이라 추적은 쉽지만, 실수로 reader에 쓰기를 보내면 런타임 에러로야 발견된다.
학습 환경은 Postgres 단일 노드라 reader/writer 풀이 같은 DB를 본다. 그래도 핵심은 분명하다 — 풀이 분리되어 있다는 사실 자체가 격리 효과를 낸다. 실제 운영에선 reader 풀의 접속 URL을 read-replica로, writer 풀을 primary로 바꾸기만 하면 같은 코드가 그대로 동작한다.
관통하는 원리
Track 5도 6도 결국 같은 말을 한다. 자원을 용도별로 나눠 서로의 최악이 전파되지 않게 막는다. 격벽(bulkhead) 패턴이다. 그리고 두 진영의 차이는 이 시리즈에 반복되는 그 축 위에 있다 — Spring의 마법(자동 라우팅) vs Go의 명시(직접 선택). 편리함을 살 것인가, 제어권을 쥘 것인가.
정리
- 풀은 FIFO 큐 → 긴 작업이 짧은 요청을 막는다. 해결은 풀 분리(격리).
- short/long 분리 시 short 풀 p95가 안정화. Spring은 트랜잭션 매니저까지 분리해야 하는 함정, Go는 conn 단위라 풀만.
- 읽기/쓰기 분리는 같은 발상의 실전형. Spring
AbstractRoutingDataSource(마법) ↔ Go 명시 호출. - 단일 노드라도 풀 분리 자체가 격벽 효과. 운영 전환은 접속 URL만 바꾸면 됨.
마지막 편은 격리·사이징으로도 안 풀리는 경우 — 느린 SQL 자체가 커넥션을 오래 잡는 문제와, 풀을 키우기 전에 원인을 제거하는 라인을 다룬다.
참고
- Spring —
AbstractRoutingDataSource· docs.spring.io - HikariCP — 다중
DataSource구성 · github.com/brettwooldridge/HikariCP - pgx ·
pgxpool다중 풀 · pkg.go.dev/github.com/jackc/pgx/v5/pgxpool - 같은 시리즈 — 1편 · 2편 · 3편
답글 남기기