커넥션 풀 튜닝 — HikariCP 설정으로 DB 성능을 끌어올리는 방법
DB 커넥션 풀 크기를 10에서 50으로 늘렸더니 오히려 성능이 떨어졌다면, 무엇이 잘못된 걸까요?
개념 정의
커넥션 풀 은 데이터베이스 커넥션을 미리 생성해두고 재사용하는 기법입니다. 매번 커넥션을 생성하면 TCP 핸드셰이크, 인증, 메모리 할당 등 비용이 발생하기 때문에, 풀에서 빌려 쓰고 반환하는 방식으로 이 비용을 제거합니다.
Spring Boot는 HikariCP 를 기본 커넥션 풀로 사용합니다.
핵심 설정 항목
spring:
datasource:
hikari:
# 풀 크기
maximum-pool-size: 10 # 최대 커넥션 수 (기본: 10)
minimum-idle: 10 # 최소 유휴 커넥션 수 (기본: maximum-pool-size)
# 타임아웃
connection-timeout: 30000 # 커넥션 대기 최대 시간 ms (기본: 30초)
idle-timeout: 600000 # 유휴 커넥션 유지 시간 ms (기본: 10분)
max-lifetime: 1800000 # 커넥션 최대 수명 ms (기본: 30분)
# 누수 탐지
leak-detection-threshold: 60000 # 커넥션 누수 감지 ms (기본: 0=비활성)
# 커넥션 검증
connection-test-query: SELECT 1 # 커넥션 유효성 검사 쿼리
validation-timeout: 5000 # 검증 타임아웃 ms
설정 항목 상세
| 설정 | 기본값 | 설명 |
|---|---|---|
maximumPoolSize | 10 | 풀이 관리하는 최대 커넥션 수 (active + idle) |
minimumIdle | = maximumPoolSize | 풀에 유지할 최소 유휴 커넥션 수 |
connectionTimeout | 30초 | 풀에서 커넥션을 얻기 위한 최대 대기 시간 |
idleTimeout | 10분 | 유휴 커넥션이 폐기되기까지의 시간 |
maxLifetime | 30분 | 커넥션의 최대 수명 (DB 타임아웃보다 짧게) |
leakDetectionThreshold | 0 (비활성) | 커넥션 누수 감지 시간 |
풀 크기 산정
HikariCP 공식 가이드 공식
풀 크기 = (코어 수 * 2) + 유효 스핀들 수
이 공식은 일반적인 OLTP 워크로드에 적합합니다. SSD를 사용한다면 스핀들 수를 1로 봅니다.
예: 4코어 CPU + SSD → (4 * 2) + 1 = 9
실제 산정 시 고려사항
풀 크기 = 동시 활성 스레드 수 × DB 커넥션 사용 비율
- Tomcat 스레드 200개, DB 호출 비율 30% → 풀 크기 ≈ 60? 아닙니다.
- 실제로는 DB도 동시에 처리할 수 있는 연결 수에 한계가 있습니다.
** 핵심 원칙: 풀이 크다고 성능이 좋은 것이 아닙니다.**
그 이유는 인과적으로 연결됩니다.
- 풀 크기를 50으로 늘리면, 50개의 쿼리가 동시에 DB에 도달합니다.
- DB의 CPU와 메모리에 부하가 증가하고, 내부 잠금(lock) 경합이 심해집니다.
- 경합이 심해지면 개별 쿼리의 응답 시간이 오히려 늘어납니다.
- 서버가 4대이고 각각 풀이 50이면 DB에 200개 커넥션이 연결되므로, 전체 인프라를 고려해야 합니다.
대부분의 경우 10~20개 면 충분합니다. 부하 테스트를 통해 최적값을 찾아야 합니다.
크기 산정 실험 방법
// 메트릭으로 풀 사용량 모니터링
@Bean
public HikariDataSource dataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setMetricsTrackerFactory(
new MicrometerMetricsTrackerFactory(meterRegistry));
return ds;
}
모니터링할 핵심 메트릭:
hikaricp.connections.active: 현재 사용 중인 커넥션 수hikaricp.connections.pending: 대기 중인 요청 수hikaricp.connections.timeout: 타임아웃 발생 횟수
pending이 0이 아니면 풀이 부족한 것이고, active가 항상 낮으면 풀이 과도한 것입니다.
maxLifetime 설정의 중요성
DB 서버나 중간 네트워크 장비(방화벽, 로드밸런서)는 일정 시간 후 유휴 커넥션을 끊습니다. MySQL의 wait_timeout 기본값은 8시간(28800초)입니다.
HikariCP maxLifetime = DB wait_timeout - 여유 시간
spring:
datasource:
hikari:
# MySQL wait_timeout이 28800초(8시간)라면
# HikariCP는 그보다 짧게 설정
max-lifetime: 1740000 # 29분 (기본 30분보다 약간 짧게)
maxLifetime을 DB 타임아웃보다 길게 설정하면, 이미 끊어진 커넥션을 사용하려다 에러가 발생합니다.
커넥션 누수 탐지
커넥션을 빌린 후 반환하지 않는 것이 커넥션 누수입니다. 풀의 커넥션이 고갈되어 전체 애플리케이션이 멈출 수 있습니다.
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 60초 이상 반환하지 않으면 경고
누수가 감지되면 이런 로그가 출력됩니다:
WARN HikariPool-1 - Connection leak detection triggered for conn0
java.lang.Exception: Apparent connection leak detected
at com.example.service.SlowService.processData(SlowService.java:42)
at ...
흔한 누수 원인
// 누수 발생: try-with-resources 미사용
public void leakyMethod() {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT ...");
ResultSet rs = ps.executeQuery();
// conn.close()를 호출하지 않으면 커넥션이 풀에 반환되지 않음
// 예외 발생 시 더 위험
}
// 올바른 방법: try-with-resources
public void safeMethod() {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT ...");
ResultSet rs = ps.executeQuery()) {
// 자동으로 close → 풀에 반환
}
}
// Spring에서는 JdbcTemplate이나 JPA를 사용하면 커넥션 관리를 자동으로 처리
다중 데이터소스 설정
여러 DB를 사용할 때 각각 별도의 커넥션 풀을 구성합니다.
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.primary.hikari")
public HikariDataSource primaryDataSource() {
return new HikariDataSource();
}
@Bean
@ConfigurationProperties("spring.datasource.secondary.hikari")
public HikariDataSource secondaryDataSource() {
return new HikariDataSource();
}
}
spring:
datasource:
primary:
hikari:
jdbc-url: jdbc:mysql://primary-db:3306/main
maximum-pool-size: 15
secondary:
hikari:
jdbc-url: jdbc:mysql://secondary-db:3306/analytics
maximum-pool-size: 5 # 분석용 DB는 적은 풀
읽기/쓰기 분리 패턴
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "read" : "write";
}
}
@Configuration
public class RoutingConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("writeDataSource") DataSource write,
@Qualifier("readDataSource") DataSource read) {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("write", write);
targetDataSources.put("read", read);
routing.setTargetDataSources(targetDataSources);
routing.setDefaultTargetDataSource(write);
return routing;
}
}
트러블슈팅 체크리스트
| 증상 | 원인 | 해결 |
|---|---|---|
ConnectionTimeout 빈발 | 풀 크기 부족 또는 커넥션 누수 | 풀 크기 증가, 누수 탐지 활성화 |
| 응답 시간 급증 | 커넥션 대기 | pending 메트릭 확인, 슬로우 쿼리 최적화 |
Connection is closed | maxLifetime > DB timeout | maxLifetime을 DB보다 짧게 설정 |
| 메모리 증가 | 풀 크기 과도 | 풀 크기 축소, 메트릭 기반 적정값 산정 |
주의할 점
1. maxLifetime을 DB 타임아웃보다 길게 설정하면 "Connection is closed" 에러가 발생한다
MySQL의 wait_timeout(기본 8시간)이나 방화벽의 유휴 커넥션 타임아웃보다 HikariCP의 maxLifetime이 길면, 이미 끊어진 커넥션을 풀에서 꺼내 사용하게 됩니다. 쿼리 실행 시 Connection is closed, Communications link failure 에러가 간헐적으로 발생하며 재현이 어렵습니다. maxLifetime은 DB 타임아웃보다 30초~1분 짧게 설정하세요.
2. 커넥션 풀 크기를 무작정 늘리면 DB에 부하가 집중된다
풀 크기를 50으로 늘리면 50개의 동시 쿼리가 DB에 몰립니다. DB의 CPU, 메모리, 내부 잠금 경합이 심해져 전체 쿼리 응답 시간이 오히려 증가합니다. 서버가 4대이고 각각 풀 크기가 50이면 DB에 200개의 동시 커넥션이 연결되므로, 전체 인프라를 고려하여 풀 크기를 산정해야 합니다.
3. 커넥션 누수를 방치하면 풀이 고갈되어 전체 서비스가 멈춘다
커넥션을 빌린 후 반환하지 않는 코드가 있으면, 시간이 지나면서 풀의 모든 커넥션이 고갈됩니다. connectionTimeout에 도달하면 모든 요청이 SQLTransientConnectionException으로 실패합니다. leakDetectionThreshold를 60초 정도로 설정하여 누수를 조기에 감지하고, Spring의 JdbcTemplate이나 JPA를 통해 커넥션 관리를 자동화하세요.
정리
| 항목 | 핵심 |
|---|---|
| maximumPoolSize | "클수록 좋다"는 오해. 대부분 10~20으로 충분 |
| maxLifetime | DB wait_timeout보다 30초~1분 짧게 |
| leakDetectionThreshold | 60초로 활성화하여 누수 조기 감지 |
| 모니터링 | active, pending, timeout 메트릭 추적 |
| 전체 인프라 고려 | 서버 수 x 풀 크기 = DB 총 커넥션 수 |