본문으로 건너뛰기
wooncloud

커넥션 풀링과 PgBouncer: 연결 고갈 막기

PostgreSQL 은 연결마다 OS 프로세스를 띄워 연결 고갈이 나기 쉬우며, PgBouncer 를 transaction 모드로 두면 서버 연결을 소수로 묶지만 prepared statement·SET·advisory lock 이 깨질 수 있습니다.

·25 min read·— views··

왜 PostgreSQL은 연결을 함부로 늘리면 죽는가

PostgreSQL은 클라이언트 연결마다 별도의 OS 프로세스를 띄웁니다. MySQL이 스레드 기반인 것과 달리, PostgreSQL의 백엔드는 postmaster가 fork로 만들어내는 무거운 프로세스입니다. 이 구조는 격리성과 안정성 측면에서 장점이 있지만, 연결 하나하나가 비싸다는 대가를 치릅니다.

연결을 열 때마다 다음 비용이 발생합니다.

  • 프로세스 생성 비용: fork + 인증 + 카탈로그 로딩입니다. 짧게 쓰고 버리는 연결이라면 이 비용이 쿼리 자체보다 클 수 있습니다.
  • 메모리: 백엔드 프로세스마다 카탈로그·릴레이션 캐시 등으로 수 MB를 상주 점유합니다(유휴 연결도 마찬가지). work_mem 은 여기에 더해 쿼리 실행 중 정렬·해시 노드마다 따로 잡히므로, 활성 연결이 많을 때 폭발하는 건 이쪽입니다.
  • 컨텍스트 스위칭: 동시에 활성화된 프로세스가 코어 수를 크게 넘으면 스케줄러가 프로세스 간 전환에 CPU를 낭비합니다. 활성 연결이 많아질수록 처리량이 오히려 떨어지는 구간이 생깁니다.

그래서 한계에 다다르면 다음 에러로 연결이 거부됩니다.

FATAL: sorry, too many connections already

max_connections를 올리면 되지 않냐고 묻기 쉽지만, 그건 근본 해법이 아닙니다. max_connections는 연결 수 상한일 뿐이고, 각 연결이 점유할 메모리(max_connections * work_mem 최악치)와 컨텍스트 스위칭 비용을 함께 끌어올립니다. 1000으로 올려두면 그 1000개가 실제로 동시에 바쁠 때 서버가 스왑에 빠지거나 스케줄러가 마비됩니다. 즉 max_connections는 "동시에 처리할 수 있는 일의 양"이 아니라 "동시에 떠 있을 수 있는 프로세스의 수"이며, 후자를 키운다고 전자가 커지지 않습니다.

핵심 인식은 이것입니다. DB가 실제로 동시에 처리할 수 있는 작업 수는 코어와 디스크에 의해 정해집니다. 연결을 그보다 많이 열어봐야 줄을 세우는 것보다 나을 게 없고, 오히려 자원 경합만 늘립니다. 그러니 "연결을 적게, 그리고 재사용"하는 것이 정답이고, 이를 위한 도구가 커넥션 풀링입니다.

풀링의 두 층위

커넥션 풀링은 보통 두 층위로 나뉩니다. 둘은 대체재가 아니라 보완재입니다.

1. 애플리케이션 내부 풀 (드라이버/ORM 풀)

애플리케이션 프로세스 안에서 DB 연결을 미리 N개 열어두고 요청마다 빌려 쓰고 반납하는 방식입니다. 거의 모든 드라이버/ORM이 내장합니다. HikariCP, pg의 Pool, SQLAlchemy 엔진 풀, Prisma 내부 풀 등이 여기 해당합니다.

이것만으로도 "요청마다 연결을 새로 여는" 최악의 패턴은 사라집니다. 하지만 한계가 명확합니다. 이 풀은 프로세스 단위로 격리됩니다. 앱 인스턴스가 10개면 풀도 10개고, 각 풀이 20개씩 열면 DB 입장에서 200개 연결입니다. 인스턴스끼리는 서로의 풀 상태를 모르므로, 전체 연결 수의 상한을 한곳에서 통제할 방법이 없습니다.

