트래픽이 갑자기 늘었는데 DB CPU보다 커넥션 수가 먼저 한계에 닿는 경우가 많습니다. 애플리케이션이 요청마다 새 연결을 만들면 DB는 쿼리보다 연결 관리에 시간을 씁니다. 커넥션 풀을 이해하면 API 서버와 DB 사이의 병목을 안정적으로 제어할 수 있습니다.
커넥션 풀을 설정하는 것과 운영하는 것은 다릅니다. 운영 단계에서는 풀 고갈, 누수(leak), 좀비 연결, 스케일아웃 시 연결 폭발 등 실제 장애 패턴을 다뤄야 합니다. 이 모듈은 모니터링 → 이상 감지 → 근본 원인 분석 → 설정 튜닝의 전체 사이클을 다룹니다.
- 1커넥션 풀 고갈 패턴 — idle in transaction, 슬로우 쿼리 연결 점유
- 2PgBouncer 운영 튜닝 — 모드별 트레이드오프와 정밀 설정
- 3HikariCP 고급 설정 — connectionTimeout, leakDetection, keepalive
- 4PgBouncer + HikariCP 2계층 아키텍처
- 5Prometheus 기반 풀 메트릭 모니터링 & 알림 설계
- 6RDS Proxy — 서버리스/컨테이너 환경의 관리형 풀링
Connection Pool 운영 & DB 성능 튜닝
2022년 여름, 서울 핀테크 스타트업 C사는 쿠버네티스 HPA가 트래픽 급증에 반응해 API 서버 파드를 5개에서 50개로 늘렸습니다. 10분 뒤 DB 알람이 울렸습니다. max_connections exceeded. 각 파드에는 HikariCP 풀이 20개 연결을 맺도록 설정되어 있었고, 50개 파드 × 20 = 1,000개 연결이 PostgreSQL(max_connections=200)로 몰려들었습니다.
개발팀은 급하게 max_connections=1000으로 올렸습니다. 그리고 10분 뒤 CPU 100%, 전체 처리량 이전의 30%로 추락. 연결 수가 늘수록 느려지는 역설이 발생했습니다.
PgBouncer를 이미 구성해 두었다면? 파드 50개가 PgBouncer로 연결하고, PgBouncer가 실제 DB 연결을 30개로 유지했을 것입니다. 장애 없이 끝났을 상황입니다.
# PgBouncer 설치
sudo apt install -y pgbouncer
# pgbench 초기화 (부하 테스트용 샘플 데이터)
pgbench -i -s 100 -U postgres mydb
# -s 100: scale factor 100 = pgbench_accounts 테이블에 1000만 건
# 기본 부하 테스트 (PgBouncer 없이)
pgbench -U postgres -c 50 -j 4 -T 30 mydb
# -c 50: 클라이언트 50개, -j 4: 스레드 4개, -T 30: 30초
# Prometheus + Grafana 로컬 실행
docker run -d -p 9090:9090 prom/prometheus
docker run -d -p 3000:3000 grafana/grafana
실행 완료 또는 조회 결과가 표시됩니다.
- 활성 연결 수—풀 크기 안에서 커넥션이 재사용되는지 확인합니다.
- 대기 시간—요청이 커넥션을 기다리느라 지연되지 않는지 봅니다.
- DB 한계—max_connections에 여유가 남는지 운영 기준으로 확인합니다.
# /etc/pgbouncer/pgbouncer.ini 기본 설정
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
# 타임아웃 설정 (장애 방지 핵심)
server_idle_timeout = 600
client_idle_timeout = 0
idle_transaction_timeout = 30 # 30초 이상 idle in transaction → 강제 해제
query_timeout = 0 # 0 = 무제한 (슬로우 쿼리 제어는 DB 측에서)
client_login_timeout = 60
# 연결 상태 모니터링
stats_period = 60
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
풀 고갈의 3가지 패턴과 진단
트래픽이 갑자기 몰렸을 때 DB 연결이 부족해지면서 요청이 대기 상태에 들어갑니다. 서비스는 DB가 느린 게 아닌데 응답이 없습니다. 커넥션 풀이 고갈된 겁니다. 원인을 찾으려면 어디서 연결이 새고 있는지 진단할 수 있어야 합니다. 풀 고갈에는 반복되는 패턴이 있고, 패턴을 알면 빠르게 원인을 좁힐 수 있습니다.

