View

728x90
반응형

PostgreSQL을 운영하는 사람들이 거의 자동으로 설치하고 가는 것이 PgBouncer다. 다만 "PgBouncer 모드 비교"라고 검색해 보면 대부분 공식문서 번역이거나 "transaction 모드를 무조건 켜라"는 통념의 반복이다. 정말 맞는 말인지 의심스러워, 백엔드 슬롯 20개를 고정하고 클라이언트를 40개·200개로 스윕하면서 직접 연결과 session/transaction/statement 3가지 풀링 모드를 한 번에 측정했다. 결론부터 말하면 "무조건 정답"은 없으며, 트래픽 패턴과 세션 기능 의존도에 따라 답이 완전히 달라진다. 이 글에서는 실측 TPS·p99 지연·거절률·hop 오버헤드·세션 기능 호환성 데이터를 그대로 제시하고, 어떤 워크로드에 어떤 모드가 맞는지 정리한다.

 

 

출처: scalegrid.io

 

PgBouncer 통념부터 먼저 정리한다

 

PgBouncer 관련 글을 보면 당연한 것처럼 전제하고 가는 통념이 몇 가지 있다. 우선 이것부터 깨고 시작해야 데이터를 제대로 읽을 수 있다.

 

"session 모드는 다중화(multiplexing)가 없다"는 정말인가

 

반은 맞고 반은 틀리다. session 모드가 클라이언트의 연결이 끊길 때까지 백엔드 슬롯을 점유하는 것은 사실이다. 다만 이것이 의미하는 바는 "동일한 동시성 천장(클라이언트 수가 백엔드 슬롯을 넘지 못함)"이지, 다중화가 0이라는 뜻은 아니다. 연결 폭풍(connection storm)을 흡수해 거절률을 0으로 만드는 효과는 그대로 존재한다. 클라이언트가 연결을 끊었다가 다시 붙는 패턴에서는 백엔드 슬롯의 재사용이 일어난다.

 

"transaction 모드가 사실상 표준"이라는 말에 깔린 가정은 무엇인가

 

이 통념은 두 가지 가정 위에 서 있다. 첫째, 트랜잭션이 짧아야 한다. 둘째, 세션 기능에 의존하지 않아야 한다. 두 가정이 모두 깨지면 transaction 풀링은 오히려 함정이다. 트랜잭션이 길면 multiplexing의 이득이 사라지며, prepared statement나 advisory lock을 쓰면 그대로 깨진다. 무조건 표준이라 할 수는 없다.

 

"statement 모드는 너무 엄격해 쓰지 않는다"는 말, 정확히 무엇이 안 되는가

 

statement 모드는 매 statement마다 백엔드 슬롯을 회수한다. 그래서 트랜잭션 자체가 깨진다. BEGIN-COMMIT 묶음이 같은 슬롯에서 처리된다는 보장이 없다. PgBouncer 공식문서가 명시한 타깃은 PL/Proxy이며, 그 외에는 극단적인 read-only burst 워크로드에서나 가끔 보이는 정도이다.

 

실험 환경 — 백엔드 20슬롯, 클라이언트 40·200 스윕

 

통념을 깨려면 데이터로 말해야 한다. 그래서 직접 시뮬레이션을 돌렸다. 실험 메타는 다음과 같다.

 

측정 지표 4종

 

처리량(TPS), p50/p99 지연(ms), 거절 연결 수(refused), 트랜잭션당 누적 hop 오버헤드(ms)이다. 여기에 더해 세션 기능 3종(prepared statement, advisory lock, 세션 SET)의 호환성도 별도로 점검했다.

 

시나리오별 모형 파라미터

 

파라미터
backend_slots 20
stmts_per_txn 6
txns_per_session 3
query_us 300 μs
hop_us 25 μs
think_us 800 μs
client_sweep 40, 200

 

왜 이 숫자들이 현실적인 부하 모델인가

 