2. 외부 풀러 (PgBouncer 등)

PgBouncer는 앱과 DB 사이에 끼어드는 독립 프로세스입니다. 앱들은 PgBouncer에 연결하고, PgBouncer가 DB로 가는 실제 연결을 소수만 유지하면서 여러 클라이언트 연결에 돌려 씁니다.

외부 풀러가 필요한 이유는 분명합니다.

  • 다중 앱 인스턴스: 인스턴스가 몇 개로 늘든 DB로 가는 실제 연결 총량을 PgBouncer 한 곳에서 상한으로 묶을 수 있습니다. 앱 풀들이 합쳐 봐야 PgBouncer의 max_db_connections를 넘지 못합니다.
  • 서버리스/단명 프로세스: 인스턴스가 수시로 뜨고 사라지는 환경에서는 앱 내부 풀이 무의미합니다(프로세스가 곧 죽으니 풀을 유지할 수 없습니다). 상주하는 외부 풀러만이 연결을 재사용할 수 있습니다.

정리하면, 앱 내부 풀은 "한 프로세스 안에서의 재사용"을, 외부 풀러는 "전체 시스템 관점에서의 연결 총량 통제"를 담당합니다. 단일 인스턴스라면 앱 풀만으로 충분할 수 있지만, 인스턴스가 여러 개거나 서버리스라면 외부 풀러가 사실상 필수가 됩니다.

PgBouncer 풀 모드 3가지

PgBouncer의 절약 효과와 제약은 pool_mode가 결정합니다. 모드마다 "서버 연결을 언제 클라이언트에게서 회수해 다른 클라이언트에게 넘길 수 있는가"가 다릅니다.

session 모드

클라이언트가 연결을 맺고 있는 동안 서버 연결 하나를 독점합니다. 클라이언트가 연결을 끊어야 서버 연결이 풀로 돌아갑니다.

  • 장점: PostgreSQL의 모든 기능이 그대로 동작합니다. 세션 상태, prepared statement, SET, advisory lock, LISTEN/NOTIFY 모두 안전합니다. 가장 호환성이 높습니다.
  • 단점: 절약 효과가 가장 적습니다. 클라이언트 연결 수와 서버 연결 수가 사실상 1:1에 수렴하므로, 앱 내부 풀과 본질적으로 같은 한계를 갖습니다. 다만 단명 클라이언트의 연결 수립 비용은 흡수해 줍니다.

transaction 모드 (권장)

서버 연결을 트랜잭션 경계 단위로 회수합니다. 클라이언트가 트랜잭션을 시작하면 서버 연결을 빌리고, 커밋/롤백하는 순간 풀로 반납합니다. 같은 클라이언트의 다음 트랜잭션은 전혀 다른 서버 연결로 처리될 수 있습니다.

  • 장점: 절약 효과가 가장 큽니다. 대부분의 웹 트래픽은 짧은 트랜잭션의 연속이고 트랜잭션 사이에는 연결이 놀고 있으므로, 그 유휴 시간을 다른 클라이언트가 채웁니다. 클라이언트 1000개를 서버 연결 20개로 받아내는 것이 가능해집니다.
  • 이것이 다중 인스턴스/서버리스 환경의 표준 선택입니다. 다만 트레이드오프가 있고, 그게 다음 절의 핵심입니다.

statement 모드

문장(statement) 하나가 끝날 때마다 서버 연결을 회수합니다. 가장 공격적인 재사용입니다.

  • 제약이 매우 큽니다: 여러 문장에 걸친 트랜잭션을 사실상 쓸 수 없습니다(멀티 문장 트랜잭션 금지). 샤딩 프록시 앞단처럼 모든 쿼리가 autocommit 단문인 특수한 상황이 아니라면 거의 쓰지 않습니다.

실무 선택은 대개 session 또는 transaction으로 좁혀집니다. 호환성을 최우선으로 두면 session, 연결 절약을 최우선으로 두면 transaction입니다.

transaction 모드의 함정

transaction 모드의 절약은 공짜가 아닙니다. "다음 트랜잭션이 다른 서버 연결로 갈 수 있다"는 성질이 곧, 세션에 묶이는 모든 상태가 깨질 수 있다는 뜻이기 때문입니다.

