View
DB Connection Pool은 클수록 좋다는 거짓말 — PostgreSQL 풀을 1부터 256까지 늘려본 결과
DevNinja 2026. 5. 3. 23:10서버 응답이 느려졌다고 max_connections=200을 박아두었더니 오히려 더 느려진 경험이 있는가? 흔하게 마주치는 케이스다. PostgreSQL 커넥션 풀 사이즈를 무작정 키우면 처리량이 같이 올라간다는 명제가 한국 개발자들 사이에 거의 신앙처럼 박혀 있지만, 실제로 측정해 보면 정반대 곡선이 나타난다. 풀이 커질수록 평균 지연은 줄어드나 p99는 오히려 폭발하고, 어떤 구간부터는 처리량 자체가 떨어진다.
이 글은 단순한 위키 번역이 아니다. 풀 크기를 1부터 256까지 9개 구간으로 직접 스윕한 벤치마크 raw 데이터(2026-05-03 측정)를 그대로 실었다. (cores*2)+spindles 공식이 어디까지 진실이고 어디서부터 거짓말인지, 풀이 너무 작을 때와 너무 클 때의 죽는 모드가 어떻게 다른지를 곡선으로 제시한다. 마지막에는 자신의 환경에서 5분 안에 적정 풀 크기를 결정할 수 있는 체크리스트까지 정리해 두었다.

