본문 바로가기
Database2026년 9월 8일19분 읽기

DB 커넥션 풀 튜닝 — PgBouncer와 풀 사이징 공식으로 커넥션 폭증 잡기

YS
김영삼
조회 8
DB 커넥션 풀 튜닝 — PgBouncer와 풀 사이징 공식으로 커넥션 폭증 잡기

커넥션 풀 크기는 크게 잡을수록 좋은 게 아니다. 대부분의 워크로드에서 최적 풀 크기는 "(코어 수 × 2) + 유효 디스크 수" 근처의 작은 두 자릿수이고, 그걸 넘기면 처리량은 오히려 떨어진다. PgBouncer 같은 외부 풀러를 transaction 모드로 앞에 두면 DB의 실제 커넥션은 수십 개로 묶으면서 수천 개의 클라이언트를 받을 수 있다.

PostgreSQL 커넥션 하나는 별도 OS 프로세스다. 그래서 비싸다. 이 글은 왜 커넥션이 비싼지, 풀 크기를 어떻게 계산하는지, transaction/session 풀링의 차이, prepared statement가 왜 transaction 모드에서 터지는지, 그리고 서버리스/람다에서 커넥션이 폭증하는 문제까지 실무에서 데인 경험을 섞어 정리한다.

몇 년 전에 트래픽이 갑자기 튄 날, DB가 FATAL: sorry, too many clients already를 뱉으며 죽었다. 급한 마음에 max_connections를 200에서 800으로 올렸다. 결과는? 더 빨리, 더 처참하게 죽었다. 커넥션 수를 늘리는 건 대부분의 경우 해결책이 아니라 문제를 키우는 짓이라는 걸 그때 배웠다. 이 글은 그 반대 방향 — 커넥션을 줄이고 재사용하는 이야기다.

항목대략적인 값메모
커넥션 1개당 백엔드 메모리약 5~10MB (work_mem 사용 시 훨씬 증가)PostgreSQL은 커넥션마다 fork된 프로세스
TCP+TLS+인증 핸드셰이크수 ms ~ 수십 ms쿼리보다 커넥션 셋업이 더 오래 걸리는 경우도 흔함
권장 풀 크기 (일반 OLTP)코어 수 × 2 ~ × 4 부근16코어면 대략 20~50, 수백 개가 아님
PgBouncer가 다룰 수 있는 client conn수천 ~ 수만server conn(default_pool_size)은 수십으로 유지
PgBouncer 프로세스 상주 메모리보통 20MB 미만 (수천 커넥션에도)단일 스레드, 이벤트 루프 기반

왜 커넥션 하나가 그렇게 비쌀까?

PostgreSQL에서 커넥션은 스레드가 아니라 별도의 OS 프로세스이기 때문이다. 클라이언트가 붙을 때마다 postmaster가 fork()로 백엔드 프로세스를 하나 만든다. 이 프로세스는 자기만의 메모리(약 5~10MB, 정렬·해시 조인이 걸리면 work_mem만큼 더), 캐시, 카탈로그 스냅샷을 들고 있다. 커넥션 수가 늘면 이 프로세스들이 CPU 스케줄러와 메모리를 두고 경쟁한다.

여기에 더해 커넥션을 여는 비용 자체가 크다. TCP 3-way, TLS 협상, SCRAM 인증, 그리고 세션 초기화(SET 문, search_path, 애플리케이션 이름 설정 등)까지 하면 수 ms에서 수십 ms가 그냥 날아간다. 짧은 쿼리를 수천 번 날리는 API라면, 쿼리 실행보다 커넥션 셋업에 더 많은 시간을 쓰는 웃픈 상황이 벌어진다. 그래서 커넥션은 만들고 버리는 게 아니라 미리 만들어 두고 재사용해야 한다. 그게 풀의 존재 이유다.

중요한 건 이거다. 커넥션이 많다고 처리량이 늘지 않는다. CPU 코어가 16개인 서버에서 활성 커넥션이 500개면, 그 500개가 16개 코어를 두고 컨텍스트 스위칭하며 서로를 방해할 뿐이다. 락 경합, 캐시 미스, 스케줄러 오버헤드가 누적되면서 오히려 총 처리량이 떨어지고 지연은 치솟는다. 이걸 "커넥션을 줄였더니 더 빨라졌다"는 반직관적 현상으로 겪는 사람이 정말 많다.

