본문으로 건너뛰기

PostgreSQL 커넥션 풀링 이해하기: PgBouncer 모드 선택부터 풀 사이즈 계산, 서버리스 커넥션 폭증 대응까지

Database15분 읽기본문 7,736

핵심 요약

이 글에서 확인할 내용

커넥션이 많다고 다 좋은 게 아니다 애플리케이션 서버를 오토스케일링으로 늘렸는데 갑자기 데이터베이스 응답이 느려지는 상황을 겪어본 적이 있다면, 원인이 CPU나 디스크가 아니라 "커넥션 수 자체"였던 경험도 있을 것입니다. PostgreSQL은 커넥션 하나마다 OS 프로세스를 하나씩 띄우는 구조를 가지고 있어서, 커넥션을 무작정 늘리는 대응은 오히려 처리량을 떨어뜨립니다.

  • 1. PostgreSQL이 커넥션마다 프로세스를 포크하는 이유와 그 비용
  • 2. maxconnections를 무작정 올리면 안 되는 이유
  • 3. 애플리케이션 레벨 풀과 외부 풀러의 역할 분담

PostgreSQL 커넥션 풀링과 PgBouncer 구성을 정리한 대표 이미지

커넥션이 많다고 다 좋은 게 아니다

애플리케이션 서버를 오토스케일링으로 늘렸는데 갑자기 데이터베이스 응답이 느려지는 상황을 겪어본 적이 있다면, 원인이 CPU나 디스크가 아니라 "커넥션 수 자체"였던 경험도 있을 것입니다. PostgreSQL은 커넥션 하나마다 OS 프로세스를 하나씩 띄우는 구조를 가지고 있어서, 커넥션을 무작정 늘리는 대응은 오히려 처리량을 떨어뜨립니다. 서버를 늘렸는데 DB가 느려졌다면, 문제는 애플리케이션이 아니라 커넥션 관리 계층에 있을 가능성이 큽니다.

이 글은 PostgreSQL이 왜 커넥션을 비싸게 취급하는지에서 출발해서, 애플리케이션 레벨 풀(HikariCP, pg-pool, SQLAlchemy pool)과 PgBouncer 같은 외부 풀러가 왜 둘 다 필요한지, PgBouncer의 세 가지 풀링 모드가 서로 다른 트레이드오프를 갖는 이유, 풀 사이즈를 감으로 정하지 않고 계산하는 방법, 그리고 Lambda 같은 서버리스 환경에서 커넥션이 순식간에 터지는 문제를 어떻게 막는지까지 순서대로 정리합니다. 개념 설명에 그치지 않고 pgbouncer.ini 설정값과 모니터링 쿼리를 실제로 쓸 수 있는 형태로 담았습니다.

1. PostgreSQL이 커넥션마다 프로세스를 포크하는 이유와 그 비용

PostgreSQL은 스레드가 아니라 프로세스 기반으로 동작합니다. 클라이언트가 연결하면 postmaster가 fork()로 백엔드 프로세스를 하나 만들고, 이 프로세스가 연결이 종료될 때까지 해당 세션을 전담합니다. 이 구조는 하나의 백엔드가 죽어도 다른 세션에 영향을 주지 않는다는 장점이 있지만, 대신 커넥션 하나하나가 스레드보다 훨씬 무거운 자원을 소비합니다.

새 백엔드 프로세스는 자체 스택과 힙, 카탈로그 캐시, 로컬 버퍼를 가집니다. 여기에 더해 정렬이나 해시 조인이 실행될 때마다 work_mem이 커넥션 단위로 추가 할당됩니다. 커넥션 하나가 복잡한 쿼리를 실행하면 work_mem을 사용하는 노드 수만큼 메모리가 곱해집니다. 커넥션 수가 늘어날수록 이 곱셈 효과가 서버 전체 메모리를 잠식합니다.

