TechFeedTechFeed
Backend

PostgreSQL 커넥션 풀 고갈로 서비스가 멈춘 새벽 3시 — PgBouncer 도입과 연결 관리 재설계 실전 기록

B2B SaaS 스타트업이 서버 인스턴스 6개 × 풀 10개 구조에서 배치 작업이 몰리며 PostgreSQL 커넥션 200개를 모두 소진한 사건의 원인 분석과 PgBouncer 도입, 배치 전용 풀 분리, 알람 설정까지의 전 과정을 기록했다.

서비스 트래픽이 두 배로 늘어난 지 3주 만에, 한 스타트업의 백엔드가 새벽 3시에 완전히 멈췄다. 원인은 PostgreSQL 커넥션 풀 고갈이었다. 이 글은 그 팀이 PgBouncer를 도입하고 연결 관리 아키텍처를 재설계한 실제 과정을 기록한다.

사건 개요 — 새벽 3시의 알람

2025년 11월, B2B SaaS 스타트업 A사(직원 12명, 엔지니어 4명)는 기존 고객사 한 곳이 대규모 배치 작업을 밤 사이에 실행하면서 트래픽이 평소의 7배로 급증했다. 새벽 3시 17분, 모든 API 엔드포인트가 too many connections 오류를 반환하기 시작했고, Slack 알람이 쏟아졌다. 서비스는 약 22분간 완전히 다운됐다.
당직 엔지니어가 EC2에 SSH로 접속했을 때 PostgreSQL 로그에는 같은 오류가 초당 수백 건씩 찍히고 있었다. RDS 인스턴스의 DatabaseConnections 지표는 한도인 200에 도달해 있었고, 새 연결 요청은 모두 거부되고 있었다. Node.js 애플리케이션 서버 6개 인스턴스가 각각 별도의 연결 풀을 유지하고 있었고, 배치 작업이 잠금 대기 상태에 빠진 연결들을 해제하지 않은 채로 추가 연결을 계속 요청하고 있었다.
PostgreSQL connection pool exhaustion monitoring dashboard
새벽 3시 RDS 커넥션 지표가 한도에 도달한 모습 (재현 화면)

원인 분석 — 커넥션 풀이 왜 고갈됐나

오전 중에 팀은 사후 분석을 진행했다. 문제는 세 가지 요소가 동시에 겹친 결과였다. 첫째, Node.js 애플리케이션에서 사용하던 pg 라이브러리의 풀 설정이 기본값(max: 10)으로 되어 있었고, 인스턴스 수가 늘면서 전체 연결 수가 선형으로 증가하는 구조였다. 둘째, 배치 작업이 트랜잭션 안에서 외부 API를 호출하고 있어서 응답 대기 시간(최대 8초)만큼 연결을 점유하고 있었다. 셋째, 커넥션 타임아웃이 30초로 설정되어 있어 고갈된 연결이 빠르게 해제되지 않았다.

연결 수 상한과 실제 사용 패턴의 괴리

아래 쿼리로 연결 상태를 확인했을 때, 전체 200개 연결 중 153개가 idle in transaction 상태로 잠겨 있었다. 실제로 쿼리를 실행하는 연결은 12개뿐이었다. 연결이 부족한 것이 아니라, 연결이 반환되지 않고 잠금 대기 상태로 쌓이고 있었던 것이다.
PostgreSQL 연결 상태 확인 쿼리
SELECT state, count(*) FROM pg_stat_activity WHERE datname = 'prod_db' GROUP BY state ORDER BY count DESC; -- 결과 예시: -- idle in transaction | 153 -- active | 12 -- idle | 31 -- (null) | 4

첫 번째 시도 — max_connections 늘리기

가장 먼저 시도한 것은 RDS 파라미터 그룹에서 max_connections를 200에서 500으로 올리는 것이었다. 적용은 간단했지만 효과는 미미했다. 커넥션 상한을 높이자 배치 작업이 더 많은 연결을 열기 시작했고, 새 한도인 500에 도달하는 시간만 40분에서 90분으로 늘어났을 뿐, 근본 문제는 해결되지 않았다.
RDS max_connections 조정 (임시 조치)
-- PostgreSQL max_connections는 공유 메모리(shared_buffers)와 연관됨 -- db.t3.medium 기준 max_connections 권장값: -- 기본: LEAST({DBInstanceClassMemory/9531392}, 5000) -- 실제 변경은 RDS 파라미터 그룹에서 적용 -- 현재 설정 확인 SHOW max_connections; -- 커넥션당 메모리 사용량 추정 SELECT sum(rss) / 1024 / 1024 AS total_mb FROM pg_backend_memory_contexts WHERE name = 'TopMemoryContext';
함정: max_connections를 무작정 올리면 PostgreSQL의 공유 메모리 사용이 늘고, 각 연결이 최소 5~10MB를 점유하므로 메모리 부족으로 인한 OOM이 발생할 수 있다. 한도 증가는 임시 처방이지 근본 해결이 아니다.