참고 PostgreSQL 14부터 유휴/많은 커넥션 상황의 확장성이 꽤 개선됐다(스냅샷 계산 등). 그래도 "프로세스 모델"이라는 근본은 그대로라, 활성 커넥션 폭증에 대한 결론은 바뀌지 않는다. 유휴 커넥션 1000개는 견딜 만해도, 동시에 쿼리를 던지는 활성 커넥션 1000개는 여전히 재앙이다.

풀 크기 공식: 얼마로 잡아야 하나?

출발점은 HikariCP와 PostgreSQL 커뮤니티가 오래 인용해 온 공식이다. 디스크 I/O가 병목인 전통적 워크로드 기준으로:

connections = (core_count * 2) + effective_spindle_count

# core_count      : 실제 물리 코어 수 (하이퍼스레딩 논리 코어 아님)
# effective_spindle_count : 동시에 I/O를 처리할 수 있는 디스크 수
#                          (SSD/NVMe는 병렬성이 높아 크게 잡아도 됨,
#                           클라우드 관리형 DB면 그냥 감으로 두 자릿수)

예를 들어 물리 8코어 + SSD 서버라면 (8 × 2) + 여유 = 대략 20 안팎이 시작점이다. "겨우 20?" 싶겠지만, 이 작은 풀이 수백 개짜리 풀보다 처리량이 높은 경우가 대부분이다. 공식은 절대적 정답이 아니라 출발점이다. 여기서 부하 테스트로 미세 조정한다.

