DB 커넥션을 매번 새로 만들면 왜 느릴까? 그리고 풀을 크게 만들면 왜 더 느려질까?

Connection Pool은 데이터베이스 성능의 숨은 핵심입니다. 공부하다 보니 "풀 사이즈를 크게 하면 빨라지겠지"라고 생각했는데, 실제로는 작은 풀이 큰 풀보다 50배 빠른 사례 가 있더라고요. 직관과 반대되는 이유를 정리했습니다.

왜 Connection Pool인가

커넥션 생성 비용

데이터베이스 커넥션을 하나 만드는 과정:

  1. TCP 3-way Handshake (네트워크 왕복)
  2. ** 인증** (사용자명, 비밀번호 검증)
  3. ** 세션 초기화** (문자셋, 타임존, 격리 수준 설정)
  4. ** 메모리 할당** (DB 서버 측 세션 메모리)

이 과정이 요청마다 반복되면 단순 쿼리보다 커넥션 생성 시간이 더 오래 걸릴 수 있습니다.

Connection Pool의 동작

PLAINTEXT
[애플리케이션]                    [Connection Pool]              [DB]
     |                                  |                         |
     |--- 커넥션 요청 ----------------->|                         |
     |                                  |--- (이미 연결된 커넥션) |
     |<-- 커넥션 반환 ------------------|                         |
     |--- 쿼리 실행 ------------------------------------------------>|
     |<-- 결과 반환 -------------------------------------------------|
     |--- 커넥션 반납 ----------------->|                         |
     |                                  |--- (풀에 보관) -------->|

미리 커넥션을 만들어 풀에 보관하고, 요청이 오면 빌려주고 끝나면 반납받는 구조입니다. TCP 핸드셰이크와 인증 과정이 사라집니다.

HikariCP 설계 철학

Spring Boot 2.0부터 기본 Connection Pool로 채택된 HikariCP는 "빠르고, 단순하고, 신뢰할 수 있는" 것을 목표로 합니다.

왜 빠른가

1. ByteCode 레벨 최적화

JAVA
// 일반적인 구현: if-else 분기
if (connection.isClosed()) { ... }

// HikariCP: 바이트코드 수준에서 분기를 최소화
// Javassist를 사용하여 프록시 클래스를 런타임에 생성
// 불필요한 메서드 호출과 체크를 제거

2. ConcurrentBag — 락 프리(Lock-free) 자료구조

JAVA
// 전통적인 풀: synchronized 블록으로 커넥션 관리
synchronized (pool) {
    connection = pool.remove(0);  // 락 경합 발생
}

// HikariCP: ConcurrentBag 사용
// - ThreadLocal로 각 스레드가 마지막에 사용한 커넥션을 기억
// - 같은 스레드가 다시 요청하면 락 없이 즉시 반환
// - ThreadLocal에 없으면 CAS(Compare-And-Swap)로 획득

3. 최소한의 코드

HikariCP의 핵심 코드는 약 6,000줄입니다. 다른 풀 라이브러리(DBCP2, C3P0)보다 월등히 적습니다. 코드가 적으면 버그가 적고, 최적화가 쉽습니다.

풀 사이즈 공식

PostgreSQL 공식 — 코어 기반 산정

HikariCP 공식 위키(About Pool Sizing)에서 인용하는 공식입니다. 원래 출처는 PostgreSQL 팀이 발표한 벤치마크(PostgreSQL wiki: Number Of Database Connections)와 Oracle의 성능 그룹 영상(Thinking Clearly About Performance)입니다.

PLAINTEXT
connections = (core_count * 2) + effective_spindle_count
  • core_count: DB 서버의 CPU 물리 코어 수 (하이퍼스레딩 제외)
  • effective_spindle_count: 동시에 I/O를 처리할 수 있는 디스크 수 (SSD는 1로 계산)

