DB 커넥션을 맺는 것은 생각보다 비싸다. TCP 연결, 인증, 세션 초기화까지 수십~수백 밀리초가 걸린다. 매 요청마다 새 커넥션을 만들면 DB가 이 비용만으로 과부하에 걸린다. 커넥션 풀은 미리 만들어 놓은 커넥션을 재사용해 이 비용을 제거한다.

비유: 택시 회사(커넥션 풀)가 차량(커넥션)을 미리 준비해두고 있다. 승객(요청)이 오면 대기 중인 차를 바로 배정한다. 목적지 도착 후 차는 회사로 돌아가(반납) 다음 승객을 기다린다. 차가 없으면 대기 또는 거절(타임아웃)한다.


커넥션 없이 매번 연결하면?

요청마다 새 커넥션 생성:
  1. 소켓 TCP 연결 (3-way handshake): ~1ms
  2. SSL 핸드셰이크: ~5ms
  3. DB 인증 (사용자/패스워드 검증): ~5ms
  4. 세션 초기화 (character set, timezone 등): ~1ms
  총: 약 10~50ms (네트워크 상황에 따라 더 길어짐)

  TPS 1,000일 때: 1,000번 × 50ms = 연결에만 초당 50초 소비 → 불가능

커넥션 풀 사용:
  미리 생성된 커넥션을 <1ms에 대여 → 실질적 비용 0

HikariCP 동작 원리

Spring Boot 2.x+의 기본 커넥션 풀. “빠른 커넥션 풀” 표방.

graph TD subgraph "HikariCP" POOL[커넥션 풀\nConcurrentBag] C1[Connection 1\n대기중] C2[Connection 2\n대기중] C3[Connection 3\n사용중] C4[Connection 4\n대기중] POOL --- C1 POOL --- C2 POOL --- C3 POOL --- C4 end T1[스레드 1] -->|getConnection| POOL POOL -->|대여| T1 T1 -->|close 호출\n실제 반납| POOL T2[스레드 2] -->|getConnection\n대기중| POOL

커넥션 생명주기

1. Pool 초기화 (minimumIdle개 커넥션 선제 생성)
2. getConnection(): 풀에서 대기 커넥션 반환
   → 없으면 최대 connectionTimeout까지 대기
   → 타임아웃 → SQLException 발생
3. 쿼리 실행
4. connection.close(): 실제 종료 아님 → 풀에 반납
5. 유휴 시간 > idleTimeout → 커넥션 닫고 풀에서 제거
6. 생존 시간 > maxLifetime → 커넥션 교체 (DB 서버 재연결 강제 종료 방어)

HikariCP 설정

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC
    username: myuser
    password: mypassword
    driver-class-name: com.mysql.cj.jdbc.Driver

    hikari:
      # 풀 크기
      maximum-pool-size: 10          # 최대 커넥션 수 (기본 10)
      minimum-idle: 5                # 유휴 커넥션 최소 유지 수
                                     # (maximum-pool-size와 같게 권장: 고정 풀)

      # 타임아웃
      connection-timeout: 30000      # 커넥션 획득 대기 시간 (ms) - 기본 30초
      idle-timeout: 600000           # 유휴 커넥션 유지 시간 (ms) - 기본 10분
      max-lifetime: 1800000          # 커넥션 최대 생존 시간 (ms) - 기본 30분
                                     # DB 서버 wait_timeout보다 짧게 설정!
      keepalive-time: 30000          # 유휴 커넥션 헬스체크 주기 (ms)

      # 연결 검증
      connection-test-query: SELECT 1  # JDBC4 미지원 드라이버용 (MySQL 불필요)
      validation-timeout: 5000         # 커넥션 유효성 검사 타임아웃

      # 풀 이름 (모니터링에서 구분)
      pool-name: HikariPool-OrderService

      # 초기화 쿼리 (세션 설정)
      connection-init-sql: "SET NAMES utf8mb4"

적정 풀 사이즈 계산

가장 많이 틀리는 부분이다. 풀이 크다고 좋은 게 아니다.

HikariCP 공식 공식

최적 풀 크기 = (CPU 코어 수 × 2) + 유효 디스크 스핀들 수

예시: 4코어 서버, SSD(스핀들 1개)
  = (4 × 2) + 1 = 9 ≈ 10

직관적 설명:
  CPU가 4개 → 동시에 4개 쿼리 실행 가능
  나머지 스레드는 IO 대기 → 이 시간에 다른 커넥션이 CPU 사용
  너무 크면: 컨텍스트 스위칭 비용 증가, DB 서버 과부하
  너무 작으면: 커넥션 대기로 처리량 감소