여기에 컨텍스트 스위칭 비용도 있습니다. 동시에 실행 가능한 백엔드 프로세스 수가 물리 코어 수를 크게 넘어서면, OS 스케줄러는 프로세스 상태를 저장하고 복원하는 작업을 반복하게 되고 이 과정에서 CPU 캐시가 계속 무효화됩니다. 게다가 트랜잭션 시작과 종료마다 공유 프로세스 배열을 갱신하기 위해 내부 락을 잡기 때문에, 활성 커넥션 수가 늘어날수록 락 경합도 함께 커집니다. 유휴 커넥션이라고 비용이 0은 아니라는 점도 AWS의 분석에서 확인할 수 있습니다. 연결만 맺어놓고 쿼리를 안 날려도 메모리와 카탈로그 캐시는 계속 점유된 상태로 남습니다.

2. max_connections를 무작정 올리면 안 되는 이유

커넥션이 부족해서 에러가 난다고 해서 max_connections를 500, 1000으로 올리는 것은 근본적인 해결책이 아닙니다. max_connections는 단순히 "이 값까지 허용한다"는 상한선이지, 그만큼의 동시 활성 커넥션을 효율적으로 처리할 수 있다는 보장이 아닙니다. PostgreSQL 공식 문서도 이 값을 늘리면 공유 메모리 요구량이 함께 늘어난다는 점을 명시하고 있습니다.

문제는 두 가지입니다. 첫째, 커넥션 수만큼 메모리가 선점되기 때문에 실제로는 놀고 있는 커넥션이 대부분이어도 서버 메모리는 이미 소모된 상태가 됩니다. 둘째, 동시에 활성화되는 프로세스가 코어 수 대비 지나치게 많아지면 컨텍스트 스위칭과 락 경합이 늘어나면서 오히려 초당 처리 가능한 쿼리 수가 줄어듭니다. 즉 커넥션을 늘렸는데 처리량은 떨어지는 역설이 발생합니다.

여기에 재시작이나 장애 복구 시나리오도 고려해야 합니다. 배포나 페일오버로 DB가 잠깐 끊겼다가 살아나면, 애플리케이션 서버들이 동시에 재연결을 시도합니다. max_connections가 크게 열려 있으면 이 순간 postmaster에 fork 요청이 몰리면서 복구 자체가 더 느려질 수 있습니다. 커넥션 수를 늘리는 것보다, 애초에 DB에 도달하는 동시 커넥션 수 자체를 줄이는 방향이 더 안정적입니다. 바로 이 지점에서 커넥션 풀링이 필요해집니다.

3. 애플리케이션 레벨 풀과 외부 풀러의 역할 분담

커넥션 풀링에는 두 계층이 있고, 이 둘은 경쟁 관계가 아니라 보완 관계입니다.

애플리케이션 레벨 풀(HikariCP, node-postgres의 pg-pool, SQLAlchemy의 QueuePool 등)은 하나의 프로세스 안에서 커넥션을 재사용해 매 요청마다 새로 연결을 맺는 비용(TLS 핸드셰이크, 인증, 카탈로그 로딩)을 없애줍니다. 하지만 이 풀은 프로세스 하나에 국한됩니다. 애플리케이션 서버가 10대씩 떠 있고 인스턴스마다 풀 크기를 20으로 잡으면, DB 입장에서는 이미 200개의 실제 커넥션을 상대해야 합니다. 애플리케이션 레벨 풀만으로는 서버 대수가 늘어날 때의 총 커넥션 폭증을 막을 수 없습니다.

여기서 PgBouncer 같은 외부 풀러가 다른 역할을 맡습니다. PgBouncer는 애플리케이션과 PostgreSQL 사이에 위치해서, 수백~수천 개의 클라이언트 커넥션을 훨씬 적은 수의 실제 PostgreSQL 백엔드 커넥션으로 다중화합니다. PgBouncer 공식 문서에 따르면 클라이언트 입장에서는 일반 PostgreSQL 서버에 연결하는 것과 동일하게 동작하면서도, 실제 서버 측 커넥션 수는 애플리케이션 인스턴스 수와 무관하게 고정된 범위로 유지됩니다.

정리하면 애플리케이션 레벨 풀은 "프로세스 내부의 연결 재사용 비용"을 줄이고, PgBouncer는 "여러 프로세스가 만드는 총 연결 수"를 제어합니다. 둘 다 켜두는 것이 일반적인 구성이며, 애플리케이션 풀 크기를 PgBouncer 앞단에서 과도하게 키우면 PgBouncer의 max_client_conn만 소진될 뿐 실질적인 이득이 없다는 점은 기억해둘 만합니다.

