트래픽이 몰리는 서비스에서 갑자기 Connection is not available, request timed out after 30000ms라는 에러가 쏟아지는 상황을 맞닥뜨린 적 있으신가요? DB 쿼리도 느리지 않고, 서버 CPU도 여유 있는데 왜 이런 에러가 날까 당황스러웠던 경험이 있을 것입니다. 원인은 대부분 HikariCP 커넥션 풀 설정에 있습니다. 커넥션 풀은 데이터베이스와 WAS 사이의 가장 중요한 성능·안정성 조율 장치입니다. 설정을 너무 작게 잡으면 고갈 에러, 너무 크게 잡으면 DB 과부하, 트랜잭션을 잘못 사용하면 풀이 금방 바닥납니다. 이 글에서는 커넥션 풀의 원리부터 HikariCP의 핵심 설정 옵션, 풀 크기 계산 공식, 실전 함정과 해결 방법까지 완전히 정리합니다.
목차
- 커넥션 풀(DBCP)이란? — 없으면 어떤 일이 벌어지나
- HikariCP가 Spring Boot의 기본이 된 이유
- HikariCP 핵심 설정 옵션 완전 해설
- 커넥션 풀 크기(maximum-pool-size) 최적 계산 공식
- 실전 함정 4가지 — 커넥션 고갈·Pool-locking·트랜잭션 남용
- WAS-DB 최적화 완성 설정 예시와 모니터링 방법
1. 커넥션 풀(DBCP)이란? — 없으면 어떤 일이 벌어지나
DB 커넥션 하나를 여는 비용
많은 개발자가 dataSource.getConnection()을 단순히 DB에 연결하는 한 줄 코드로 여깁니다. 하지만 실제로는 상당히 무거운 작업이 숨어 있습니다.
커넥션을 맺는 작업은 상당히 무거운 작업입니다. DB 드라이버와 DB 간에 TCP/IP 통신을 맺어야 하고, 그 과정에서 3-way handshake와 같은 네트워크 동작들이 수반됩니다. 그뿐만 아니라 username, password를 바탕으로 인증 처리도 해야 되고, 인증이 끝난 후 DB는 별도의 세션도 만들어야 합니다. Infobank01
매 요청마다 이 과정을 반복하면 어떻게 될까요? 커넥션 생성 자체가 병목이 되어 응답 시간이 크게 늘어납니다. 동시 요청이 100개면 커넥션 생성 비용도 100배가 됩니다. WAS와 DB 모두 이 비용을 감당하느라 정작 쿼리 처리에 집중할 여유가 없어집니다.
커넥션 풀이 이 문제를 해결하는 원리
데이터베이스 커넥션 풀을 사용하면 데이터베이스 요청이 들어올 때마다 데이터베이스 연결을 수립하고, 통신한 뒤, 닫는 과정을 거치지 않아도 됩니다. 데이터베이스 커넥션 풀에는 사전에 데이터베이스와 이미 연결이 수립된 다수의 커넥션들이 존재합니다. WAS는 데이터베이스 커넥션이 필요할 때 직접 커넥션을 생성하지 않고, 커넥션 풀 컨테이너로부터 커넥션을 하나 건네받고, 사용을 마치면 반납합니다. TAXLY
커넥션 풀의 동작 흐름을 정리하면 이렇습니다.
[애플리케이션 시작]
→ 미리 N개의 DB 커넥션 생성 (3-way handshake, 인증 포함)
→ 풀(Pool)에 보관
[요청 처리]
→ 스레드가 풀에서 커넥션 꺼냄 (빠름 — TCP 연결 재사용)
→ DB 쿼리 실행
→ 커넥션 풀에 반납 (닫지 않음 — 다음 요청을 위해 보관)
[커넥션 부족 시]
→ connectionTimeout 동안 대기
→ 시간 내 획득 실패 → SQLException 발생
이 구조가 바로 DBCP(Database Connection Pool)의 핵심입니다.
2. HikariCP가 Spring Boot의 기본이 된 이유
압도적인 성능 벤치마크
Java 생태계에는 Tomcat DBCP, Commons DBCP2, c3p0, BoneCP, HikariCP 등 여러 커넥션 풀 구현체가 있습니다. 그런데 Spring Boot 2.0부터 HikariCP를 기본 내장 커넥션 풀로 채택했습니다. 이유는 단순합니다.
HikariCP는 바이트코드 수준까지 극단적으로 최적화되어 있습니다. 또한 미세한 최적화와 Collection 프레임워크를 영리하게 사용한 덕분에 다른 커넥션 풀 구현체들과 비교해 압도적인 벤치마크 결과를 보입니다. TAXLY
HikariCP 내부 자료구조 — ConcurrentBag
HikariCP가 빠른 이유 중 하나는 내부적으로 ConcurrentBag이라는 특수 자료구조를 사용하기 때문입니다.
HikariCP는 ConcurrentBag이라는 자료구조를 사용해 커넥션을 관리합니다. HikariCP의 커넥션 획득 과정에서 모든 커넥션 획득 요청은 lock을 먼저 획득해야 합니다. 동시에 많은 요청이 들어올 경우 lock 획득을 위한 대기 시간이 발생합니다. Tossbank
ConcurrentBag의 핵심 구성요소는 세 가지입니다.
threadList는 스레드가 사용한 커넥션의 이력을 남기며 캐시와 비슷한 역할로 사용됩니다. 사용한 이력이 있으면 사용했던 커넥션을 빠르게 반환해 줍니다. sharedList는 실제로 커넥션을 가지고 있는 공간입니다. handOffQueue는 sharedList에 사용할 커넥션이 없어서 스레드가 커넥션을 기다릴 때 앞에서 대기하는 큐입니다. Infobank01
같은 스레드가 이전에 사용한 커넥션을 threadList에서 먼저 가져오는 지역성(locality) 최적화가 ConcurrentBag의 핵심 아이디어입니다.
3. HikariCP 핵심 설정 옵션 완전 해설
Spring Boot의 application.yml로 HikariCP를 설정하는 방법과 각 옵션의 의미를 완전히 정리합니다.
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC
username: user
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: MyHikariPool # 풀 이름 (로그 식별용)
maximum-pool-size: 10 # 최대 커넥션 수 ★
minimum-idle: 5 # 최소 유휴 커넥션 수
connection-timeout: 30000 # 커넥션 획득 대기 최대 시간 (ms)
idle-timeout: 600000 # 유휴 커넥션 유지 시간 (ms)
max-lifetime: 1800000 # 커넥션 최대 수명 (ms)
keepalive-time: 120000 # 커넥션 생존 확인 주기 (ms)
connection-test-query: SELECT 1 # JDBC4 미지원 드라이버 전용 헬스체크
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
각 옵션 상세 해설
① maximum-pool-size (가장 중요)
유휴 및 사용 중인 커넥션을 포함해서 풀에 보관할 수 있는 최대 커넥션 수입니다. 사용할 수 있는 커넥션이 없다면 connectionTimeout 시간만큼 대기합니다. 시간을 초과하면 SQLException이 발생합니다. Infobank01
② minimum-idle
minimum-idle 값은 풀에서 유지될 최소 유휴 커넥션 수입니다. WAS 시스템의 경우 일반적으로 컴퓨터 자원을 효율적으로 관리하기 위하여 커넥션 풀의 유휴 연결 수를 minimumIdle 값만큼 제한하게 됩니다. 응답 속도가 매우 중요한 시스템(실시간 게임, 금융)은 minimum-idle = maximum-pool-size로 설정해 항상 최대 커넥션을 유지하는 것이 좋습니다. Infomegawp
③ connection-timeout
connectionTimeout 옵션은 풀에서 커넥션을 가져올 때 이용 가능한 커넥션을 기다리는 최대 대기 시간을 지정하는 옵션입니다. 풀이 고갈되어서 이용 가능한 커넥션이 없을 때 다른 스레드에서 점유 중인 커넥션이 풀로 반환되어서 이용 가능해질 때까지 대기하는 시간을 의미합니다. Infomegawp
④ max-lifetime
커넥션이 maxLifetime만큼 지났을 때 사용 중인 커넥션은 바로 폐기되지 않고, 작업이 완료되면 폐기됩니다. 하지만 유휴 커넥션은 바로 폐기됩니다. DB 서버의 wait_timeout보다 몇 초 짧게 설정해야 DB 쪽에서 먼저 커넥션을 끊어버리는 상황을 방지할 수 있습니다. 예: MySQL wait_timeout이 28800초(8시간)라면 max-lifetime: 1800000(30분) 권장. Infobank01
⑤ idle-timeout
풀에 있는 커넥션 수가 minimumIdle에 도달했을 때, 이후에 반환되는 커넥션에 대해서 바로 반환되지 않고, idleTimeout만큼 유휴 상태로 있다가 폐기됩니다. Infobank01
⑥ connection-test-query
connectionTestQuery 옵션은 풀에서 커넥션을 가져올 때 HikariCP가 커넥션을 반환하기 전에 DB와의 물리적인 연결이 아직 살아있는지를 확인하는 헬스 체크 용도로 사용되는 쿼리를 지정하는 옵션입니다. JDBC4 드라이버를 지원한다면 이 옵션은 설정하지 않는 것을 권장합니다. 최신 MySQL Connector/J는 JDBC4를 지원하므로 대부분 생략해도 됩니다. Infomegawp
4. 커넥션 풀 크기(maximum-pool-size) 최적 계산 공식
“무조건 크게 잡으면 되지 않나?”의 함정
처음에는 maximum-pool-size: 100처럼 크게 잡으면 문제없을 것 같습니다. 하지만 커넥션도 결국 객체이고 메모리를 차지합니다. 커넥션 개수와 메모리는 trade-off 관계입니다. 사용자가 1000명일 경우 DBCP 크기를 1000개로 설정한 것은 커넥션을 과도하게 생성하여 낭비한 셈입니다. 100개의 커넥션도 많습니다. TAXLY
더 중요한 문제가 있습니다. DB 서버 입장에서 커넥션 수가 너무 많으면 컨텍스트 스위칭 오버헤드가 늘어나 오히려 처리 성능이 떨어집니다.
HikariCP 공식 권장 풀 크기 공식
HikariCP 공식문서에서는 아래와 같은 커넥션 풀 사이즈를 권장합니다. Vegan Life
connections = (core_count × 2) + effective_spindle_count
core_count는 CPU의 코어 수를 의미하고, effective_spindle_count는 데이터베이스 서버가 동시 관리할 수 있는 I/O 개수입니다. core_count × 2는 코어 수에 근접할수록 좋지만, 디스크 및 네트워크와 CPU의 속도 차이로 인한 여유시간을 활용하기 위해 계수 2를 곱해 줍니다. effective_spindle_count에서 하드디스크는 하나의 spindle을 가집니다. 디스크가 n개 존재하면 spindle_count는 n이 될 수 있습니다. 예를 들어 하드디스크가 있는 8-core CPU를 가진 서버에서는 DBCP 사이즈를 대략 (8 × 2) + 1 = 17로 설정해야 합니다. Vegan Life
실제 환경에서의 계산 예시
| DB 서버 환경 | 계산 | 권장 풀 크기 |
|---|---|---|
| 4 코어 SSD (spindle 없음) | (4 × 2) + 0 | 8 |
| 8 코어 HDD 1개 | (8 × 2) + 1 | 17 |
| 16 코어 SSD RAID (4개) | (16 × 2) + 4 | 36 |
WAS 인스턴스 수를 고려한 DB 총 커넥션 수
WAS 인스턴스가 여러 개라면 DB가 받는 총 커넥션 수도 함께 고려해야 합니다.
DB 전체 커넥션 수 = WAS 인스턴스 수 × maximum-pool-size
예: WAS 4대 × 풀 크기 10 = DB에 40개 커넥션
DB 서버의 max_connections 설정을 확인하고, 이를 초과하지 않도록 각 WAS 인스턴스의 maximum-pool-size를 조정해야 합니다.
Pool-locking 방지 공식
Pool-locking 현상이란 하나의 스레드에서 커넥션을 획득한 후 중첩해서 추가적인 커넥션을 획득하고자 할 때 풀이 고갈된 상태여서 커넥션을 획득하지 못하고 대기하는 현상을 말합니다. Infomegawp
이 현상을 방지하기 위한 최소 풀 크기 공식이 있습니다.
pool_size = Tn × (Cm - 1) + 1
Tn = 전체 스레드 수
Cm = 하나의 스레드에서 동시에 필요한 최대 커넥션 수
예를 들어 스레드 수가 10개이고, 하나의 트랜잭션에서 최대 2개의 커넥션이 필요하다면 최소 풀 크기 = 10 × (2 – 1) + 1 = 11이 됩니다.
5. 실전 함정 4가지 — 커넥션 고갈·Pool-locking·트랜잭션 남용
함정 ① @Transactional 범위 안에서 외부 API 호출
가장 흔하게 발생하는 커넥션 고갈 원인입니다.
@Transactional이 붙은 메서드는 DB 커넥션을 계속 점유한 상태입니다. 느린 API 호출 동안 DB는 아무 일도 하지 않는데도 커넥션이 점유됩니다. 이로 인해 Connection Pool이 고갈됩니다. Welloffmap
java
// ❌ 잘못된 패턴 — 외부 API 호출 동안 커넥션이 계속 점유됨
@Transactional
public void saveAndRequest() {
repository.save(data); // 커넥션 획득 후 DB 작업
aiClient.callExternalApi(); // 3~10초 소요 — 이 동안 커넥션 점유!
repository.update(result); // 이후 DB 작업
}
// ✅ 개선된 패턴 — 외부 API 호출을 트랜잭션 밖으로 분리
public void saveAndRequest() {
// 1단계: DB 작업만 트랜잭션으로 처리
doDbWork();
// 2단계: 외부 API 호출 (커넥션 없음)
String result = aiClient.callExternalApi();
// 3단계: 결과로 다시 DB 작업
doUpdateWork(result);
}
@Transactional
private void doDbWork() { repository.save(data); }
@Transactional
private void doUpdateWork(String result) { repository.update(result); }
함정 ② 초기 웜업 없이 트래픽 수용 — 초기 지연 폭발
HikariCP는 기본적으로 커넥션 풀을 비동기로 채웁니다. 이로 인해 애플리케이션이 시작되고 포트가 열린 후에도 커넥션 풀이 완전히 채워지지 않은 상태일 수 있습니다. 이때 요청이 들어오면 커넥션 생성으로 인한 지연이 발생할 수 있습니다. Tossbank
yaml
# 커넥션 풀이 완전히 채워질 때까지 포트를 열지 않음
spring:
datasource:
hikari:
initialization-fail-timeout: 8000 # 8초 내에 풀 초기화 실패 시 앱 종료
또는 JVM 옵션으로 제어할 수 있습니다.
-Dcom.zaxxer.hikari.blockUntilFilled=true
함정 ③ max-lifetime을 DB의 wait_timeout보다 길게 설정
DB 서버(MySQL 기본값 wait_timeout = 28800초 = 8시간)에서 오래된 커넥션을 먼저 끊어버리면, HikariCP 풀에는 이미 죽은 커넥션이 남아 있게 됩니다. 이 커넥션을 가져다 쓰면 Communications link failure 에러가 발생합니다.
yaml
# MySQL wait_timeout = 28800s 기준
hikari:
max-lifetime: 1800000 # 30분 = 1800초 (wait_timeout보다 훨씬 짧게)
keepalive-time: 120000 # 2분마다 커넥션 생존 확인
함정 ④ WAS 스레드 수와 커넥션 풀 크기의 불균형
Tomcat WAS의 스레드 수(기본값 200)와 커넥션 풀 크기(기본값 10)의 불균형이 있다면 어떻게 될까요? 200개의 스레드 중 10개만 동시에 DB를 사용할 수 있으므로 나머지 190개는 커넥션을 기다리며 대기합니다. 지연 시간이 쌓이면서 connection-timeout을 초과하고 에러가 발생합니다.
스레드 수와 커넥션 풀 크기는 함께 조율해야 합니다. 모든 요청이 DB를 사용하지는 않으므로 커넥션 풀 크기를 스레드 수의 50~70% 수준으로 시작해 부하 테스트로 적절한 값을 찾는 것을 권장합니다.
6. WAS-DB 최적화 완성 설정 예시와 모니터링 방법
프로덕션 권장 완성형 설정
yaml
# application-prod.yml
spring:
datasource:
url: jdbc:mysql://prod-db:3306/appdb
?useSSL=true
&serverTimezone=Asia/Seoul
&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
pool-name: ProdHikariPool
maximum-pool-size: 20 # 8코어 SSD DB 기준: (8×2)+4
minimum-idle: 20 # 항상 최대 유지 (고트래픽 환경)
connection-timeout: 5000 # 5초 (너무 길면 에러가 쌓임)
idle-timeout: 300000 # 5분
max-lifetime: 1800000 # 30분 (MySQL wait_timeout보다 짧게)
keepalive-time: 120000 # 2분
initialization-fail-timeout: 10000
data-source-properties:
cachePrepStmts: true # PreparedStatement 캐싱
prepStmtCacheSize: 250 # 캐시 크기
prepStmtCacheSqlLimit: 2048 # 캐시할 SQL 최대 길이
useServerPrepStmts: true # 서버 사이드 PreparedStatement
PreparedStatement 캐싱 옵션을 반드시 넣어야 하는 이유
PreparedStatement 캐시를 활성화하여 성능을 향상시킵니다. PreparedStatement 캐시의 최대 크기를 250으로 설정합니다. 같은 SQL을 반복 실행할 때 파싱 비용을 없애기 때문에 OLTP 환경에서 유의미한 성능 향상을 기대할 수 있습니다. TAXLY
HikariCP 상태 모니터링 — JMX와 Actuator 활용
HikariCP는 HikariPoolMXBean과 HikariConfigMXBean이라는 JMX(Java Management Extensions)를 통해 커넥션 풀의 상태 및 설정을 관리할 수 있습니다. Imfnsec
Spring Boot Actuator와 Micrometer를 함께 사용하면 커넥션 풀 지표를 Prometheus + Grafana로 실시간 모니터링할 수 있습니다.
yaml
# application.yml — Actuator 및 Hikari 메트릭 활성화
management:
endpoints:
web:
exposure:
include: health, metrics
metrics:
tags:
application: ${spring.application.name}
java
// 코드에서 직접 모니터링하는 방법
@Autowired
private DataSource dataSource;
public void logPoolStatus() {
HikariDataSource hikariDS = (HikariDataSource) dataSource;
HikariPoolMXBean poolMXBean = hikariDS.getHikariPoolMXBean();
log.info("Active connections: {}", poolMXBean.getActiveConnections());
log.info("Idle connections: {}", poolMXBean.getIdleConnections());
log.info("Total connections: {}", poolMXBean.getTotalConnections());
log.info("Threads awaiting: {}", poolMXBean.getThreadsAwaitingConnection());
}
지속적 튜닝 — 모니터링할 핵심 지표
정기적인 모니터링을 통해 TPS와 응답 시간 변화를 관찰하고, 필요에 따라 maximum-pool-size, connectionTimeout 등의 설정을 조정하여 최적의 성능을 유지할 수 있습니다. Syz-shops
| 지표 | 의미 | 액션 기준 |
|---|---|---|
hikaricp.connections.active | 현재 사용 중인 커넥션 | 최대값에 근접하면 증설 검토 |
hikaricp.connections.pending | 대기 중인 스레드 수 | 0 이상이면 풀 크기 점검 |
hikaricp.connections.acquire | 커넥션 획득 시간(ms) | 50ms 이상이면 병목 |
hikaricp.connections.creation | 커넥션 생성 횟수 | 자주 발생하면 minimum-idle 증가 |
결론
HikariCP 커넥션 풀 설정의 핵심을 세 가지로 요약하면 이렇습니다. 첫째, maximum-pool-size는 무조건 크게 잡는 것이 아니라 HikariCP 공식인 (core_count × 2) + effective_spindle_count를 기준으로 시작해 부하 테스트로 튜닝해야 합니다. 둘째, @Transactional 범위 안에 외부 API 호출이나 긴 작업을 포함시키지 않아야 하며, 트랜잭션은 최대한 짧게 유지해야 합니다. 셋째, max-lifetime은 DB 서버의 wait_timeout보다 짧게 설정하고, keepalive-time으로 커넥션 생존을 주기적으로 확인하며, Actuator + Micrometer로 지속적으로 모니터링해야 합니다. 한 번의 설정으로 영원히 최적인 커넥션 풀은 없습니다. 트래픽 패턴이 변하면 설정도 따라 변해야 합니다.
ℹ️ 참고 안내 본 글의 설정 예시는 Spring Boot 3.x + HikariCP 5.x + MySQL 8.x 기준으로 작성되었습니다. PostgreSQL, Oracle 등 다른 DB를 사용하는 경우 드라이버 및 일부 설정값이 다를 수 있습니다. 풀 크기 최적 값은 반드시 실제 환경에서 부하 테스트(JMeter, nGrinder, k6 등)를 통해 검증하시기 바랍니다.
답글 남기기