PostgreSQL은 커넥션 하나를 OS 프로세스 하나로 매핑하는 프로세스 기반 아키텍처입니다. 안정성과 격리에는 장점이지만, 커넥션이 수백 개로 늘어나는 순간 스케줄링 오버헤드와 메모리 압박이 급격히 커집니다. 애플리케이션이 오토스케일링으로 불어나거나 서버리스 함수가 커넥션을 폭발적으로 열면 FATAL: sorry, too many clients already 에러와 함께 DB가 응답을 멈춥니다.

이 문제의 정석적인 해법이 PgBouncer입니다. 애플리케이션과 PostgreSQL 사이에 앉아, 수천 개의 클라이언트 연결을 수십 개의 실제 서버 커넥션으로 다중화(multiplexing)하는 경량 풀러입니다. 이 글에서는 풀링 모드 설계, transaction 모드의 지뢰(prepared statement·advisory lock·SET), 풀 크기 산정, 운영 지표까지 실무 관점에서 정리합니다.

왜 PostgreSQL은 커넥션에 취약한가

애플리케이션 서버는 커넥션당 스레드를 쓰지만, PostgreSQL은 커넥션마다 별도의 백엔드 프로세스를 fork합니다. 프로세스 하나는 work_mem·정렬 버퍼·플랜 캐시로 수 MB에서 수십 MB의 상주 메모리를 잡아, 커넥션 500개면 수 GB가 사라집니다.

더 큰 비용은 스케줄링과 잠금 경합입니다. 활성 백엔드가 코어 수를 넘으면 컨텍스트 스위칭이 폭증하고, 유휴 커넥션조차 스냅샷을 뜰 때 순회하는 ProcArray를 길게 만들어 활성 쿼리까지 느려집니다.

핵심 통찰은 “클라이언트 연결 수”와 “동시에 쿼리를 실행하는 커넥션 수”는 다른 값이라는 점입니다. 웹 요청 1000개가 열려 있어도 실제 SQL 실행 중인 것은 수십 개뿐이며, PgBouncer는 바로 이 간극을 이용합니다.

PgBouncer의 다중화 원리

PgBouncer는 클라이언트가 맺는 client connection과 PostgreSQL에 맺는 server connection을 별도로 관리하며, 이 둘은 1:1이 아닙니다. 한 client가 서버 자원을 쓰는 짧은 순간에만 server connection을 빌려주고 유휴가 되면 회수해 다른 client에게 재배정합니다.

덕분에 max_client_conn = 5000이면서 default_pool_size = 20인 구성이 가능합니다. 5000개 클라이언트가 붙어도 PostgreSQL이 보는 실제 커넥션은 20개뿐이며, 이 회수의 공격성을 결정하는 것이 풀링 모드입니다.

# /etc/pgbouncer/pgbouncer.ini — 기본 골격
[databases]
app = host=10.0.1.20 port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256   # 최신 PG 는 scram 권장 (md5 지양)
pool_mode = transaction
max_client_conn = 5000      # 받을 수 있는 클라이언트 총량
default_pool_size = 20      # (DB, user) 쌍당 서버 커넥션 수

세 가지 풀링 모드 설계

PgBouncer의 성패는 pool_mode 선택에 달려 있습니다. 각 모드는 “server connection을 언제 반납하는가”를 정의하며, 곧 세션 상태 유지 여부와 직결됩니다.

  • session: 연결이 끊길 때까지 독점. 다중화 이득이 거의 없어 커넥션 수 제한 용도로만 유효하나 SET·LISTEN/NOTIFY·세션 락 모두 안전.
  • transaction: COMMIT/ROLLBACK 순간 반납. 다중화 효율이 가장 높아 사실상의 표준. 대신 세션 상태가 트랜잭션 경계를 넘지 못함.
  • statement: 개별 쿼리가 끝나면 반납. 멀티 스테이트먼트 트랜잭션 금지. 특수 상황용.

절대다수의 OLTP 워크로드는 transaction 모드가 정답입니다. 다만 “한 세션의 SET이나 준비된 구문이 다음 트랜잭션에서 다른 server connection을 만나 사라질 수 있다”는 전제를 이해해야 합니다. 아래에서 이 지뢰들을 다룹니다.

지뢰 1: Prepared Statement 재사용 붕괴

대부분의 드라이버(psycopg·PgJDBC·node-postgres)는 성능을 위해 서버 사이드 prepared statement를 씁니다. 이 준비된 구문은 특정 server connection에 귀속되는데, transaction 모드에서는 다음 트랜잭션이 다른 server connection에 배정될 수 있어 prepared statement "s1" does not exist 에러가 납니다.

