View

728x90
반응형

새벽 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가지, 그리고 셋 중 무엇을 골라야 하는지 정리한 의사결정표까지. 운영 중인 풀 사이즈를 점검하는 데 그대로 써먹을 수 있는 숫자 위주로 정리했다.

https://www.postgresql.org/

 

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~~ 천장 뚫리는 순간 캐스케이딩 실패

 

결정 트리로 보면 다음 순서다.

 

  1. 지금 죽기 직전인가? → pool_1로 일단 살린다. 처리량이 떨어져도 죽지는 않는다.
  2. 사용자 응답 시간이 깎이는 중인가? → reduced_clients로 큐를 깎는다. p99가 회복된다.
  3. 마이크로서비스 / 멀티 인스턴스라 클라이언트가 본질적으로 많은가? → pgbouncer로 영구 해결한다.
  4. 풀을 무작정 키우기? → 절대 안 된다. 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 천장과 비교하면 정말 큰일 날 뻔한 부분을 발견할 수도 있다. 새벽 알람 폭탄을 맞기 전에 미리 손보는 편이 훨씬 싸게 먹힌다.

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31