statement 처리 시간 300μs는 인덱스를 잘 탄 단순 SELECT의 평균값이다. hop 25μs는 PgBouncer가 동일 호스트의 unix socket으로 붙어 있을 때 흔히 나오는 수치이다. think time 800μs는 애플리케이션이 결과를 받고 다음 쿼리를 만들기까지 걸리는 시간으로, 짧은 트랜잭션 워크로드의 보편적 모델이다. 백엔드 20슬롯은 클라이언트 < 백엔드(40개)클라이언트 > 백엔드(200개) 양쪽 시나리오를 만들기 위한 설정이다.

 

직접 연결과 PgBouncer 4모드 처리량 비교

 

여기가 핵심이다. 4가지 모드를 동일 백엔드·동일 부하로 돌린 결과를 표로 정리했다.

 

모드 클라이언트 ok_txns conn_refused wall_ms TPS p50_ms p99_ms hop_ms/txn
direct 40 60 20 27.368 2,192.3 7.66 7.75 0.00
session 40 120 0 56.975 2,106.2 7.45 9.09 0.30
transaction 40 120 0 53.461 2,244.6 15.41 22.00 0.35
statement 40 120 0 27.237 4,405.6 7.83 10.19 0.30
direct 200 60 180 27.551 2,177.7 7.72 8.04 0.00
session 200 600 0 301.810 1,988.0 8.24 12.01 0.30
transaction 200 600 0 235.620 2,546.5 76.14 79.85 0.35
statement 200 600 0 121.495 4,938.5 34.67 52.65 0.30

 

클라이언트 < 백엔드 슬롯 (40 클라이언트) — 직접 연결도 충분하다

 

40 클라이언트 케이스부터 보면 다소 의외이다. direct 연결이 TPS 2,192를 기록하는데, 이는 session 모드(2,106)보다 미세하게 빠르다. 이유는 단순하다. 클라이언트 수가 백엔드 슬롯보다 적다면 풀러 hop은 그저 손해다. direct는 hop 오버헤드가 0ms이고, session은 트랜잭션당 0.3ms를 추가로 소모한다. 다만 direct 역시 60건만 처리하고 20건은 거절되었는데(클라이언트 40 vs 슬롯 20), 풀러를 끼지 않으면 burst 흡수가 되지 않는다는 뜻이다. 이것이 session 모드의 진짜 가치, 즉 multiplexing이 아닌 connection storm 흡수이다.

 

클라이언트 > 백엔드 슬롯 (200 클라이언트) — 거절률이 폭발한다

 

200 클라이언트 시나리오가 진짜 갈리는 구간이다. direct는 60건만 처리하고 180건이 거절된다. 풀러가 없다면 max_connections를 넘는 순간 그대로 끝이다. 반면 session/transaction/statement 모드는 모두 거절 0건이다. 풀러가 connection storm을 흡수해 버린다.

 

transaction 모드가 throughput을 2배 끌어올리는 구간

 

200 클라이언트에서 transaction 모드의 TPS는 2,546으로 session(1,988) 대비 28% 더 높다. statement 모드는 4,938로 session 대비 약 2.5배이다. 짧은 트랜잭션에서 클라이언트 think time(800μs) 동안 백엔드를 다른 트랜잭션에 넘겨주는 다중화가 throughput을 직접 끌어올리는 것이다. 다만 함정이 있다. transaction 모드의 p99 지연이 79.85ms로 session(12ms) 대비 6배 이상 튄다. 큐잉이 발생하는 것이다. throughput은 벌었지만 꼬리 지연(tail latency)을 잃은 셈이다.

 

stdout 원문에도 이 트레이드오프가 그대로 드러나 있다.

 

[Throughput sweep]
mode         clients  ok_txns   refused   wall_ms   tps       p50_ms   p99_ms   hop_ms
direct       200      60        180       27.6      2177.7    7.72     8.04     0.00
session      200      600       0         301.8     1988.0    8.24     12.01    0.30
transaction  200      600       0         235.6     2546.5    76.14    79.85    0.35
statement    200      600       0         121.5     4938.5    34.67    52.65    0.30

 

