DB 커넥션 풀 크기, 어떻게 결정해야 할까?
DB Connection Pool
DB 커넥션을 미리 만들어서, 필요할 때 빌려 쓰고, 다 쓰면 반납하는 방식이다.
DB 커넥션은 새로 만들 때마다 비용이 많이 드는 작업이다.
- TCP, TLS, DB 인증 및 PostgreSQL 서버 프로세스 생성을 거쳐야 한다.
- 누적 오버헤드와 컨텍스트 스위칭 등 시스템에 부하를 준다.
PostgreSQL은 프로세스 기반으로 동작하므로, 커넥션 수가 늘어나면 서버 프로세스와 메모리 사용량도 함께 증가한다. 실제 사용량은 버전과 설정, 쿼리 패턴에 따라 달라진다.
커넥션 풀을 사용하면 매번 커넥션을 생성할 필요가 없어진다.
- 커넥션 생성에 필요한 시간을 줄인다.
- 커넥션 생성 비용을 재사용으로 분산한다.
어떤 기준으로 결정해야 하는가?
커넥션 풀 크기를 정할 때 선택지는 대략 다음과 같다.
- 수정 없이 기본값 그대로 사용
- 경험과 감으로 설정
- 업계에서 알려진 공식을 출발점으로 사용
- 부하 테스트로 검증하고 결정
대부분의 언어와 라이브러리의 기본값은 보수적으로 작게 설정되어 있다.
예를 들어 Java의 HikariCP는 10, Node.js의 pg는 10, Python SQLAlchemy는 pool_size 5와 max_overflow 10, .NET의 ADO.NET 연결 풀은 최대 100이 기본값이다.
기본값은 안전한 출발점이지, 모든 서비스에 맞는 정답은 아니다.
실무 Pool 사이즈의 분포도
DB 서버의 코어 수와 쿼리 패턴은 Pool 사이즈 설정에 영향을 준다.
아래 값은 업계 표준이 아니라, 부하 테스트를 시작하기 위한 경험적 범위다.
-
일반적인 개발·스테이징 환경
- 테스트 목적에 맞춰 자유롭게 설정한다.
- 단, 한 요청이 여러 커넥션을 동시에 점유한다면 데드락 가능성을 고려한다.
- 대략
5~10
-
4~8코어 정도의 일반 API 서버, 소규모 운영10~20— 흔히 볼 수 있는 범위
-
16코어 및 READ/WRITE가 분리된 트래픽 많은 서비스, 중규모 운영20~30
-
고트래픽 운영
30~50
위 숫자는 서비스의 쿼리 종류, 트랜잭션 점유 시간, DB 사양에 따라 크게 달라진다. 가능하면 부하 테스트로 검증해야 한다.
Pool 크기를 키우면 처리량이 늘어나는가?
항상 그렇지는 않다. DB는 일정 연결 수를 넘으면 정반대로 동작할 수 있다.
- 일정 연결 수를 넘기면 처리량이 오히려 떨어질 수 있다.
- 연결 수를 줄이면 CPU와 락 경쟁이 줄어 응답 속도와 처리량이 좋아질 수 있다.
따라서 “풀이 크면 무조건 빠르다”가 아니라, DB가 포화되기 전까지의 최적점을 찾아야 한다.
Pool 사이즈의 공식
Pool Size = (core_count * 2) + effective_spindle_countcore_count: DB 서버의 물리 CPU 코어 수effective_spindle_count: 독립적으로 I/O를 수행할 수 있는 디스크의 수를 추정한 값
이 공식은 절대적인 답이 아니라 부하 테스트나 측정을 시작하기 위한 경험적 출발점이다. SSD와 NVMe 환경에서는 HDD의 회전축 개념을 그대로 적용하기 어렵기 때문에, 저장장치의 실제 I/O 특성과 DB 지표를 함께 확인해야 한다.
커넥션 여러 개를 동시에 잡는 코드의 계산
한 요청이 커넥션 여러 개를 동시에 잡는 코드, 예를 들어 중첩 트랜잭션이나 REQUIRES_NEW를 사용한다면 커넥션 풀 고갈과 데드락을 고려해야 한다.
Pool Size >= Tn * (Cm - 1) + 1Tn: 동시에 실행될 수 있는 스레드 수Cm: 한 스레드가 동시에 잡는 최대 커넥션 수
일반적인 요청은 스레드당 커넥션을 하나만 잡는 경우가 많으므로, 이 공식을 항상 복잡하게 적용할 필요는 없다. 다만 한 메서드 안에서 독립적인 커넥션을 여러 개 점유하는 구조라면 먼저 코드 구조를 확인해야 한다.
왜 코어 x 2인가?
쿼리는 CPU 처리와 I/O 대기를 번갈아 수행한다.
CPU 처리 -> I/O 대기 -> CPU 처리 -> I/O 대기 -> ...한 쿼리가 I/O를 기다리는 동안 다른 작업이 CPU를 사용할 수 있도록, 물리 코어 수보다 많은 동시 작업을 허용하자는 직관에서 core_count * 2가 출발점으로 사용된다.
다만 CPU 처리 구간이 겹치면 실행 단위끼리 경쟁할 수 있다. 그래서 이 값은 “두 배로 설정하면 항상 좋다”는 뜻이 아니라, I/O 대기와 CPU 사용의 균형을 측정하기 위한 시작점에 가깝다.
왜 하이퍼스레딩은 물리 코어처럼 계산하지 않는가?
하이퍼스레딩은 하나의 물리 CPU 코어를 OS에서 2개의 논리 CPU처럼 보이게 하는 기술이다.
두 하드웨어 스레드가 실행 자원을 나눠 사용하므로, 논리 코어 2개가 물리 코어 2개와 같은 성능을 내는 것은 아니다.
Physical Core
├─ Hardware Thread A
├─ Hardware Thread B
│
├─ Execution Unit
├─ Cache
├─ Memory bandwidth
└─ 기타 CPU 자원두 Logical CPU는 실행 유닛, 캐시, 메모리 대역폭 등 내부 자원을 공유한다.
따라서 DB처럼 캐시와 메모리 접근, 동기화 비용의 영향을 받는 워크로드에서는 하이퍼스레딩의 효과가 물리 코어 증가와 같다고 보기 어렵다. 실제 효과는 워크로드별로 측정해야 한다.
spindle count는 무엇을 의미하는가?
spindle은 HDD의 물리 디스크 회전축을 의미한다.
HDD 디스크가 여러 개라면 서로 독립적인 I/O를 동시에 수행할 여지가 커지기 때문에, 과거에는 동시 I/O 능력을 표현하는 지표로 사용했다.
effective_spindle_count는 CPU가 디스크 I/O를 기다리는 동안 다른 작업을 처리할 수 있다는 관점에서, 동시에 독립적인 I/O를 수행할 수 있는 디스크 수를 추정한 값이다.
이는 HDD 시대의 개념이다. SSD와 NVMe에는 회전축이 없으므로 값을 기계적으로 대입하기보다, 실제 지연 시간과 IOPS, DB의 I/O 대기 지표를 기준으로 판단해야 한다.
커넥션이 많아질수록 느려지는 이유
동시 처리 단위 n을 늘릴수록 자원 경쟁(a)과 일관성 비용(b)이 누적되어 어느 지점부터 처리량이 오히려 감소한다.
— USL(Universal Scalability Law), Neil Gunther, 1993
Context Switching 증가
코어 수에 비해 동시에 실행 가능한 스레드(Runnable Thread)가 지나치게 많으면 CPU는 실제 작업보다 스레드 전환에 더 많은 시간을 사용하게 된다.
CPU Core = 4
Runnable Thread = 100
Thread A -> Thread E -> Thread I -> Thread M -> ...스레드가 교체될 때 CPU 레지스터 상태를 저장하고 다음 스레드의 상태를 복원하는 비용이 발생한다.
CPU registers
- RIP / PC : 지금 어느 명령어를 실행 중인가
- RSP : 스택 포인터
- General registers
- RAX
- RBX
- RCX
...
- FLAGS
- SIMD / FPU registers스레드는 교체될 때 다음과 같은 커널 자료구조에 스레드 상태를 저장한다.
task_struct : 프로세스/스레드 하나를 표현하는 커널 자료구조
thread_struct : 그 안에 CPU 아키텍처별 스레드 상태를 담는 구조컨텍스트 스위칭이 많아지면 레지스터 저장·복원뿐 아니라 캐시 지역성 저하와 스케줄링 비용도 커질 수 있다. 다만 모든 컨텍스트 스위칭이 L1·L2 캐시를 완전히 무효화하는 것은 아니므로, 실제 병목은 CPU 사용률과 context-switch 지표로 확인해야 한다.
프로세스 단위 전환에서는 서로 다른 가상 주소 공간을 사용하기 때문에 Page Table과 TLB 관련 비용까지 영향을 줄 수 있다.
Lock 경쟁 증가
같은 테이블의 같은 행을 노리는 트랜잭션이 많아질수록 락 대기 시간이 늘어난다.
처리 시간보다 대기 시간이 길어지는 역전도 발생할 수 있다.
Tx A: UPDATE account SET balance = ... WHERE id = 1
Tx B: UPDATE account SET balance = ... WHERE id = 1
Tx C: UPDATE account SET balance = ... WHERE id = 1UPDATE 문이 같은 행을 수정하면 필요한 행 수준 락을 획득한다.
A가 먼저 실행되는 동안 B와 C는 락을 기다린다.
- 같은 자원에 동시에 접근하는 트랜잭션이 많을수록 락 대기와 대기 중인 트랜잭션이 늘어난다.
- 커넥션을 늘려도 실제 변경 대상이 같다면 처리량은 늘지 않고 대기만 증가할 수 있다.
캐시 효율 저하
많은 커넥션이 서로 다른 데이터를 읽으면 DB의 메모리 캐시인 shared_buffers와 OS Page Cache의 지역성이 떨어질 수 있다.
그 결과 캐시 미스가 늘고 디스크 I/O가 증가할 수 있다.
다만 이 현상은 데이터 접근 패턴과 메모리 크기에 따라 달라진다.
PostgreSQL은 DB 단의 메모리 캐시 이후 OS 단의 Page Cache도 사용한다.
결국 동시에 일하는 커넥션은 CPU와 저장장치, 락 경합을 고려해 DB가 처리할 수 있는 양까지만 의미가 있다.
커넥션 풀 설정 시나리오 — 단일 WAS
DB 사양: 8코어
디스크: SSD + RAM
트랜잭션 패턴: 한 요청당 1커넥션
p95 쿼리 시간: 50ms
목표 TPS: 300
구성: WAS 단일 인스턴스1. 처리량 공식으로 시작점 잡기
DB 코어 * 2 + 0 = 16SSD 환경에서는 spindle을 기계적으로 0으로 두고 시작한다.
단, 이것은 측정을 위한 초기값일 뿐이다.
2. Little's Law로 처리 능력 추정하기
동시 처리 수 / 평균 처리 시간 = 처리량
16 / 0.05(50ms) = 320 TPS이 계산은 16개의 커넥션이 평균 50ms 동안 계속 사용된다는 단순한 가정이다.
실제 서비스에서는 쿼리 종류와 트랜잭션 점유 시간, CPU·I/O·락 대기 때문에 결과가 달라진다.
예를 들어 실효율을 80%로 가정하면 다음과 같다.
320 TPS * 0.8 = 256 TPS단순 추정상 목표 TPS에 조금 못 미친다.
3. 시작값 16으로 부하 테스트
이제 maximumPoolSize를 16에서 시작하고, 커넥션 수를 단계적으로 바꾸면서 sweet spot을 찾는다.
sweet spot: 최소한의 자원으로 최대의 효과를 내는 최적의 지점이나 상태
HikariCP의 active, pending, idle, total 메트릭을 함께 모니터링한다.
- RDS라면 CPU, 메모리, 디스크 지표를 확인한다.
- Database Insights가 켜져 있다면 DB Load, Wait Event, Top SQL 등을 확인한다.
- 필요하다면
postgres_exporter로 PostgreSQL 지표를 Grafana에 전송한다.
목표에 도달하지 못했다면?
목표를 미달했다고 바로 풀을 키우는 것은 마지막 선택지에 가깝다.
- 쿼리와 인덱스를 최적화해 p95를 단축한다.
- 풀을
18~20정도까지 점진적으로 확장하며 다시 측정한다. - 그래도 부족하다면 DB 자원을 증설하거나 Read Replica를 추가한다.
커넥션 풀 설정 시나리오 — 다중 WAS
각 WAS에 DB 코어 수 x 2를 그대로 적용하면 안 된다.
각 인스턴스의 풀 크기가 아니라 DB 전체가 감당할 수 있는 동시 커넥션 수를 계산해야 한다.
- 컨텍스트 스위칭과 자원 경쟁이 증가한다.
max_connections를 초과하면too many connections가 발생한다.
안정적으로 접근하려면 전체 목표 커넥션 수를 인스턴스에 분배한다.
예를 들어 서버가 4대라면 다음과 같이 시작할 수 있다.
16 / 4 = 4단, 현실적인 운영에서는 장애 여유를 고려해야 한다.
1대가 장애 나면 다른 WAS가 부담을 떠안을 수 있기 때문이다.
WAS 4대 * 풀 5~6개 = 전체 20~24개다만 장애 상황에서 실제로 DB가 이 수를 감당할 수 있는지는 별도로 부하 테스트해야 한다.
DB의 max_connections보다 작게만 설정하면 충분한 것이 아니라, 운영·관리용 커넥션과 다른 애플리케이션의 커넥션도 함께 고려해야 한다.
Pod 수가 동적으로 변하는 환경에서는 Pod 수가 늘 때마다 풀 크기를 수동으로 조정하기 어렵다.
PgBouncer 같은 외부 connection pooler를 사용하면 애플리케이션과 DB 사이에서 커넥션을 통합 관리할 수 있다.
권장값 적용 전 체크리스트
풀 축소는 확장보다 위험할 수 있다.
트래픽이 폭증했을 때 풀 부족이 즉시 장애로 이어질 수 있기 때문이다.
현재 평균 active 커넥션 측정
hikaricp.connections.active, hikaricp.connections.pending 등의 메트릭으로 피크 시간대에 커넥션이 얼마나 활성화되는지 확인한다.
평균뿐 아니라 p95·p99와 pending 발생 여부를 함께 봐야 한다.
측정값으로 적용 가능 여부 판단
각 WAS의 사용률을 다음처럼 계산할 수 있다.
평균 active / 풀 사이즈 = 사용률- 25% 이하 — 예: 풀 10, active 2.5. 피크와 pending이 없다면 풀 축소 후보가 될 수 있다.
- 25~60% — 신중히 적용하고 부하 테스트를 병행한다.
- 60% 이상 — 풀이 의미 있게 사용 중이므로, 섣불리 줄이기보다 대기 원인과 쿼리 점유 시간을 먼저 분석한다.
이 기준 역시 절대적인 정책이 아니라 관측을 시작하기 위한 휴리스틱이다.
실무에서 주의할 함정
Long Transaction
트랜잭션 안에서 외부 API 호출이나 무거운 처리 로직을 수행하면 커넥션 점유 시간이 길어진다.
외부 호출은 가능하면 트랜잭션 밖으로 분리하자.
AWS S3 호출도 아무리 짧고 실패하지 않을 것처럼 보여도 외부 호출이다.
Idle Connection Timeout
HikariCP에는 커넥션 수명과 유휴 커넥션 정리를 위한 설정이 있다.
maxLifetime: 하나의 커넥션이 풀 내부에서 살아 있을 수 있는 최대 유지 시간idleTimeout:minimumIdle보다 많은 유휴 커넥션을 풀에서 제거하기까지 기다리는 시간
이 값들은 DB나 네트워크 장비의 idle timeout과는 별개의 HikariCP 설정이다.
DB·로드밸런서·방화벽이 유휴 커넥션을 끊는 환경이라면, HikariCP의 maxLifetime을 해당 제한보다 조금 짧게 설정하는 방식으로 예기치 않은 단절을 줄일 수 있다.
정확한 값은 인프라의 실제 timeout을 확인해 결정해야 한다.
Cm > 1인 로직 — 여러 커넥션을 사용하는 코드
한 메서드 안에서 여러 커넥션을 동시에 점유하는 패턴이 있다면, 풀 크기를 키우기보다 코드 구조를 먼저 고치는 것이 우선이다.
- 트랜잭션 경계를 분리한다.
- 정말 별도 트랜잭션이 필요한 경우에만
REQUIRES_NEW를 사용한다. - 동일 트랜잭션 안에서 불필요하게 커넥션을 여러 개 획득하지 않는지 확인한다.
Leak Detection 설정
leakDetectionThreshold=60000설정한 시간 이상 커넥션이 풀로 반환되지 않으면 HikariCP가 로그로 알려준다.
느린 정상 쿼리를 누수로 오인할 수 있으므로 운영에서는 로그를 원인 분석용으로 사용하고, 문제 해결 후에는 적절히 조정한다.
점검할 체크리스트
maximumPoolSize를 무조건 임의의50,100으로 두지 않고 DB 사양과 부하 테스트 결과를 확인한다.- 고정 풀을 사용할 때는
minimumIdle과maximumPoolSize를 같게 설정해 풀 크기 변동을 줄일 수 있다. connectionTimeout은 예시로 30초에서 시작할 수 있지만, 장애 전파와 사용자 응답 정책에 맞춰 결정한다.leakDetectionThreshold로 커넥션 누수를 조기에 진단한다.- 트랜잭션 안에서 외부 API를 호출하는 코드를 분리한다. 커넥션 풀 고갈의 흔한 원인이다.
- HikariCP 메트릭 모니터링 대시보드를 구축한다.
- DB
max_connections와인스턴스 수 * 풀당 커넥션 수를 비교한다. 운영·관리용 커넥션까지 고려해 여유를 둔다. - 부하 테스트로 포화 상태(Saturation Point)를 측정한다. 풀 크기를 바꿔가며 TPS와 p95, CPU, I/O, 락 대기의 곡선을 확인한다.
결론적으로 커넥션 풀 크기는 공식 하나로 끝나는 설정이 아니다.
공식은 시작점을 제시하고, 최종 결정은 쿼리 점유 시간과 DB의 CPU·I/O·락 대기, 애플리케이션의 active·pending 메트릭을 함께 보면서 내려야 한다.