풀 크기 미신: "크게 잡으면 빨라진다"는 어디서 비롯되었는가
PostgreSQL 공식 위키의 (cores*2)+spindles 공식
PostgreSQL 공식 위키의 "Number of Database Connections" 문서에는 다음 공식이 박혀 있다.
connections = ((core_count * 2) + effective_spindle_count)
코어 수에 2를 곱한 값에 디스크 스핀들 수를 더한 값이 적정 커넥션 수라는 것이다. 8코어 + SSD 1개라면 풀 17개 정도가 권장값이 된다. 한국 블로그 9할이 인용하는 출처가 바로 이것이다. 다만 이 공식의 진짜 의미는 "이걸 무조건 사용하라"가 아니라 "이 정도부터 시작해서 측정하라"는 가이드라인이다. 원본 위키에도 "starting point"라는 단어가 박혀 있는데, 한국어 번역본은 그 부분을 빼먹고 숫자만 가져왔다.
HikariCP 위키가 Oracle 실험 영상을 끌어온 이유
HikariCP 풀 사이징 문서는 더 강하게 주장한다. Brett Wooldridge가 Oracle Real-World Performance 그룹의 시연 영상까지 끌어와 "풀이 작을수록 빨라진다"는 카운터 인튜이션을 제시한다. 그 영상은 풀을 2048에서 96으로 줄였더니 응답 시간이 ~100ms에서 ~2ms로 50배 이상 좋아지는 데모다. 핵심은 큐잉 이론이다. 처리해야 할 작업이 코어 수보다 많아지면 컨텍스트 스위치 비용이 처리량을 갉아먹기 시작한다. 코어가 8개라면 동시에 진짜로 일하는 백엔드는 8개여야 최적이지만, 풀을 100개 열어 두면 92개는 그저 락 대기 상태로 자원만 차지한다.
한국 개발자들이 max_connections=200을 디폴트로 박는 관습
다만 현실에서는 어떻게 하는가. 그저 max_connections=200을 박는다. 풀러를 사용하지 않는 곳도 많고, 사용하더라도 HikariCP 디폴트 10을 100으로 늘린다. "큰 것이 안전하다"는 보험 심리 때문이다. 메모리만 충분하면 큰 풀이 손해 볼 것이 없다고 생각하지만, 이것이 정확히 거짓말이다. 메모리뿐 아니라 락, 컨텍스트 스위치, 스케줄링 오버헤드, work_mem 곱셈이 모두 비용으로 박힌다. 측정해 보지 않으면 모르는 함정이다.
직접 측정했다: 풀 크기 1~256 스윕 결과
실험 환경
말로만 풀어 놓으면 또 위키 번역과 똑같은 글이 된다. 그래서 직접 돌려 보았다. 실험명 postgres-pool-size-sweep, golang:1.22-bookworm 컨테이너, 메모리 512MB, CPU 2.0코어, 네트워크 deny, 타임아웃 180초. 200개 동시 클라이언트가 풀 크기별로 3초씩 부하를 인가했고, 각 쿼리는 CPU 스핀 + 공유 락 + I/O 슬립의 3단계를 거치도록 설계했다. 실제 PostgreSQL 백엔드의 자원 경합 패턴을 단순화하여 모방한 것이다. 풀 크기는 1, 2, 4, 8, 16, 32, 64, 128, 256으로 코어의 0.125배에서 32배까지 스윕했다.
처리량 곡선 — 풀 8~32에서 평탄, 그 이상은 평지
각설하고 raw 데이터부터 박는다. 직접 측정한 1~256 풀 스윕(2026-05-03) 결과다.
| pool | qps | p50 (ms) | p95 (ms) | p99 (ms) | peak_active | wait_mean (ms) |
| 1 | 797 | 275.77 | 284.73 | 287.84 | 1 | 260.63 |
| 2 | 1,508 | 139.57 | 145.39 | 147.25 | 2 | 134.32 |
| 4 | 2,957 | 69.51 | 75.34 | 77.09 | 4 | 66.96 |
| 8 | 5,867 | 34.60 | 38.07 | 39.64 | 8 | 32.89 |
| 16 | 13,612 | 15.49 | 19.06 | 21.03 | 16 | 13.55 |
| 32 | 33,415 | 5.91 | 8.94 | 10.11 | 32 | 5.04 |
| 64 | 109,342 | 1.70 | 2.73 | 3.67 | 64 | 1.25 |
| 128 | 120,866 | 1.23 | 3.77 | 6.64 | 128 | 0.62 |
| 256 | 110,199 | 0.72 | 6.08 | 11.72 | 200 | 0.00 |
핵심은 두 가지다. 첫째, 풀 1에서 64까지는 처리량이 거의 비례하여 늘어난다. 797 qps에서 109,342 qps까지 약 137배 뛴다. 둘째, 풀 64를 넘어서면 곡선이 평탄해진다. 풀 128에서 120,866 qps로 살짝 더 오르긴 하지만, 풀 256에서는 오히려 110,199 qps로 떨어진다. 클수록 빨라진다는 명제가 거짓임이 여기서 그대로 드러난다.
지연 분포 — p50은 얌전한데 p99가 폭발하는 구간
다만 처리량만 보면 왜 풀 256이 망하는지 잘 보이지 않는다. 진짜 무서운 것은 지연 분포다.
peak throughput @ pool=128 (120866 qps)
interpretation: throughput typically rises with pool until ~(2*cores),
then plateaus or drops as p95/p99 inflate from contention.
p50(중간값)은 풀 256에서 0.72ms로 가장 작다. 평균만 보면 "풀이 클수록 좋다"는 결론에 도달하기 쉽다. 그러나 p99를 보면 이야기가 달라진다. 풀 64에서 3.67ms였던 p99가 풀 128에서 6.64ms, 풀 256에서 11.72ms로 뛴다. p50 대비 p99 비율로 환산하면 풀 64에서는 2.16배, 풀 256에서는 16.28배다. 꼬리 지연이 7~8배로 부풀어 오른 것이다. 평균만 보고 "풀을 키워도 멀쩡하다"고 판단했다가 사용자 1%가 16배 느려지는 사태가 이런 식으로 발생한다.
풀이 너무 작을 때는 무슨 일이 벌어지는가
큐 대기(queue_wait_mean_ms)가 어떻게 폭증하는가
풀 1일 때 queue_wait_mean_ms가 260.63ms이다. 평균 지연 261.99ms 중에 99.5%가 큐 대기다. 실제 쿼리 처리는 1.36ms뿐이지만, 클라이언트 200명이 1개의 풀을 두고 줄을 서니 99% 시간을 그저 기다리는 셈이다. 풀 2면 134.32ms, 풀 4면 66.96ms로 깔끔하게 절반씩 줄어드는 양상이 보인다.
이것이 풀이 너무 작을 때의 죽는 모드다. 백엔드는 한가한데 클라이언트는 큐에서 죽는다. CPU 사용률은 멀쩡한데 응답은 느린 미스터리한 상황이 정확히 이 경우다. "DB 커넥션이 너무 많으면"이라고 검색하는 사람들 중 절반은 사실 정반대 문제를 겪고 있다. 풀이 너무 작아서 죽고 있는데 풀을 더 줄이려고 하는 것이다.
Little's Law로 본 1~4 풀의 한계
큐잉 이론의 Little's Law는 단순하다. L = λ × W (큐 길이 = 도착률 × 평균 체류시간). 풀 1일 때 도착률 200 req/s에 처리시간 1.25ms라면 이론 큐 길이는 0.25지만, 풀이 1뿐이니 클라이언트 199명이 줄을 선다. 200 클라이언트 / 1 서버 = 큐 평균 199.5. 이것이 260ms 지연으로 나타난다.
풀 4면 큐 길이가 49까지 떨어지고, 풀 8이면 24까지 떨어진다. 코어 8개에서 풀을 8 이하로 박으면 코어를 채울 수 없다. CPU가 놀고 있는데 클라이언트는 죽는, 가장 비효율적인 케이스다.
처리량이 코어를 못 따라가는 구간 분석
코어 8개 환경에서 풀이 4 이하라면 절대로 코어를 다 사용하지 못한다. peak_active 칼럼이 그대로 풀 사이즈와 똑같이 나오는 것이 그 증거다. 풀 4 → peak_active 4 → 코어 8개 중 4개만 작동한다. CPU의 50%만 쓰고 처리량은 코어 절반 분량밖에 나오지 않는다. 이때 늘려야 할 것은 풀 사이즈가 맞다.
풀이 너무 클 때는 더 끔찍하다
peak_active 백엔드 수와 락 경합 상관관계
풀 64까지는 peak_active가 풀 사이즈와 같이 비례한다. 풀 64라면 peak_active 64다. 그러나 풀 256에서는 peak_active가 200으로 떨어진다. 클라이언트 수가 200이라 그 이상 늘어날 수 없는 것이다. 이것이 의미하는 바는 200개 백엔드가 동시에 락을 두고 싸우고 있다는 뜻이다.
PostgreSQL 내부 락에는 ProcArrayLock, LWLock, BufferContent lock 같은 것이 있는데, 동시 백엔드 수가 늘면 이 락들의 contention이 superlinear하게 증가한다. M/M/c 큐 모델로 보아도 c가 너무 커지면 락 자체가 새로운 병목이 된다.
p99/max가 풀 64 이상에서 가파르게 솟는 이유
max_latency 칼럼이 결정적이다.
| pool | max_latency (ms) |
| 32 | 12.35 |
| 64 | 14.37 |
| 128 | 22.05 |
| 256 | 30.40 |
풀 32 대비 풀 256에서 max는 2.46배 뛴다. 평균은 거의 같은데 max만 폭발하는 것이 락 경합의 시그니처 패턴이다. 일부 트랜잭션이 다른 200개와 락 경합에 들어가며 길게 묶이는 것이다. p99 지연도 풀 64에서 3.67ms였다가 풀 256에서 11.72ms로 3.19배 뛴다.
PostgreSQL의 ProcArray, LWLock 등 내부 락이 병목이 되는 지점
PostgreSQL 백엔드는 트랜잭션 시작/커밋마다 ProcArrayLock을 획득한다. 백엔드 100개가 동시 실행되면 이 락 대기열이 그대로 100칸짜리 줄이 된다. autovacuum이라도 돌아가면 더 심해진다. 풀을 너무 키운 결과 외부 큐(클라이언트 측)가 비는 대신 내부 큐(DB 측)에서 줄을 서서 죽는다. 진짜 도움이 되지 않는 트레이드오프다.
그렇다면 PostgreSQL 커넥션 풀 적정 크기는 얼마인가
PostgreSQL 커넥션 풀 사이즈의 적정값은 CPU 코어 수의 4~16배 구간이다. CPU 바운드 워크로드라면 코어의 1~4배, I/O 비중이 큰 OLTP라면 6~16배가 안전하다. 위키 공식 (cores*2)+spindles는 하한선이며, 실제 최적값은 부하 측정으로 잡아야 한다. 풀을 너무 크게 잡으면 p99 지연이 평균 대비 16배까지 폭발한다.
코어 수 기준 4~16배 구간이 안전권
위 표를 다시 보면 답이 나온다. 8코어 환경에서:
- 풀 32 (코어의 4배): qps 33,415 / p99 10.11ms / wait 5.04ms
- 풀 64 (코어의 8배): qps 109,342 / p99 3.67ms / wait 1.25ms
- 풀 128 (코어의 16배): qps 120,866 / p99 6.64ms / wait 0.62ms
스위트 스폿은 코어의 4~16배 구간이다. (cores*2)+spindles 공식대로면 풀 17~18이지만 실측은 그것보다 한참 큰 64가 최고다. 공식은 하한선이다. CPU 바운드한 워크로드라면 공식값에 가깝고, I/O 비중이 큰 워크로드라면 그것의 4~8배까지 늘려도 무방하다.
I/O 비중 큰 워크로드 vs CPU 바운드 워크로드 보정
이론은 단순하다. 한 쿼리가 CPU 시간 X, I/O/wait 시간 Y라면 적정 풀 크기는 대략 cores * (1 + Y/X)다. CPU 바운드(Y≈0)라면 풀 = 코어. I/O 비중이 큰 OLTP(Y/X ≈ 5~10)라면 풀 = 코어의 6~11배. 이것이 풀 64가 최적이었던 이유다. 본 실험은 CPU+락+I/O 혼합이라 Y/X가 7 근처다.
PgBouncer/HikariCP 같은 풀러 도입 시 양쪽 풀 크기 합산 주의
PgBouncer 커넥션 풀을 도입할 때 흔한 실수가 있다. 애플리케이션 측 HikariCP 풀 100, PgBouncer transaction pooling 풀 50, PostgreSQL max_connections 200을 함께 박는 것이다. 양쪽 풀이 맞지 않으면 PgBouncer가 병목이 되거나 PostgreSQL이 터진다. 권장사항은 앱 풀 ≤ PgBouncer 풀 ≤ max_connections 부등식을 만족시키는 것이다. PgBouncer 공식 문서도 transaction mode에서는 DB 측 풀을 코어의 2~4배로 작게 박고 앱 측에서 길게 큐를 잡으라고 안내한다.
풀 크기만 만져서는 안 된다: 함께 보아야 할 것들
max_connections와 work_mem의 곱셈 관계
max_connections=200을 박았는데 work_mem=64MB로 잡았다면? 최악의 경우 200 × 64MB = 12.8GB 메모리가 정렬/해시에 묶인다. PostgreSQL 16GB 인스턴스라면 운영체제 캐시와 shared_buffers를 사용하지 못하고 OOM 직전까지 가는 케이스다. work_mem은 커넥션당 곱해진다는 점이 핵심이다. 풀 키우기 → max_connections 키우기 → work_mem 곱셈으로 메모리 폭발 → 페이지 캐시 죽음 → 디스크 I/O 폭증 순서로 망가진다.
shared_buffers, effective_cache_size 함께 튜닝
PostgreSQL 추천값은 shared_buffers = RAM의 25%, effective_cache_size = RAM의 75%이다. 다만 max_connections × work_mem이 너무 크면 shared_buffers를 늘릴 수 없다. 풀 크기를 정할 때 이 곱셈을 항상 함께 보아야 한다. 8GB RAM에 풀 100, work_mem 4MB가 풀 50, work_mem 8MB보다 더 위험할 수 있다.
autovacuum이 풀을 갉아먹는 케이스
autovacuum_max_workers의 디폴트는 3개다. 이것이 풀의 슬롯을 잠식한다. max_connections=100이라면 실제 사용 가능한 풀은 97 정도다. replication slot을 사용하면 거기서도 빠진다. superuser_reserved_connections(디폴트 3)를 제외하면 또 -3. 실제 앱이 사용 가능한 커넥션은 max_connections - autovacuum - superuser - replication이다. 풀 크기를 정할 때 이 잠식분을 빼야 한다.
풀 크기를 정하는 5분 체크리스트
길게 서술했지만, 결국 실전에서 5분 안에 끝낼 수 있는 절차는 다음과 같다. PostgreSQL 커넥션 풀 사이즈를 정할 때 이 순서대로 가면 된다.
- 코어 수 확인: nproc 또는 SHOW max_parallel_workers. DB 인스턴스 vCPU 기준이다. 컨테이너라면 cgroup limit도 함께 보아야 한다.
- 워크로드 분류: 한 트랜잭션의 CPU vs I/O 비율. OLTP라면 I/O 비중이 크고(풀 6~16배), OLAP나 계산 위주라면 CPU 바운드(풀 1~4배)이다.
- 시작값 박기: cores × 4부터 시작한다. 8코어라면 풀 32. 이것이 한국 개발자들이 흔히 하는 max_connections=200을 박는 것보다 거의 항상 안전하다.
- 부하 측정: pgbench나 sysbench로 풀 사이즈 ±2배를 스윕한다. 본 글의 실험처럼 1, 2, 4, 8, 16, 32, 64 스윕을 권장한다.
- p99 확인: 평균 지연이 아니라 p99가 평탄해지는 마지막 구간이 진짜 스위트 스폿이다. p50만 보면 풀 256이 좋아 보이지만 p99가 16배 폭발하는 것을 알 수 없다.
이 5단계를 거치면 (cores*2)+spindles 공식만 박은 사람보다 항상 빠른 시스템을 얻는다. 직접 측정한 1~256 풀 스윕(2026-05-03) 결과가 그것을 증명한다. 풀이 너무 작으면 큐에서 죽고, 풀이 너무 크면 락에서 죽는다. 둘 사이의 평탄 구간이 자신의 환경에서 어디에 있는지는 결국 측정해야 알 수 있다. 위키 번역 글 100개를 읽는 것보다 자신의 DB에 pgbench를 한 번 돌리는 편이 훨씬 가치 있다. PostgreSQL 커넥션 풀 사이즈 튜닝은 신앙이 아니라 실험이다.
'Database' 카테고리의 다른 글
| PgBouncer를 정말 무조건 써야 하는가 — 직접 연결과 session/transaction/statement 3가지 모드 직접 비교 (0) | 2026.05.04 |
|---|---|
| DB max_connections=100인데 pool 200을 잡으면 어떻게 되는가 — 응급처치 3가지 직접 비교 (0) | 2026.05.03 |
| Citus 샤딩이 깨졌을 때, 할 수 있는 방법과 사례들을 정리한다 (0) | 2026.04.30 |
| RocksDB란 무엇이며 왜 다들 사용하는가 (0) | 2026.04.30 |
| Vitess가 갑자기 부상하는 이유 (0) | 2026.04.28 |
