Moment Note

데이터베이스 커넥션 풀 최적화: 커넥션 고갈 없이 처리량 높이기

서버 & 인프라 ·

웹 서비스가 트래픽 피크를 맞이하면 가장 먼저 무너지는 곳은 대개 데이터베이스 레이어입니다. “Too many connections” 에러, 쿼리 타임아웃, 결국 서비스 전체 다운 — 이 패턴은 커넥션 풀을 제대로 설계하지 않은 시스템에서 반복적으로 나타납니다. 커넥션 풀은 단순히 “숫자를 크게 잡으면 된다”는 오해가 많지만, 실제로는 정밀한 산정과 모니터링이 필요한 영역입니다. 이 글에서는 커넥션 풀 크기 산정 공식부터 HikariCP·PgBouncer 실전 설정, 누수 탐지, 그리고 운영 모니터링 지표까지 단계별로 살펴봅니다.

왜 커넥션 풀이 필요하고 어디서 막히는가

데이터베이스 커넥션은 생성 비용이 높습니다. PostgreSQL 기준으로 신규 커넥션 하나를 맺는 데 TCP 핸드셰이크, 인증, 세션 메모리 할당(기본 5~10 MB)까지 수십 밀리초가 소요됩니다. 요청마다 커넥션을 새로 생성·해제하면 DB 서버 프로세스 수가 요청 수에 비례해 폭발적으로 늘어납니다.

커넥션 풀은 이 비용을 분할상각합니다. 미리 일정 수의 커넥션을 만들어 두고, 애플리케이션 요청이 들어오면 풀에서 빌려주고 작업이 끝나면 반납받습니다. 문제는 풀 크기를 잘못 설정할 때 두 방향으로 장애가 납니다.

  • 풀이 너무 작을 때: 대기 큐가 쌓이다가 acquisition timeout 발생 → 503/500 에러
  • 풀이 너무 클 때: DB 서버 프로세스 수 초과, context switching 폭증, 메모리 압박 → 전체 쿼리 성능 저하

PostgreSQL의 max_connections 기본값은 100입니다. 애플리케이션 인스턴스 10개가 각각 pool size 20으로 붙으면 200개 커넥션이 필요하지만 DB는 100개만 허용하므로 절반의 연결 시도는 실패합니다. 이 충돌 지점을 정확히 계산하는 것이 첫 번째 과제입니다.

커넥션 풀 크기 산정 공식

HikariCP 문서에서 인용되어 유명해진 공식이 있습니다.

# Hikari 권장 공식 (OLTP 기준)
pool_size = (core_count * 2) + effective_spindle_count

# 예시: 4코어 CPU, SSD(spindle=1)
pool_size = (4 * 2) + 1 = 9

이 공식은 I/O 대기 중인 스레드가 CPU를 양보하는 패턴을 가정합니다. SSD 환경에서는 spindle을 1로 계산하고, HDD RAID는 실제 디스크 수를 씁니다. 실제 운영에서는 이 값을 출발점으로 삼고, 아래 변수를 곱해 조정합니다.

# 실무 조정 공식
max_pool = base_pool_size * safety_factor / app_instances

# 변수 설명
# base_pool_size : 위 공식 결과 (예: 9)
# safety_factor  : 1.2~1.5 (버스트 여유)
# app_instances  : 동시 실행 중인 애플리케이션 서버 수

# 예시: base=9, safety=1.3, instances=4
# max_pool_per_instance = (9 * 1.3) / 4 ≈ 2.9 → 3
# DB max_connections >= 3 * 4 = 12 (여기에 admin 예약 10개 추가)

이 계산이 직관보다 훨씬 작은 숫자를 내놓는 경우가 많습니다. “왜 이렇게 적냐”는 반응이 나오지만, 커넥션이 많다고 처리량이 늘지 않습니다. CPU 코어가 한정되어 있으면 병렬 실행 가능한 쿼리 수는 결국 코어 수에 수렴하고, 초과 커넥션은 대기만 합니다.

HikariCP 실전 설정

Spring Boot + PostgreSQL 조합에서 가장 많이 쓰이는 HikariCP 설정입니다.