과거에는 클라이언트 사이드 준비 구문 전환(PgJDBC prepareThreshold=0, psycopg3 prepare_threshold=None)이 유일한 답이었습니다. 하지만 PgBouncer 1.21부터 프로토콜 수준 prepared statement 추적이 추가되었습니다.

# 최신 PgBouncer: transaction 모드에서 서버 사이드 준비 구문 유지
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200   # 서버 커넥션당 캐시할 구문 수 (0=비활성)

이를 켜면 PgBouncer가 각 server connection에 준비 구문을 자동으로 다시 심어주어, transaction 모드에서도 서버 사이드 준비 구문을 그대로 쓸 수 있습니다. 단, 확장 쿼리 프로토콜로 만든 구문만 처리하므로, 명시적 PREPARE SQL 문을 직접 실행하는 드라이버는 추적되지 않습니다.

지뢰 2: 세션 상태와 SET, advisory lock

transaction 모드에서 트랜잭션 밖의 세션 상태는 신뢰할 수 없습니다. 대표적으로 아래가 깨집니다.

  • 트랜잭션 밖 SET search_path → 다음 쿼리는 다른 커넥션이라 초기화됨
  • 세션 pg_advisory_lock() → 잡은 커넥션과 해제 커넥션이 달라져 락이 영구히 남음
  • LISTEN/NOTIFY → 어느 커넥션이 리스닝 중인지 보장 불가
  • 임시 테이블 → 커넥션 종속이라 사라짐

해법은 모든 상태를 트랜잭션 내부로 국한하는 것입니다. search_pathSET LOCAL로, 세션 락은 트랜잭션 수준 advisory lock(pg_advisory_xact_lock)으로 바꾸면 COMMIT 시 자동 해제됩니다.

-- 나쁜 예: 세션 SET + 세션 advisory lock (transaction 모드에서 위험)
SET search_path TO tenant_42;
SELECT pg_advisory_lock(42);        -- 해제 커넥션이 달라질 수 있음

-- 좋은 예: 트랜잭션 로컬로 모든 상태를 가둔다
BEGIN;
SET LOCAL search_path TO tenant_42; -- COMMIT/ROLLBACK 시 원복
SELECT pg_advisory_xact_lock(42);   -- 트랜잭션 종료 시 자동 해제
COMMIT;

LISTEN/NOTIFY가 반드시 필요한 워커라면 그 워커만 분리합니다. PgBouncer는 [databases] 항목마다 다른 pool_mode를 줄 수 있어(예: app_notify = ... pool_mode=session), 한 인스턴스에서 transaction 풀과 알림용 session 풀을 함께 운용할 수 있습니다.

풀 크기 산정: 클라이언트 수가 아니라 활성 동시성

가장 흔한 실수는 default_pool_size를 애플리케이션 커넥션 수에 맞춰 크게 잡는 것입니다. 그러면 풀러를 두고도 DB는 여전히 수백 개 커넥션을 받아 도입 의미가 사라집니다. 산정 기준은 DB가 동시에 유용하게 실행할 수 있는 쿼리 수입니다. CPU 바운드면 코어 수 근처, I/O가 섞인 OLTP면 코어의 2~4배가 출발점이며, 나머지는 대기 큐에서 짧게 기다리게 하는 것이 오히려 전체 처리량을 높입니다.

# 산정 예시 (16코어, SSD, 혼합 OLTP) — 병렬 처리 쿼리 수 ≈ core * 2~4
default_pool_size = 40      # (db,user) 쌍당 서버 커넥션
reserve_pool_size = 10      # 순간 버스트용 예비 풀
reserve_pool_timeout = 3    # 기본 풀 소진 후 예비 개방까지 대기(초)
min_pool_size = 8           # 최소 상시 유지 (콜드 스타트 완화)
# max_connections >= (풀 쌍 수)*(default+reserve) + superuser 예약

여러 (database, user) 조합은 각각 별도 풀을 가집니다. 사용자 10명 × DB 3개면 풀이 30개가 되므로, 총합이 max_connections를 넘지 않게 역산하세요.

운영 관측: SHOW 명령과 지표

PgBouncer는 pgbouncer라는 가상 관리 DB에서 SHOW 명령으로 내부 상태를 노출하며, 이는 곧 운영 대시보드와 경보의 원천이 됩니다.

# 관리 콘솔 접속 (admin_users 로 지정된 계정)
psql "host=127.0.0.1 port=6432 dbname=pgbouncer user=pgb_admin"

SHOW POOLS;   # cl_waiting 이 0 이 아니면 풀 소진 신호
# database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
SHOW SERVERS; # 서버 커넥션 상태 (idle/active/used/tested)
SHOW STATS;   # 처리량·평균 쿼리·평균 대기 시간 통계