hop 오버헤드는 무시해도 되는 수준인가

 

PgBouncer 관련 글이 자주 얼버무리는 부분이 hop 비용이다. "거의 무시할 만하다"로 넘기는 경우가 많지만, 짧은 statement 워크로드에서는 의외로 커진다.

 

트랜잭션당 누적 hop 시간 계산

 

데이터를 보면 session/statement 모드는 트랜잭션당 0.30ms, transaction 모드는 0.35ms의 hop 오버헤드를 소모한다. 25μs hop이 statement 6회씩 트랜잭션마다 들어가므로 누적되는 것이다. statement당 백엔드 처리 시간이 300μs인데, 여기에 25μs hop이 붙으면 per-statement 오버헤드가 약 8% 추가된다.

 

짧은 statement에서는 hop 비중이 의외로 크다

 

OLTP 워크로드처럼 인덱스를 잘 탄 1ms 미만 쿼리가 대다수라면 hop 비중은 5~10% 수준이다. 무시할 수 없는 숫자이다. 반대로 백엔드 처리 시간이 50ms를 넘는 분석성 쿼리에서는 hop 25μs가 그야말로 노이즈 수준이라 의미가 없다.

 

직접 연결로 빠지는 편이 나은 워크로드의 정의

 

PgBouncer 성능 데이터를 종합하면 다음 조건을 모두 만족할 때 풀러를 끼지 않는 편이 더 빠르다.

 

  • 동시 클라이언트 수 < max_connections
  • 워커가 connection lifetime을 길게 유지(connection storm 없음)
  • statement당 백엔드 처리 시간이 짧음(100~500μs)
  • 세션 기능(prepared statement 등) 적극 사용

 

내부 마이크로서비스 간 통신처럼 클라이언트 수가 통제되는 환경이 여기에 해당한다. 외부 트래픽을 받는 API 게이트웨이 뒤편이라면 그냥 풀러를 깔아야 한다.

 

세션 기능 호환성 — 여기서 모드가 갈린다

 

처리량이 전부가 아니다. 이 부분이 PgBouncer 모드 선택의 진짜 분기점이다. transaction과 statement 모드는 백엔드 연결을 트랜잭션·statement 경계에서 회수하므로, 세션 상태가 통째로 깨진다.

 

모드 prepared_stmt advisory_lock session_setting
direct OK OK OK
session OK OK OK
transaction BROKEN BROKEN BROKEN
statement BROKEN BROKEN BROKEN

 

prepared statement — transaction/statement 모드에서 깨지지만 우회법은 있다

 

서버 측 prepared statement는 백엔드 세션에 캐시되는데, transaction 모드에서는 다음 트랜잭션이 다른 백엔드 슬롯에 붙을 수 있어 캐시가 날아간다. 우회법은 두 가지다. 첫째, 클라이언트 측 prepared statement 캐시를 끄는 것이다(예: JDBC prepareThreshold=0). 둘째, PgBouncer 1.21부터 지원되는 protocol-level prepared statements 옵션을 켜는 것이다. 후자가 더 깔끔하지만 클라이언트 드라이버 호환성 확인은 필수이다.

 

advisory lock — session 모드를 강제한다

 

pg_advisory_lock은 세션이 살아 있는 동안에만 유효하다. transaction 모드에서는 트랜잭션이 끝나는 순간 백엔드가 풀에 반환되면서 락이 사라진다. 분산 잠금을 advisory lock으로 구현했다면 session 모드 외에는 답이 없다. 굳이 transaction 풀링으로 가고 싶다면 pg_advisory_xact_lock(트랜잭션 스코프) 변형으로 갈아타야 한다.

 

세션 단위 SET (search_path, timezone 등) 동작 매트릭스

 