두 번째 시도 — 애플리케이션 레벨 풀 조정

팀은 pg의 풀 설정을 조정하고, 트랜잭션 안에서 외부 API 호출을 제거하는 코드 수정을 진행했다. 연결 풀의 max를 인스턴스당 10에서 5로 낮추고, idleTimeoutMillisconnectionTimeoutMillis를 짧게 조정했다. 이 변경으로 안정성이 다소 향상됐지만 배치 작업이 몰리는 피크 타임에는 여전히 연결 경합이 발생했다.
node-postgres 풀 설정 개선 전/후
// 변경 전 const pool = new Pool({ max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); // 변경 후 const pool = new Pool({ max: 5, idleTimeoutMillis: 10000, connectionTimeoutMillis: 3000, allowExitOnIdle: true, }); // 트랜잭션 안에서 외부 API 호출 제거 // 변경 전 (잘못된 패턴) async function processOrder(orderId) { const client = await pool.connect(); await client.query('BEGIN'); await externalApiCall(orderId); // 연결 점유 중 외부 호출 await client.query('INSERT INTO orders ...'); await client.query('COMMIT'); client.release(); } // 변경 후 (올바른 패턴) async function processOrder(orderId) { const result = await externalApiCall(orderId); // 트랜잭션 밖에서 const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('INSERT INTO orders ...', [result]); await client.query('COMMIT'); } finally { client.release(); } }
PgBouncer architecture diagram showing connection pooling between app servers and PostgreSQL
PgBouncer를 중간 계층으로 도입한 아키텍처 구성도

최종 해결 — PgBouncer 도입

애플리케이션 레벨 수정만으로는 다수의 서버 인스턴스와 배치 작업이 동시에 실행되는 환경에서 연결 수를 예측 가능하게 제어하기 어려웠다. 팀은 결국 PostgreSQL과 애플리케이션 사이에 PgBouncer를 미들웨어로 배치하기로 결정했다. PgBouncer는 수백 개의 클라이언트 연결을 실제 PostgreSQL 연결 수십 개로 다중화(multiplex)하는 커넥션 풀러다.
PgBouncer는 세 가지 풀링 모드를 지원한다. Session mode는 클라이언트 세션 동안 연결을 유지하고, Transaction mode는 트랜잭션 단위로 연결을 할당·반환하며, Statement mode는 SQL 문 단위로 연결을 재사용한다. A사는 트랜잭션 내부에서 SET이나 LISTEN 같은 세션 상태를 사용하지 않기 때문에 Transaction mode를 선택했다. 이 모드가 연결 재사용률이 가장 높다.
pgbouncer.ini 핵심 설정
[databases] prod_db = host=rds-endpoint.amazonaws.com port=5432 dbname=prod_db [pgbouncer] listen_port = 5432 listen_addr = 0.0.0.0 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt ; 핵심 풀링 설정 pool_mode = transaction max_client_conn = 1000 ; 클라이언트에서 받을 수 있는 최대 연결 default_pool_size = 40 ; 실제 PostgreSQL에 유지할 연결 min_pool_size = 10 reserve_pool_size = 5 reserve_pool_timeout = 3 ; 연결 수명 설정 server_lifetime = 3600 ; 서버 연결 최대 수명 (초) server_idle_timeout = 600 ; 유휴 연결 제거 시간 client_idle_timeout = 0 ; 클라이언트 유휴 타임아웃 비활성화 ; 로깅 log_connections = 0 log_disconnections = 0 log_pooler_errors = 1 stats_period = 60 [users] ; userlist.txt에 SHA-256 해시로 사용자 등록
애플리케이션 연결 설정 변경 (PgBouncer 경유)
// 변경 전: RDS 직접 연결 const pool = new Pool({ host: 'prod.xxxxxx.us-east-1.rds.amazonaws.com', port: 5432, database: 'prod_db', max: 5, }); // 변경 후: PgBouncer 경유 const pool = new Pool({ host: 'pgbouncer.internal', // PgBouncer EC2 내부 주소 port: 5432, database: 'prod_db', max: 50, // PgBouncer가 다중화하므로 클라이언트 측 상한 여유있게 설정 // PgBouncer transaction mode에서 사용 불가 항목 // SET 명령, LISTEN/NOTIFY, 세션 레벨 준비 구문 (prepared statement) 주의 }); // prepared statement는 simple query 모드로 전환 // node-postgres에서: statement_cache_size=0 옵션 추가