가장 중요한 조기 경보 지표는 cl_waitingmaxwait입니다. cl_waiting이 지속적으로 0보다 크고 maxwait가 수백 밀리초 이상으로 늘면 풀이 소진되어 클라이언트가 server connection을 기다린다는 뜻이며, 이때는 default_pool_size를 늘리기보다 느린 쿼리를 잡거나 트랜잭션 범위를 줄이는 것이 우선입니다.

  • cl_waiting > 0 지속: 풀 소진 → 슬로우 쿼리·긴 트랜잭션 조사.
  • sv_idle 항상 높음: 풀 과대 산정 → 크기를 줄인다.
  • avg_query_time 급증: DB 자체 병목 → PgBouncer로 해결 불가.

타임아웃과 서킷 브레이커

PgBouncer는 여러 타임아웃으로 병목이 상류로 전파되는 것을 막습니다. 이 값을 명시하지 않으면 한 느린 쿼리가 전체 풀을 잠식하고 대기 큐가 무한정 길어져 애플리케이션 전체가 멈춥니다.

[pgbouncer]
query_wait_timeout = 5        # 풀 대기 상한, 초과 시 즉시 에러(fail fast)
query_timeout = 30            # 단일 쿼리 상한, 초과 시 커넥션 강제 종료
idle_transaction_timeout = 15 # 유휴 트랜잭션 정리
server_lifetime = 3600        # 서버 커넥션 최대 수명
server_idle_timeout = 300

query_wait_timeout은 사실상 애플리케이션 레벨 서킷 브레이커로, DB 과부하 시 무한 대기 대신 빠르게 실패시켜 상류의 재시도·폴백이 작동하게 합니다. idle_transaction_timeout은 트랜잭션을 열어 놓고 방치하는 코드를 끊어 그 커넥션을 재배정합니다.

마무리

PgBouncer는 “커넥션을 무한정 받아주는 마법”이 아니라, 클라이언트 연결 수와 DB의 실제 동시 처리 능력을 분리해 주는 얇은 계층입니다. 핵심은 두 가지입니다. 워크로드에 맞는 pool_mode를 고르고 transaction 모드의 세션 상태 제약(prepared statement·SET·advisory lock)을 정확히 이해하는 것, 그리고 default_pool_size를 클라이언트 수가 아니라 DB의 활성 동시성에 맞춰 작게 잡고 cl_waiting을 지표로 튜닝하는 것입니다. 커넥션 폭주는 대개 긴 트랜잭션과 슬로우 쿼리가 근본 원인이므로, PgBouncer로 폭주를 흡수하되 그 뒤에서 트랜잭션 범위와 쿼리 성능을 함께 다듬어야 합니다.

자주 묻는 질문

Q. PgBouncer를 도입했는데도 PostgreSQL 커넥션 수가 줄지 않습니다. 무엇을 확인해야 하나요?

A. 먼저 pool_modesession이 아닌지 확인하세요. session 모드는 클라이언트마다 서버 커넥션을 독점해 다중화 이득이 거의 없습니다. 그다음 SHOW POOLS에서 (database, user) 조합 수를 세어 보세요. 조합이 많으면 각각 default_pool_size만큼 쓰므로 총합이 커지고, sv_idle이 높다면 풀을 과대 산정한 것이니 줄이세요.

Q. transaction 모드에서 ORM이 prepared statement 에러를 냅니다. 클라이언트 사이드로 전환하면 성능이 떨어지지 않나요?

A. 최신 PgBouncer(1.21+)라면 max_prepared_statements를 켜서 서버 사이드 준비 구문을 유지하는 것이 최선이며, 성능 손실 없이 transaction 모드를 쓸 수 있습니다. 이 옵션이 없으면 prepareThreshold=0 전환이 불가피한데, 반복 쿼리가 많으면 플랜 재생성 비용이 늘 수 있으니 슬로우 쿼리 로그로 영향을 측정하세요.

Q. 애플리케이션 자체 커넥션 풀(HikariCP 등)과 함께 써도 되나요?

A. 함께 쓰는 것이 오히려 권장됩니다. 애플리케이션 풀은 획득 지연과 로컬 재사용을, PgBouncer는 전역 다중화로 DB 커넥션 총량 통제를 담당합니다. 다만 애플리케이션 풀을 작게(인스턴스당 2~5) 잡아 그 합이 max_client_conn 안에 들어오게 하고, 풀의 max-lifetime을 PgBouncer server_lifetime보다 짧게 두어 양쪽이 같은 커넥션을 동시에 끊는 오류를 피하세요.