공식의 근거는 이렇습니다. 디스크 I/O를 기다리는 동안 CPU는 다른 쿼리를 처리할 수 있으므로, 코어당 2개의 커넥션이면 CPU가 쉬지 않고 일할 수 있습니다. + spindle_count는 디스크 병렬 I/O 여유분입니다.

예시:

  • 4코어 서버 + SSD: (4 × 2) + 1 = 9개
  • 8코어 서버 + SSD: (8 × 2) + 1 = 17개

PostgreSQL wiki에서는 "a formula which has held up pretty well across a lot of benchmarks for years is that for optimal throughput the number of active connections should be somewhere near (core_count * 2) + effective_spindle_count"라고 설명합니다. 이 공식은 PostgreSQL에만 국한되지 않고 대부분의 RDBMS에서 유효합니다.

작은 풀이 큰 풀보다 빠른 이유

직관과 완전히 반대되는 결과입니다. HikariCP wiki에서 인용한 Oracle 성능 그룹의 벤치마크에서, 풀 사이즈를 2,048에서 96으로 줄이는 것만으로 응답 시간이 ~100ms에서 ~2ms로 50배 개선 된 사례가 있습니다.

왜?

1. CPU 컨텍스트 스위칭

PLAINTEXT
4코어 서버에 커넥션 200개
→ 200개의 스레드가 동시에 DB 작업을 요청
→ 실제로는 4개만 동시 실행 가능
→ 나머지 196개는 대기하면서 컨텍스트 스위칭 발생
→ 스위칭 비용이 실제 작업 시간보다 커짐

2. 디스크 I/O 경합

PLAINTEXT
커넥션이 많으면:
→ 동시에 디스크에 접근하는 쿼리가 많아짐
→ 디스크 큐에 요청이 쌓임
→ 각 쿼리의 I/O 대기 시간 증가
→ 커넥션 점유 시간 증가 → 악순환

3. 수학적 비유

고속도로에 비유하면:

  • 4차선 도로에 차 10대: 모두 빠르게 통과
  • 4차선 도로에 차 1,000대: 모두 느려짐 (정체)
  • 커넥션 풀도 같은 원리

주요 설정값

YAML
# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10          # 풀 최대 크기
      minimum-idle: 10               # 유휴 커넥션 최소 수 (= maximum과 같게!)
      connection-timeout: 10000      # 커넥션 획득 대기 시간 (ms)
      idle-timeout: 600000           # 유휴 커넥션 유지 시간 (ms)
      max-lifetime: 1800000          # 커넥션 최대 수명 (ms)

설정 포인트

maximum-pool-size: 위의 공식으로 결정. 무조건 크게 잡지 말 것.

minimum-idle = maximum-pool-size (고정 크기 풀 권장):

PLAINTEXT
❌ minimum-idle: 5, maximum-pool-size: 50
→ 부하 증가 시 커넥션 생성 → 부하 감소 시 제거 → 반복 오버헤드

✅ minimum-idle: 10, maximum-pool-size: 10
→ 항상 10개 유지 → 커넥션 생성/제거 오버헤드 없음

connection-timeout: 웹 애플리케이션은 10초 정도로 설정. 기본값 30초는 사용자가 너무 오래 기다림.

max-lifetime: DB의 wait_timeout보다 ** 짧게** 설정. MySQL 기본 wait_timeout이 28800초(8시간)이면, max-lifetime은 25200초(7시간) 정도로.

Connection Pool Deadlock

풀 데드락은 모니터링하기 어렵고, 발생하면 시스템이 완전히 멈춥니다.

발생 원인

JAVA
// ❌ 위험한 패턴: 하나의 요청이 여러 커넥션을 순차 획득
@Transactional  // 커넥션 1 획득
public void processOrder() {
    orderRepository.save(order);       // 커넥션 1 사용

    // 내부에서 또 다른 트랜잭션 시작 (새 커넥션 필요!)
    notificationService.send(order);   // 커넥션 2 획득 시도
    // 풀이 가득 차면 여기서 영원히 대기
}

