PgBouncer vs Supavisor: PostgreSQL 연결 풀링, 서버리스 시대에 무엇을 써야 하나
Vercel에 Next.js 앱을 처음 올렸다가 too many connections 에러를 마주했던 날이 생각납니다. 저도 2년 전 사이드 프로젝트에서 PgBouncer Transaction 모드를 쓰다가 Prisma Prepared Statement가 조용히 실패하는 원인을 못 찾고 한참 헤맸었거든요. 당시엔 방법이 마땅치 않았는데, 2024년 Supavisor 1.0 GA 출시와 PgBouncer 1.21의 Prepared Statement 지원 이후로 선택 기준 자체가 달라졌습니다. 지금이 이 비교를 다시 정리하기에 딱 좋은 시점입니다.
이 글에서는 Supavisor와 PgBouncer의 구조적 차이, 실제 성능 수치, 그리고 내 상황에 어떤 풀러가 맞는지를 숫자와 실제 설정 예시를 통해 직접 비교해봅니다. 트렌드만 보고 Supavisor를 무조건 골라야 하는 건지, 아니면 PgBouncer가 여전히 더 나은 선택인지, 결론 먼저 말씀드리면 — 상황에 따라 다릅니다. 그 기준을 찾아가봅시다.
핵심 개념
왜 연결 풀러가 필요한가
PostgreSQL은 클라이언트 연결 하나당 별도의 OS 프로세스를 fork합니다. 연결이 100개면 프로세스가 100개, 1,000개면 1,000개가 생깁니다. 프로세스 하나당 5~10MB 메모리를 잡아먹고 컨텍스트 스위칭 오버헤드까지 쌓이면 — 연결 수가 500개를 넘어서는 시점부터 실제로 쿼리 응답 시간이 눈에 띄게 올라가는 걸 경험하게 됩니다. DB 서버가 실제 쿼리를 처리하기 전에 이미 리소스를 낭비하고 있는 상황이죠.
Connection Pooler(연결 풀러): 애플리케이션과 PostgreSQL 사이에서 클라이언트 연결 수천 개를 소수의 실제 DB 서버 연결로 집약해주는 미들웨어. 클라이언트는 풀러와 연결하고, 풀러는 DB와의 연결을 재사용합니다.
서버리스 환경이 이 문제를 훨씬 더 심각하게 만들었습니다. 전통적인 서버는 연결을 한 번 맺고 계속 재사용하지만, Vercel Functions나 AWS Lambda는 함수 호출마다 새 연결을 시도합니다. 트래픽이 조금만 몰려도 DB 연결이 순식간에 한계에 도달하는 이유가 바로 이겁니다.
PgBouncer: 20년의 검증
PgBouncer는 2007년에 등장한 C 기반 단일 스레드 경량 풀러입니다. 1,000개 클라이언트당 약 2MB의 메모리만 사용할 정도로 극도로 가볍고, p99 레이턴시가 50 클라이언트 기준 4ms 미만으로 빠릅니다.
세 가지 풀링 모드를 지원하는데, 실무에서는 대부분 Transaction 모드를 씁니다.
| 모드 | 연결 반환 시점 | 특징 |
|---|---|---|
| Transaction | 트랜잭션 완료 시 | 가장 효율적, 세션 상태 의존 기능 제한 |
| Session | 클라이언트 연결 종료 시 | 기능 제약 없음, 절약 효과 낮음 |
| Statement | 각 쿼리 완료 시 | 멀티 쿼리 트랜잭션 불가 |
Transaction 모드에서 오래된 골칫거리가 하나 있었는데, Prepared Statement를 쓸 수 없었다는 겁니다. PgBouncer 1.21(2023년)에서 max_prepared_statements 설정으로 드디어 이 문제가 해결됐습니다.
# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200
# 운영 환경에서는 scram-sha-256 고려
auth_type = md5Prepared Statement: SQL 쿼리를 미리 파싱·컴파일해두고 파라미터만 바꿔 재실행하는 기능. 반복 쿼리에서 파싱 오버헤드를 줄여줍니다. Prisma ORM이 기본적으로 이 방식을 사용합니다.
단, 임시 테이블(TEMP TABLE), Advisory Lock, SET 명령으로 설정한 세션 변수는 여전히 Transaction 모드와 맞지 않습니다. 이런 기능을 쓰는 앱이라면 Session 모드나 직접 연결을 사용하는 것이 좋습니다.
Supavisor: 클라우드 네이티브 설계
Supavisor는 Supabase가 2023년 Elixir로 새로 만든 PostgreSQL 연결 풀러입니다. "왜 멀쩡한 PgBouncer를 두고 새로 만들었냐"는 질문이 당연히 나오는데, 설계 목표 자체가 다릅니다.
PgBouncer는 단일 DB 인스턴스를 위한 사이드카 프로세스로 설계됐습니다. Supavisor는 처음부터 수천 개의 Postgres 인스턴스를 하나의 클러스터에서 동시에 풀링하는 멀티테넌트 SaaS 인프라를 위해 만들어진 겁니다.
Supabase 자체 벤치마크 기준으로 64코어 단일 인스턴스에서 50만 연결, 두 인스턴스로 100만 연결 처리 수치가 나왔습니다. 실제 프로덕션에서는 하드웨어 스펙과 쿼리 패턴에 따라 크게 달라질 수 있지만, PgBouncer의 단일 스레드 CPU 포화 문제를 아예 다른 방식으로 우회했다는 점은 분명합니다.
BEAM VM: Erlang 생태계의 런타임. OS 스레드가 아닌 초경량 가상 프로세스를 수백만 개 동시 실행할 수 있고, 프로세스 간 메시지 패싱으로 통신하는 Actor 모델을 기반으로 합니다(Actor 모델 = 각자 독립적인 상태를 가진 프로세스들이 메시지로만 소통하는 동시성 패턴). Elixir도 이 위에서 동작하고, 덕분에 Supavisor가 엄청난 수의 연결을 동시에 처리할 수 있습니다.
읽기 레플리카 라우팅도 눈에 띄는 기능입니다. Supavisor는 쿼리를 파싱해 SELECT는 등록된 레플리카 전체에 랜덤 분산하고, INSERT/UPDATE/DELETE는 프라이머리로 자동 라우팅합니다. PgBouncer에는 없는 기능이라 직접 구현하려면 HAProxy나 pgpool-II를 별도로 구성해야 했던 부분입니다.
실전 적용
개념을 알았으니 이제 실제로 어떤 스택에서 어떻게 설정하는지 케이스별로 살펴보겠습니다.
예시 1: Next.js + Prisma + Vercel 서버리스 배포
서버리스 환경에서 가장 흔하게 맞닥뜨리는 패턴입니다. Prisma는 기본적으로 Prepared Statement를 사용하고, Vercel 함수는 호출마다 새 연결을 시도하기 때문에 풀러 없이는 금방 DB 연결이 포화됩니다.
Supabase를 쓰고 있다면 두 개의 URL을 분리해서 설정하는 방식이 표준으로 자리잡았습니다.
# .env
# 일반 쿼리: Supavisor Transaction 모드 (포트 6543)
DATABASE_URL="postgresql://user:pass@db.xxx.supabase.co:6543/postgres?pgbouncer=true"
# 마이그레이션용 직접 연결 (포트 5432)
DIRECT_URL="postgresql://user:pass@db.xxx.supabase.co:5432/postgres"// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}| 설정 | 용도 | 이유 |
|---|---|---|
DATABASE_URL (포트 6543) |
런타임 쿼리 | 풀러를 통해 연결 재사용 |
DIRECT_URL (포트 5432) |
마이그레이션 | ALTER TABLE 등은 세션 유지 필요 |
?pgbouncer=true |
Prisma 호환성 파라미터 | Prisma가 서버 사이드 Prepared Statement 대신 인라인 파라미터를 사용하도록 강제 |
?pgbouncer=true 파라미터를 빼먹으면 Prisma가 Prepared Statement를 시도하다 간헐적 에러가 날 수 있습니다. Supavisor 1.0부터 Named Prepared Statement를 지원하지만, 풀러를 통과하는 연결에서는 이 파라미터를 붙여두는 편이 안전합니다.
예시 2: 전통적인 서버 앱 (Rails / Django / Spring)
트래픽이 안정적이고 연결 수가 수백~수천 수준인 앱이라면 PgBouncer가 여전히 더 나은 선택일 수 있습니다. 설정도 단순하고, 레이턴시도 낮고, 20년간 쌓인 운영 노하우가 있습니다.
# /etc/pgbouncer/pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
# 운영 환경에서는 scram-sha-256 사용을 강력히 권장합니다 (PostgreSQL 14+ 기본값)
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
# PgBouncer 1.21+ Prepared Statement 지원
max_prepared_statements = 200
# 주의: server_reset_query는 session 모드에서만 실행됩니다
# transaction 모드에서는 실질적으로 동작하지 않습니다
server_reset_query = DISCARD ALL
log_connections = 1
log_disconnections = 1# 현재 풀 상태 확인
psql -h localhost -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"Rails ActiveRecord나 Django의 경우 앱 레벨에서도 연결 풀을 관리하기 때문에, default_pool_size를 앱 인스턴스 수 × 앱 레벨 풀 크기로 계산해서 설정하는 것이 좋습니다. 앱 서버 4대, 각각 풀 크기 5면 default_pool_size = 20 정도가 적당합니다.
예시 3: 멀티테넌트 SaaS + 읽기 레플리카
이쯤에서 표만 보면 Supavisor가 무조건 우위처럼 보이지만, 실제로는 멀티테넌트 구조처럼 명확한 이점이 있는 상황에서 진가를 발휘합니다.
테넌트별로 Postgres 인스턴스가 분리된 구조에서 단일 Supavisor 클러스터가 모든 테넌트 연결을 독립적으로 풀링합니다. 한 테넌트의 쿼리가 폭주해도 다른 테넌트에 영향을 주지 않도록 풀이 격리되는 것이 핵심입니다.
읽기 레플리카 등록도 비교적 간단합니다. Supabase 대시보드 또는 API를 통해 레플리카 정보를 등록해두면, 이후부터 SELECT는 레플리카 전체에 자동 분산되고 쓰기는 프라이머리로 라우팅됩니다.
# 멀티테넌트 환경에서 Supavisor 연결 예시
# 테넌트 A
TENANT_A_DB_URL="postgresql://tenant_a:pass@pooler.supabase.co:6543/tenant_a?pgbouncer=true"
# 테넌트 B
TENANT_B_DB_URL="postgresql://tenant_b:pass@pooler.supabase.co:6543/tenant_b?pgbouncer=true"
# 읽기 전용 쿼리용 (레플리카 자동 분산)
TENANT_A_READ_URL="postgresql://tenant_a:pass@pooler.supabase.co:6543/tenant_a?pgbouncer=true&read=true"PgBouncer로 이걸 구현하려면 테넌트마다 별도 인스턴스를 띄우고, 읽기/쓰기 분산을 위해 HAProxy나 pgpool-II를 추가로 구성해야 했습니다. 운영 복잡도 차이가 상당합니다.
장단점 분석
장점
PgBouncer
| 항목 | 내용 |
|---|---|
| 극저 레이턴시 | p99 < 4ms (50 클라이언트 기준, Tembo 벤치마크) |
| 극소 메모리 | 1,000 클라이언트당 약 2MB |
| 운영 성숙도 | 2007년부터 20년 프로덕션 검증, 방대한 운영 사례 |
| 단순한 설정 | ini 파일 하나로 대부분 설정 가능 |
| 범용 호환성 | 모든 Postgres 버전, 모든 환경에서 동작 |
Supavisor
| 항목 | 내용 |
|---|---|
| 수평 확장 | Supabase 자체 벤치마크 기준 단일 인스턴스 50만 연결 처리 |
| 멀티테넌시 내장 | 테넌트별 풀 격리, 연결 고갈 방지 |
| 읽기 레플리카 로드밸런싱 | SELECT 자동 분산, 쓰기 자동 프라이머리 라우팅 |
| 서버리스 최적화 | 폭발적 연결 증가 처리에 강함 |
| Named Prepared Statement | 1.0부터 지원 |
단점 및 주의사항
PgBouncer
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 단일 스레드 병목 | CPU 1코어 포화 시 처리량 한계 | 다중 인스턴스 앞에 HAProxy 구성 |
| 멀티테넌시 미지원 | DB 인스턴스별 별도 PgBouncer 필요 | 인스턴스당 독립 배포 |
| 읽기/쓰기 라우팅 불가 | 레플리카 분산 기능 없음 | PgCat 또는 pgpool-II 고려 |
| Transaction 모드 기능 제한 | 임시 테이블, Advisory Lock 사용 불가 | Session 모드 전환 또는 직접 연결 |
Supavisor
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 레이턴시 높음 | PgBouncer 대비 80~160% 높음 | 레이턴시 민감 워크로드엔 PgBouncer 유지 |
| 자체 호스팅 복잡 | Elixir 스택 운영 경험 필요 | Supabase 관리형 사용 권장 |
| PgBouncer 동시 사용 불가 | 두 풀러를 같은 DB에 동시 연결하면 연결 고갈 | 하나만 선택, 연결 문자열 교체로 전환 |
TPS 벤치마크 참고: Tembo 독립 벤치마크 기준 PgBouncer 약 44,000 TPS, Supavisor 약 21,700 TPS, PgCat 약 59,000 TPS. TPS만으로 선택 기준을 삼는 건 위험합니다. 확장성, 운영 편의성, 기능 지원이 함께 고려돼야 합니다.
실무에서 가장 흔한 실수
-
PgBouncer Transaction 모드에서 Prepared Statement 그냥 쓰기: 저도 이 함정에 빠졌었는데, Prisma·SQLAlchemy 같은 ORM이 기본적으로 Prepared Statement를 사용한다는 걸 모르고 Transaction 모드로 연결하면 간헐적 에러가 납니다. PgBouncer 1.21 미만이라면
?pgbouncer=true파라미터로 ORM 레벨에서 인라인 파라미터 방식으로 전환하거나, 버전을 업그레이드해max_prepared_statements를 설정하는 방향이 좋습니다. -
Supavisor와 PgBouncer를 동시에 연결하기: 스택오버플로우에서도 종종 보이는 실수인데, Supabase로 마이그레이션하는 과정에서 기존 PgBouncer 연결 문자열(포트 6543)을 그대로 두고 Supavisor도 추가로 연결하면 두 풀러가 동일 DB에 동시에 붙어 연결 수가 두 배로 늘어납니다. 전환할 때는 반드시 하나만 남겨두는 것이 안전합니다.
-
마이그레이션에 풀러 연결 사용하기:
ALTER TABLE,CREATE INDEX CONCURRENTLY같은 DDL은 세션이 유지돼야 합니다. Transaction 모드 풀러를 통해 마이그레이션을 실행하면 중간에 연결이 끊기거나 락 문제가 생길 수 있습니다. PrismaDIRECT_URL처럼 마이그레이션 전용 직접 연결을 별도로 두는 패턴을 권장합니다.
마치며
풀러 선택은 트렌드가 아니라 내 워크로드의 연결 패턴, 확장 요구사항, 그리고 팀의 운영 역량에 맞춰야 합니다.
Supavisor가 2023~2024년 사이 큰 주목을 받은 건 서버리스 트래픽 패턴이 폭발적으로 늘어난 시기와 정확히 맞물리기 때문입니다. 하지만 Elixir 스택을 자체 운영할 경험이 팀에 없다면, Supabase 관리형을 쓰는 게 아닌 이상 운영 비용이 생각보다 올라갈 수 있습니다. 반면 안정적인 트래픽에 레이턴시가 중요한 전통적 서버 앱이라면 PgBouncer가 여전히 더 나은 선택입니다. Vercel/Lambda 기반 서버리스, Supabase 사용, 멀티테넌트 SaaS, 읽기 레플리카 분산이 필요하다면 자연스럽게 Supavisor 쪽으로 기울게 될 거예요.
지금 바로 시작해볼 수 있는 3단계입니다.
-
현재 연결 현황 파악: 아래 명령으로 실제 DB에 맺힌 연결 수와 상태를 확인해볼 수 있습니다.
idle상태 연결이 전체의 절반을 넘는다면 풀러 도입 효과가 충분합니다. 참고로idle in transaction상태가 많다면 트랜잭션이 제대로 닫히지 않는 코드 문제일 수 있으니, 풀러 도입 전에 애플리케이션 코드를 먼저 점검해보시면 좋습니다.sqlSELECT count(*), state FROM pg_stat_activity GROUP BY state; -
워크로드 유형 분류: 서버리스(Vercel/Lambda) 환경이거나 멀티테넌트 구조라면 Supavisor를, 안정적인 트래픽의 전통적 서버 앱이라면 PgBouncer Transaction 모드를 우선 검토해볼 수 있습니다. Supabase를 이미 쓰고 있다면 Supavisor가 기본 제공되니 포트 6543 연결 문자열을 바로 활용해보시면 됩니다.
-
pgbench로 전후 비교: 아래 명령으로 풀러 적용 전후 TPS와 레이턴시를 직접 측정해볼 수 있습니다.
-c 50은 동시 클라이언트 수,-T 60은 실행 시간(초)입니다. 내 환경에서의 실제 효과를 수치로 확인할 수 있는 가장 빠른 방법입니다.bashpgbench -h localhost -p 6432 -U myuser -T 60 -c 50 mydb
참고 자료
- Supavisor 1.0: a scalable connection pooler for Postgres | Supabase Blog
- Supavisor: Scaling Postgres to 1 Million Connections | Supabase Blog
- Benchmarking PostgreSQL connection poolers: PgBouncer, PgCat and Supavisor | Tembo
- Migrating from PgBouncer | Supavisor 공식 문서
- GitHub - supabase/supavisor
- Prepared Statements in Transaction Mode for PgBouncer | Crunchy Data
- PostgreSQL Connection Pooling: PgBouncer, Supavisor & Built-In | DEV Community
- How we fixed Postgres connection pooling on serverless with PgDog | Circleback