SET search_path TO myschema 같은 세션 변수도 transaction 모드에서는 다음 트랜잭션에 보장되지 않는다. 멀티테넌시 SaaS에서 스키마 분리를 search_path에 의존했다면 그대로는 transaction 모드로 갈 수 없다. SET LOCAL로 트랜잭션 스코프로 바꾸거나, 매 트랜잭션 시작 시 search_path를 명시적으로 설정해야 한다.

 

그래서 어떤 모드를 쓸 것인가 — 의사결정 트리

 

여기까지 데이터를 모두 보았다면 이제 자기 워크로드에 매핑할 수 있다. session과 transaction 사이에서 고민하는 사람들 대부분이 이 트리를 그려 두지 않고 시작해 헛수고를 한다.

 

클라이언트 < 백엔드 슬롯 + connection storm 없음 → 직접 연결 또는 session

 

  • 내부 서비스 간 통신, 워커 풀 사이즈가 통제됨
  • 워크로드 특성상 connection lifetime이 길게 유지됨
  • → 직접 연결로 hop을 절약하거나, 안전망으로 session 모드를 채택한다

 

클라이언트 > 백엔드 슬롯 + 세션 기능 미사용 → transaction

 

  • 외부 API 트래픽이나 서버리스 함수처럼 클라이언트 수가 폭발한다
  • prepared statement·advisory lock·세션 SET 의존이 0이다
  • → transaction 풀링이 throughput과 거절률을 동시에 해결한다
  • 단, p99 지연이 튀는 부분은 반드시 모니터링해야 한다

 

prepared statement 의존 + transaction 사용 희망 → 클라이언트 측 캐시 비활성화

 

  • 클라이언트 prepared cache를 끄거나 protocol-level prepared 옵션을 활성화한다
  • ORM이라면 prepareThreshold=0 같은 옵션을 찾아본다
  • 트레이드오프: prepared의 이점 일부를 포기하고 transaction 풀링의 throughput 이득을 취한다

 

advisory lock + 분산 락 필요 → session 강제

 

  • session 모드 외에는 답이 없다
  • 또는 PostgreSQL 락 대신 Redis Redlock 같은 외부 락으로의 전환을 검토한다

 

statement 모드를 정말 써야 하는 희귀 케이스

 

statement 모드는 거의 사용하지 않는다. PgBouncer 공식문서가 지목하는 PL/Proxy 호환성을 맞추거나, 트랜잭션 자체가 없는 read-only burst 워크로드(예: 캐시 무효화용 단발 SELECT 폭주)에서나 가끔 보인다. 일반 OLTP에서는 트랜잭션이 깨지므로 답이 되지 않는다.

 

정리 — PgBouncer 모드 선택은 통념이 아닌 트레이드오프이다

 

PgBouncer 모드 비교 결과를 4가지로 압축하면 다음과 같다.

 

  • "무조건 transaction"은 거짓말이다. 짧은 트랜잭션과 세션 기능 미사용 조건일 때만 throughput에서 압승이며, 세션 기능을 쓰면 그대로 깨진다.
  • 클라이언트가 백엔드보다 적으면 직접 연결도 무방하다. 풀러 hop이 손해이므로 굳이 끼지 않는 편이 빠르다. 다만 burst 흡수는 되지 않는다.
  • 세션 기능 의존도가 모드를 강제한다. prepared statement·advisory lock·세션 SET 셋 모두 transaction/statement 모드에서 깨진다.
  • statement 모드는 거의 사용하지 않는다. PL/Proxy 호환과 극단적 burst에 한정된다.

 

본인 환경에서 동일하게 4모드를 돌려 보는 것을 강력히 권한다. 클라이언트 수, 백엔드 슬롯, 트랜잭션 길이, 세션 기능 의존도 — 이 네 변수만 자신의 숫자로 맞춰 시뮬레이션을 돌려 보면, 어떤 PgBouncer 모드가 자기 워크로드에 맞는지 30분 안에 답이 나온다. "남들이 transaction을 쓰니 나도 transaction" 식으로 가지 말고, 데이터로 결정해야 한다.

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31