패턴 1: idle in transaction 누적
가장 흔한 풀 고갈 원인. 애플리케이션 코드에서 트랜잭션을 열고 예외 발생 시 COMMIT/ROLLBACK 없이 연결이 풀로 반환되면, DB 서버에는 해당 연결이 'idle in transaction' 상태로 남습니다. 이 연결은 PgBouncer가 회수할 수 없어 점진적으로 쌓입니다.
-- PostgreSQL에서 현재 idle in transaction 세션 확인
SELECT
pid,
client_addr,
application_name,
state,
now() - state_change AS idle_duration,
LEFT(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY idle_duration DESC;
-- 5분 이상 idle in transaction 세션 강제 종료
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > INTERVAL '5 minutes';
// 나쁜 예: 예외 발생 시 트랜잭션 미처리
public void processOrder(Long orderId) {
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
// ... 쿼리 실행 ...
// 예외 발생 시 conn.rollback() 없이 메서드 종료
// → 연결이 풀에 반환되지만 DB에는 idle in transaction으로 남음
conn.commit();
}
// 좋은 예: try-finally 또는 try-with-resources로 반드시 처리
public void processOrder(Long orderId) {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try {
// ... 쿼리 실행 ...
conn.commit();
} catch (Exception e) {
conn.rollback(); // 반드시 롤백
throw e;
}
} // 자동으로 conn.close() → 풀에 반환
}
패턴 2: 슬로우 쿼리의 연결 점유
쿼리 하나가 30초 걸리면, 그 연결은 30초 동안 풀에서 사라집니다. 풀 사이즈가 10이고 모두 느린 쿼리를 실행 중이면 풀은 고갈됩니다.
-- 현재 30초 이상 실행 중인 쿼리 확인
SELECT
pid,
now() - query_start AS duration,
state,
wait_event_type,
wait_event,
LEFT(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > INTERVAL '30 seconds'
ORDER BY duration DESC;
-- HikariCP에서 연결 점유 시간 분포 확인 (Prometheus 쿼리)
-- histogram_quantile(0.99, hikaricp_connection_usage_nanos_bucket) / 1e9
-- → p99 연결 점유 시간 (초)
패턴 3: 연결 누수 (Connection Leak)
연결을 가져갔지만 반환하지 않는 코드 버그. 조용히 축적되다가 어느 날 풀이 고갈됩니다.
# HikariCP 연결 누수 감지 설정 (application.yml)
spring:
datasource:
hikari:
leak-detection-threshold: 5000 # 5초 이상 반환 안 된 연결 경고
# 로그 출력 예:
# Connection leak detection triggered for com.zaxxer.hikari.pool.ProxyConnection
# Stack trace of leaked connection:
# at com.example.service.OrderService.processOrder(OrderService.java:47)
# PgBouncer에서 풀 상태 실시간 확인
# PgBouncer 관리 DB 접속 (포트 6432, 데이터베이스명 'pgbouncer')
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
SHOW POOLS;
-- database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait
-- mydb | app | 23 | 7 | 20 | 5 | 0 | 0 | 0 | 2
-- cl_waiting=7: 대기 중인 클라이언트 (풀 부족 신호)
-- maxwait=2: 최대 대기 시간(초)
SHOW STATS;
-- total_query_count, total_query_time, avg_query_time 등
SHOW CLIENTS;
-- 연결된 클라이언트 목록과 상태
HikariCP 고급 설정 — 프로덕션 튜닝
앱 인스턴스를 늘렸는데 DB에서 too many connections 오류가 납니다. 인스턴스별로 커넥션 풀을 독립적으로 설정하다 보니 전체 DB 연결 수가 max_connections를 초과했습니다. 풀 사이즈를 무조건 크게 잡으면 DB가 연결 관리 오버헤드로 느려집니다. HikariCP 설정은 CPU 코어 수, 인스턴스 수, DB 설정을 함께 고려해야 합니다. 계산 공식과 주요 파라미터를 알면 환경에 맞는 값을 산출할 수 있습니다.
풀 사이즈 계산과 멀티 인스턴스 설계
단일 인스턴스 기준 공식: connections = (CPU 코어 수 × 2) + 유효 디스크 수
멀티 인스턴스 환경: 총 DB 연결 = 인스턴스 수 × 풀 사이즈 ≤ max_connections × 0.8
# Spring Boot application.yml — 프로덕션 권장 설정
spring:
datasource:
url: jdbc:postgresql://pgbouncer-host:6432/mydb?ApplicationName=order-service
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
# 풀 크기: (4코어 × 2) + 1 SSD = 9. 여유분 포함해 10
maximum-pool-size: 10
minimum-idle: 3 # 최소 유지 유휴 연결
# 타임아웃 설정 (ms 단위)
connection-timeout: 3000 # 3초: 풀에서 연결 못 받으면 즉시 실패 (기본 30초는 너무 김)
idle-timeout: 600000 # 10분: 유휴 연결 제거 (PgBouncer server_idle_timeout보다 짧게)
max-lifetime: 1800000 # 30분: 연결 최대 수명 (DB의 wait_timeout - 30초)
keepalive-time: 60000 # 1분: 유휴 연결 유효성 확인 (방화벽 끊김 방지)
# 연결 유효성 검사
connection-test-query: SELECT 1
validation-timeout: 2000 # 유효성 검사 타임아웃
# 누수 감지 (개발/스테이징)
leak-detection-threshold: 10000 # 10초 이상 반환 안 된 연결 경고
pool-name: OrderService-HikariPool
# Prometheus 메트릭 노출
register-mbeans: true
connection-timeout 설정의 중요성
기본값 30초는 대부분의 상황에서 너무 깁니다. 풀이 고갈됐을 때 30초 동안 기다리는 요청들이 쌓이면 HTTP 타임아웃, 상위 서비스 연쇄 장애로 이어집니다.
// connection-timeout = 3000 (3초) 설정 시 동작
// 풀에 연결이 없으면 3초 후 즉시 SQLException
// → 빠른 실패(fail-fast)로 상위 서비스에 즉시 에러 전파
// → Circuit Breaker가 빠르게 발동해 연쇄 장애 방지
// 응용: Circuit Breaker와 함께 사용
@CircuitBreaker(name = "database", fallbackMethod = "fallbackGetOrder")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
public Order fallbackGetOrder(Long orderId, Exception ex) {
log.error("DB 연결 실패, 캐시에서 조회: {}", orderId);
return orderCache.get(orderId);
}
max-lifetime과 DB wait_timeout 정합성
DB 서버의 wait_timeout(MySQL) 또는 tcp_keepalives_idle(PostgreSQL)보다 HikariCP의 max-lifetime이 길면, DB가 연결을 먼저 끊어버려 "Connection is closed" 에러가 발생합니다.
-- MySQL: wait_timeout 확인
SHOW VARIABLES LIKE 'wait_timeout'; -- 기본 28800초 (8시간)
-- PostgreSQL: 연결 유지 설정 확인
SHOW tcp_keepalives_idle;
SHOW tcp_keepalives_interval;
# 규칙: max-lifetime < DB wait_timeout - 30초
# MySQL wait_timeout = 28800초(8시간)이면
# max-lifetime = 28770000ms (8시간 - 30초)
# 실무 권장: 30분으로 설정 (대부분의 DB 설정보다 짧음)
max-lifetime: 1800000 # 30분
2계층 풀링 아키텍처 & 모니터링
앱 인스턴스가 10개로 늘어났고 각 인스턴스 HikariCP 풀 크기가 10입니다. 이론상 DB에 최대 100개의 연결이 동시에 열릴 수 있습니다. PostgreSQL의 max_connections가 100이라면 가끔 연결 거부가 발생합니다. PgBouncer를 앞에 두면 실제 DB 연결을 25개로 제한하면서 앱 단의 100개 연결 요청을 다중화할 수 있습니다. 2계층 풀링은 대규모 앱 배포에서 DB 연결 수를 통제하는 표준 패턴입니다.
PgBouncer + HikariCP 2계층 아키텍처
앱 인스턴스 1 [HikariCP: 10개]
앱 인스턴스 2 [HikariCP: 10개] → PgBouncer [실제 DB 연결: 25개] → PostgreSQL
앱 인스턴스 3 [HikariCP: 10개]
...
앱 인스턴스 N [HikariCP: 10개]
클라이언트 연결: N × 10개 → DB 실제 연결: 25개 (고정)
HikariCP는 각 JVM 인스턴스 내에서 빠른 연결 재사용을 처리하고, PgBouncer는 여러 인스턴스 전체의 DB 연결 총량을 통제합니다. 쿠버네티스에서 파드가 100개로 늘어나도 DB 연결은 25개로 유지됩니다.
# PgBouncer 부하 테스트 비교
# PgBouncer 없이 (PostgreSQL 직접 연결)
pgbench -U postgres -h postgres-host -p 5432 \
-c 200 -j 8 -T 60 mydb
# 결과: TPS=823, 연결 에러 다수 (max_connections 초과)
# PgBouncer를 통한 연결
pgbench -U postgres -h pgbouncer-host -p 6432 \
-c 200 -j 8 -T 60 mydb
# 결과: TPS=3847, 에러 0 (PgBouncer가 200개를 25개로 관리)
Prometheus 기반 모니터링 설정
# docker-compose.yml — PgBouncer exporter 포함
version: '3.8'
services:
pgbouncer-exporter:
image: prometheuscommunity/pgbouncer-exporter:latest
environment:
PGBOUNCER_EXPORTER_HOST: pgbouncer
PGBOUNCER_EXPORTER_PORT: 6432
PGBOUNCER_EXPORTER_USER: pgbouncer
PGBOUNCER_EXPORTER_PASSWORD: ${PGBOUNCER_ADMIN_PASS}
ports:
- "9127:9127"
# Prometheus 알림 규칙 (alert.rules.yml)
groups:
- name: connection_pool
rules:
- alert: HikariPoolPendingHigh
expr: hikaricp_connections_pending > 3
for: 1m
labels:
severity: warning
annotations:
summary: "HikariCP 풀 대기 세션 증가"
description: "{{ $labels.pool }} 풀에서 {{ $value }}개 요청이 연결 대기 중"
- alert: PgBouncerClientWaiting
expr: pgbouncer_stats_clients_waiting > 10
for: 30s
labels:
severity: critical
annotations:
summary: "PgBouncer 클라이언트 대기 급증"
- alert: IdleInTransactionSpike
expr: |
pg_stat_activity_count{state="idle in transaction"} > 20
for: 2m
labels:
severity: warning
annotations:
summary: "idle in transaction 세션 급증 — 연결 누수 가능성"
-- 핵심 모니터링 쿼리 (정기 실행 권장)
-- 1. 연결 상태 분포
SELECT state, count(*) AS cnt
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state
ORDER BY cnt DESC;
-- 2. 애플리케이션별 연결 수
SELECT application_name, count(*) AS connections
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY application_name
ORDER BY connections DESC;
-- 3. 가장 오래 실행 중인 쿼리 TOP 5
SELECT
pid,
ROUND(EXTRACT(EPOCH FROM (now() - query_start))::NUMERIC, 1) AS seconds,
state,
LEFT(query, 100) AS query
FROM pg_stat_activity
WHERE state IN ('active', 'idle in transaction')
ORDER BY query_start ASC
LIMIT 5;
실습 — 풀 고갈 재현 및 복구
# Step 1: 인위적으로 풀 고갈 상황 재현
# PgBouncer default_pool_size = 5로 임시 축소
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "
SET default_pool_size = 5;
RELOAD;
"
# Step 2: 동시 접속 50개로 부하 생성
pgbench -h 127.0.0.1 -p 6432 -U app -c 50 -j 4 -T 30 mydb &
# Step 3: 실시간 상태 모니터링 (다른 터미널)
watch -n 1 'psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"'
-- SHOW POOLS 출력 (풀 고갈 상태)
database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
mydb | app | 5 | 45 | 5 | 0 | 8
-- cl_waiting=45: 45개 클라이언트가 연결 대기
-- maxwait=8: 최대 8초 대기 중
# Step 4: 풀 사이즈 복구
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "
SET default_pool_size = 25;
RELOAD;
"
# 복구 후 SHOW POOLS
# cl_waiting=0, sv_active=25, maxwait=0
# Step 5: idle in transaction 시뮬레이션
psql -h 127.0.0.1 -p 6432 -U app mydb -c "BEGIN;"
# 위 세션을 그대로 두고 다른 터미널에서 30초 후 확인
psql -U postgres -c "
SELECT pid, state, now() - state_change AS idle_time
FROM pg_stat_activity
WHERE state = 'idle in transaction';
"
# PgBouncer의 idle_transaction_timeout=30 설정으로 자동 해제됨
상황: 갑자기 API 서버 전체에서 이 에러가 터지기 시작합니다. 풀 크기는 충분해 보이는데 모든 연결이 고갈됩니다.
원인 분석: 트랜잭션을 열어둔 채 반환하지 않은 연결이 풀을 고갈시킵니다.
-- 1. 현재 DB 연결 상태 확인
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;
-- 결과:
-- idle in transaction | 47 ← 범인 발견
-- active | 2
-- idle | 1
47개의 'idle in transaction' 세션이 모든 풀 연결을 점유하고 있었습니다. 배포된 코드에서 특정 API 핸들러가 트랜잭션을 열고 외부 HTTP API를 호출한 뒤 타임아웃이 나도 롤백 없이 반환되는 버그가 있었습니다.
// 버그 있는 코드
@Transactional
public void checkout(Long userId) {
Order order = orderRepo.save(new Order(userId)); // 트랜잭션 시작
paymentApi.charge(order.getId()); // 외부 API — 타임아웃 발생
// @Transactional이 예외를 잡아 롤백하려 하지만
// 외부 API 호출에서 RuntimeException이 아닌 checked exception이면
// 기본 설정에서 롤백이 일어나지 않음
}
// 수정된 코드
@Transactional(rollbackFor = Exception.class)
public void checkout(Long userId) {
Order order = orderRepo.save(new Order(userId));
try {
paymentApi.charge(order.getId());
} catch (Exception e) {
throw new RuntimeException("결제 실패", e); // 롤백 트리거
}
}
즉시 복구: 장시간 열려있는 트랜잭션을 강제 종료해 풀을 되살립니다.
-- 5분 이상 idle in transaction 강제 종료
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > INTERVAL '5 minutes';
재발 방지: PgBouncer idle_transaction_timeout = 30 설정으로 30초 이상 idle in transaction은 자동 해제. HikariCP leak-detection-threshold = 10000 (10초)으로 누수 즉시 로그 출력.
상황: 애플리케이션 재배포 후 DB 연결이 전혀 되지 않습니다. PgBouncer 로그에 인증 실패가 반복됩니다. PostgreSQL에 직접 접속은 성공합니다.
원인: PgBouncer는 auth_file(userlist.txt)에 저장된 자체 사용자 목록으로 먼저 인증합니다. PostgreSQL 비밀번호를 변경했는데 PgBouncer의 userlist.txt를 업데이트하지 않은 경우, 또는 auth_type = scram-sha-256으로 변경했는데 userlist.txt가 md5 해시만 가진 경우에 발생합니다.
# PgBouncer userlist.txt 형식 확인
cat /etc/pgbouncer/userlist.txt
# "app" "md5해시값" ← md5 방식
# "app" "SCRAM-SHA-256$......" ← scram 방식
# PostgreSQL에서 SCRAM 해시 추출
psql -U postgres -c "
SELECT usename, passwd
FROM pg_shadow
WHERE usename = 'app';
"
# passwd 값을 userlist.txt에 그대로 복사
# 또는 auth_query 방식으로 PgBouncer가 PostgreSQL에 직접 인증을 위임
# pgbouncer.ini에 추가:
# auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1
# auth_user = pgbouncer_auth # pg_shadow 읽기 권한을 가진 전용 계정
# 설정 재로드
sudo systemctl reload pgbouncer
# 또는
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "RELOAD;"
교훈: 비밀번호 순환(rotation) 정책이 있는 환경에서는 PgBouncer auth_query 방식이 안전합니다. userlist.txt를 수동으로 관리하면 비밀번호 변경 때마다 PgBouncer도 업데이트해야 합니다.
실무 시나리오: 쿠버네티스 스케일아웃 대응 DB 연결 설계
쿠버네티스 HPA가 부하에 따라 파드 수를 3~50개로 조정하는 환경에서 DB 연결 설계.
잘못된 설계 (흔한 실수):
- 각 파드 HikariCP
maximum-pool-size: 20 - 50파드 × 20 = 1,000 연결 → PostgreSQL
max_connections: 200초과 → 장애
올바른 설계:
- PgBouncer를 DB 앞에 배치 (Deployment로 2개 레플리카, 각각
max_client_conn: 2000,default_pool_size: 15) - 파드는 PgBouncer에 연결, HikariCP
maximum-pool-size: 5(파드 내 빠른 재사용만) - 50파드 × 5 = 250개 → PgBouncer 실제 DB 연결: 30개 (고정)
- PostgreSQL
max_connections: 100— 여유분 충분
모니터링 체계:
hikaricp_connections_pending> 2이면 PgBouncer pool_size 검토pg_stat_activity idle in transaction> 10이면 코드 버그 의심- 매주
pg_stat_statements로 슬로우 쿼리 TOP 10 리뷰
면접 포인트: "max_connections를 늘리면 되지 않나요?"에 대한 답: CPU 코어 수 이상의 연결은 컨텍스트 스위칭 비용으로 성능 역전이 발생합니다. 올바른 접근은 PgBouncer로 실제 DB 연결을 최소로 유지하면서 애플리케이션 쪽 동시 요청은 무제한에 가깝게 처리하는 것입니다.
여기까지 Database 트랙의 모든 모듈을 완료했습니다. SQL 기초부터 트랜잭션, 인덱스, 쿼리 최적화, NoSQL, 고가용성, 커넥션 풀까지 — 실무 백엔드 개발자가 DB와 직접 마주치는 거의 모든 상황을 다뤘습니다.