,

[백엔드 운영] 긴 작업이 짧은 요청을 잡아먹을 때 — 풀 격리와 읽기/쓰기 분리

이 글의 위치: 기술 블로그 → 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 자체가 커넥션을 오래 잡는 문제와, 풀을 키우기 전에 원인을 제거하는 라인을 다룬다.


참고

답글 남기기

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