Skip to content

필사 모드: 커넥션 풀 완전 가이드: 풀링 모드가 애플리케이션과 맺는 계약

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

이 블로그에는 커넥션 풀 크기, 크게 잡으면 손해인 이유가 이미 있습니다. 풀 크기를 왜 코어 수 근처로 잡아야 하는지, 대기열을 어디에 세워야 하는지를 다루는 크기 산정 편입니다.

이 글은 그다음 질문을 다룹니다. 크기를 정했다면, 그 커넥션을 어떤 방식으로 재사용할 것인가. 세션 풀링, 트랜잭션 풀링, 문장 풀링은 각각 다른 계약입니다. 트랜잭션 풀링으로 바꾸면 커넥션 효율이 극적으로 좋아지지만, 그 대가로 애플리케이션이 쓸 수 없게 되는 기능들이 생깁니다. 그 목록을 모른 채 모드를 바꿔서 나는 사고가 현장에 가장 많습니다. SET search_path가 다음 요청에 새어 나가거나, 세션 수준 어드바이저리 락이 영영 풀리지 않거나, 준비된 문장이 "does not exist" 오류를 내는 식입니다.

기준은 PostgreSQL 18PgBouncer 이며, 인용한 기본값은 각각의 공식 문서에서 확인했습니다.

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가 각 서버 커넥션에 필요한 준비를 대신 수행하는 방식입니다.

그래도 안전한 조합을 확인하는 순서는 이렇습니다.

  1. PgBouncer 버전이 준비된 문장 추적을 지원하는지 확인합니다. 이 기능은 특정 버전에서 도입되었으므로 사용 중인 버전의 문서에서 확인하세요.
  2. max_prepared_statements가 0이 아닌지 확인합니다.
  3. 드라이버 쪽 설정을 확인합니다. 서버 사이드 준비를 끄는 옵션이 있는 드라이버가 많습니다. 끄면 확실히 안전하지만 계획 재사용 이득을 잃습니다.

여기에 연결되는 성능 문제가 하나 더 있습니다. 준비된 문장을 쓰면 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 POOLSSHOW 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_waitingidle in transaction을 대시보드에 올려 둡니다.

커넥션 동작을 직접 실험해 보려면 Postgres 놀이터를 활용하세요.

참고 자료

이어서 읽기

현재 단락 (1/114)

이 블로그에는 [커넥션 풀 크기, 크게 잡으면 손해인 이유](/blog/database/2026-07-26-connection-pool-sizing)가 이미 있습니다. 풀 크기를 ...

작성 글자: 0원문 글자: 9,798작성 단락: 0/114