# application.yml (Spring Boot)
spring:
  datasource:
    hikari:
      maximum-pool-size: 10          # 위 공식 결과
      minimum-idle: 5                # 유휴 최소 유지 커넥션
      connection-timeout: 3000       # 커넥션 획득 대기 최대 3초
      idle-timeout: 600000           # 유휴 커넥션 10분 후 반환
      max-lifetime: 1800000          # 커넥션 최대 수명 30분
      keepalive-time: 120000         # 커넥션 유지 핑 간격 2분
      connection-test-query: SELECT 1
      pool-name: MainHikariPool
      leak-detection-threshold: 5000 # 5초 이상 미반납 시 경고 로그

leak-detection-threshold는 커넥션 누수 디버깅의 핵심입니다. 이 값을 설정하면 HikariCP가 커넥션을 빌려간 스레드의 스택 트레이스를 로그에 남겨서 어떤 코드 경로에서 반납을 안 했는지 추적할 수 있습니다.

max-lifetime은 DB 서버 측 wait_timeout(MySQL) 또는 tcp_keepalives_idle(PostgreSQL) 보다 짧게 설정해야 합니다. DB가 먼저 끊어버린 커넥션을 풀이 살아있다고 착각하면 “broken pipe” 에러가 납니다.

PgBouncer로 풀 확장하기

애플리케이션 인스턴스가 수십 개로 늘어나면 각 인스턴스의 HikariCP 풀을 합산한 커넥션 수가 PostgreSQL max_connections를 빠르게 초과합니다. 이때 미들웨어 풀러인 PgBouncer를 도입합니다.

# /etc/pgbouncer/pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction          # 트랜잭션 단위 풀링 (권장)
max_client_conn = 1000           # 앱에서 받을 수 있는 최대 클라이언트
default_pool_size = 25           # DB 방향 실제 커넥션 수
reserve_pool_size = 5            # 비상용 예비 커넥션
reserve_pool_timeout = 3         # 예비 커넥션 활성화 대기(초)
server_idle_timeout = 600        # 서버 측 유휴 커넥션 회수
client_idle_timeout = 0          # 클라이언트 측 타임아웃 (0=비활성)
server_lifetime = 3600           # 서버 커넥션 최대 수명(초)

pool_mode에는 세 가지 선택지가 있습니다.

모드 커넥션 반환 시점 세션 변수 유지 권장 상황
session 클라이언트 연결 종료 시 O SET, LISTEN/NOTIFY 사용
transaction 트랜잭션 완료 시 X (주의) 일반 OLTP (권장)
statement 쿼리 완료 시 X 멀티 스테이트먼트 트랜잭션 불가

transaction 모드에서는 SET search_path, 준비된 구문(prepared statement), advisory lock이 세션을 넘나들며 깨질 수 있습니다. JDBC의 prepareThreshold=0 또는 Hibernate의 hibernate.jdbc.use_get_generated_keys=false 같은 설정으로 호환성을 맞춰야 합니다.

커넥션 누수·타임아웃 대응

커넥션 누수는 코드가 커넥션을 빌린 후 예외 발생 시 반납하지 않는 경로에서 발생합니다. Java에서는 try-with-resources 또는 Spring의 @Transactional이 안전망이 되지만, 놓치는 경우가 있습니다.

// 누수 발생 패턴 (잘못된 예)
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT ...");
// 예외 발생 시 conn.close() 호출 안 됨

// 안전한 패턴
try (Connection conn = dataSource.getConnection();
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery("SELECT ...")) {
    // 작업
} // AutoCloseable: 예외 시에도 자동 close

타임아웃 계층도 중요합니다. 각 레이어에 적절한 타임아웃을 설정해야 한 곳의 느린 쿼리가 전체 풀을 잠식하지 않습니다.

  • HikariCP connection-timeout: 풀에서 커넥션을 못 얻으면 이 시간 후 예외. 3~5초 권장.
  • 쿼리 타임아웃: JDBC setQueryTimeout() 또는 PostgreSQL statement_timeout GUC. 슬로우 쿼리 격리.
  • 트랜잭션 타임아웃: Spring @Transactional(timeout=30)으로 장기 트랜잭션 강제 롤백.
  • 소켓 타임아웃: JDBC URL에 socketTimeout=30(초) 추가. 네트워크 단절 시 무한 블록 방지.