prepared statement (서버사이드)

가장 흔하게 부딪히는 문제입니다. 서버사이드 prepared statement는 특정 서버 연결에 등록됩니다. 드라이버가 PREPARE를 한 연결에서 해 두고 다음 트랜잭션에서 그 prepared statement를 EXECUTE하려 하면, 그 사이 PgBouncer가 트랜잭션 경계에서 연결을 바꿔버려 해당 연결에는 그 prepared statement가 존재하지 않습니다. 그러면 다음과 같은 에러가 납니다.

ERROR: prepared statement "s1" does not exist

대응은 둘 중 하나입니다.

  • 드라이버에서 서버사이드 prepared statement를 끕니다 (아래 코드 예 참고).
  • PgBouncer가 prepared statement를 인지/추적하도록 설정합니다. PgBouncer 1.21+는 max_prepared_statements를 0보다 크게 주면 transaction 모드에서도 prepared statement를 풀 전반에 걸쳐 처리할 수 있습니다.

SET / 세션 상태

SET search_path = ..., SET timezone = ..., SET ROLE ... 같은 세션 변수는 한 서버 연결에만 적용됩니다. transaction 모드에서는 다음 트랜잭션이 다른 연결로 가면 그 설정이 사라집니다. 세션 변수에 의존하는 코드는 동작이 비결정적이 됩니다. 트랜잭션 범위로 한정되는 SET LOCAL은 같은 트랜잭션 안에서만 쓰는 한 안전합니다.

advisory lock

세션 수준 advisory lock(pg_advisory_lock)은 연결에 묶입니다. 락을 잡은 연결과 해제하려는 연결이 달라질 수 있으므로 transaction 모드에서는 신뢰할 수 없습니다. 굳이 써야 한다면 트랜잭션 수준 락(pg_advisory_xact_lock)을 씁니다. 이건 트랜잭션이 끝나면 자동 해제되므로 경계를 넘지 않습니다.

LISTEN / NOTIFY

LISTEN은 그 연결에 알림 채널을 구독시키는데, transaction 모드에서는 그 연결이 곧 다른 클라이언트에게 넘어가므로 알림을 받을 수 없습니다. LISTEN/NOTIFY가 필요한 워크로드는 PgBouncer를 우회하거나 별도의 session 모드 풀을 둡니다.

핵심 원칙: transaction 모드에서는 "단일 트랜잭션을 넘어 연결에 들러붙는 상태"를 만들지 않습니다. 트랜잭션 안에서 자기완결적인 SQL만 쓰면 안전합니다.

서버리스/람다의 연결 폭발

서버리스 환경은 위 문제들을 극단으로 끌고 갑니다. 트래픽이 몰리면 함수 인스턴스가 수십, 수백 개로 동시에 뜹니다. 각 인스턴스는 자기 요청을 처리하려고 DB 연결을 엽니다. 인스턴스 200개가 각자 연결 1개씩만 열어도 DB에는 순식간에 200개 연결이 꽂힙니다. 인스턴스가 내부 풀을 갖고 있으면 더 나빠집니다.

게다가 서버리스 인스턴스는 단명이라 앱 내부 풀이 거의 무의미합니다. 함수가 끝나면 프로세스가 동결/종료되므로 풀을 유지해 재사용할 시간이 없습니다. 콜드 스타트마다 연결 수립 비용도 새로 뭅니다.

그래서 서버리스에서는 앞단에 상주하는 풀러를 두는 것이 사실상 필수입니다. PgBouncer를 transaction 모드로 두거나, 매니지드 풀러(클라우드 제공형 연결 풀러)를 씁니다. 함수들은 풀러에 연결하고, 풀러가 DB로 가는 실제 연결을 소수로 묶습니다. 이렇게 하면 인스턴스가 몇 개로 폭발하든 DB가 보는 연결 수는 풀러가 정한 상한 안에 머뭅니다.