실전 계산

시나리오: 4코어 서버, Spring Boot 앱, Tomcat 스레드 200개

잘못된 접근: 풀 크기 = 200 (스레드 수만큼)
  → DB 서버가 200개 동시 쿼리 처리 → 실제로는 더 느려짐

올바른 접근:
  DB 서버 CPU = 8코어
  최적 풀 크기 = (8 × 2) + 1 = 17 ≈ 20

  하지만 앱 서버가 3대라면:
  서버당 풀 크기 = 20 / 3 = 7 (반올림해서 7~10)
  총 DB 연결 = 7 × 3 = 21개 → DB 최적 처리량

TPS 기반 계산:
  목표 TPS = 1,000
  평균 쿼리 응답 시간 = 50ms
  필요 커넥션 = 1,000 × 0.05 = 50개
  (Little's Law: N = λ × W)

커넥션 풀 관련 장애 패턴

1. 커넥션 풀 고갈 (Pool Exhaustion)

증상:
  - HikariPool-1 - Connection is not available, request timed out after 30000ms
  - API 응답 지연 후 전체 다운

원인:
  1. 트랜잭션 내에서 외부 API 호출
  2. 트랜잭션을 닫지 않음 (예외 처리 누락)
  3. 풀 크기 대비 동시 요청 급증
  4. 느린 쿼리로 커넥션 오래 점유

진단:
  SELECT * FROM information_schema.processlist; -- MySQL
  → 커넥션 상태와 실행 쿼리 확인
// 나쁜 패턴: 트랜잭션 안에서 외부 HTTP 호출
@Transactional
public OrderResult createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));

    // ❌ 커넥션 점유 중에 외부 API 호출 (수백 ms ~ 수초)
    PaymentResult payment = paymentClient.charge(request.getAmount());

    order.complete(payment);
    return OrderResult.from(order);
}

// 좋은 패턴: 외부 호출을 트랜잭션 밖으로
public OrderResult createOrder(OrderRequest request) {
    // 1. 외부 API 먼저 호출 (커넥션 사용 안함)
    PaymentResult payment = paymentClient.charge(request.getAmount());

    // 2. DB 작업만 트랜잭션 안에서
    return createOrderWithPayment(request, payment);
}

@Transactional
public OrderResult createOrderWithPayment(OrderRequest request, PaymentResult payment) {
    Order order = orderRepository.save(new Order(request));
    order.complete(payment);
    return OrderResult.from(order);
}

2. 커넥션 누수 (Connection Leak)

// 누수 발생: close() 호출 안 됨
public void badQuery() throws SQLException {
    Connection conn = dataSource.getConnection();
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("SELECT * FROM orders");
    // 예외 발생 시 close() 호출 안됨 → 커넥션 반납 안됨
    processResults(rs);
    conn.close();  // ← 예외 전에 닫아야 함
}

// 올바른 방법: try-with-resources
public void goodQuery() throws SQLException {
    try (Connection conn = dataSource.getConnection();
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery("SELECT * FROM orders")) {
        processResults(rs);
    }  // 자동으로 close() 호출 (예외 발생 시에도)
}
# 누수 감지 설정
hikari:
  leak-detection-threshold: 2000  # 2초 이상 반납 안된 커넥션 경고 로그
경고 로그 예시:
Connection leak detection triggered for
  com.mysql.cj.jdbc.ConnectionImpl@1234abcd on thread http-nio-8080-exec-5,
  stack trace follows
  ...
  at com.example.OrderService.createOrder(OrderService.java:45)
→ 해당 코드 라인 확인

3. 데드락 (Deadlock)

커넥션 풀 데드락 (HikariCP):
  스레드 A: 커넥션 1 보유 → 커넥션 2 대기
  스레드 B: 커넥션 2 보유 → 커넥션 1 대기
  → 무한 대기

발생 조건:
  maximumPoolSize = 1 (단 하나의 커넥션)
  트랜잭션 A가 커넥션 1 사용 중
  트랜잭션 A 내부에서 새 트랜잭션 B 시작 (Propagation.REQUIRES_NEW)
  트랜잭션 B가 커넥션 1 대기 → 데드락

해결:
  maximumPoolSize를 2 이상으로 설정
  REQUIRES_NEW 사용 시 풀 크기 고려
// 데드락 유발 패턴
@Service
@Transactional  // 커넥션 1 사용
public class OrderService {

    @Autowired
    private AuditService auditService;

    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        auditService.log(order);  // 내부에서 REQUIRES_NEW 트랜잭션 → 커넥션 2 필요!
    }
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 새 커넥션 필요
    public void log(Order order) {
        auditRepository.save(new AuditLog(order));
    }
}