모니터링 지표

HikariCP는 Micrometer를 통해 Prometheus/Grafana로 지표를 내보낼 수 있습니다. 핵심 지표는 다음과 같습니다.

지표 의미 경보 기준
hikaricp_connections_active 현재 사용 중인 커넥션 수 max의 80% 이상 지속
hikaricp_connections_pending 커넥션 대기 중인 스레드 수 1 이상 발생 시 즉시 알람
hikaricp_connections_timeout_total 타임아웃으로 실패한 획득 시도 수 누적 증가 시 긴급
hikaricp_connection_acquired_nanos 커넥션 획득 소요 시간 p99 > 1초
pg_stat_activity (PostgreSQL) DB 측 실제 커넥션 상태 idle in transaction 다수

PostgreSQL에서 idle in transaction 상태의 커넥션이 많다면 트랜잭션을 열어 두고 작업을 하지 않는 코드가 있다는 신호입니다. idle_in_transaction_session_timeout을 30초 이내로 설정해 강제 종료하는 것이 안전합니다.

실전 팁 요약

  • 풀 크기는 CPU 코어 기반 공식에서 시작하고, 부하 테스트로 검증한다.
  • HikariCP leak-detection-threshold를 개발 환경에서 항상 활성화한다.
  • PgBouncer transaction 모드에서는 prepared statement 주의, 필요 시 statement 모드로 전환.
  • 모든 타임아웃 레이어(획득·쿼리·트랜잭션·소켓)를 명시적으로 설정한다.
  • Prometheus + Grafana 대시보드에 connections_pending·connections_timeout 경보를 추가한다.

마무리

커넥션 풀 최적화는 “숫자를 키우는” 문제가 아니라 “병목 지점을 이해하고 각 레이어에 맞는 안전장치를 두는” 문제입니다. 공식 기반 초기 설정 → 부하 테스트 → 모니터링 지표 수집 → 점진적 튜닝의 사이클을 돌리면, 트래픽 피크에도 커넥션 고갈 없이 안정적인 처리량을 유지할 수 있습니다. PgBouncer 같은 미들웨어 풀러는 스케일아웃 환경에서 강력한 선택지이지만, 세션 상태 제약을 먼저 충분히 검토해야 합니다.

자주 묻는 질문

Q. HikariCP maximum-pool-size를 100으로 설정했는데 커넥션 고갈이 계속 납니다. 더 늘려야 할까요?

A. 대부분의 경우 더 늘리는 것은 역효과입니다. 먼저 connections_pending 지표와 PostgreSQL pg_stat_activity에서 idle in transaction 커넥션 비율을 확인하세요. 트랜잭션이 오래 열려 있거나 누수가 있는 경우 풀 크기와 무관하게 고갈됩니다. 슬로우 쿼리 최적화와 트랜잭션 범위 축소가 우선입니다.

Q. PgBouncer transaction 모드에서 Hibernate를 쓸 때 주의사항은 무엇인가요?

A. Hibernate는 기본적으로 server-side prepared statement를 사용하는데, transaction 모드에서는 세션이 커넥션을 넘나들어 준비된 구문이 사라집니다. JDBC URL에 prepareThreshold=0을 추가하거나, Hibernate 설정에서 hibernate.jdbc.use_streams_for_binary=false와 함께 use_get_generated_keys=false를 설정해 클라이언트 사이드 준비 구문을 비활성화하세요.

Q. 마이크로서비스 환경에서 각 서비스마다 커넥션 풀을 두면 DB 커넥션이 너무 많아집니다. 어떻게 관리하나요?

A. PgBouncer를 각 DB 앞에 두고, 각 마이크로서비스의 HikariCP maximum-pool-size를 2~5로 낮추는 전략이 효과적입니다. 서비스 수 * 인스턴스 수 * 풀 크기의 곱이 PgBouncer의 max_client_conn을 넘지 않도록 설계하고, PgBouncer에서 DB로의 실제 커넥션은 default_pool_size로 제한합니다. 이렇게 하면 수십 개의 마이크로서비스가 DB에 수백 개의 커넥션을 맺더라도 DB 측 실제 프로세스는 수십 개로 유지됩니다.