현실적인 접근은 리틀의 법칙(Little's Law)으로 역산하는 것이다. 필요한 커넥션 수 ≈ 목표 TPS × 평균 쿼리 시간(초). 초당 2000건을 처리하고 쿼리 평균이 5ms(0.005초)라면 2000 × 0.005 = 10개면 충분하다. 여기에 지터와 스파이크 대비 여유를 조금 얹는다. 이 계산을 해 보면 "왜 풀이 이렇게 작아도 되는지" 감이 온다.

시나리오목표 TPS평균 쿼리권장 풀(리틀 법칙 + 여유)
가벼운 OLTP API2,0003ms약 6 → 여유 두고 10~15
무거운 조인/집계 섞임50030ms약 15 → 20~30
배치/리포팅 (긴 쿼리)50200ms약 10 → 별도 풀로 격리

개인적으로는 애플리케이션 인스턴스 수까지 곱해서 계산하는 걸 자주 잊는다. 앱 서버 10대가 각각 풀 20을 열면 DB 입장에선 200개다. 총합이 DB의 max_connections를 넘지 않게 — 이게 핵심이고, 여기서 PgBouncer가 구원투수로 등장한다.

PgBouncer는 무엇을 해결하나?

PgBouncer는 애플리케이션과 PostgreSQL 사이에 앉는 경량 커넥션 풀러다. 앱은 수천 개의 커넥션을 PgBouncer에 열고, PgBouncer는 뒤쪽 DB에는 수십 개의 실제 커넥션만 유지하면서 이를 돌려쓴다. 단일 스레드 이벤트 루프라 메모리를 거의 안 먹는다 — 수천 클라이언트를 붙여도 상주 메모리가 20MB를 넘기 어렵다.

기본 설정 예시는 이렇다:

# /etc/pgbouncer/pgbouncer.ini
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction          # 대부분의 웹앱에 권장
max_client_conn = 5000           # 앱이 붙을 수 있는 총 커넥션
default_pool_size = 20           # DB당 실제 서버 커넥션 (여기가 진짜 풀 크기)
reserve_pool_size = 5            # 급증 대비 예비
reserve_pool_timeout = 3         # 예비 풀 대여 대기(초)
server_idle_timeout = 600        # 유휴 서버 커넥션 정리(초)

여기서 앱 쪽 커넥션 풀(HikariCP 등)은 오히려 넉넉하게 잡아도 된다. 실제 DB 부하를 좁히는 병목은 default_pool_size가 담당하기 때문이다. 앱 풀은 PgBouncer까지의 로컬 커넥션일 뿐이라 값이 싸다.

transaction 풀링 vs session 풀링, 뭐가 다른가?

가장 중요한 결정이 pool_mode다. 세 가지가 있는데 실무에선 session과 transaction 둘 중 하나다.

  • session: 클라이언트가 커넥션을 잡으면 연결이 끊길 때까지 같은 서버 커넥션을 독점한다. 풀링 효과가 가장 약하다. 세션 레벨 기능(임시 테이블, 세션 변수, LISTEN/NOTIFY, advisory lock)을 다 쓸 수 있는 대신, 커넥션 재사용률이 낮다.
  • transaction: 서버 커넥션을 트랜잭션 단위로 빌려주고 커밋/롤백되면 즉시 반납한다. 재사용률이 압도적으로 높아 실질적인 풀링 이득의 대부분이 여기서 나온다. 대신 세션에 걸쳐 상태를 유지하는 기능은 못 쓴다.
  • statement: 문장 하나 끝날 때마다 반납. 멀티 스테이트먼트 트랜잭션 자체가 금지된다. 특수한 상황(오토커밋 전용) 아니면 거의 안 쓴다.
기능/특성session 풀링transaction 풀링
커넥션 재사용률낮음매우 높음
SET / 세션 변수가능위험 (다음 트랜잭션엔 다른 커넥션)
임시 테이블 / advisory lock가능불가
LISTEN / NOTIFY가능불가
prepared statement문제 없음함정 있음 (아래 참고)
서버리스/고동시성 적합부족최적

결론부터 말하면 웹 API 백엔드는 거의 다 transaction 모드가 정답이다. 다만 앱이 세션 상태에 은근히 의존하고 있으면 transaction 모드로 바꾼 순간 미묘한 버그가 터진다. 그중 가장 악명 높은 게 prepared statement다.

transaction 모드의 prepared statement 함정

한 줄 요약: 서버 사이드 prepared statement는 특정 서버 커넥션에 묶여 있는데, transaction 모드에선 매 트랜잭션마다 다른 커넥션을 배정받기 때문에 준비해 둔 statement가 그 커넥션에 없어서 터진다. 전형적인 에러가 이거다:

ERROR: prepared statement "S_1" does not exist
-- 또는 재사용 시
ERROR: prepared statement "S_1" already exists

JDBC(PgJDBC)는 성능을 위해 같은 쿼리를 여러 번 실행하면 자동으로 서버 prepared statement로 승격시킨다. JPA/Hibernate를 쓰면 대부분 여기에 해당한다. 이게 session 모드에선 아무 문제가 없다가, transaction 모드로 전환하는 순간 프로덕션에서 산발적으로 위 에러가 튄다. 나도 이걸로 하루를 날린 적이 있다. 로그만 봐선 원인을 잡기 어렵다.

해결책은 두 갈래다:

  • PgBouncer 1.21+ 사용: 이 버전부터 transaction 모드에서 프로토콜 레벨 prepared statement를 지원한다. max_prepared_statements를 0보다 크게 설정하면 PgBouncer가 각 서버 커넥션에 statement를 알아서 준비/추적해 준다.
  • 클라이언트에서 서버 prepared statement 끄기: 구버전 PgBouncer라면 드라이버 옵션으로 비활성화한다. PgJDBC는 prepareThreshold=0, Npgsql은 Max Auto Prepare=0, node-postgres/asyncpg 등도 각자 옵션이 있다.
# PgBouncer 1.21+ — transaction 모드에서 prepared statement 살리기
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200    # 0이면 비활성(기본). 커넥션당 추적할 개수

# 구버전이라면 클라이언트 쪽에서 끄는 방법 (JDBC 예)
# jdbc:postgresql://pgbouncer:6432/appdb?prepareThreshold=0
주의 asyncpg는 내부적으로 이름 있는 prepared statement와 statement 캐시를 공격적으로 쓴다. PgBouncer transaction 모드 뒤에 둘 거라면 statement_cache_size=0 그리고 고유 statement 이름을 끄는 설정이 필요하다. Supabase/Neon 같은 관리형 pooler를 쓸 때 "가끔 쿼리가 실패해요" 문의의 상당수가 이 조합이다.

서버리스와 람다에서 커넥션이 폭증하는 이유

서버리스에서 커넥션이 터지는 근본 원인은 각 함수 인스턴스가 커넥션을 공유하지 못하기 때문이다. 전통적인 앱 서버는 프로세스 하나가 풀을 들고 여러 요청이 나눠 쓴다. 반면 람다는 동시 요청 수만큼 격리된 실행 환경이 뜨고, 각각이 자기 커넥션을 연다. 동시성이 1000이면 커넥션도 순식간에 1000개로 치솟는다. 이걸 "connection storm"이라 부른다.

게다가 콜드 스타트마다 새 커넥션을 열고, 함수가 얼어붙었다(freeze) 녹으면(thaw) 죽은 커넥션이 남는다. TLS 핸드셰이크 비용이 매 콜드 스타트에 붙으니 지연도 나빠진다. 대응 전략은 이렇다:

  • 핸들러 밖에서 커넥션 생성: 핸들러 함수 바깥(모듈 스코프)에 커넥션을 두면 같은 실행 환경이 재사용될 때(warm start) 커넥션도 재사용된다. 이것만으로도 커넥션 수가 크게 준다. 단, 함수당 풀 크기는 1~2로 작게.
  • 전용 pooler를 앞에 둔다: AWS라면 RDS Proxy, 또는 PgBouncer를 별도로 띄운다. 람다는 pooler에만 붙고, pooler가 DB 커넥션을 수십 개로 묶는다. 이게 정공법이다.
  • 동시성 상한(reserved concurrency) 설정: 람다 동시성 자체를 DB가 감당할 수준으로 제한한다. 무제한 스케일이 DB를 죽이는 걸 막는 안전판.
  • HTTP 기반 접근: Data API나 HTTP 드라이버(Neon/Supabase의 serverless driver 등)로 아예 상시 커넥션을 없애는 방법도 있다. 커넥션 상태가 없으니 storm 자체가 성립하지 않는다.
// Node.js Lambda — 핸들러 밖에서 풀 생성(warm start 재사용)
import { Pool } from 'pg';

// 모듈 스코프: 실행 환경이 살아있는 동안 재사용됨
const pool = new Pool({
  host: process.env.PGBOUNCER_HOST,  // RDS Proxy 또는 PgBouncer 엔드포인트
  max: 1,                            // 함수 인스턴스당 1개면 충분
  idleTimeoutMillis: 30000,
});

export const handler = async (event) => {
  const { rows } = await pool.query('SELECT id FROM orders WHERE user_id = $1', [event.userId]);
  return { statusCode: 200, body: JSON.stringify(rows) };
};
참고 RDS Proxy도 내부적으로는 커넥션 pinning 개념이 있어서, 세션 상태를 건드리는 특정 SQL(예: 특정 SET, advisory lock)을 실행하면 그 커넥션이 클라이언트에 고정(pinned)되어 재사용이 안 된다. CloudWatch의 DatabaseConnectionsBorrowLatency와 pinning 지표를 보면서 튜닝하는 걸 권한다.

실전 체크리스트: 무엇부터 손댈까

순서가 중요하다. 커넥션 문제로 급할 때 나는 이 순서로 본다.

  1. 먼저 지금 커넥션이 몇 개고 뭘 하고 있는지 확인한다. SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — idle in transaction이 많으면 커넥션 수가 아니라 애플리케이션의 트랜잭션 누수가 진짜 범인이다.
  2. 앱 풀 크기 × 인스턴스 수의 총합이 max_connections를 넘는지 계산한다. 넘으면 PgBouncer 도입.
  3. PgBouncer는 transaction 모드 + default_pool_size 20 부근에서 시작해 부하 테스트로 조정.
  4. prepared statement 에러가 뜨면 PgBouncer 1.21+로 올리거나 클라이언트 옵션으로 서버 prepared statement 제어.
  5. 서버리스면 pooler(RDS Proxy 등)를 반드시 앞에 두고, 람다 동시성 상한을 건다.

마지막으로 하나만 더. 커넥션 풀 튜닝은 "큰 값 = 안전"이라는 직관이 정반대로 작동하는 영역이다. 문제가 터졌을 때 반사적으로 숫자를 키우고 싶은 충동을 참고, 위 순서대로 원인을 좁히는 게 훨씬 빨리 끝난다. 나는 그걸 서버 한 번 죽여 먹고 배웠다.

자주 묻는 질문

PgBouncer 풀 크기는 몇으로 시작하는 게 좋나요?

default_pool_size를 물리 코어 수의 2~4배 부근, 대략 20 안팎에서 시작하세요. 리틀의 법칙(목표 TPS × 평균 쿼리 시간)으로 하한을 계산한 뒤 부하 테스트로 올려가며 처리량이 정체되는 지점을 찾으면 됩니다. 수백은 거의 항상 과합니다.

transaction 모드에서 트랜잭션을 명시적으로 안 쓰는 쿼리도 풀링되나요?

네. autocommit 단일 쿼리는 그 자체가 하나의 암묵적 트랜잭션이라, 쿼리가 끝나면 서버 커넥션이 즉시 풀로 반납됩니다. 명시적 BEGIN...COMMIT 블록은 그 블록 전체 동안 커넥션을 잡고 있다가 커밋/롤백 시 반납됩니다.

"prepared statement does not exist" 에러가 왜 갑자기 뜨나요?

PgBouncer를 transaction 모드로 두고 드라이버가 서버 사이드 prepared statement를 쓰기 때문입니다. 매 트랜잭션마다 다른 서버 커넥션을 받는데 그 커넥션엔 statement가 준비돼 있지 않아서 터집니다. PgBouncer 1.21+로 올려 max_prepared_statements를 켜거나, 드라이버에서 서버 prepared statement를 비활성화하세요.

앱 커넥션 풀(HikariCP)과 PgBouncer를 둘 다 써도 되나요?

네, 오히려 흔한 구성입니다. 앱 풀은 PgBouncer까지의 로컬 커넥션이라 넉넉히 잡아도 되고, 실제 DB 부하를 좁히는 병목은 PgBouncer의 default_pool_size가 담당합니다. 다만 앱 풀에서 서버 prepared statement가 켜져 있다면 transaction 모드와의 충돌을 반드시 점검하세요.

서버리스에서 RDS Proxy와 PgBouncer 중 뭘 써야 하나요?

AWS 관리형 환경에서 운영 부담을 줄이고 싶으면 RDS Proxy가 편합니다(IAM 인증, 자동 페일오버 연동 등). 세밀한 튜닝, 멀티 클라우드, 비용 절감이 우선이면 직접 띄운 PgBouncer가 유리합니다. 어느 쪽이든 핵심은 "람다가 DB에 직접 붙지 않게 한다"는 원칙입니다.

커넥션을 늘렸는데 오히려 느려졌습니다. 왜죠?

활성 커넥션이 CPU 코어 수를 크게 넘으면 컨텍스트 스위칭, 락 경합, 캐시 미스가 누적되어 총 처리량이 떨어지고 지연이 커지기 때문입니다. PostgreSQL은 커넥션당 프로세스 모델이라 이 현상이 더 뚜렷합니다. 풀을 줄이고 PgBouncer로 앞단을 흡수하는 방향이 맞습니다.

idle in transaction 커넥션이 많은데 풀 크기를 늘려야 하나요?

아니요, 그건 대체로 애플리케이션이 트랜잭션을 열어 두고 오래 잡고 있다는 신호입니다. 풀을 늘리면 문제를 가릴 뿐 근본 원인(느린 외부 호출을 트랜잭션 안에서 하거나, 커밋 누락 등)은 그대로입니다. idle_in_transaction_session_timeout을 걸어 방치된 트랜잭션을 끊고, 코드에서 트랜잭션 범위를 좁히세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록