,

[백엔드 운영] 풀을 키우기 전에 — 느린 SQL이 커넥션을 잡고 있을 때

이 글의 위치: 기술 블로그 → Backend · 운영 판단. 작성·검증 방식은 Editorial Policy 참조.
커넥션 풀 운영 시리즈 마지막 5편. 풀을 읽고(1편) 설정하고(2편) 지키고(3편) 나눴다면(4편), 마지막은 풀 바깥의 원인 — 느린 SQL — 을 다룬다.


"풀이 작다"는 성급한 결론

active가 max에 붙어 있는 걸 보고 "풀이 작으니 키우자"라고 결론 내리는 건 사이징 글에서 경계한 바로 그 함정이다. 키우기 전에 물어야 한다 — 각 active 커넥션이 지금 어떤 SQL을 몇 ms째 잡고 있는가?

active가 높은 데는 두 가지 다른 이유가 있다. 진짜로 빠른 쿼리가 많이 몰린 것일 수도 있고, 느린 쿼리 몇 개가 커넥션을 오래 붙잡고 있는 것일 수도 있다. 앞은 풀을 키울 신호지만, 뒤는 쿼리를 고칠 신호다. 둘을 가르는 지표가 held time(이 커넥션을 몇 ms째 잡고 있나)이다.

1편의 Active Queries 패널이 이걸 보여준다 — held 200ms↑는 노랑, 1000ms↑는 빨강. held가 긴 행이 풀을 갉아먹는 범인이고, 그 행의 SQL을 보면 원인이 바로 나온다.


데모: 인덱스 하나가 바꾸는 그래프

학습 환경의 시드 데이터에는 items.order_id인덱스가 의도적으로 빠져 있다. 그래서 SELECT ... FROM items WHERE order_id = ?가 매번 순차 스캔(seq scan) 으로 전체 테이블을 훑는다. 쿼리 한 건이 커넥션을 잡는 시간이 길어지고, held가 빨갛게 뜨고, active가 max에 눌어붙는다.

여기서 인덱스를 추가한다.

CREATE INDEX IF NOT EXISTS idx_items_order ON items(order_id);

같은 부하를 인덱스 추가 전후로 돌려 비교하면 — seq scan이 인덱스 스캔으로 바뀌며 각 쿼리의 hold time이 눈에 띄게 줄고, active 평균이 내려가고, 같은 풀로 처리 가능한 RPS가 늘어난다. 풀 설정은 한 글자도 안 건드렸는데 풀 압박이 풀린 것이다. 이게 원인 제거 라인이다 — 풀 튜닝(1~4편)이 증상을 관리하는 라인이라면, SQL 튜닝은 원인을 없애는 라인이다.


왜 원인 제거가 풀 키우기보다 나은가

사이징 글에서 검산에 썼던 Little’s Law로 설명하면 깔끔하다.

필요 커넥션 수 ≈ 초당 쿼리 수(λ) × 쿼리 1건의 커넥션 점유 시간(W)

풀을 키우는 건 우변의 결과값을 수용하려고 좌변을 늘리는 일이다. 하지만 인덱스로 W(점유 시간) 자체를 줄이면, 같은 λ에 필요한 커넥션 수가 원천적으로 작아진다. seq scan을 인덱스 스캔으로 바꿔 W가 절반이 되면 필요 풀도 절반이 된다. 그리고 결정적으로 — 풀 키우기에는 천장이 있지만(DB max_connections), SQL을 빠르게 하는 데는 그 천장이 없다.

그래서 1편 점검 순서의 결론이 반복된다: "풀을 키운다"는 언제나 마지막 카드. 그 앞에 느린 SQL 제거, 작업 격리, 누수 차단이 먼저 온다.


두 진영의 관측 도구

"어떤 SQL이 얼마나 오래 잡고 있나"를 보려면 SQL의 시작과 끝을 후킹해야 한다. 접근이 다르다.

Spring Go
SQL 시작/끝 후킹 DataSource proxy (datasource-proxy / P6Spy) pgx Tracer 인터페이스
실행계획 확인 JdbcTemplate.queryForList("EXPLAIN ANALYZE ...") pool.Query(ctx, "EXPLAIN ANALYZE ...")

Spring은 DataSource를 프록시로 감싸 모든 쿼리의 실행 시간을 가로채고, Go는 pgx가 제공하는 Tracer 콜백(TraceQueryStart/TraceQueryEnd)으로 같은 일을 한다. 학습 환경에서는 두 백엔드 모두 /metrics/activeactive 커넥션별 SQL과 held time을 같은 형식으로 노출하도록 맞춰, 같은 화면에서 비교할 수 있게 했다. 도구는 달라도 보려는 것 — held 긴 SQL의 정체 — 은 같다.


시리즈를 닫으며

다섯 편을 한 줄씩 요약하면 이렇다.

  1. 지표active+idle=total≤max, awaiting은 별개. Hikari 게이지 vs pgxpool 카운터.
  2. timeout/lifetime — 빨리 실패시켜라(fast-fail). maxLifetime은 DB 타임아웃보다 짧게.
  3. 누수 — Hikari 한 줄 vs Go 직접 구현. 근본 해결은 코드의 close 보장.
  4. 격리 — 긴 작업이 짧은 요청을 막지 못하게 풀을 나눠라(격벽).
  5. 느린 SQL — 풀을 키우기 전에 점유 시간(W)을 줄여라.

관통하는 한 문장은 사이징 글의 그것과 같다 — 풀 사이징의 목표는 "더 많이"가 아니라 "충분히 작게 두고, 진짜 원인을 먼저 잡는 것"이다. HikariCP든 pgxpool이든, 화면에 뜨는 숫자가 달라도 그 아래의 판단은 같다.


정리

  • active가 높다고 풀이 작은 게 아니다. held time으로 "빠른 쿼리가 많은지, 느린 쿼리가 오래 잡는지"를 가른다.
  • 인덱스로 점유 시간(W)을 줄이면 같은 풀로 더 많은 처리량 — Little’s Law가 그 이유.
  • 풀 키우기엔 max_connections 천장이 있지만 SQL 튜닝엔 없다. 그래서 풀 확장은 마지막 카드.
  • 관측은 Spring DataSource proxy ↔ Go pgx Tracer. 도구는 달라도 보는 것은 같다.

참고

답글 남기기

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