본문으로 건너뛰기
yutils

DB connection pool 은 어떻게 동작할까?

DB connection 여는 비용, connection pool 의 동작, pool 고갈, 적정 크기 계산, PgBouncer session vs transaction 모드, serverless connection storm 까지 정리.

약 8분 읽기

하나의 HTTP request 가 DB connection 을 새로 여는 데 30ms. 실제 query 는 1ms. 즉 99% 의 시간이 "연결 준비" 에 낭비된다. request 가 초당 수천 개라면 이 비용은 곧 장애로 번진다. 해법은 간단하다 — 연결을 매번 새로 만들지 말고 미리 열어두고 재사용한다. 이게 connection pool. 이 가이드는 연결이 왜 비싼지, pool 이 정확히 무엇인지, pool 고갈·크기 계산·PgBouncer 모드· serverless 함정까지 정리한다.

DB connection 을 여는 비용

"연결" 은 한 번의 동작이 아니다. 여러 round-trip 과 서버 자원 할당이 쌓인 결과다:

새 PostgreSQL connection 한 번:

1. TCP handshake           (SYN / SYN-ACK / ACK)      ~1 RTT
2. TLS handshake           (인증서 교환 + 키 협상)    ~2 RTT
3. 인증                    (startup + password/scram) ~1-2 RTT
4. 서버측 자원 할당        (backend process fork)     ~수 ms

합계 — 같은 데이터센터 안에서도 수 ms ~ 수십 ms.
원격 / cross-AZ 면 RTT 가 커져 100ms 도 가능.

특히 마지막 단계가 핵심이다. PostgreSQL 은 connection 마다 별도 backend process 를 fork 한다 — 수 MB 의 메모리 + OS process 생성 비용. MySQL 은 thread 를 spawn 한다 (process 보다 가볍지만 여전히 공짜 아님). 연결 자체가 서버 자원을 점유하는 무거운 객체다.

# connection 없이 매 request 마다 새 연결
request → connect (30ms) → query (1ms) → disconnect → 응답
                ↑ 96% 가 연결 overhead

# pool 로 재사용
request → acquire (0.1ms) → query (1ms) → release → 응답
                ↑ 연결은 이미 열려 있음

Connection pool 이란

Pool 은 미리 열어둔 연결의 고정 집합이다. 애플리케이션은 연결을 직접 만들지 않고 pool 에서 빌려서(acquire) 쓰고 반납한다(release). 반납은 close 가 아니다 — 연결은 살아 있고 다음 요청이 재사용한다.

pool 시작 시 min 개수만큼 미리 연결 open

[conn1] [conn2] [conn3] [conn4]  ← idle, 대기 중

요청 A: acquire → conn1 빌림 → query → release → conn1 반납
요청 B: acquire → conn2 빌림 → query → release → conn2 반납
       (동시에 진행 가능 — 빌린 연결만큼 병렬)

핵심: 연결의 수명 ≠ 요청의 수명.
     연결은 길게 살고, 요청은 잠깐 빌려 쓴다.

Pool 의 lifecycle 파라미터:

  • min size — 항상 유지할 최소 연결 수. 부팅 시 warm-up.
  • max size — 동시에 열 수 있는 최대 연결 수. 넘치면 요청은 대기.
  • idle timeout — 일정 시간 안 쓰인 연결은 닫아 자원 회수 (min 까지).
  • max lifetime — 너무 오래된 연결은 강제로 폐기·재생성. DB 측 timeout·메모리 누수 회피.
  • validation / health check — 빌려주기 전 SELECT 1 로 살아있는지 확인. 죽은 연결을 건네는 사고 방지.

Pool 고갈 (exhaustion)

max = 10. 모든 연결이 사용 중.

[conn1..conn10]  ← 전부 checked out (busy)

요청 11: acquire → 빈 연결 없음 → 대기열에 들어감
요청 12: acquire → 대기
...

- 누가 release 하면 → 대기 중인 요청에 넘김
- acquire timeout (예: 5초) 안에 못 받으면 → 에러

에러 메시지:
  "connection pool timeout" (app 측)
  "remaining connection slots are reserved" (DB 측)
  "FATAL: sorry, too many clients already" (DB max 초과)

고갈의 흔한 원인은 느린 query 다. query 하나가 2초 걸리면 그 연결은 2초간 묶인다. 동시 요청이 많으면 pool 이 순식간에 빈다. 또 다른 원인은 연결 누수(leak) — 코드가 release 를 빠뜨리면 연결이 영영 반납 안 되고 pool 이 서서히 말라간다.