4. PgBouncer의 세 가지 풀링 모드와 제약

PgBouncer는 pool_mode 설정으로 서버 커넥션을 클라이언트에게 언제 반환할지 결정합니다. 이 선택이 애플리케이션이 쓸 수 있는 PostgreSQL 기능의 범위를 바꿉니다.

모드서버 커넥션 반환 시점Prepared StatementSET / 세션 변수Advisory Lock, 임시 테이블
session클라이언트 연결 종료 시완전 지원완전 지원완전 지원
transaction트랜잭션 커밋/롤백 시max_prepared_statements 설정 시 부분 지원SET LOCAL만 트랜잭션 범위에서 안전트랜잭션 경계를 넘으면 깨짐
statement각 SQL 문 실행 직후사실상 미지원사실상 미지원미지원, 멀티 스테이트먼트 트랜잭션 불가

session 모드는 가장 안전하지만 풀링 효과가 가장 작습니다. 클라이언트가 연결을 유지하는 동안 서버 커넥션을 계속 물고 있기 때문에, 유휴 커넥션이 많은 워크로드에서는 이점이 크지 않습니다.

transaction 모드는 실무에서 가장 널리 쓰이는 기본값입니다. 트랜잭션이 끝나자마자 서버 커넥션을 반환하기 때문에 훨씬 적은 서버 커넥션으로 많은 클라이언트를 감당할 수 있습니다. 다만 세션 상태가 트랜잭션 경계를 넘어 유지된다고 가정하는 코드는 깨집니다. 예를 들어 SET search_path = tenant_a처럼 세션 범위 SET을 실행하면, 이 값이 다음 트랜잭션에서는 다른 클라이언트에게 그대로 노출될 수 있습니다.

-- transaction 모드에서 위험한 패턴: 세션 범위 SET이 다음 트랜잭션에 새어나갈 수 있음
BEGIN;
SET search_path = tenant_a;   -- 세션 전체에 적용되어 커넥션 반환 후에도 유지됨
SELECT * FROM orders;
COMMIT;
-- 이 커넥션이 다른 클라이언트에게 재할당되면 tenant_a search_path가 그대로 남아있을 수 있다

-- 안전한 패턴: SET LOCAL은 현재 트랜잭션에만 적용되고 커밋/롤백 시 자동 해제됨
BEGIN;
SET LOCAL search_path = tenant_a;
SELECT * FROM orders;
COMMIT;

Prepared statement도 오래된 제약이었습니다. PostgreSQL의 prepared statement는 세션 단위 객체라서, 트랜잭션 모드에서 커넥션이 매번 바뀌면 "이 이름의 prepared statement가 없다"는 오류가 발생하곤 했습니다. PgBouncer 1.21부터는 max_prepared_statements를 0이 아닌 값으로 설정하면 프로토콜 레벨 prepared statement를 내부적으로 추적해 여러 서버 커넥션에서 공유할 수 있게 되었지만(Crunchy Data의 설명 참고), SQL 레벨 PREPARE/DEALLOCATE나 일부 드라이버 구현까지 온전히 호환되는 것은 아니므로 반드시 스테이징 환경에서 검증해야 합니다.

statement 모드는 가장 공격적인 풀링이지만, 하나의 트랜잭션 안에서 여러 문장을 실행하는 일반적인 애플리케이션에는 맞지 않습니다. 통계성 단일 쿼리를 대량으로 흘려보내는 특수한 게이트웨이 용도가 아니라면 잘 쓰이지 않습니다.

본문 중간 핵심 흐름을 시각화한 Database 일러스트

5. 풀 사이즈는 어떻게 계산하는가

풀 크기를 무작정 크게 잡으면 다시 2번에서 이야기한 문제로 돌아갑니다. 풀은 "많을수록 좋은 것"이 아니라 "DB가 실제로 소화할 수 있는 동시 실행 수"에 맞춰야 합니다.