이때도 transaction 모드의 제약은 그대로 적용됩니다. 서버리스용 드라이버/ORM은 보통 prepared statement를 끄거나 풀러 호환 모드를 명시적으로 켜야 합니다.

풀 사이징: 크다고 빠르지 않다

흔한 오해가 "풀을 크게 잡을수록 빠르다"는 것입니다. 실제로는 반대인 구간이 넓습니다.

DB가 동시에 실제로 진행시킬 수 있는 작업 수는 코어 수와 디스크(I/O 병렬성)에 의해 정해집니다. 활성 연결이 그 수를 넘으면, 추가된 연결은 진짜로 병렬 처리되는 게 아니라 자원을 두고 경합하면서 컨텍스트 스위칭과 락 경합만 늘립니다. 결과적으로 작은 풀로 요청을 큐에 세워 차례로 처리하는 편이 전체 처리량과 응답시간 모두에서 더 나은 경우가 많습니다.

널리 인용되는 출발점 공식은 다음과 같습니다.

connections ≈ (core_count * 2) + effective_spindle_count
  • core_count: 물리 코어 수(하이퍼스레딩 논리 코어가 아니라 물리 코어 기준)입니다. CPU가 일하는 동안 다른 연결이 I/O를 대기할 수 있으므로 ×2를 둡니다.
  • effective_spindle_count: 디스크가 동시에 처리 가능한 I/O 병렬성의 척도입니다. HDD라면 디스크(스핀들) 개수에 가깝고, SSD/NVMe라면 그보다 큰 값으로 봅니다.

예를 들어 코어 8개에 적당한 디스크 병렬성을 가정하면 활성 풀 크기는 대략 수십 개 수준이지 수백 개가 아닙니다. 이 공식은 정밀한 정답이 아니라 출발점입니다. 실측 부하 테스트로 응답시간 곡선이 꺾이는 지점을 찾아 조정해야 합니다. 다만 방향은 분명합니다. 의심스러우면 줄여야 합니다.

여기서 풀러의 또 다른 가치가 드러납니다. 작은 서버 풀을 유지하면서도, 클라이언트가 잠깐 그 상한을 넘으면 PgBouncer가 큐에 세워 흡수합니다. 즉 "동시 실행은 작게, 동시 접속은 크게"를 분리해서 다룰 수 있습니다.

설정과 코드

pgbouncer.ini 핵심