// 누수 — 예외 발생 시 release 안 됨
const conn = await pool.acquire();
const rows = await conn.query(sql);   // 여기서 throw 하면?
pool.release(conn);                    // ← 도달 못 함, 연결 영구 누수

// 올바른 패턴 — finally 로 항상 반납
const conn = await pool.acquire();
try {
  return await conn.query(sql);
} finally {
  pool.release(conn);                  // 예외든 정상이든 항상 반납
}

Pool 크기 — 클수록 빠르지 않다

직관과 달리 max 를 키운다고 처리량이 오르지 않는다. 오히려 떨어진다. 연결이 100개여도 CPU 코어가 8개면 동시에 일하는 query 는 결국 8개 수준. 나머지는 context switch·lock·disk I/O 경쟁만 늘린다.

연결 수 vs 처리량 (대략적 곡선)

처리량
  │        ___________
  │      /            \____
  │    /                    \___  ← 너무 많으면 오히려 하락
  │  /                            (CPU/disk 경쟁)
  │/
  └──────────────────────────────── 연결 수
   적정점은 보통 생각보다 작다

PostgreSQL 커뮤니티의 경험칙 — 적정 연결 수는 코어 수 × 작은 계수 수준이다. 자주 인용되는 출발점:

connections ≈ (core_count × 2) + effective_spindle_count

예: 8 코어 + SSD(spindle 사실상 1) → 약 17 ~ 20 연결

이건 상한이 아니라 "이 근처에서 시작해 측정하라" 는 출발점.
대부분의 OLTP 워크로드는 수십 개로 충분하다.

Little's Law 로도 같은 결론에 닿는다 — 필요 동시연결 = 처리율(req/s) × 평균 query 시간(s). 초당 500 req, query 가 평균 10ms 면 필요한 동시 연결은 500 × 0.01 = 5. pool 을 50, 100 으로 키울 이유가 없다. query 가 빠르면 적은 연결로도 충분하다.

가장 흔한 사고는 곱셈을 잊는 것이다:

app server 20대 × pool max 10 = 최대 200 연결 시도
하지만 PostgreSQL max_connections = 100

→ 21번째 연결부터 "too many clients already"
→ app 절반이 DB 에 못 붙는 장애

규칙: (app 인스턴스 수 × pool max) ≤ DB max_connections
     (superuser 예약분 + 다른 서비스 몫도 빼고)

Pooling 의 위치 — in-app vs 외부 pooler

두 가지 층위에서 pool 을 둘 수 있다:

  • in-app pool — 애플리케이션 프로세스 안에서 연결을 관리. 예: Java 의 HikariCP, Node 의 pg.Pool, Go 의 database/sql. 가장 단순하고 latency 가 낮다. 단점 — 프로세스마다 자기 pool 을 갖는다. 인스턴스가 늘면 곱셈 문제 발생.
  • 외부 pooler — app 과 DB 사이에 별도 프록시. 예: PgBouncer, RDS Proxy. 수천 개의 app 연결을 받아 소수의 DB 연결로 다중화(multiplex)한다. 많은 app 인스턴스를 운영할 때 DB 측 연결 수를 일정하게 묶어준다.
# 외부 pooler 없이 — 곱셈 폭발
app1 (pool 20) ─┐
app2 (pool 20) ─┼→ DB (200 연결 부담)
... ×10        ─┘

# PgBouncer 한 단계 추가 — 다중화
app1 (pool 20) ─┐
app2 (pool 20) ─┼→ PgBouncer → DB (20 연결만)
... ×10        ─┘     ↑ 수천 client 연결을 소수 서버 연결로 압축

PgBouncer pooling 모드

PgBouncer 는 client 연결을 언제 실제 DB 연결에 묶을지에 따라 세 모드를 제공한다. tradeoff 가 크니 정확히 이해해야 한다:

session pooling (기본)
  client 가 연결을 잡으면 disconnect 할 때까지 DB 연결 1개 점유.
  → 다중화 효과 거의 없음. 기존 동작과 동일.
  → SET / prepared statement / advisory lock 등 세션 상태 안전.

transaction pooling (serverless 권장)
  트랜잭션 시작~COMMIT 동안만 DB 연결 점유. 끝나면 즉시 반납.
  → 다중화 효과 최대. 수천 client → 수십 DB 연결.
  → 주의: 세션 상태가 트랜잭션 경계에서 사라짐.
     SET search_path, 세션 변수, 일부 prepared statement,
     LISTEN/NOTIFY, advisory lock 이 깨질 수 있음.