풀 사이즈가 10이고, 10개의 요청이 동시에 이 코드를 실행하면:

  • 10개의 요청이 각각 커넥션 1개씩 획득 (풀 가득 참)
  • 모두 커넥션 2를 기다림
  • 아무도 커넥션 1을 반환하지 못함 → ** 데드락**

방지 방법

JAVA
// ✅ 하나의 트랜잭션에서 하나의 커넥션만 사용
@Transactional
public void processOrder() {
    orderRepository.save(order);
    // 알림은 트랜잭션 밖에서 비동기로 처리
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
    notificationService.send(event.getOrder());  // 별도 커넥션
}

풀 사이즈 공식에 ** 동시 커넥션 획득 수를 반영** (HikariCP wiki: Pool Locking 참고):

PLAINTEXT
pool_size = T × (M - 1) + 1

T = 최대 동시 스레드 수
M = 하나의 스레드가 동시에 필요한 최대 커넥션 수

이 공식의 의미는, T개의 스레드가 각각 M개의 커넥션을 동시에 잡으려 할 때 데드락이 발생하지 않는 최소 풀 사이즈입니다. M=1이면 pool_size = 1(커넥션을 하나씩만 잡으니 데드락 불가), M=2이면 pool_size = T + 1이 됩니다.

OSIV와 커넥션 풀의 함정

OSIV(Open Session In View) 는 Spring Boot에서 기본으로 켜져 있습니다 (spring.jpa.open-in-view=true).

문제

PLAINTEXT
HTTP 요청 시작
  ├─ 커넥션 획득 ← 여기서 획득!
  ├─ @Service 비즈니스 로직 (DB 쿼리)
  ├─ 외부 API 호출 (3초 소요) ← 커넥션 점유 중!
  ├─ 뷰 렌더링 ← 커넥션 점유 중!
  └─ HTTP 응답 완료
  └─ 커넥션 반납 ← 여기서야 반납!

DB 쿼리는 0.01초면 끝나는데, 외부 API 호출과 뷰 렌더링 동안 커넥션을 불필요하게 점유합니다. 10개 커넥션 풀에 외부 API 응답이 3초씩 걸리면, 3초 * N 동안 커넥션이 잠기게 됩니다.

해결

YAML
spring:
  jpa:
    open-in-view: false  # OSIV 끄기

OSIV를 끄면 @Transactional 범위 안에서만 커넥션을 사용합니다. 뷰에서 Lazy Loading이 필요하면 서비스 레이어에서 미리 로딩하거나 DTO로 변환하세요.

모니터링 필수 메트릭

운영 환경에서는 반드시 커넥션 풀을 모니터링해야 합니다.

JAVA
// HikariCP 메트릭 (Micrometer 연동 시 자동 수집)
// hikaricp.connections.active    — 현재 사용 중인 커넥션 수
// hikaricp.connections.idle      — 유휴 커넥션 수
// hikaricp.connections.pending   — 커넥션 대기 중인 스레드 수 ← 이게 0이 아니면 경고!
// hikaricp.connections.timeout   — 타임아웃 발생 횟수

pending이 지속적으로 0보다 크면 풀 사이즈가 부족하거나, 커넥션을 너무 오래 점유하는 코드가 있다는 신호입니다.

정리

  • 커넥션 생성은 비용이 크므로 풀을 사용하여 재사용
  • HikariCP는 바이트코드 최적화와 ConcurrentBag으로 락 경합을 최소화
  • 풀 사이즈 공식: (코어 수 * 2) + 1 — 작은 풀이 큰 풀보다 빠름
  • 고정 크기 풀 권장 (minimum-idle = maximum-pool-size)
  • 하나의 요청에서 여러 커넥션 획득하면 데드락 위험
  • OSIV는 커넥션 점유 시간을 늘리므로 끄는 것을 권장

커넥션 풀 튜닝의 핵심은 "적절히 작게"입니다. 직관과 반대이지만, 데이터가 이를 증명합니다. 감으로 50~100으로 잡지 말고 공식으로 계산하세요.

References

댓글 로딩 중...