PgBouncer transaction vs session 모드, 뭘 쓸까

Postgres 앞에 PgBouncer를 두는 건 거의 관습처럼 되어 있다. 그런데 막상 처음 도입할 때 보면 pool_mode 선택지가 세 개 있고, 그중 실제로 쓰는 건 거의 둘 — transaction과 session이다. 둘 다 써본 입장에서 정리해본다. 어느 쪽이 정답이라기보다, 트레이드오프가 꽤 명확하다.
참고로 PgBouncer 1.21 이후 transaction 모드에서도 prepared statements가 지원되기 시작했다. 이게 의외로 선택 기준을 흔든다. 예전에는 ORM/드라이버 호환성 때문에 어쩔 수 없이 session 모드를 골랐던 케이스들이 다시 transaction으로 돌아갈 만한 여지가 생겼다.
session 모드 — 가장 안전하지만 풀링 효과가 약하다
pool_mode = session은 클라이언트가 연결을 끊을 때까지 동일한 서버 연결을 점유한다. 사실상 PgBouncer가 그냥 reverse proxy 역할만 하는 셈이다.
장점은 명확하다. Postgres 세션이 가지는 모든 기능이 그대로 작동한다. SET LOCAL이 아닌 SET, LISTEN/NOTIFY, advisory lock(특히 session-level), prepared statements, temporary table, cursor — 전부 안전하다. ORM이 connection을 들고 있다가 임의의 타이밍에 메타데이터를 캐싱하는 동작도 문제없다.
단점도 명확하다. 풀링이 거의 의미가 없다. 100명이 접속하면 100개의 백엔드 연결이 그대로 묶인다. 그러면 PgBouncer를 왜 두냐? 보통은 max_connections 한계는 못 줄여도, 연결 establishment 비용(TLS handshake, auth 왕복)을 PgBouncer 측에서 흡수하는 효과는 남는다. 그래도 풀 효율로만 보면 효과가 약하다.
우리 팀에서는 백오피스 어드민 툴이나 batch worker처럼 동시 연결이 적고 transaction 경계가 모호한 워크로드에 session 모드를 쓴다.
transaction 모드 — 풀링이 진짜 작동한다
pool_mode = transaction은 트랜잭션이 끝나는 순간 서버 연결을 풀에 반납한다. 클라이언트가 BEGIN을 하지 않은 statement도 PgBouncer 입장에서는 implicit transaction으로 보고 statement가 끝나면 반납이다.
이 모드에서 풀링이 본격적으로 의미가 생긴다. API 서버 50대가 각각 connection 20개씩 들고 있어도, 실제 백엔드 연결은 30~50개 수준으로 줄어든다. p99 응답 시간은 거의 영향을 안 받는다. Postgres 입장에서 connection이 늘어나면 work_mem × backend 수로 메모리 압박이 비선형으로 커지는데, 이걸 막아준다.
문제는 트랜잭션 바깥에서 가지는 상태가 다 깨진다는 점이다. SET search_path를 트랜잭션 밖에서 쓰면 다음 쿼리에서 사라진다. LISTEN은 동작 자체가 안 된다. session-level advisory lock도 마찬가지. prepared statement는 — 예전에는 깨졌다. 지금은 다르다.
1.21 이후, prepared statements 게임이 바뀌었다
PgBouncer 1.21에서 max_prepared_statements 옵션이 추가되면서 transaction 모드에서도 prepared statement가 동작한다. 내부적으로는 PgBouncer가 클라이언트가 보낸 prepared statement를 자체적으로 추적하고, 새로운 서버 연결로 라우팅될 때 lazy하게 재준비한다.
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200
이게 왜 중요하냐면, JDBC, asyncpg, pgx, Prisma 같은 모던 드라이버들은 거의 다 prepared statement를 기본으로 사용한다. 예전에는 이 때문에 transaction 모드를 못 쓰거나, 드라이버 쪽에서 prepareThreshold=0을 설정해 prepared 기능을 꺼야 했다. 둘 다 별로였다. 전자는 풀링 못 쓰는 거고, 후자는 plan caching 이점을 포기하는 거다.
지금은 PgBouncer 1.21+를 깔고 max_prepared_statements만 켜면 된다. 단, 이 카운터는 PgBouncer가 메모리에 들고 있는 캐시 크기다. 너무 크게 잡으면 PgBouncer 자체의 메모리가 늘어난다. 한 200~500 정도면 보통 충분하다.
그래서 결론
새로 도입한다면 거의 무조건 transaction 모드부터 시작한다. 풀링 효과가 진짜고, prepared statement 문제도 해결됐다. 단, 두 가지는 미리 확인한다.
첫째, LISTEN/NOTIFY를 쓰는 코드가 있는지. 결제 워커나 이벤트 라우팅에서 이걸 쓰는 팀이 가끔 있다. 있으면 그 워커만 따로 session 모드 풀에 붙이거나, NOTIFY는 트랜잭션 안에서만 쓰고 LISTEN은 직접 Postgres에 붙는 식으로 분리해야 한다.
둘째, session-level advisory lock. pg_advisory_lock()(트랜잭션 끝나도 유지되는 버전). 분산 락 용도로 가끔 쓰는데, 이건 transaction 모드에서는 절대 못 쓴다. pg_advisory_xact_lock()로 바꿔야 한다.
이 두 가지가 깨끗하면 transaction이 정답이다. 안 깨끗하면 풀을 분리하거나 일부를 session 모드로 둔다. PgBouncer는 여러 인스턴스를 두는 게 운영상 자연스럽다 — 한 박스에 transaction 풀과 session 풀 둘 다 띄우는 건 흔하다.
마지막으로 한 가지. transaction 모드 켜자마자 production에 바로 올리지 말고, 스테이징에서 트랜잭션 바깥의 SET, advisory lock, temp table 패턴이 있는지 한 번 grep해보길 권한다. 코드 리뷰만으로는 잘 안 잡힌다. 우리도 처음에 SET TIME ZONE이 트랜잭션 바깥에서 호출되는 코드를 놓쳤다가 환불 안내 메일이 UTC 기준으로 나가는 사고를 겪은 적 있다. 그 이후로는 항상 BEGIN; SET LOCAL ...; COMMIT; 패턴으로 통일했다.
혹시 다른 운영 노하우 쓰시는 분 있으면 댓글 남겨주세요.