statement pooling (가장 공격적)
  statement 하나 끝날 때마다 연결 반납.
  → multi-statement 트랜잭션 자체가 불가능. 매우 제한적.

transaction mode 가 다중화 효율과 안전성의 균형점 이라 serverless·고동시성 환경에서 가장 많이 쓰인다. 단, 드라이버가 세션 단위로 거는 기능을 못 쓰게 되니, prepared statement 를 끄거나 (혹은 protocol-level 이 아닌 simple query 로) 맞춰야 한다. 트랜잭션 동작 자체의 의미는 how-database-transactions-work 가이드를 참고하라.

Serverless 함정 — connection storm

Lambda·edge function 처럼 인스턴스가 요청량에 따라 수백·수천 개로 자동 확장되는 환경에서는 pooling 의 전제가 무너진다. 각 인스턴스가 자기만의 pool 을 따로 연다:

트래픽 급증 → Lambda 가 동시에 1,000 인스턴스로 확장

각 인스턴스: "내 pool 을 열자" → DB 연결 5개씩
→ 1,000 × 5 = 5,000 연결 시도

PostgreSQL max_connections = 100
→ 즉시 "too many clients already"
→ cold start 마다 TCP+TLS+auth 비용 폭증 (connection storm)

문제의 본질 — serverless 함수는 수명이 짧고 상태를 공유하지 않으므로, in-app pool 이 효과를 못 낸다 (연결을 재사용하기도 전에 인스턴스가 사라진다). 그래서 serverless Postgres 에는 외부 transaction-mode pooler 가 필수다:

  • PgBouncer (transaction mode) — 수천 함수 연결을 받아 소수 DB 연결로 압축.
  • RDS Proxy — AWS 관리형. Lambda 의 연결 폭증을 흡수하도록 설계됨.
  • Supabase / Neon 의 pooler endpoint, Cloudflare Hyperdrive — edge 환경용 pooled 접속점.

규칙 — serverless 에서는 함수 안의 pool max 를 아주 작게(1~2) 두고, 실제 다중화는 외부 pooler 에 맡긴다. 함수 트래픽을 여러 인스턴스로 분산하는 앞단 라우팅은 how-load-balancers-actually-work 가이드의 주제와 맞닿아 있다.

흔한 함정

1. release 누락

가장 흔한 누수. 위에서 본 대로 항상 finally (또는 언어별 RAII / context manager / using) 로 반납을 보장하라.

2. max 를 무작정 키우기

"느려서 pool 을 200 으로 늘렸다" 는 거의 항상 역효과. 먼저 느린 query 를 찾아 고쳐라. 연결이 묶이는 시간을 줄이는 게 우선이다.

3. acquire timeout 미설정

timeout 이 없으면 pool 고갈 시 요청이 무한 대기 → 스레드·이벤트 루프가 묶여 장애 전파. 합리적인 timeout (예: 2~5초) 을 두고 빠르게 실패시켜라.

4. max lifetime 없이 방치

오래 산 연결은 DB 측 메모리 누적·네트워크 단절·failover 후 stale 상태를 만든다. max lifetime 으로 주기적으로 갈아끼워라.

5. transaction mode 에서 세션 기능 사용

PgBouncer transaction mode 인데 prepared statement 나 세션 변수에 의존하면 간헐적으로 깨진다. 드라이버 설정을 모드에 맞춰라.

참고 자료

요약

  • DB connection 은 TCP + TLS + auth + 서버측 process/thread 생성 으로 수 ms ~ 수십 ms. 매번 새로 열면 낭비.
  • Pool = 미리 열어둔 연결의 고정 집합. acquire → use → release, 연결은 닫지 않고 재사용.
  • 핵심 파라미터 — min / max / idle timeout / max lifetime / health check.
  • 고갈은 느린 query 와 release 누수가 주범. 항상 finally 로 반납.
  • 크기는 클수록 빠르지 않다. 코어 수 × 작은 계수, Little's Law 로 추정. (app 수 × pool max) ≤ DB max_connections.
  • in-app pool (HikariCP 등) vs 외부 pooler (PgBouncer / RDS Proxy). 인스턴스 많으면 외부 pooler 로 다중화.
  • PgBouncer 모드 — session(안전·비효율) / transaction(균형) / statement(공격적·제약). transaction 이 표준.
  • serverless 는 connection storm 위험. transaction-mode 외부 pooler 필수, 함수 내 pool 은 최소로.
가이드 목록으로