Split View: 커넥션 풀 완전 가이드: 풀링 모드가 애플리케이션과 맺는 계약
커넥션 풀 완전 가이드: 풀링 모드가 애플리케이션과 맺는 계약
- 들어가며
- 1. 커넥션 하나가 실제로 무엇을 소모하는가
- 2. 삼중 예산 — 세 개의 숫자를 함께 정한다
- 3. 세 가지 풀링 모드
- 4. 트랜잭션 풀링에서 깨지는 것들
- 5. 준비된 문장 문제
- 6. 타임아웃 지형도 — 어느 계층에 무엇을 거는가
- 7. 관측 — 어디에 줄이 서 있는가
- 8. 워크로드를 나누는 풀 분리
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 커넥션 풀 크기, 크게 잡으면 손해인 이유가 이미 있습니다. 풀 크기를 왜 코어 수 근처로 잡아야 하는지, 대기열을 어디에 세워야 하는지를 다루는 크기 산정 편입니다.
이 글은 그다음 질문을 다룹니다. 크기를 정했다면, 그 커넥션을 어떤 방식으로 재사용할 것인가. 세션 풀링, 트랜잭션 풀링, 문장 풀링은 각각 다른 계약입니다. 트랜잭션 풀링으로 바꾸면 커넥션 효율이 극적으로 좋아지지만, 그 대가로 애플리케이션이 쓸 수 없게 되는 기능들이 생깁니다. 그 목록을 모른 채 모드를 바꿔서 나는 사고가 현장에 가장 많습니다. SET search_path가 다음 요청에 새어 나가거나, 세션 수준 어드바이저리 락이 영영 풀리지 않거나, 준비된 문장이 "does not exist" 오류를 내는 식입니다.
기준은 PostgreSQL 18 과 PgBouncer 이며, 인용한 기본값은 각각의 공식 문서에서 확인했습니다.
1. 커넥션 하나가 실제로 무엇을 소모하는가
PostgreSQL은 커넥션마다 운영체제 프로세스를 하나 띄웁니다. 스레드가 아니라 프로세스입니다. 이 구조가 커넥션 풀 논의의 출발점입니다.
비용은 세 층위에서 발생합니다.
프로세스 자체의 메모리. 백엔드 프로세스마다 캐시와 작업 영역이 붙습니다. 카탈로그 캐시와 계획 캐시는 그 커넥션이 건드린 객체 수에 비례해 커집니다. 파티션이 많은 테이블을 여러 세션이 건드리면 이 부분이 누적됩니다.
작업 메모리. work_mem의 기본값은 4MB인데, 이 값은 커넥션당이 아니라 정렬이나 해시 연산 하나당 적용됩니다. 한 쿼리가 정렬 세 개와 해시 조인 두 개를 포함하면 그 쿼리 하나가 다섯 배를 씁니다. 해시 계열은 hash_mem_multiplier(기본값 2.0)까지 곱해집니다. 커넥션 수 곱하기 work_mem으로 최악을 계산하는 흔한 방식은 실제로는 과소 추정입니다.
공유 자원의 크기. 문서는 명확히 적습니다. "PostgreSQL은 특정 자원을 max_connections 값에 직접 근거해 크기를 정한다. 이 값을 늘리면 공유 메모리를 포함한 해당 자원의 할당이 늘어난다." max_connections의 기본값은 문서 표현으로 "일반적으로 100개"이며 커널 설정에 따라 더 적을 수 있습니다.
그리고 가장 큰 비용은 메모리가 아닙니다. 컨텍스트 스위칭과 잠금 경합입니다. 코어가 16개인 서버에 활성 커넥션 500개를 붙이면, CPU는 실제 작업보다 프로세스를 바꿔 태우는 데 시간을 씁니다. 이것이 앞선 글이 "풀을 크게 잡으면 손해"라고 말한 이유입니다.
2. 삼중 예산 — 세 개의 숫자를 함께 정한다
실전 구성에는 커넥션 숫자가 최소 세 곳에 있습니다. 이 셋을 따로 정하면 반드시 어긋납니다.
[애플리케이션 인스턴스 N개]
각 인스턴스의 커넥션 풀 크기 = A
↓ 최대 N × A 개의 클라이언트 커넥션
[PgBouncer]
max_client_conn (받아 줄 수 있는 클라이언트 수)
default_pool_size (DB/사용자 쌍마다 실제로 열 서버 커넥션 수)
↓ 최대 (풀 개수 × default_pool_size) 개의 서버 커넥션
[PostgreSQL]
max_connections
세 층의 관계를 규칙으로 정리하면 이렇습니다.
규칙 1 — PgBouncer의 max_client_conn은 애플리케이션이 만들 수 있는 최대 커넥션 수보다 커야 합니다. PgBouncer 문서 기준 max_client_conn의 기본값은 100입니다. 인스턴스 20개가 각각 풀 10을 쓰면 200이 필요한데 기본값은 100이므로, 기본값 그대로 두면 애플리케이션이 커넥션 거부를 받습니다.
규칙 2 — 서버 커넥션 총합이 max_connections보다 확실히 작아야 합니다. PgBouncer의 default_pool_size 기본값은 20이며, 이 값은 데이터베이스와 사용자 쌍마다 적용됩니다. 데이터베이스 3개에 사용자 4명이면 최악의 경우 12개 풀 × 20 = 240개의 서버 커넥션이 열릴 수 있습니다. max_db_connections(기본값 0, 무제한)로 데이터베이스 단위 상한을 걸어 두는 것이 안전합니다.
규칙 3 — max_connections에는 여유를 남깁니다. superuser_reserved_connections의 기본값은 3이고 reserved_connections의 기본값은 0입니다. 장애 상황에서 관리자가 붙을 자리를 남기는 것이 이 예약분의 목적입니다. 모니터링 에이전트, 백업 도구, 마이그레이션 러너의 몫도 따로 계산해 두세요.
3. 세 가지 풀링 모드
PgBouncer 문서가 정의하는 세 가지 모드입니다. 인용한 문장이 곧 계약입니다.
- session — "클라이언트가 연결을 끊은 뒤 서버가 풀로 반환된다. 기본값." 즉 클라이언트 하나가 커넥션 하나를 통째로 점유합니다. 애플리케이션 입장에서는 PgBouncer가 없는 것과 동일하게 동작하므로 아무것도 깨지지 않습니다. 대신 커넥션 절약 효과가 거의 없습니다.
- transaction — "트랜잭션이 끝난 뒤 서버가 풀로 반환된다." 실전에서 가장 많이 쓰는 모드입니다. 유휴 시간이 긴 웹 애플리케이션에서 커넥션 수를 한 자릿수 배로 줄여 줍니다.
- statement — "질의가 끝난 뒤 서버가 풀로 반환된다. 이 모드에서는 여러 문장에 걸친 트랜잭션이 금지된다." 다중 문장 트랜잭션이 아예 불가능하므로 용도가 매우 제한적입니다.
기본값이 session이라는 사실을 놓치는 경우가 많습니다. PgBouncer를 앞에 세워 두고도 커넥션 수가 그대로라면 모드를 확인해 보세요.
모드를 고르는 기준은 단순합니다. 트랜잭션 밖에서 세션 상태에 의존하는 코드가 하나도 없다면 transaction을 씁니다. 하나라도 있으면 그 코드를 고치거나 session에 머물러야 합니다. 다음 절이 그 목록입니다.
4. 트랜잭션 풀링에서 깨지는 것들
PgBouncer 문서의 설명은 이렇습니다. 트랜잭션 풀링 모드에서 "클라이언트는 세션 기반 기능을 사용해서는 안 된다. 각 트랜잭션이 서로 다른 커넥션에서 끝나므로 세션 상태가 달라지기 때문이다."
구체적으로 깨지는 것들입니다.
SET / RESET. 트랜잭션 밖에서 실행한 SET search_path, SET timezone, SET statement_timeout은 그 서버 커넥션에 남습니다. 다음 트랜잭션은 다른 커넥션에 배정될 수 있으므로 설정이 사라진 것처럼 보이고, 반대로 그 커넥션을 받은 다른 클라이언트에게는 설정이 새어 나갑니다. 후자가 더 위험합니다. 멀티테넌트 애플리케이션에서 search_path로 스키마를 전환하는 설계는 트랜잭션 풀링과 결코 함께 쓸 수 없습니다. 트랜잭션 안에서 SET LOCAL을 쓰면 안전합니다. 트랜잭션 종료와 함께 되돌아가기 때문입니다.
-- 안전: 트랜잭션 경계 안에서만 유효하다
BEGIN;
SET LOCAL statement_timeout = '5s';
SELECT ...;
COMMIT;
LISTEN / NOTIFY. LISTEN은 세션에 등록되는 상태입니다. 트랜잭션이 끝나면 커넥션이 반환되므로 알림을 받을 방법이 없습니다. 알림 기반 구조가 필요하면 그 커넥션만 별도의 session 모드 풀로 분리해야 합니다.
세션 수준 어드바이저리 락. 가장 위험한 항목입니다. PostgreSQL 문서에 따르면 세션 수준 어드바이저리 락은 "명시적으로 해제되거나 세션이 종료될 때까지 유지되며 트랜잭션 롤백에도 살아남습니다." 트랜잭션 풀링에서는 락을 잡은 커넥션과 락을 푸는 커넥션이 다를 수 있고, 그러면 락이 영영 풀리지 않습니다. 반드시 트랜잭션 수준 함수(pg_advisory_xact_lock, pg_try_advisory_xact_lock)만 쓰세요. 이 함수들은 트랜잭션 종료 시 자동 해제됩니다.
WITH HOLD 커서. 트랜잭션 종료 후에도 살아남는 커서인데, 커넥션이 반환되므로 접근할 수 없습니다.
임시 테이블. 세션에 속하므로 다음 트랜잭션에서 사라진 것처럼 보입니다. 한 트랜잭션 안에서 만들고 쓰고 버리는 패턴(ON COMMIT DROP)만 안전합니다.
5. 준비된 문장 문제
가장 자주 부딪히는 항목이라 따로 다룹니다.
대부분의 드라이버는 파라미터 바인딩을 위해 준비된 문장을 씁니다. 준비된 문장은 서버 커넥션에 이름으로 등록되므로, 트랜잭션 풀링에서 다른 커넥션에 배정되면 prepared statement "S_1" does not exist 오류가 납니다.
PgBouncer는 max_prepared_statements로 이 문제를 완화합니다. 문서 기준 기본값은 200 이며, 트랜잭션 및 문장 풀링 모드에서 프로토콜 수준으로 준비된 문장을 추적해 줍니다. PgBouncer가 각 서버 커넥션에 필요한 준비를 대신 수행하는 방식입니다.
그래도 안전한 조합을 확인하는 순서는 이렇습니다.
- PgBouncer 버전이 준비된 문장 추적을 지원하는지 확인합니다. 이 기능은 특정 버전에서 도입되었으므로 사용 중인 버전의 문서에서 확인하세요.
max_prepared_statements가 0이 아닌지 확인합니다.- 드라이버 쪽 설정을 확인합니다. 서버 사이드 준비를 끄는 옵션이 있는 드라이버가 많습니다. 끄면 확실히 안전하지만 계획 재사용 이득을 잃습니다.
여기에 연결되는 성능 문제가 하나 더 있습니다. 준비된 문장을 쓰면 PostgreSQL이 일반 계획(generic plan) 을 선택할 수 있고, 값 분포가 치우친 컬럼에서는 이것이 재앙이 될 수 있습니다. 이 동작은 plan_cache_mode가 제어하며 허용 값은 auto(기본값), force_custom_plan, force_generic_plan입니다. 준비된 문장을 켜기로 했다면 이 파라미터의 존재도 함께 알고 있어야 합니다.
6. 타임아웃 지형도 — 어느 계층에 무엇을 거는가
요청 하나가 지나는 경로마다 타임아웃이 있고, 이들이 서로 모순되면 진단이 불가능해집니다.
애플리케이션 풀 계층. 풀에서 커넥션을 얻기까지 기다리는 시간의 상한입니다. 이 값이 없으면 데이터베이스가 느려질 때 애플리케이션 스레드가 전부 대기 상태로 쌓입니다.
PgBouncer 계층. query_wait_timeout의 기본값은 120초입니다. 클라이언트가 풀에서 서버 커넥션을 배정받기까지 기다리는 시간입니다. server_idle_timeout의 기본값은 600초로, 오래 놀고 있는 서버 커넥션을 닫습니다.
PostgreSQL 계층. 세 개의 타임아웃이 있고 셋 다 기본값이 0, 즉 비활성 입니다. statement_timeout은 지정 시간을 넘는 문장을 중단하고, lock_timeout은 잠금 대기가 지정 시간을 넘으면 중단하며, idle_in_transaction_session_timeout은 트랜잭션을 열어 둔 채 노는 세션을 종료합니다. PostgreSQL 17부터는 트랜잭션 전체 시간을 제한하는 transaction_timeout도 있으며 기본값은 역시 0입니다.
배치 순서의 원칙은 바깥이 안쪽보다 길어야 한다입니다. 애플리케이션 타임아웃이 statement_timeout보다 짧으면, 애플리케이션은 포기했는데 서버에서는 쿼리가 계속 돌아 자원을 씁니다. 반대로 배치하면 서버가 먼저 끊어 주므로 자원이 회수됩니다.
-- 서비스 계정과 배치 계정에 다른 예산을 준다
ALTER ROLE app_web SET statement_timeout = '10s';
ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_web SET lock_timeout = '3s';
ALTER ROLE app_batch SET statement_timeout = '30min';
ALTER ROLE app_batch SET idle_in_transaction_session_timeout = '5min';
7. 관측 — 어디에 줄이 서 있는가
느려졌을 때 물어야 할 첫 질문은 "어느 계층에 대기열이 있는가"입니다. 계층마다 보는 지표가 다릅니다.
애플리케이션 풀. 풀 대기 시간과 활성 커넥션 수. 대기 시간이 늘고 있는데 데이터베이스는 한가하다면 풀이 너무 작은 것입니다.
PgBouncer. 관리 콘솔에 붙어 SHOW POOLS와 SHOW STATS를 봅니다. cl_waiting(서버 배정을 기다리는 클라이언트 수)이 지속적으로 0보다 크면 default_pool_size가 부족하거나 서버가 느린 것입니다.
PostgreSQL. pg_stat_activity에서 상태와 대기 이벤트를 봅니다.
-- 상태별 커넥션 분포: idle in transaction이 많으면 애플리케이션 쪽 문제다
SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY count(*) DESC;
-- 무엇을 기다리는가
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
state가 가질 수 있는 값은 문서에 정의되어 있습니다. active, idle, idle in transaction, idle in transaction (aborted), fastpath function call, starting, disabled입니다. idle in transaction이 많다면 커넥션 풀 문제가 아니라 애플리케이션이 트랜잭션을 열어 놓고 다른 일을 하고 있다는 뜻입니다. 풀을 키워도 해결되지 않고 오히려 악화됩니다.
wait_event_type의 값은 Lock, LWLock, BufferPin, IO, IPC, Client, Timeout, Activity, Extension입니다. Client가 많으면 서버가 아니라 클라이언트를 기다리는 중이므로 데이터베이스는 병목이 아닙니다.
8. 워크로드를 나누는 풀 분리
한 풀에 모든 워크로드를 섞으면 가장 느린 쿼리가 전체를 막습니다. 실전에서는 풀을 성격별로 나눕니다.
OLTP 풀 — 짧고 잦은 요청. 트랜잭션 풀링, 작은 default_pool_size, 짧은 statement_timeout.
배치/리포트 풀 — 길고 드문 요청. 별도 데이터베이스 사용자로 분리해 statement_timeout을 길게 주고, 풀 크기는 작게 유지합니다. 리포트는 읽기 복제본으로 보내는 것이 더 낫습니다.
세션 상태가 필요한 풀 — LISTEN/NOTIFY나 세션 어드바이저리 락이 필요한 소수의 커넥션. session 모드 풀로 따로 둡니다.
마이그레이션/관리 풀 — DDL을 실행하는 경로. 트랜잭션 풀링에서 CREATE INDEX CONCURRENTLY 같은 명령은 트랜잭션 블록 밖에서 실행되어야 하므로 별도 취급이 필요합니다.
분리의 실질적 이점은 격리입니다. 리포트 쿼리가 폭주해도 OLTP 풀의 서버 커넥션은 그대로 남아 있습니다. 같은 이유로 서킷 브레이커도 풀 단위로 거는 것이 맞습니다.
한 가지 덧붙입니다. PgBouncer 자체는 단일 프로세스로 동작하므로 PgBouncer가 CPU 한 코어를 다 쓰면 그것이 병목이 됩니다. 처리량이 큰 환경에서는 PgBouncer를 여러 인스턴스로 띄우거나 애플리케이션 노드마다 사이드카로 배치하는 구성을 검토하세요. 다중 프로세스 지원 여부와 설정은 사용 중인 버전의 문서에서 확인해야 합니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: 트랜잭션 풀링으로 바꾼 뒤 멀티테넌트 애플리케이션에서 가끔 다른 테넌트의 데이터가 보입니다. 왜일까요?
정답: SET search_path를 트랜잭션 밖에서 실행하고 있기 때문입니다.
설명: 트랜잭션 풀링에서는 트랜잭션이 끝나면 서버 커넥션이 풀로 반환됩니다. 트랜잭션 밖에서 실행한 SET은 그 서버 커넥션에 남고, 그 커넥션을 다음에 받는 다른 클라이언트가 그 설정을 물려받습니다. 스키마 기반 테넌트 분리에서는 이것이 곧 데이터 유출입니다. 대응은 두 가지입니다. SET LOCAL을 써서 트랜잭션 경계 안에서만 유효하게 만들거나, 스키마 전환 대신 tenant_id 컬럼과 행 수준 보안으로 설계를 바꾸는 것입니다. PgBouncer 문서 자체가 트랜잭션 풀링에서 "클라이언트는 세션 기반 기능을 사용해서는 안 된다"라고 명시합니다.
퀴즈 2: 야간 배치가 pg_advisory_lock으로 중복 실행을 막는데, 트랜잭션 풀링 도입 후 두 번째 실행부터 영영 락을 얻지 못합니다.
정답: 세션 수준 어드바이저리 락이 해제되지 못하고 남았기 때문입니다.
설명: PostgreSQL 문서에 따르면 세션 수준 어드바이저리 락은 명시적으로 해제되거나 세션이 끝날 때까지 유지되며 트랜잭션 롤백에도 살아남습니다. 트랜잭션 풀링에서는 pg_advisory_lock()을 호출한 트랜잭션이 끝나는 순간 커넥션이 반환되고, pg_advisory_unlock()은 다른 커넥션에서 실행될 수 있습니다. 그러면 락은 원래 커넥션에 남은 채로 아무도 풀지 못합니다. PgBouncer가 그 서버 커넥션을 닫을 때까지 지속됩니다. 해결은 트랜잭션 수준 함수로 바꾸는 것입니다.
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-batch'));
-- 작업 수행
COMMIT; -- 여기서 락이 자동 해제된다
퀴즈 3: PgBouncer를 앞에 세웠는데 PostgreSQL의 커넥션 수가 전혀 줄지 않았습니다.
정답: pool_mode가 기본값 session으로 남아 있을 가능성이 큽니다.
설명: PgBouncer 문서 기준 pool_mode의 기본값은 session이며, 이 모드에서는 "클라이언트가 연결을 끊은 뒤에야 서버가 풀로 반환"됩니다. 커넥션을 오래 유지하는 애플리케이션 풀 앞에 세우면 절약 효과가 사실상 0입니다. 확인은 관리 콘솔에서 SHOW CONFIG로 현재 pool_mode를 보는 것이고, 바꾸기 전에 4절의 목록(SET, LISTEN/NOTIFY, 세션 어드바이저리 락, WITH HOLD 커서, 임시 테이블, 준비된 문장)을 애플리케이션이 쓰고 있는지 먼저 점검해야 합니다.
퀴즈 4: 트래픽이 늘어 애플리케이션 인스턴스를 10개에서 40개로 늘렸더니 커넥션 거부가 발생합니다. 어디를 봐야 할까요?
정답: 세 계층의 숫자를 함께 봐야 합니다. 특히 PgBouncer의 max_client_conn입니다.
설명: PgBouncer 문서 기준 max_client_conn의 기본값은 100입니다. 인스턴스 40개가 각각 풀 10을 쓰면 최대 400개의 클라이언트 커넥션이 필요하므로 기본값으로는 부족합니다. 동시에 서버 쪽도 확인해야 합니다. default_pool_size의 기본값은 20이고 데이터베이스와 사용자 쌍마다 적용되므로, 풀이 여러 개면 서버 커넥션 총합이 max_connections(기본값 일반적으로 100)를 넘길 수 있습니다. 올바른 조정은 max_client_conn을 크게(클라이언트를 받아 주는 문턱), default_pool_size는 작게(서버를 보호하는 문턱) 잡는 것입니다. 대기열은 PgBouncer에 세우고 데이터베이스에는 세우지 않는다는 원칙입니다.
퀴즈 5: pg_stat_activity를 보니 idle in transaction 상태의 커넥션이 60개입니다. 풀을 키우면 해결될까요?
정답: 아닙니다. 악화됩니다.
설명: idle in transaction은 트랜잭션을 열어 둔 채 클라이언트의 다음 명령을 기다리는 상태입니다. 즉 데이터베이스는 놀고 있고 애플리케이션이 다른 일을 하고 있다는 뜻입니다. 원인은 대개 트랜잭션 안에서 외부 API를 호출하거나, ORM이 요청 시작 시점에 트랜잭션을 열고 응답 직전까지 유지하는 설계입니다. 이 상태의 커넥션은 자리만 차지하는 것이 아니라 VACUUM이 죽은 행을 회수하지 못하게 막습니다. 풀을 키우면 이런 커넥션이 더 늘어날 뿐입니다. 대응은 트랜잭션 경계를 좁히는 코드 수정이고, 방어선은 idle_in_transaction_session_timeout 설정입니다. 이 값의 기본값은 0으로 비활성이므로 기본 설정에서는 아무런 보호가 없습니다.
마치며
커넥션 풀은 성능 도구이기 전에 계약입니다. 세션 풀링은 "아무것도 바꾸지 않겠다"는 계약이고, 트랜잭션 풀링은 "세션 상태를 포기하는 대가로 커넥션을 아끼겠다"는 계약입니다. 계약 조항을 읽지 않고 서명하면 반드시 대가를 치릅니다.
도입 순서를 정리하면 이렇습니다. 먼저 애플리케이션이 세션 상태에 의존하는 지점을 전부 찾아냅니다(SET, LISTEN, 세션 어드바이저리 락, 임시 테이블, 준비된 문장). 그것들을 트랜잭션 안으로 옮기거나 별도 풀로 분리합니다. 그다음 트랜잭션 풀링으로 전환하고, 세 계층의 숫자를 함께 정하고, 타임아웃을 바깥부터 안쪽으로 짧아지게 배치합니다. 마지막으로 cl_waiting과 idle in transaction을 대시보드에 올려 둡니다.
커넥션 동작을 직접 실험해 보려면 Postgres 놀이터를 활용하세요.
참고 자료
- PostgreSQL 18, Connections and Authentication: https://www.postgresql.org/docs/18/runtime-config-connection.html (2026-08-15 확인)
- PostgreSQL 18, Resource Consumption: https://www.postgresql.org/docs/18/runtime-config-resource.html (2026-08-15 확인)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (2026-08-15 확인)
- PostgreSQL 18, Explicit Locking (Advisory Locks): https://www.postgresql.org/docs/18/explicit-locking.html (2026-08-15 확인)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (2026-08-15 확인)
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (2026-08-15 확인)
- PgBouncer, Configuration: https://www.pgbouncer.org/config.html (2026-08-15 확인)
이어서 읽기
- 이전 편: 파티셔닝과 샤딩 완전 가이드 — 한 노드의 한계를 넘는 순서
- 다음 편: 데이터베이스 캐싱 전략 완전 가이드 — 무효화가 전부다
- 커넥션 풀 크기, 크게 잡으면 손해인 이유 — 풀 크기 산정
- 트랜잭션 격리 수준 완전 가이드 — 트랜잭션 경계 설계
- Postgres 놀이터 — 세션 상태 실험
The Complete Guide to Connection Pools: The Contract a Pooling Mode Makes with Your Application
- Introduction
- 1. What a Single Connection Actually Costs
- 2. The Triple Budget — Deciding Three Numbers Together
- 3. The Three Pooling Modes
- 4. What Breaks Under Transaction Pooling
- 5. The Prepared Statement Problem
- 6. The Timeout Landscape — What Belongs on Which Layer
- 7. Observability — Where Is the Line Forming
- 8. Splitting Pools by Workload
- Quiz: Test Yourself
- Closing
- References
- Continue Reading
Introduction
This blog already has a post on Why a Big Connection Pool Costs You — Deciding Where to Put the Queue. That is the sizing installment, covering why pool size should be set near the core count and where the queue belongs.
This post covers the next question. Once you have decided on a size, in what manner will you reuse that connection? Session pooling, transaction pooling, and statement pooling are each a different contract. Switching to transaction pooling improves connection efficiency dramatically, but in exchange there are features your application can no longer use. Not knowing that list before switching modes is the single most common cause of incidents in the field: SET search_path leaking into the next request, a session-level advisory lock that never gets released, a prepared statement throwing a "does not exist" error.
The reference is PostgreSQL 18 and PgBouncer, and every default value quoted here was confirmed against each project's own official documentation.
1. What a Single Connection Actually Costs
PostgreSQL spins up one operating system process for every connection. Not a thread — a process. This design is the starting point for the entire connection pool discussion.
The cost shows up at three layers.
The memory of the process itself. Every backend process carries its own cache and working area. The catalog cache and plan cache grow in proportion to the number of objects that connection has touched. When many sessions touch a table with a large number of partitions, this piece accumulates.
Working memory. work_mem defaults to 4MB, but that value applies not per connection but per individual sort or hash operation. If a single query contains three sorts and two hash joins, that one query alone uses five times that amount. Hash operations get multiplied further, up to hash_mem_multiplier (default 2.0). The common way of estimating the worst case as connection count times work_mem is, in practice, an underestimate.
The size of shared resources. The documentation states this plainly: "PostgreSQL sizes certain resources based directly on the value of max_connections; raising this value increases the allocation of that resource, including shared memory." The default for max_connections is, in the documentation's own words, "typically 100," and it can be lower depending on kernel settings.
And the biggest cost is not memory at all. It is context switching and lock contention. Attach 500 active connections to a server with 16 cores, and the CPU spends more time switching processes in and out than doing actual work. This is exactly why the earlier post said "a big pool costs you."
2. The Triple Budget — Deciding Three Numbers Together
A real-world setup has connection counts sitting in at least three places. Decide these three independently, and they are guaranteed to fall out of alignment.
[N application instances]
connection pool size per instance = A
↓ up to N × A client connections
[PgBouncer]
max_client_conn (how many clients it will accept)
default_pool_size (server connections actually opened per DB/user pair)
↓ up to (number of pools × default_pool_size) server connections
[PostgreSQL]
max_connections
Turning the relationship between the three layers into rules gives us this.
Rule 1 — PgBouncer's max_client_conn has to be greater than the maximum number of connections the application can create. Per the PgBouncer documentation, max_client_conn defaults to 100. If 20 instances each use a pool of 10, you need 200, but the default is 100, so leaving it at the default means the application gets connections refused.
Rule 2 — the total of server connections has to stay comfortably below max_connections. PgBouncer's default_pool_size defaults to 20, and that value applies per database-and-user pair. With 3 databases and 4 users, the worst case opens 12 pools × 20 = 240 server connections. It is safer to cap things per database with max_db_connections (default 0, unlimited).
Rule 3 — leave headroom in max_connections. superuser_reserved_connections defaults to 3 and reserved_connections defaults to 0. The point of this reservation is to leave a slot for an administrator to connect during an incident. Budget separately for monitoring agents, backup tools, and migration runners too.
3. The Three Pooling Modes
These are the three modes as PgBouncer's documentation defines them. Each quoted sentence is, quite literally, the contract.
- session — "the server is released back to the pool after the client disconnects. Default." In other words, one client occupies one connection outright. From the application's point of view, this behaves exactly as if PgBouncer were not there at all, so nothing breaks. In exchange, it saves almost no connections.
- transaction — "the server is released back to the pool after the transaction finishes." This is the mode used most in practice. For web applications with long idle periods, it cuts connection counts by a single-digit multiple.
- statement — "the server is released back to the pool after the query finishes. Multi-statement transactions are disallowed in this mode." Multi-statement transactions become entirely impossible, so its use is very limited.
The fact that the default is session is easy to miss. If you have put PgBouncer in front and connection counts have not moved at all, check the mode.
The criterion for choosing a mode is simple. If not a single piece of code depends on session state outside a transaction, use transaction. If even one does, you either have to fix that code or stay on session. The next section is that list.
4. What Breaks Under Transaction Pooling
PgBouncer's documentation puts it this way. In transaction pooling mode, "the client must not use any session-based features, since each transaction ends up on a different connection and thus sees different session state."
Here, concretely, is what breaks.
SET / RESET. Running SET search_path, SET timezone, or SET statement_timeout outside a transaction leaves it sitting on that server connection. The next transaction can be assigned a different connection, so the setting appears to have vanished, and conversely, it leaks into whichever other client receives that connection next. The latter is far more dangerous. A design that switches schemas via search_path in a multi-tenant application can never be combined with transaction pooling. Using SET LOCAL inside a transaction is safe, because it reverts the moment the transaction ends.
-- Safe: only valid inside the transaction boundary
BEGIN;
SET LOCAL statement_timeout = '5s';
SELECT ...;
COMMIT;
LISTEN / NOTIFY. LISTEN registers state onto the session. Once the transaction ends, the connection is returned, so there is no way left to receive the notification. If you need a notification-based architecture, that connection alone has to be split off into a separate session-mode pool.
Session-level advisory locks. This is the most dangerous item of all. Per the PostgreSQL documentation, a session-level advisory lock "is held until explicitly released or the session ends, and it survives a transaction rollback." Under transaction pooling, the connection that acquired the lock and the connection that releases it can be different ones, and when that happens the lock never gets released, ever. Always use only the transaction-level functions (pg_advisory_xact_lock, pg_try_advisory_xact_lock). These release automatically when the transaction ends.
WITH HOLD cursors. These are cursors that survive after the transaction ends, but since the connection gets returned, they become unreachable.
Temporary tables. These belong to the session, so they appear to vanish in the next transaction. Only the pattern of creating, using, and discarding within a single transaction (ON COMMIT DROP) is safe.
5. The Prepared Statement Problem
This is the item people run into most often, so it gets its own section.
Most drivers use prepared statements for parameter binding. A prepared statement is registered by name on a specific server connection, so under transaction pooling, getting assigned a different connection produces a prepared statement "S_1" does not exist error.
PgBouncer mitigates this problem with max_prepared_statements. Per the documentation, the default is 200, and it tracks prepared statements at the protocol level in transaction and statement pooling modes. The mechanism is that PgBouncer performs the necessary preparation on each server connection on the client's behalf.
Even so, here is the order for confirming a safe combination.
- Confirm whether your PgBouncer version supports prepared statement tracking. This feature was introduced in a specific version, so check the documentation for the version you are actually running.
- Confirm that
max_prepared_statementsis not 0. - Check the driver-side settings. Many drivers offer an option to turn off server-side preparation. Turning it off is certainly safe, but you lose the benefit of plan reuse.
There is one more performance issue connected to this. Using prepared statements lets PostgreSQL choose a generic plan, and on a column with a skewed value distribution, that can be disastrous. This behavior is controlled by plan_cache_mode, whose allowed values are auto (default), force_custom_plan, and force_generic_plan. If you decide to turn prepared statements on, you need to know this parameter exists too.
6. The Timeout Landscape — What Belongs on Which Layer
Every leg a single request passes through has its own timeout, and if these contradict each other, diagnosis becomes impossible.
The application pool layer. This is the upper bound on how long you wait to get a connection from the pool. Without this value, every application thread piles up in a waiting state the moment the database slows down.
The PgBouncer layer. query_wait_timeout defaults to 120 seconds — this is how long a client waits to be assigned a server connection from the pool. server_idle_timeout defaults to 600 seconds, and it closes server connections that have been sitting idle too long.
The PostgreSQL layer. There are three timeouts here, and all three default to 0, meaning disabled. statement_timeout aborts a statement that runs past the specified time, lock_timeout aborts a lock wait that runs past the specified time, and idle_in_transaction_session_timeout terminates a session that is sitting idle with a transaction left open. Since PostgreSQL 17 there is also transaction_timeout, which caps the total duration of a transaction, and its default is likewise 0.
The principle for ordering these is that the outer layer must be longer than the inner one. If the application timeout is shorter than statement_timeout, the application gives up while the query keeps running on the server and burning resources. Order it the other way, and the server cuts it off first, so the resources get reclaimed.
-- Give the service account and the batch account different budgets
ALTER ROLE app_web SET statement_timeout = '10s';
ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_web SET lock_timeout = '3s';
ALTER ROLE app_batch SET statement_timeout = '30min';
ALTER ROLE app_batch SET idle_in_transaction_session_timeout = '5min';
7. Observability — Where Is the Line Forming
The first question to ask when things slow down is "which layer has the queue." Each layer has different metrics to watch.
The application pool. Pool wait time and active connection count. If wait time is climbing while the database is sitting idle, the pool is too small.
PgBouncer. Connect to the admin console and check SHOW POOLS and SHOW STATS. If cl_waiting (the number of clients waiting for a server assignment) is consistently above 0, either default_pool_size is too small or the server is slow.
PostgreSQL. Check state and wait events in pg_stat_activity.
-- Connection distribution by state: a lot of idle in transaction points to an application-side problem
SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY count(*) DESC;
-- What is it waiting on
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
The values state can take are defined in the documentation: active, idle, idle in transaction, idle in transaction (aborted), fastpath function call, starting, and disabled. A lot of idle in transaction is not a connection pool problem — it means the application is holding a transaction open while doing something else. Enlarging the pool will not fix this; it makes things worse.
The values of wait_event_type are Lock, LWLock, BufferPin, IO, IPC, Client, Timeout, Activity, and Extension. A lot of Client means the server is waiting on the client rather than the other way around, so the database is not the bottleneck.
8. Splitting Pools by Workload
Mix every workload into one pool, and the single slowest query blocks all of it. In practice, pools get split by their character.
OLTP pool — short, frequent requests. Transaction pooling, a small default_pool_size, a short statement_timeout.
Batch/report pool — long, infrequent requests. Split this off under a separate database user, give it a long statement_timeout, and keep the pool size small. It is better to send reports to a read replica.
The pool that needs session state — the small number of connections that need LISTEN/NOTIFY or session-level advisory locks. Keep these separate in a session-mode pool.
Migration/admin pool — the path that runs DDL. Under transaction pooling, commands like CREATE INDEX CONCURRENTLY need to run outside a transaction block, so they need separate handling.
The real benefit of splitting is isolation. Even if report queries run wild, the OLTP pool's server connections stay right where they are. For the same reason, a circuit breaker should also be applied per pool.
One more thing to add. PgBouncer itself runs as a single process, so if PgBouncer maxes out one CPU core, that becomes your bottleneck. In high-throughput environments, consider running multiple PgBouncer instances or deploying one as a sidecar per application node. Whether multi-process support exists, and how to configure it, needs to be checked against the documentation for the version you are running.
Quiz: Test Yourself
Quiz 1: After switching to transaction pooling, your multi-tenant application occasionally shows another tenant's data. Why?
Answer: Because SET search_path is being run outside a transaction.
Explanation: Under transaction pooling, the server connection is returned to the pool as soon as the transaction ends. A SET run outside a transaction stays on that server connection, and whichever other client picks up that connection next inherits the setting. In schema-based tenant isolation, that is data leakage, plain and simple. There are two responses: use SET LOCAL so it is only valid inside the transaction boundary, or redesign away from schema switching toward a tenant_id column with row-level security. PgBouncer's own documentation states, for transaction pooling, that "the client must not use any session-based features."
Quiz 2: A nightly batch job uses pg_advisory_lock to prevent duplicate runs, but after adopting transaction pooling, it can never acquire the lock again starting from the second run.
Answer: Because the session-level advisory lock never got released and was left behind.
Explanation: Per the PostgreSQL documentation, a session-level advisory lock is held until it is explicitly released or the session ends, and it survives a transaction rollback. Under transaction pooling, the connection is returned the instant the transaction that called pg_advisory_lock() ends, and pg_advisory_unlock() can end up running on a different connection. When that happens, the lock stays behind on the original connection and nobody can release it — it persists until PgBouncer closes that server connection. The fix is to switch to the transaction-level functions.
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-batch'));
-- do the work
COMMIT; -- the lock is released automatically here
Quiz 3: You put PgBouncer in front, but PostgreSQL's connection count has not dropped at all.
Answer: pool_mode is very likely still sitting at its default of session.
Explanation: Per the PgBouncer documentation, pool_mode defaults to session, and in that mode "the server is released back to the pool only after the client disconnects." Put it in front of an application pool that holds connections for a long time, and the savings are effectively zero. Check this by viewing the current pool_mode with SHOW CONFIG on the admin console, and before you change it, first check whether your application uses anything from the section 4 list (SET, LISTEN/NOTIFY, session advisory locks, WITH HOLD cursors, temporary tables, prepared statements).
Quiz 4: Traffic grew, you scaled application instances from 10 to 40, and now you are getting connections refused. Where should you look?
Answer: You have to look at all three layers' numbers together, especially PgBouncer's max_client_conn.
Explanation: Per the PgBouncer documentation, max_client_conn defaults to 100. If 40 instances each use a pool of 10, you need up to 400 client connections, so the default falls short. At the same time, you have to check the server side too. default_pool_size defaults to 20 and applies per database-and-user pair, so with multiple pools the total server connections can exceed max_connections (whose default is typically 100). The correct adjustment is to set max_client_conn large — the threshold for accepting clients — and keep default_pool_size small — the threshold that protects the server. The principle is to put the queue on PgBouncer, not on the database.
Quiz 5: pg_stat_activity shows 60 connections in the idle in transaction state. Will enlarging the pool fix this?
Answer: No. It makes things worse.
Explanation: idle in transaction is the state of holding a transaction open while waiting for the client's next command. In other words, the database is sitting idle while the application is off doing something else. The usual cause is a design that calls an external API inside a transaction, or an ORM that opens a transaction at the start of a request and holds it open until just before the response. A connection in this state does not just occupy a slot — it also blocks VACUUM from reclaiming dead rows. Enlarging the pool only produces more connections like this. The fix is code changes that narrow the transaction boundary, and the safety net is the idle_in_transaction_session_timeout setting. Its default is 0, meaning disabled, so the default configuration offers no protection at all.
Closing
Before it is a performance tool, a connection pool is a contract. Session pooling is the contract that says "I will not change anything." Transaction pooling is the contract that says "I will save connections in exchange for giving up session state." Sign either one without reading the terms, and you will pay for it.
Here is the adoption order, laid out. First, find every place where the application depends on session state (SET, LISTEN, session advisory locks, temporary tables, prepared statements). Move those inside a transaction or split them off into a separate pool. Then switch to transaction pooling, decide the numbers for all three layers together, and arrange timeouts so they get shorter moving from the outside in. Finally, put cl_waiting and idle in transaction on your dashboard.
To experiment with connection behavior yourself, use the PostgreSQL Playground.
References
- PostgreSQL 18, Connections and Authentication: https://www.postgresql.org/docs/18/runtime-config-connection.html (retrieved 2026-08-15)
- PostgreSQL 18, Resource Consumption: https://www.postgresql.org/docs/18/runtime-config-resource.html (retrieved 2026-08-15)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (retrieved 2026-08-15)
- PostgreSQL 18, Explicit Locking (Advisory Locks): https://www.postgresql.org/docs/18/explicit-locking.html (retrieved 2026-08-15)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (retrieved 2026-08-15)
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (retrieved 2026-08-15)
- PgBouncer, Configuration: https://www.pgbouncer.org/config.html (retrieved 2026-08-15)
Continue Reading
- Previous: The Complete Guide to Partitioning and Sharding — the order for moving past a single node
- Next: The Complete Guide to Database Caching Strategies — invalidation is everything
- Why a Big Connection Pool Costs You — Deciding Where to Put the Queue — pool sizing
- The Complete Guide to Transaction Isolation Levels — designing around transaction boundaries
- PostgreSQL Playground — experiment with session state