View
새벽 3시에 알람 폭탄을 맞아본 적이 있는가? FATAL: sorry, too many clients already 메시지가 로그에 도배되는 그 광경 말이다. 이는 거의 다 DB max_connections=100임에도 애플리케이션 풀을 200, 300으로 박아둔 상황에서 발생한다. 풀을 키우면 처리량이 늘어날 것이라는 직관 때문에 다들 한 번씩 밟는 함정인데, 실제로 어떻게 깨지는지 실측 데이터로 본 적은 거의 없을 것이다.
그래서 직접 측정했다. golang:1.22-bookworm 컨테이너에서 max_connections=100 천장을 고정해둔 채, pool 200을 넘기는 raw_saturate부터 pool=1 직렬화, pgbouncer 트랜잭션 풀링, 클라이언트 축소까지 5가지 풀 전략을 같은 부하로 줄세웠다. 처리량, 에러율, p99 지연, 꼬리 회복 시간까지 모두 측정했다.
각설하고, 이 글에서 다룰 내용은 다음과 같다. pool > max_connections일 때 왜 즉시 깨지는가, 새벽에 5분 안에 살려야 할 때 쓰는 응급처치 3가지, 그리고 셋 중 무엇을 골라야 하는지 정리한 의사결정표까지. 운영 중인 풀 사이즈를 점검하는 데 그대로 써먹을 수 있는 숫자 위주로 정리했다.

