왜 커넥션 풀링이 필요한가

PostgreSQL은 커넥션 하나당 백엔드 프로세스를 하나 생성한다. 프로세스는 수 MB의 메모리를 잡고, max_connections를 늘릴수록 컨텍스트 스위칭과 락 경합 비용도 함께 커진다. 실무에서 수백~수천 개의 애플리케이션 스레드가 각자 커넥션을 물면, DB는 실제 CPU 코어 수보다 훨씬 많은 프로세스를 스케줄링하느라 처리량이 오히려 떨어진다.

애플리케이션 측 풀(HikariCP 등)만으로는 부족한 경우가 많다. 인스턴스가 20대이고 각자 풀 크기가 20이면 400개의 물리 커넥션이 열린다. PgBouncer 같은 외부 풀러를 DB 앞에 두면, 수천 개의 클라이언트 커넥션을 소수의 서버 커넥션으로 다중화할 수 있다.

PgBouncer의 세 가지 풀링 모드

핵심은 "언제 서버 커넥션을 클라이언트에게서 회수하느냐"다. 이 시점이 모드를 결정하고, 사용 가능한 기능의 범위를 좌우한다.

모드회수 시점다중화 효율세션 기능
session클라이언트 연결 종료 시낮음모두 사용 가능
transaction트랜잭션 종료 시높음일부 제한
statement쿼리 하나 끝날 때마다매우 높음많이 제한

대부분의 웹 서비스는 transaction 모드가 정답이다. 트랜잭션 사이의 유휴 커넥션을 즉시 회수하므로, 실제 쿼리를 실행하는 순간에만 서버 커넥션을 점유한다.

transaction 모드의 함정

효율이 높은 만큼 세션 단위 상태에 의존하는 기능이 깨진다. 트랜잭션마다 다른 서버 커넥션에 배정될 수 있기 때문이다. 대표적으로 문제가 되는 것들:

  • 프리페어드 스테이트먼트(protocol-level prepared statement)
  • SET으로 설정한 세션 변수, search_path
  • 어드바이저리 락(pg_advisory_lock) 중 세션 스코프
  • LISTEN/NOTIFY, 임시 테이블, WITH HOLD 커서

특히 JDBC나 asyncpg처럼 프리페어드 스테이트먼트를 기본으로 캐싱하는 드라이버는 조용히 실패한다. 다음 트랜잭션이 다른 서버 커넥션으로 가면서 "prepared statement does not exist" 오류가 터진다.

드라이버 설정으로 대응하기

드라이버 쪽에서 프리페어드 스테이트먼트 캐시를 끄거나 우회해야 한다. Python asyncpg는 statement cache를 비활성화한다.

# asyncpg + PgBouncer(transaction 모드)
import asyncpg

conn = await asyncpg.connect(
    dsn="postgresql://app@pgbouncer:6432/app",
    statement_cache_size=0,          # 서버측 prepared 캐시 사용 안 함
    server_settings={"application_name": "checkout-svc"},
)

JDBC(PostgreSQL 드라이버)는 prepareThreshold=0으로 서버 프리페어를 끄거나, PgBouncer 1.21+의 프리페어드 스테이트먼트 지원을 켠다. HikariCP 같은 앱 풀을 함께 쓴다면, 앱 풀 크기 × 인스턴스 수가 PgBouncer의 default_pool_size를 넘지 않게 계산하는 것이 중요하다.

PgBouncer 설정 예시

실제 pgbouncer.ini는 다음과 같다. 풀 크기 산정이 핵심이며, 무작정 키우면 DB의 max_connections를 초과한다.

[databases]
app = host=127.0.0.1 port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
default_pool_size = 20      ; DB당 서버 커넥션 상한
min_pool_size = 5
reserve_pool_size = 5       ; 급증 시 여유분
max_client_conn = 2000      ; 클라이언트 허용 수
server_idle_timeout = 300

default_pool_size는 대략 "DB CPU 코어 수 × 2~4" 범위에서 시작해 부하 테스트로 조정한다. max_client_conn은 크게 잡아도 서버 커넥션과 무관하므로 넉넉히 둔다.

운영 중 상태 관찰

PgBouncer는 관리용 가상 DB pgbouncer를 제공한다. 여기에 접속해 풀 포화 여부를 실시간으로 본다.

psql -h 127.0.0.1 -p 6432 -U admin pgbouncer

-- 대기(cl_waiting)가 지속적으로 쌓이면 풀 부족 신호
SHOW POOLS;

-- 각 서버 커넥션의 상태(active/idle/used) 분포
SHOW SERVERS;

SHOW POOLS의 cl_waiting이 0보다 크게 유지되면 default_pool_size가 부족하거나, 긴 트랜잭션이 커넥션을 오래 점유하는 것이다. 후자라면 풀을 늘리기 전에 느린 쿼리부터 잡아야 한다.

모드 선택 정리

정리하면, 세션 상태에 의존하는 레거시나 LISTEN/NOTIFY가 필수라면 session 모드를 쓰되 다중화 이점은 포기한다. 일반적인 무상태 웹 백엔드는 transaction 모드에 드라이버 프리페어 캐시 비활성화 조합이 표준이다. statement 모드는 멀티 스테이트먼트 트랜잭션 자체가 금지되므로, 오토커밋 읽기 전용 워크로드처럼 특수한 경우에만 고려한다. 어떤 모드든 풀 크기 산정과 유휴/대기 지표 관찰이 실제 안정성을 좌우한다.