안녕하세요, 자바파커입니다.
서버 CPU는 20%, 메모리도 여유롭고, DB 부하도 평소와 같습니다. 그런데 응답이 안 옵니다. 그리고 잠시 뒤 로그에 이게 찍힙니다.
Connection is not available, request timed out after 30000ms
결론부터 말씀드리면 — 커넥션 풀 고갈은 DB가 느려서 생기는 문제가 아닙니다. 대부분은 애플리케이션이 커넥션을 필요 이상으로 오래 붙잡고 있어서 생깁니다. 그래서 DB 지표를 아무리 봐도 원인이 안 보입니다.
커넥션 풀은 왜 있나
DB 연결 하나를 새로 만드는 데는 생각보다 큰 비용이 듭니다.
| 단계 | 하는 일 |
|---|---|
| TCP 핸드셰이크 | 3-way handshake |
| 인증 | 계정·비밀번호 검증 |
| 세션 초기화 | 문자셋, 타임존, 격리 수준 설정 |
합치면 수십 ms입니다. 요청 하나가 10ms 만에 끝나는 API라면 연결 비용이 본 작업보다 큽니다.
그래서 미리 연결을 몇 개 만들어 두고 돌려 씁니다. 이게 커넥션 풀입니다. Java 진영에서는 HikariCP가 사실상 표준이고, Spring Boot 기본값도 이것입니다.
여기서 중요한 성질이 하나 나옵니다.
풀 크기 = 동시에 DB 작업을 할 수 있는 최대 개수
maximumPoolSize가 10이면, 11번째 요청은 무조건 기다립니다. 서버 스레드가 200개든 500개든 상관없습니다.
고갈은 이렇게 일어난다
풀이 10개인 서버에서 순서대로 벌어지는 일입니다.
| 상태 | 활성 | 대기 | 겉으로 보이는 증상 |
|---|---|---|---|
| 평상시 | 3 / 10 | 0 | 정상. 응답 40ms |
| 트래픽 증가 | 10 / 10 | 0 | 아직 정상. 응답 조금 느려짐 |
| 대기 발생 | 10 / 10 | 12 | 응답이 갑자기 초 단위로 뜀 |
| 고갈 | 10 / 10 | 80+ | 타임아웃 예외, 사실상 장애 |
무서운 건 세 번째에서 네 번째로 넘어가는 속도입니다. 대기가 생기기 시작하면 응답이 느려지고, 느려지니까 커넥션을 더 오래 잡고, 그래서 대기가 더 쌓입니다. 되먹임이 걸려서 몇 초 만에 무너집니다.
그리고 이때 DB는 한가합니다. 실제로 일하는 커넥션은 10개뿐이니까요. 그래서 DB 모니터링만 보면 "아무 문제 없음"으로 나옵니다.
가장 흔한 원인 — 트랜잭션 안에서 외부 호출
실무에서 압도적 1위입니다. 이 코드입니다.
@Transactional
public void order(Long orderId) {
Order order = orderRepository.find(orderId); // 커넥션 획득
paymentClient.approve(order); // 외부 결제 API — 3초
order.setPaid(true); // 커넥션 반납
}@Transactional이 붙는 순간 메서드 시작에서 커넥션을 잡고 끝날 때까지 놓지 않습니다. 가운데 있는 외부 API 호출 3초 동안, 이 커넥션은 아무 일도 안 하면서 점유돼 있습니다.
풀이 10개일 때 이 API가 초당 4번만 들어와도 계산이 이렇게 됩니다.
4 req/s × 3초 점유 = 항상 12개가 필요
→ 풀 10개로는 부족 → 대기 → 고갈초당 4건짜리 트래픽에 서버가 죽습니다. 결제사가 느려지기라도 하면 더 빨리 무너지고요.
고치는 방법 — 트랜잭션을 쪼갠다
public void order(Long orderId) {
Order order = txTemplate.execute(s -> orderRepository.find(orderId)); // 20ms
paymentClient.approve(order); // 커넥션 없이 3초
txTemplate.execute(s -> orderRepository.markPaid(orderId)); // 20ms
}점유 시간이 3초에서 40ms로 줄었습니다. 같은 풀 10개로 훨씬 많은 요청을 처리합니다.
원칙은 한 줄입니다.
트랜잭션 안에서는 DB 작업만 한다. 외부 호출·파일 IO·무거운 계산은 밖으로 뺀다.
나머지 원인 4가지
1. 커넥션 누수
가져간 커넥션을 반납하지 않는 경우입니다. try-with-resources를 안 쓴 JDBC 코드나, 트랜잭션 밖에서 직접 커넥션을 여는 코드에서 나옵니다.
누수는 재시작 후 서서히 늘어나다가 며칠 뒤 터지는 패턴이라 재현이 어렵습니다. HikariCP에 이 설정을 켜 두세요.
spring:
datasource:
hikari:
leak-detection-threshold: 20000 # 20초 이상 점유되면 스택트레이스 로그누수 지점을 스택트레이스로 정확히 찍어 줍니다.
2. 느린 쿼리
쿼리 하나가 2초 걸리면 그 커넥션은 2초 동안 묶입니다. 원인의 대부분은 인덱스입니다 — 인덱스가 왜 빨라지는지와 걸어도 안 타는 경우는 DB 인덱스는 왜 빨라지나에 정리해 뒀습니다.
3. N+1 쿼리
목록 100건을 가져오면서 각 항목마다 추가 쿼리를 날리는 경우입니다. 요청 하나가 커넥션을 잡은 채 쿼리 101번을 실행합니다. 점유 시간이 100배로 늘어납니다.
4. 풀 크기를 너무 작게 — 또는 너무 크게 잡음
작게 잡으면 당연히 부족합니다. 그런데 크게 잡아도 문제가 됩니다.
풀을 100으로 올리면 DB에 동시 쿼리 100개가 몰립니다. DB의 CPU 코어는 그대로인데 컨텍스트 스위칭과 락 경합만 늘어서 전체가 더 느려집니다. 풀을 키웠는데 응답이 더 나빠졌다면 이겁니다.
HikariCP 문서가 제시하는 출발점은 이렇습니다.
풀 크기 = (CPU 코어 수 × 2) + 디스크 수4코어 서버면 10 언저리입니다. 생각보다 훨씬 작습니다. 그리고 이 값은 애플리케이션 서버 대수만큼 곱해져서 DB에 도달한다는 점을 잊으면 안 됩니다. 서버 5대 × 풀 20이면 DB는 커넥션 100개를 감당해야 합니다.
증상으로 원인 찾기
장애 상황에서 순서대로 보시면 됩니다.
| 증상 | 의심할 것 | 확인 방법 |
|---|---|---|
| 활성이 항상 최대, DB는 한가함 | 트랜잭션 안 외부 호출 | 느린 API 구간의 @Transactional 범위 |
| 재시작 후 서서히 증가 | 커넥션 누수 | leak-detection-threshold 로그 |
| 특정 API에서만 발생 | 느린 쿼리 · N+1 | 해당 쿼리 EXPLAIN, 쿼리 실행 횟수 |
| 풀을 키웠는데 더 느려짐 | 풀 과대 설정 | DB 쪽 CPU·락 대기 지표 |
| 배포 직후부터 발생 | 신규 코드의 트랜잭션 범위 | 직전 커밋의 @Transactional 변경 |
가장 먼저 볼 지표는 활성 커넥션 수와 대기 스레드 수입니다. Spring Boot Actuator를 쓰신다면 바로 나옵니다.
hikaricp.connections.active # 사용 중
hikaricp.connections.pending # 대기 중 ← 이게 0보다 크면 이미 위험pending이 0이 아닌 순간이 있다면, 아직 장애가 아니어도 이미 한계에 닿아 있다는 뜻입니다.
타임아웃은 반드시 걸어 두세요
고갈이 무서운 건 한 서비스의 지연이 전체로 번지기 때문입니다. 결제사가 느려지면 커넥션이 묶이고, 그 서버가 죽으면 앞단이 재시도하고, 재시도가 부하를 더 키웁니다.
그래서 두 가지를 같이 잡아야 합니다.
spring:
datasource:
hikari:
connection-timeout: 3000 # 커넥션 못 받으면 3초 만에 실패
max-lifetime: 1800000 # 30분마다 커넥션 교체connection-timeout을 기본값 30초로 두면, 요청 스레드가 30초씩 붙잡혀서 서버 스레드까지 같이 고갈됩니다. 빨리 실패하는 편이 낫습니다.
그리고 앞단에서 재시도 폭주를 끊어 주는 장치가 필요합니다. 서킷 브레이커와 레이트 리밋은 API 게이트웨이가 하는 일에서 다뤘습니다.
한계를 미리 알고 싶으시면 부하 테스트로 재현해 보는 게 가장 확실합니다 — JMeter로 부하 테스트하기를 참고하세요.
자주 묻는 것
Q. 풀 크기를 늘리면 해결되지 않나요?
일시적으로는 버팁니다. 하지만 원인이 "커넥션을 오래 잡는 코드"라면 트래픽이 조금만 더 늘면 똑같이 터집니다. 그리고 앞서 말씀드린 대로 과하게 키우면 DB 쪽이 먼저 무너집니다. 점유 시간을 줄이는 게 먼저이고, 크기 조정은 그다음입니다.
Q. 스레드 풀을 키우면 더 많이 처리되나요?
아니요. 오히려 나빠집니다. 스레드가 늘어봐야 커넥션을 못 받아서 대기만 길어지고, 대기 중인 스레드가 메모리를 잡습니다. 병목은 커넥션 풀이지 스레드가 아닙니다.
Q. 읽기 전용 쿼리도 커넥션을 잡나요?
잡습니다. 다만 @Transactional(readOnly = true)를 붙이면 플러시 모드가 꺼져서 오버헤드가 줄고, 읽기 전용 복제본(Read Replica)으로 라우팅할 수 있는 길이 열립니다. 조회가 많은 서비스라면 읽기와 쓰기의 풀을 분리하는 게 효과적입니다.
정리
- 커넥션 풀 크기는 동시 DB 작업의 상한입니다
- 고갈은 대부분 DB가 아니라 애플리케이션이 커넥션을 오래 붙잡아서 생깁니다
- 1순위 원인은 트랜잭션 안의 외부 호출 — 트랜잭션을 쪼개면 점유 시간이 수십 배 줄어듭니다
- 풀은 크게 잡는다고 좋아지지 않습니다.
(코어 수 × 2) + 디스크 수에서 출발하세요 hikaricp.connections.pending이 0이 아니면 이미 한계입니다
혹시 커넥션 풀 고갈로 장애를 겪어 보셨다면, 원인이 무엇이었나요? 댓글로 남겨 주시면 사례를 모아 다음 글에 정리해 보겠습니다.