애플리케이션 레벨 풀에 대해서는 HikariCP 위키가 제시하는 공식이 널리 참고됩니다. connections = ((core_count * 2) + effective_spindle_count)(HikariCP 공식 위키)입니다. 코어 수는 하이퍼스레딩을 제외한 물리 코어 기준이고, effective spindle count는 활성 데이터셋이 전부 캐시에 올라가 있다면 0에 가깝고, 캐시 히트율이 낮을수록 실제 스핀들 수에 가까워집니다. HikariCP 위키는 SSD에 대해서는 아직 이 공식이 얼마나 잘 들어맞는지 별도로 검증된 바가 없다고 명시하면서도, 탐색·회전 지연이 없는 만큼 스레드 수를 코어 수에 가깝게(즉 스핀들 값을 낮게) 잡는 편이 유리하다고 언급합니다. 하드디스크 한 대 기준 예시로는 4코어 서버라면 대략 9~10 정도가 시작점이며, 이보다 큰 값은 대부분 처리량을 늘리지 못하고 컨텍스트 스위칭 비용만 더합니다. 다만 이 공식은 위키 자체가 밝히듯 절대적인 정답이 아니라 튜닝의 출발점으로 다뤄야 합니다.

PgBouncer 쪽에서는 default_pool_size가 데이터베이스/사용자 조합당 유지할 서버 커넥션 수를 정하고, max_client_conn이 PgBouncer가 받아들이는 전체 클라이언트 커넥션 상한을 정합니다. 여러 애플리케이션 인스턴스가 늘어나도 default_pool_size는 그대로 유지되므로, PostgreSQL이 실제로 받는 커넥션 수는 애플리케이션 서버 대수와 무관하게 안정적으로 유지됩니다.

; pgbouncer.ini 예시 - transaction 모드 기본 구성
[databases]
production = host=127.0.0.1 port=5432 dbname=production

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
pool_mode = transaction

; 클라이언트(애플리케이션) 쪽 최대 동시 연결 수
max_client_conn = 1000

; DB/유저 조합당 유지할 실제 서버 커넥션 수
default_pool_size = 20

; 스파이크 상황에서 임시로 더 열어줄 여유분
reserve_pool_size = 5
reserve_pool_timeout = 3

; 트랜잭션 모드에서 prepared statement 공유 허용
max_prepared_statements = 200

여러 애플리케이션 인스턴스가 하나의 PgBouncer를 공유한다면, 각 인스턴스의 풀 크기 합이 PgBouncer의 default_pool_size를 과도하게 넘지 않도록 조정해야 합니다. 인스턴스 쪽 풀을 크게 잡아도 PgBouncer 뒷단이 좁으면 결국 PgBouncer 큐에서 대기하게 될 뿐입니다.

6. 서버리스와 람다 환경의 커넥션 폭증 대응

전통적인 상시 구동 서버라면 인스턴스 수가 어느 정도 예측 가능하지만, AWS Lambda 같은 서버리스 환경은 이 가정을 깨뜨립니다. 함수 실행이 순간적으로 수백~수천 개의 동시 실행으로 확장될 수 있고, 각 실행 컨텍스트가 자체적으로 DB 커넥션을 맺으려 하면 순식간에 PostgreSQL의 max_connections를 넘어버립니다. 500개의 동시 실행이 각각 5개씩 커넥션을 열기만 해도 2500개가 됩니다.

이 문제는 애플리케이션 레벨 풀로 해결되지 않습니다. Lambda 실행 컨텍스트는 재사용될 수도 있지만 보장되지 않고, 스케일 아웃 시점에는 완전히 새로운 컨텍스트가 계속 생겨나기 때문입니다. 그래서 서버리스 환경에서는 외부 풀러를 DB 앞단에 상시 배치하는 방식이 표준적인 대응이 됩니다.

AWS 진영에서는 RDS Proxy가 이 역할을 합니다. AWS 공식 블로그에 따르면 RDS Proxy는 Lambda를 포함한 애플리케이션과 RDS 사이에서 커넥션을 다중화하고, IAM 기반 인증과 빠른 페일오버 전환까지 함께 제공합니다. 여기서 흔히 놓치는 함정이 있는데, SQLAlchemy 같은 ORM의 자체 풀(QueuePool)을 RDS Proxy 앞단에서 그대로 켜두면 이중 풀링이 되어 오히려 RDS Proxy의 클라이언트 커넥션 한도를 빨리 소진시킬 수 있습니다. Lambda + RDS Proxy 조합에서는 ORM 쪽 풀을 NullPool로 바꿔 커넥션 관리를 RDS Proxy에 위임하는 방식이 권장됩니다.