결과와 지표

PgBouncer 도입 2주 후, 팀은 동일한 수준의 배치 작업을 재현 테스트로 실행했다. RDS에 유지되는 실제 연결 수는 최대 42개 수준으로 안정적으로 유지됐다. 클라이언트 측 연결은 수백 개가 열리더라도 PostgreSQL 입장에서는 40개 수준의 연결만 보이는 구조가 됐다.
PgBouncer stats showing connection pool utilization before and after
SHOW STATS 명령으로 확인한 PgBouncer 풀 사용량 — 피크 시 실제 PostgreSQL 연결이 42개로 안정화된 상태

재발 방지를 위한 아키텍처 개선

팀은 PgBouncer 도입과 함께 두 가지 추가 개선을 진행했다. 첫째, 배치 작업을 별도의 DB 유저(batch_user)로 분리하고, PgBouncer에서 해당 유저의 풀 크기를 별도로 제한했다. 이 유저는 최대 10개 연결만 사용할 수 있어 배치 작업이 실시간 API 연결을 잠식하지 않는다. 둘째, CloudWatch에 DatabaseConnections 알람을 한도의 70% 수준(140개)에서 경고가 발생하도록 설정해 미리 대응할 수 있는 시간을 확보했다.
배치 작업 전용 풀 분리 (pgbouncer.ini)
[databases] ; 실시간 API 전용 풀 prod_db = host=rds-endpoint port=5432 dbname=prod_db pool_size=40 ; 배치 작업 전용 풀 (별도 포트 또는 별도 DB 설정) prod_db_batch = host=rds-endpoint port=5432 dbname=prod_db user=batch_user pool_size=10 pool_mode=session ; CloudWatch Alarm (Terraform 예시) resource "aws_cloudwatch_metric_alarm" "db_connections_warning" { alarm_name = "rds-connections-70pct" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "DatabaseConnections" namespace = "AWS/RDS" period = 60 statistic = "Average" threshold = 140 # 200의 70% alarm_actions = [aws_sns_topic.ops_alerts.arn] }
Transaction mode 사용 시 주의: PgBouncer transaction mode에서는 SET search_path, LISTEN/NOTIFY, 세션 레벨 PREPARE 구문, pg_advisory_lock이 정상적으로 동작하지 않는다. 이 기능을 사용한다면 session mode를 쓰거나 해당 쿼리는 직접 연결로 라우팅해야 한다.

이 사례에서 얻은 3가지 교훈

1. 인스턴스 수 × 풀 크기 = 실제 연결 수를 항상 계산하라. 이 팀의 경우 서버 6개 × 풀 10개 = 60개가 기본이었는데, 오토스케일링으로 인스턴스가 늘어나면서 계산이 어긋났다. 시스템 설계 시 스케일아웃 시나리오에서의 최대 연결 수를 미리 계산해두지 않으면 장애는 트래픽이 늘 때 반드시 발생한다.
2. 트랜잭션은 가능한 짧게, 외부 I/O는 트랜잭션 밖으로 빼라. 이 팀의 배치 작업은 트랜잭션 안에서 외부 API를 8초 동안 기다리고 있었다. 그 8초 동안 연결은 잠기고 다른 요청은 연결을 얻지 못한다. 트랜잭션은 DB 작업만 포함해야 한다는 원칙은 교과서에 있지만, 실제 프로덕션 코드에서 지켜지지 않는 경우가 많다.
3. 커넥션 풀러는 선택이 아니라 필수 인프라다. PgBouncer, pgpool-II, RDS Proxy 같은 커넥션 풀러는 서버 인스턴스가 4개를 넘어가는 순간부터 도입을 검토해야 한다. AWS를 사용한다면 RDS Proxy가 관리형으로 간편하지만 비용이 있고, PgBouncer는 EC2에 직접 띄우면 비용 없이 동일한 효과를 얻을 수 있다. A사는 결국 월 $23 EC2(t3.micro)에 PgBouncer를 올려 문제를 해결했다.
PostgreSQLPgBouncer커넥션 풀데이터베이스장애 대응RDSNode.js트러블슈팅스타트업백엔드

관련 포스트

Redis 캐시 장애 대응 72시간 — 캐시 스탬피드 발생부터 아키텍처 개선까지2026-04-02Node.js 22 LTS 새 기능 총정리2026-03-12PostgreSQL 17 주요 변경점과 마이그레이션 가이드2026-03-17Node.js vs Bun vs Deno — 2026년 런타임 비교 실무 가이드2026-03-20