---
title: "커넥션 풀링과 PgBouncer: 연결 고갈 막기"
description: "PostgreSQL이 연결마다 프로세스를 띄우는 구조와 연결 고갈 문제를 짚고, PgBouncer의 session·transaction·statement 풀 모드와 서버리스 환경의 함정, 풀 사이징을 정리한다."
date: 2026-05-30
updated: 2026-05-30T09:00:00.000Z
tags: [postgresql, database, performance, backend]
canonical: https://blog.wooncloud.com/posts/postgres-connection-pooling
---

![커넥션 풀링과 PgBouncer: 연결 고갈 막기](/images/posts/postgres-connection-pooling/61481f3f-55e4-4631-a992-f1a0b9d3333a.webp)

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

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

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

- **프로세스 생성 비용**: `fork` + 인증 + 카탈로그 로딩. 짧게 쓰고 버리는 연결이라면 이 비용이 쿼리 자체보다 클 수 있다.
- **메모리**: 백엔드 프로세스마다 `work_mem`, 캐시, 카탈로그 등으로 수 MB를 점유한다. 연결 수백 개면 수 GB가 연결 유지에만 쓰인다.
- **컨텍스트 스위칭**: 동시에 활성화된 프로세스가 코어 수를 크게 넘으면 스케줄러가 프로세스 간 전환에 CPU를 낭비한다. 활성 연결이 많아질수록 처리량이 오히려 떨어지는 구간이 생긴다.

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

```text
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가 존재하지 않는다. 그러면 다음과 같은 에러가 난다.

```text
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 병렬성)에 의해 정해진다. 활성 연결이 그 수를 넘으면, 추가된 연결은 진짜로 병렬 처리되는 게 아니라 자원을 두고 경합하면서 컨텍스트 스위칭과 락 경합만 늘린다. 결과적으로 **작은 풀로 요청을 큐에 세워 차례로 처리하는 편이 전체 처리량과 응답시간 모두에서 더 나은** 경우가 많다.

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

```text
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 핵심

```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)를 가리킨다.

```ts
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에 다음을 준다.

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

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

```ini
# ORM 연결 문자열 예 (PgBouncer transaction 모드 호환 플래그)
postgresql://app@127.0.0.1:6432/appdb?pgbouncer=true
```

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

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

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

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

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

```sql
-- 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` 출발점)로 큐를 세워 처리하는 편이 더 빠른 경우가 많다. 동시 실행은 작게, 동시 접속은 풀러로 크게 — 둘을 분리하라.