Supabase 생태계에서는 Elixir로 구현된 Supavisor가 비슷한 역할을 합니다. PostgreSQL 네이티브 프로토콜을 그대로 지원하면서 서버리스/엣지 환경에 맞춘 풀링을 제공합니다. 어떤 풀러를 쓰든 핵심 원칙은 같습니다. 커넥션 수 제어 책임을 상태가 없는(stateless) 함수 실행 계층이 아니라, 항상 떠 있는 별도 계층으로 옮기는 것입니다.

7. pg_stat_activity와 SHOW POOLS로 모니터링하기

풀 사이즈와 모드를 설정했다고 끝이 아닙니다. 실제로 커넥션이 어디서 병목을 만드는지 관찰할 수 있어야 값을 조정할 근거가 생깁니다.

PostgreSQL 쪽에서는 pg_stat_activity로 현재 세션 상태별 분포를 볼 수 있습니다.

-- 상태별 커넥션 수 확인: active가 아닌데 커넥션을 오래 물고 있는 세션이 있는지 본다
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'production'
GROUP BY state
ORDER BY count(*) DESC;

-- idle in transaction이 오래 지속되는 세션을 찾는다 (풀을 묶어두는 주범)
SELECT pid, usename, state, now() - state_change AS idle_duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '30 seconds'
ORDER BY idle_duration DESC;

idle in transaction 상태가 오래 지속되는 세션은 애플리케이션이 트랜잭션을 커밋/롤백하지 않고 방치하고 있다는 신호이며, transaction 모드 PgBouncer에서는 이 세션이 서버 커넥션 하나를 계속 붙잡아 풀 전체를 좁게 만듭니다.

PgBouncer 자체는 관리용 콘솔 데이터베이스에 접속해 SHOW POOLS;로 풀 상태를 확인할 수 있습니다.

database   | user    | cl_active | cl_waiting | sv_active | sv_idle | maxwait
production | appuser |       142 |          6 |        20 |       0 |       2

cl_active는 현재 쿼리를 실행 중인 클라이언트, cl_waiting은 서버 커넥션을 기다리는 클라이언트, sv_activesv_idle은 실제 PostgreSQL과 맺어진 서버 커넥션의 사용 중/유휴 상태를 뜻합니다. cl_waiting이 지속적으로 0보다 크다면 default_pool_size가 부족하다는 신호이고, sv_idle이 항상 과도하게 높다면 반대로 풀이 필요 이상으로 크게 잡혀 있다는 신호입니다. 이 값들은 PgBouncer 문서에 정의된 표준 관리 명령으로, 별도 에이전트 없이도 주기적으로 스크레이핑해 대시보드로 만들 수 있습니다.

마치며: 커넥션 풀링 설계 체크리스트

PostgreSQL의 프로세스 기반 구조를 이해하면 왜 커넥션 풀링이 선택이 아니라 필수인지 자연스럽게 납득이 됩니다. 다음 다섯 가지를 운영 전 점검 항목으로 삼을 만합니다.

  • max_connections를 늘리기 전에, 정말 동시 활성 커넥션이 많은 것인지 아니면 유휴 커넥션이 쌓여있는 것인지 pg_stat_activity로 먼저 확인했는가
  • 애플리케이션 레벨 풀과 PgBouncer를 함께 쓰면서, 양쪽 풀 크기가 서로 상쇄되거나 이중으로 커지지 않도록 맞췄는가
  • pool_mode = transaction을 쓴다면 세션 범위 SET, prepared statement, advisory lock, 임시 테이블을 코드베이스 전체에서 걷어냈거나 max_prepared_statements로 명시적으로 대응했는가
  • 풀 사이즈를 감이 아니라 코어 수 기반 공식으로 먼저 계산하고, 이후 cl_waiting/sv_idle 지표를 보며 조정했는가
  • 서버리스나 오토스케일링 환경이라면 애플리케이션 레벨 풀만으로는 충분하지 않다는 전제하에 RDS Proxy나 Supavisor 같은 상시형 풀러를 앞단에 두었는가

함께 읽으면 좋은 글