본문으로 건너뛰기
wooncloud

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

PostgreSQL이 연결마다 프로세스를 띄우는 구조와 연결 고갈 문제를 짚고, PgBouncer의 session·transaction·statement 풀 모드와 서버리스 환경의 함정, 풀 사이징을 정리한다.

·23 min read· views·

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

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

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

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

  • 프로세스 생성 비용: fork + 인증 + 카탈로그 로딩. 짧게 쓰고 버리는 연결이라면 이 비용이 쿼리 자체보다 클 수 있다.
  • 메모리: 백엔드 프로세스마다 work_mem, 캐시, 카탈로그 등으로 수 MB를 점유한다. 연결 수백 개면 수 GB가 연결 유지에만 쓰인다.
  • 컨텍스트 스위칭: 동시에 활성화된 프로세스가 코어 수를 크게 넘으면 스케줄러가 프로세스 간 전환에 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, pgPool, 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에 맺을 수 있는 총 연결 수
 
# 전체 DB로 가는 서버 연결 총상한 (DB의 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 출발점)로 큐를 세워 처리하는 편이 더 빠른 경우가 많다. 동시 실행은 작게, 동시 접속은 풀러로 크게 — 둘을 분리하라.