모니터링

Actuator + Prometheus

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  metrics:
    tags:
      application: ${spring.application.name}
주요 메트릭 (Prometheus):
hikaricp_connections_active      현재 사용 중인 커넥션 수
hikaricp_connections_idle        대기 중인 커넥션 수
hikaricp_connections_pending     커넥션 대기 중인 스레드 수 ← 이게 오르면 위험
hikaricp_connections_timeout_total  커넥션 획득 타임아웃 횟수 ← 이게 오르면 장애
hikaricp_connections_acquire_ms  커넥션 획득 소요 시간

Grafana 알림:
  hikaricp_connections_pending > 0 → 경고
  hikaricp_connections_timeout_total 증가 → 긴급

수동 모니터링

@Component
public class HikariPoolMonitor {

    private final DataSource dataSource;

    @Scheduled(fixedDelay = 5000)
    public void logPoolStats() {
        if (dataSource instanceof HikariDataSource hikariDataSource) {
            HikariPoolMXBean pool = hikariDataSource.getHikariPoolMXBean();
            log.info("Pool stats - Active: {}, Idle: {}, Pending: {}, Total: {}",
                pool.getActiveConnections(),
                pool.getIdleConnections(),
                pool.getThreadsAwaitingConnection(),
                pool.getTotalConnections()
            );
        }
    }
}

멀티 데이터소스

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingDataSource(
            @Qualifier("writeDataSource") DataSource write,
            @Qualifier("readDataSource") DataSource read) {

        AbstractRoutingDataSource routing = new AbstractRoutingDataSource() {
            @Override
            protected Object determineCurrentLookupKey() {
                return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                    ? "read" : "write";
            }
        };

        routing.setTargetDataSources(Map.of("write", write, "read", read));
        routing.setDefaultTargetDataSource(write);
        return routing;
    }
}
spring:
  datasource:
    write:
      jdbc-url: jdbc:mysql://master-db:3306/mydb
      hikari:
        maximum-pool-size: 10
        pool-name: WritePool
    read:
      jdbc-url: jdbc:mysql://replica-db:3306/mydb
      hikari:
        maximum-pool-size: 20  # 읽기 부하가 더 많으므로 크게
        pool-name: ReadPool

극한 시나리오

시나리오 1: 트래픽 스파이크로 풀 고갈

상황: 프로모션으로 TPS 10배 급증
결과: 커넥션 풀 고갈 → connection timeout → API 500 오류

즉각 대응:
1. maximum-pool-size 임시 증가 (DB 서버 커넥션 한계 내에서)
2. 느린 쿼리 식별 및 킬 (KILL {process_id})
3. 로드밸런서에서 일부 트래픽 차단 (서킷 브레이커)

중기 대응:
1. 쿼리 최적화 (인덱스, 쿼리 튜닝)
2. 읽기 전용 레플리카 추가 + 읽기 분리
3. 자주 조회되는 데이터 Redis 캐싱
4. DB 앞에 PgBouncer/ProxySQL 같은 커넥션 풀러 추가

시나리오 2: DB 서버 재시작 후 커넥션 오류

문제: DB 서버가 재시작되면 기존 커넥션은 죽어있음
      그런데 HikariCP는 이 커넥션이 죽었는지 모름 → 사용 시 오류

설정으로 방어:
hikari:
  max-lifetime: 1800000      # 30분마다 커넥션 재생성
  keepalive-time: 30000      # 30초마다 유휴 커넥션에 ping
  connection-test-query: SELECT 1  # 사용 전 검증

DB 서버 설정 확인:
  wait_timeout=28800 (MySQL 기본: 8시간)
  → max-lifetime < wait_timeout 이어야 함
  → 권장: max-lifetime = 1800000 (30분) < wait_timeout

시나리오 3: 마이크로서비스 환경에서 DB 커넥션 폭증

문제: 서비스 인스턴스 50개 × 풀 크기 20 = 1,000개 DB 커넥션
      DB 서버 최대 커넥션 500개 → 연결 거부

해결:
1. 서비스당 풀 크기 줄이기
   → 인스턴스 50개 × 풀 10 = 500개

2. PgBouncer / ProxySQL 도입:
   앱 → ProxySQL(커넥션 멀티플렉싱) → DB
   1,000개 앱 커넥션 → 실제 DB 커넥션 50개로 압축

3. 서비스 계층별 DB 분리 (MSA):
   각 서비스가 자체 DB를 가져 총 커넥션 수 분산