[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
 
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
# 핵심 3종
pool_mode = transaction          ; 트랜잭션 단위 재사용 (다중 인스턴스/서버리스 표준)
default_pool_size = 20           ; DB당 유지할 서버 연결 수 (작게, 사이징 공식 기준)
max_client_conn = 1000           ; 클라이언트가 PgBouncer에 맺을 수 있는 총 연결 수
 
# [databases] 항목 하나당 서버 연결 상한 (유저 무관).
# 총량은 대략 (DB 항목 수 × 이 값) 이므로, 그 합이 PostgreSQL 의 max_connections 보다 낮아야 한다.
max_db_connections = 50
 
# transaction 모드에서 prepared statement를 풀러가 추적하게 하려면 (PgBouncer 1.21+)
# 0이면 추적 안 함 → 드라이버에서 prepared statement를 꺼야 함
max_prepared_statements = 200

세 옵션의 의미를 분리해서 보는 게 중요합니다.

  • max_client_conn: 앱 쪽에서 들어오는 연결의 상한입니다. 크게 잡아도 됩니다(연결 수립 비용을 PgBouncer가 흡수).
  • default_pool_size: DB로 나가는 연결의 상한입니다. 작게 잡습니다(사이징 공식).
  • 둘 사이의 간극이 곧 PgBouncer가 큐로 흡수하는 동시성입니다.

앱 드라이버 풀 (Node.js, node-postgres)

PgBouncer를 두더라도 앱 내부 풀은 보통 그대로 둡니다. 단 PgBouncer를 향하므로 앱 풀 크기는 작게 잡고, 포트는 PgBouncer(6432)를 가리킵니다.

import { Pool } from "pg";
 
const pool = new Pool({
  host: "127.0.0.1",
  port: 6432, // PgBouncer 포트 (DB의 5432가 아님)
  database: "appdb",
  max: 10, // 인스턴스당 작게. (인스턴스 수 * max) 가 PgBouncer max_client_conn 안에 들어오게
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});
 
export async function getUser(id: number) {
  // 단일 트랜잭션으로 자기완결 — transaction 모드에서 안전
  const { rows } = await pool.query("select id, email from users where id = $1", [id]);
  return rows[0];
}

transaction 모드: prepared statement 끄기

max_prepared_statements로 PgBouncer가 추적하게 하지 않을 거라면, 드라이버에서 서버사이드 prepared statement를 꺼야 합니다. 드라이버별로 방식이 다릅니다.

JDBC(예: HikariCP + PostgreSQL JDBC)에서는 연결 URL에 다음을 줍니다.

# JDBC URL 파라미터: 서버사이드 prepared 비활성
prepareThreshold=0

ORM 계열은 연결 문자열에 풀러 호환 플래그를 받는 경우가 많습니다. 예를 들어 일부 ORM은 ?pgbouncer=true(또는 동등한 옵션)를 붙이면 내부적으로 prepared statement를 비활성화하거나 풀러 안전 모드로 전환합니다.

# ORM 연결 문자열 예 (PgBouncer transaction 모드 호환 플래그)
postgresql://[email protected]:6432/appdb?pgbouncer=true

node-postgres는 기본적으로 단순 쿼리에서 서버사이드 prepared statement를 강제하지 않으므로 transaction 모드와 잘 맞습니다. 다만 명시적으로 name을 준 prepared 쿼리를 쓴다면 같은 문제가 생기니 transaction 모드에서는 피합니다.

안전한 패턴: 트랜잭션 자기완결

transaction 모드에서 멀티 문장 트랜잭션을 쓸 때는, 트랜잭션을 명시적으로 묶어 같은 서버 연결 위에서 한 번에 처리되도록 합니다. 트랜잭션 경계 안에서는 PgBouncer가 연결을 회수하지 않으므로 안전합니다.

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

반면 아래처럼 세션에 상태를 남기고 그것을 다음 트랜잭션에서 기대하는 패턴은 깨집니다.

-- transaction 모드에서 위험: 두 문장이 서로 다른 서버 연결로 갈 수 있다
SET search_path = tenant_42;      -- 이 연결에만 적용됨
SELECT * FROM orders;             -- 다른 연결로 가면 search_path 가 기본값

이런 건 매 쿼리에서 스키마를 명시(tenant_42.orders)하거나 SET LOCAL을 트랜잭션 안에서 쓰는 식으로 바꿉니다.

정리

  • PostgreSQL은 연결마다 OS 프로세스를 띄우므로 연결이 비쌉니다. max_connections를 올리는 건 메모리·컨텍스트 스위칭 비용을 함께 올리는 미봉책이지 해법이 아닙니다.
  • 풀링은 두 층위입니다. 앱 내부 풀은 프로세스 안에서 연결을 재사용하고, **외부 풀러(PgBouncer)**는 전체 시스템의 연결 총량을 한곳에서 통제합니다. 단일 인스턴스면 앱 풀로 충분할 수 있지만, 다중 인스턴스나 서버리스면 PgBouncer가 사실상 필수입니다.
  • 풀 모드는 호환성의 session, 절약의 transaction, 제약 많은 statement로 나뉩니다. 다중 인스턴스/서버리스의 표준은 transaction 모드입니다.
  • transaction 모드의 대가는 세션 상태가 트랜잭션을 넘어 유지되지 않는다는 것입니다. **prepared statement·SET·세션 advisory lock·LISTEN**이 깨지므로, 드라이버에서 prepared statement를 끄거나 PgBouncer가 추적하게 하고, 트랜잭션 자기완결 패턴을 지킵니다.
  • 풀은 크다고 빠르지 않습니다. 코어/디스크 기준의 작은 활성 풀((core_count * 2) + effective_spindle_count 출발점)로 큐를 세워 처리하는 편이 더 빠른 경우가 많습니다. 동시 실행은 작게, 동시 접속은 풀러로 크게 — 둘을 분리해야 합니다.