max_connections는 천장이고 pool은 사다리다 — 둘이 충돌하면 벌어지는 일
max_connections는 DB가 받아들일 수 있는 동시 커넥션의 절대 천장이다. PostgreSQL 기본값이 100이고, 이를 늘리려면 메모리도 같이 잡아먹는다. 커넥션 하나당 work_mem, temp_buffers, 백엔드 프로세스 fork 비용까지 모두 따라붙는다. 그래서 무작정 늘릴 수 없다.
반면 pool은 애플리케이션이 미리 잡아두는 사다리와 같다. 클라이언트 코드에서 maxPoolSize=200을 박으면 우리 앱 인스턴스 하나가 DB로 200개 커넥션을 열려고 시도한다. 인스턴스가 5대면 1000개다. 천장이 100인데도 그렇다.
여기서 충돌이 발생한다. 풀이 100을 넘는 순간 DB는 새 커넥션 요청에 대고 FATAL: sorry, too many clients already를 뱉는다. 클라이언트 라이브러리가 fail_fast로 설정돼 있으면 즉시 에러를 던지고, 그렇지 않으면 200ms든 5초든 풀 큐에서 대기하다가 타임아웃이 발생한다.
"풀을 키우면 처리량이 늘어나겠지"라는 것이 가장 흔한 오해다. 실측해보면 정반대다.
실제로 풀=300으로 잡고 부하를 돌리면 어떻게 되는가? 천장에 닿는 순간부터 38%가 즉시 실패한다. 처리량은 22,000 QPS가 찍히긴 하는데, 그중 84,439건이 db_full 에러다. 즉 "처리량처럼 보이는 숫자"의 절반 가까이가 실은 에러 응답이다. 사용자에게는 500 에러 폭탄이 떨어진 셈이다.
실험 세팅 — golang 컨테이너에서 5개 전략을 줄세웠다
측정은 2026-05-03에 진행했다. 환경은 다음과 같다.
부하 패턴은 4단계 10초로 구성했다. 워밍업 1초(버림) → 정상 3초(120 클라이언트) → 버스트 2초(300 클라이언트) → 회복 4초(120 클라이언트). 쿼리 모델은 균등 3~7ms에 4% 확률로 0~25ms 롱테일을 더한 형태다. 실제 운영 트래픽과 비슷하게 구성했다.
루프 모델은 클로즈드 루프 고루틴이다. 성공 시 300µs sleep, 에러 시 5ms sleep. 즉 에러가 발생하면 살짝 백오프가 걸리도록 했다.
5가지 전략의 표는 다음과 같다.
| 전략 | 풀 크기 | fail_fast | 클라이언트 배수 | 타임아웃 | 의도 |
| baseline | 80 | false | 1.0× | 200ms | 천장 아래로 안전하게 |
| raw_saturate | **300** | true | 1.0× | 0ms | 천장 뚫고 즉사 보기 |
| pool_1 | 1 | false | 1.0× | 200ms | 응급처치 ① 직렬화 |
| pgbouncer | 40 | false | 1.0× | 200ms | 응급처치 ② 멀티플렉싱 |
| reduced_clients | 80 | false | **0.25×** | 200ms | 응급처치 ③ 클라이언트 축소 |
종합 결과부터 제시한다. stdout 그대로다.
strategy samples err% qps p50_stdy p99_stdy p99_brst p99_recv
------------------------------------------------------------------------------------------
baseline 118286 0.00% 11825 7.51 26.10 39.80 26.67
raw_saturate 221086 38.19% 22106 5.57 24.51 23.57 24.22
pool_1 7134 78.60% 713 202.54 220.65 220.17 215.99
pgbouncer 58797 0.00% 5879 16.64 35.44 63.13 36.18
reduced_clients 48773 0.00% 4876 5.64 25.38 23.23 24.14
--- error breakdown by type ---
raw_saturate db_full=84439
pool_1 pool_timeout=5607
raw_saturate는 풀=300이라 천장이 뚫리자마자 84,439건이 db_full로 즉사한 것이 보인다. 클라이언트 측에서 fail_fast=true로 두었으니 대기조차 하지 않고 바로 에러가 떨어진다. 풀을 키우면 처리량이 늘어난다는 직관이 정반대로 작동한다는 증거다.
응급처치 ① — pool=1로 일단 직렬화하기
언제 쓰는가: 새벽 장애가 발생하여 "5분 안에 살려라" 상황. 일단 죽지만 않게 하면 되는 배치 작업이나 백오피스 시스템.
동작 원리: 모든 요청이 단일 커넥션 큐에 줄을 선다. 동시성=1로 강제 직렬화를 거는 셈이다. DB 천장이고 뭐고 일단 1개만 쓰니까 충돌 자체가 없다.
실측 결과: pool_1 결과를 다시 보면 처리량은 713 QPS다. baseline(11,825)의 6%에 불과하다. 다만 p99 지연은 220ms로 거의 일정하다. steady에서도 220.65ms, 버스트에서도 220.17ms, 회복에서도 215.99ms로 변동이 거의 없다.
에러율 78.6%는 다소 충격적으로 보일 수 있는데, 이는 클로즈드 루프에서 200ms 타임아웃이 빡빡한 탓에 큐 대기가 길어진 요청들이 잘려나간 결과다. 즉 pool_1로 살리면 들어온 요청 일부는 떨궈낸다. 다만 떨군 것은 떨군 것이고, 받아낸 것은 안정적으로 처리한다. db_full로 캐스케이딩 실패하는 raw_saturate와는 결이 완전히 다르다.
자바 진영 HikariCP 설정이라면 다음과 같다.
spring:
datasource:
hikari:
maximum-pool-size: 1
connection-timeout: 200
validation-timeout: 100
Go pgx라면 다음과 같다.
config, _ := pgxpool.ParseConfig(dsn)
config.MaxConns = 1
config.MinConns = 1
pool, _ := pgxpool.NewWithConfig(ctx, config)
한계: 처리량 절벽이다. 트래픽이 큰 사용자 대면 서비스에는 임시방편 이상으로 쓸 수 없다. 큐가 길어지면서 사용자 응답 시간이 200ms 근처로 고정된다. 응답이 늦더라도 죽지만 않으면 되는 배치성 워크로드에서나 쓸 수 있는 응급조치다.
응급처치 ② — pgbouncer 트랜잭션 풀링으로 다중화하기
언제 쓰는가: 클라이언트 인스턴스는 많은데 DB 천장은 늘릴 수 없을 때. 마이크로서비스 환경에서 인스턴스를 10대씩 굴리면 풀 사이즈 합산이 금방 100을 넘어선다. 이때의 정답이다.
동작 원리: pgbouncer가 클라이언트 풀과 DB 풀을 분리한다. 클라이언트는 pgbouncer로 1000개 커넥션을 열어도, pgbouncer는 DB로 40개만 유지한다. pool_mode=transaction으로 두면 트랜잭션 단위로 DB 커넥션을 빌려주고 반납한다. 동시성 비대칭이 흡수되는 구조다.
실측 결과: pgbouncer 전략은 DB 커넥션 40개로 baseline(80)에 근접한 처리량 5,879 QPS를 기록했다. 에러율은 0%다. p99도 steady 35.44ms, burst 63.13ms로 안정적이다.
pgbouncer 58797 0.00% 5879 16.64 35.44 63.13 36.18
물론 baseline 11,825 QPS에는 미치지 못한다. 트랜잭션 풀 라우팅 오버헤드가 다소 존재한다. 다만 DB 커넥션을 절반(80 → 40)으로 줄이면서도 에러 없이 받아냈다는 점이 핵심이다. 클라이언트가 늘어날수록 이 비대칭 흡수 효과는 커진다.
설정 예시는 다음과 같다.
[databases]
mydb = host=db-primary port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
default_pool_size = 40
max_client_conn = 1000
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
default_pool_size는 DB max_connections 대비 여유를 두고 잡아야 한다. 천장이 100이면 40~60 사이가 무난하다. 다른 직접 접속(관리자 콘솔, 백업 등)이 있으므로 100을 다 써서는 안 된다.
도입 함정: 트랜잭션 풀링은 PgBouncer 공식 문서에도 나와 있듯 다음 기능들이 깨진다.
- prepared statement 캐시: 같은 세션이 보장되지 않으므로 pgx든 jdbc든 prepared statement를 쓰지 않도록 꺼야 한다. JDBC라면 prepareThreshold=0, pgx라면 default_query_exec_mode=exec 또는 cache_describe.
- SET LOCAL 외 SET: 트랜잭션이 끝나면 다른 세션으로 넘어가므로 세션 변수가 모두 날아간다. SET LOCAL만 안전하다.
- advisory lock: 세션 단위 락이라 트랜잭션 풀링과 맞지 않는다. 트랜잭션 단위 advisory lock(pg_advisory_xact_lock)으로 바꿔야 한다.
- LISTEN/NOTIFY: 세션 종속이라 사용할 수 없다.
이를 챙기지 않고 도입하면 더 큰 사고가 발생한다. 도입 전에 코드베이스 grep을 한 번 돌리고 가는 것을 강력히 추천한다.
응급처치 ③ — 클라이언트 수 자체를 줄이기
언제 쓰는가: 응답 시간 SLA가 빡빡한 사용자 트래픽. p95, p99 지연이 가장 중요한 서비스.
동작 원리: 풀 사이즈는 그대로 두고, 동시 in-flight 요청 수 자체를 1/4로 깎는다. 큐 길이가 짧아지므로 대기 시간이 줄어든다. 처리량을 포기하는 대신 지연 분포를 짭짤하게 가져가는 전략이다.
실측 결과: reduced_clients(client_scale=0.25)는 4,876 QPS, 에러율 0%, p99 25.38ms를 기록했다. 단계별로 보면 더 깔끔하다.
| 단계 | p50 | p95 | p99 | p99.9 |
| steady | 5.500ms | 7.918ms | 24.135ms | 30.076ms |
| burst | 5.608ms | 7.931ms | 23.226ms | 30.536ms |
| recovery | 5.500ms | 7.918ms | 24.135ms | 30.076ms |
버스트 구간에서도 p99가 23ms대로 떨어진다. baseline 버스트 p99가 39.80ms인 것과 비교하면 p99 지연이 절반에 가깝다. 사용자 경험 측면에서는 가장 깔끔하다.
실전 적용: 큐 깊이, 워커 수, rate limiter, semaphore 패턴으로 깎는다. Go라면 다음과 같다.
sem := make(chan struct{}, 30) // 동시 in-flight 30개로 제한
func handle(ctx context.Context, q string) error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
return db.Exec(ctx, q)
}
스프링이라면 Bulkhead 패턴(resilience4j)을 써서 동시 호출 수 제한을 걸면 된다.
한계: 처리량이 1/4로 떨어지므로 트래픽 자체를 줄일 수 없는 서비스(공개 API, 광고 서버 등)에서는 쓸 수 없다. 다만 사내 도구나 관리자 페이지처럼 사용자 수가 한정된 곳에서는 정말 잘 먹힌다.
셋 중 무엇을 골라야 하는가 — 의사결정표
응급처치 3가지를 한 축에서 비교한 매트릭스다.
| 상황 | 추천 처치 | 이유 |
| 즉시 죽지만 않으면 됨 (배치, 백오피스) | **pool_1** | 직렬화로 안정성 확보, 처리량은 포기 |
| 응답 시간 SLA 빡빡함 (사용자 트래픽) | **reduced_clients** | p99 가장 안정, 큐 짧게 유지 |
| 동시 클라이언트 수가 본질적으로 많음 (마이크로서비스) | **pgbouncer** | 천장 아래에서 다중화 |
| 절대 하면 안 되는 것 | ~~raw_saturate~~ | 천장 뚫리는 순간 캐스케이딩 실패 |
결정 트리로 보면 다음 순서다.
- 지금 죽기 직전인가? → pool_1로 일단 살린다. 처리량이 떨어져도 죽지는 않는다.
- 사용자 응답 시간이 깎이는 중인가? → reduced_clients로 큐를 깎는다. p99가 회복된다.
- 마이크로서비스 / 멀티 인스턴스라 클라이언트가 본질적으로 많은가? → pgbouncer로 영구 해결한다.
- 풀을 무작정 키우기? → 절대 안 된다. raw_saturate 결과를 다시 보라.
raw_saturate 데이터를 다시 제시한다.
strategy samples err% qps
raw_saturate 221086 38.19% 22106
err: db_full=84439
22,106 QPS는 함정 숫자다. 38.19%가 에러다. 실제 처리량은 13,665 QPS인데, 그마저도 천장을 뚫고 들어가 다른 정상 클라이언트 커넥션까지 잡아먹는 connection storm을 만들어낸다. 이것이 새벽에 캐스케이딩 장애를 일으키는 전형적 패턴이다.
응급 끝나면 해야 할 것 — 천장 자체를 다시 보기
응급처치는 응급일 뿐이다. 끝나면 천장 자체를 다시 보고 영구 대응을 해야 한다.
max_connections를 늘리는 것이 능사가 아닌 이유
PostgreSQL은 커넥션 하나당 백엔드 프로세스 하나를 fork한다. 메모리 비용이 직선으로 늘어난다. work_mem 기본 4MB에 추가 메모리까지 합치면 커넥션 1개당 10~20MB는 잡는다. max_connections를 100에서 500으로 늘리면 메모리만 4~8GB 더 필요하다.
게다가 컨텍스트 스위칭 비용도 함께 늘어난다. CPU 코어가 8개인데 active 커넥션이 200개라면 스케줄러가 헛돌기 시작한다. PostgreSQL 공식 위키 가이드 역시 connections == ((core_count * 2) + effective_spindle_count) 공식을 권장한다. 8코어 SSD 환경이라면 17개 정도가 이론 최적이다. 100도 사실 많은 편이다.
pgbouncer를 영구 도입할 때 체크리스트
- [ ] prepared statement 끄기 또는 protocol level prepared로 전환
- [ ] SET 사용처 grep — SET LOCAL로 바꾸거나 제거
- [ ] advisory lock 점검 — pg_advisory_xact_lock으로 마이그레이션
- [ ] LISTEN/NOTIFY 제거 — Redis pub/sub 등으로 대체
- [ ] default_pool_size 산정 — DB max_connections × 0.5 정도로 시작
- [ ] 모니터링 — pgbouncer SHOW POOLS, SHOW STATS 대시보드 구축
- [ ] HA 구성 — pgbouncer 자체가 SPOF가 되지 않도록 다중화
모니터링 지표
pg_stat_activity에서 확인해야 할 것은 다음과 같다.
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;
- idle_in_transaction: 이 값이 늘어나면 클라이언트가 트랜잭션을 열어둔 채 일을 하지 않고 있는 것이다. statement_timeout을 짧게 두고, idle_in_transaction_session_timeout도 걸어두는 편이 좋다.
- application_name: 커넥션마다 application_name을 분리해두면 어떤 서비스가 풀을 잡아먹는지 보인다. 무조건 설정하라.
HikariCP 풀 사이즈 산정
HikariCP wiki – About Pool Sizing이 자바 진영의 성경인데, 거기서 권장하는 값은 의외로 작다. "작은 풀에 스레드가 대기 줄을 서는 구조"가 핵심 메시지다. 권장 공식은 ((core_count * 2) + effective_spindle_count)로 PostgreSQL wiki와 동일하다. 코어 수의 2배 + 디스크 수 정도면 충분하다는 결론이다. 다만 우리처럼 max_connections 천장이 낮은 경우라면 이 권장값보다 더 작게 잡는 것이 맞다.
핵심 정리 — pool은 max_connections 천장 아래로, raw_saturate는 절대 금지
5가지 전략을 한 줄씩 다시 정리한다.
- baseline (pool=80): 천장 아래에서 안정. 11,825 QPS, 에러 0%. 정상 운영 모드.
- raw_saturate (pool=300): 천장 뚫고 즉사. 38% 에러. 절대 해서는 안 되는 짓.
- pool_1: 직렬화 응급처치. 713 QPS이지만 죽지 않는다. 5분 안에 살려야 할 때.
- pgbouncer: 트랜잭션 풀링으로 다중화. 5,879 QPS, 에러 0%. 영구 해결책으로 가장 추천.
- reduced_clients: 클라이언트 1/4. 4,876 QPS, p99 가장 안정. SLA가 빡빡할 때.
핵심 메시지는 단순하다. pool은 DB max_connections 천장 아래로 둘 것. 천장이 뚫리면 즉사한다. 풀을 키운다고 처리량이 늘어나지 않는다. 오히려 connection storm을 만나서 다 같이 죽는다.
응급처치 3가지 — pool=1 직렬화, pgbouncer, 클라이언트 축소 — 중에서 지금 상황에 맞는 것을 골라 적용하면 된다. 근본 해결은 pgbouncer를 영구 도입하고 max_connections 산정 공식을 다시 살펴보는 것이다.
지금 운영 중인 서비스의 풀 사이즈를 한 번 점검해보라. 인스턴스 수 × pool size를 계산해보고 max_connections 천장과 비교하면 정말 큰일 날 뻔한 부분을 발견할 수도 있다. 새벽 알람 폭탄을 맞기 전에 미리 손보는 편이 훨씬 싸게 먹힌다.
'Database' 카테고리의 다른 글
| MySQL REPEATABLE READ가 정말로 phantom read를 막는지 18셀 실험으로 직접 검증하다 (0) | 2026.05.05 |
|---|---|
| PgBouncer를 정말 무조건 써야 하는가 — 직접 연결과 session/transaction/statement 3가지 모드 직접 비교 (0) | 2026.05.04 |
| DB Connection Pool은 클수록 좋다는 거짓말 — PostgreSQL 풀을 1부터 256까지 늘려본 결과 (0) | 2026.05.03 |
| Citus 샤딩이 깨졌을 때, 할 수 있는 방법과 사례들을 정리한다 (0) | 2026.04.30 |
| RocksDB란 무엇이며 왜 다들 사용하는가 (0) | 2026.04.30 |
