Java DB connection pool 부족 장애는 어떻게 시작됐나
이번 글의 주제는 Java 서비스에서 DB connection pool 부족으로 실제 장애가 발생했을 때 어떤 흐름으로 원인을 추적하고 해결했는지에 대한 이야기입니다. Spring Boot 기반의 Java 애플리케이션에서 MySQL을 사용하고 있었고, 커넥션 풀은 HikariCP를 사용하던 상황으로 보면 됩니다.
처음 증상은 단순했습니다. 일부 API 응답이 느려지기 시작했고, 곧이어 요청 일부가 실패했습니다. 애플리케이션 로그에는 DB 연결을 얻지 못했다는 메시지가 찍혔고, 장애 당시에는 API 서버 자체가 죽은 것은 아니었지만 정상적으로 요청을 처리하지 못하는 상태였습니다.
java.sql.SQLTransientConnectionException:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
이 로그만 보면 가장 먼저 떠오르는 해결책은 connection pool 크기를 늘리는 것입니다. 하지만 실무에서는 여기서 바로 커넥션 개수를 올리면 문제가 더 커질 수 있습니다. 애플리케이션 입장에서는 커넥션을 더 많이 열 수 있어도, DB가 감당할 수 있는 최대 연결 수와 쿼리 처리량은 따로 존재하기 때문입니다.
DB connection pool 부족 증상과 실제 영향도
DB connection pool 부족은 Java 애플리케이션에서 요청 처리 스레드가 DB 커넥션을 기다리다가 타임아웃되는 형태로 자주 드러납니다. 이때 애플리케이션 CPU나 메모리가 여유 있어 보여도 API 장애는 발생할 수 있습니다.
장애 당시 가장 먼저 확인한 지표는 API 에러율, 응답 시간, HikariCP active connection, idle connection, pending thread 수였습니다. 특히 pending thread가 증가한다는 것은 요청들이 DB 커넥션을 얻기 위해 대기하고 있다는 의미입니다.
hikaricp.connections.active = 30
hikaricp.connections.idle = 0
hikaricp.connections.pending = 48
hikaricp.connections.timeout = 증가
여기서 중요한 포인트는 active connection이 max pool size에 붙어 있고 idle connection이 0인 상태입니다. 커넥션 풀이 모두 사용 중이라는 뜻입니다. 여기에 pending 값까지 증가하면 새 요청은 DB 커넥션을 얻지 못하고 기다리게 됩니다.
이 상태가 길어지면 장애는 DB 요청이 필요한 API부터 시작됩니다. 로그인, 주문, 결제, 조회처럼 DB 접근이 필요한 API가 먼저 느려지고, 이후에는 WAS 요청 스레드까지 밀리면서 서비스 전체가 느려진 것처럼 보일 수 있습니다.
Java DB connection pool 문제를 추적한 순서
Java DB connection pool 장애를 볼 때는 애플리케이션 설정만 보면 부족합니다. 커넥션 풀이 가득 찬 이유가 설정값 때문인지, 느린 쿼리 때문인지, 트랜잭션 범위가 너무 긴 것인지, 커넥션 누수인지 구분해야 합니다.
1. HikariCP 설정 확인
먼저 현재 설정값을 확인했습니다. 운영 환경에서 max pool size가 얼마인지, connection timeout은 몇 초인지, idle timeout과 max lifetime은 어떻게 잡혀 있는지 보는 작업입니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
여기서 maximum-pool-size는 동시에 DB 커넥션을 사용할 수 있는 최대 개수입니다. 값이 너무 작으면 요청이 몰릴 때 대기가 생기지만, 너무 크게 잡으면 DB 서버의 max_connections나 쿼리 처리 능력을 압박할 수 있습니다.
2. DB에서 현재 연결 상태 확인
다음으로 MySQL 쪽에서 실제 커넥션 상태를 확인했습니다. 애플리케이션은 커넥션 풀이 부족하다고 말하지만, DB 입장에서는 어떤 세션들이 오래 살아 있는지 봐야 원인에 가까워집니다.
SHOW PROCESSLIST;
또는 information_schema를 사용해서 조금 더 정리해서 볼 수 있습니다.
SELECT
ID,
USER,
HOST,
DB,
COMMAND,
TIME,
STATE,
INFO
FROM information_schema.PROCESSLIST
WHERE DB = 'service_db'
ORDER BY TIME DESC;
이때 Sleep 상태의 커넥션이 많다고 해서 무조건 문제라고 단정하면 안 됩니다. 커넥션 풀을 쓰면 일정 수의 커넥션이 유휴 상태로 유지되는 것은 자연스러운 동작입니다. 봐야 할 것은 Query 상태로 오래 잡혀 있는 세션, 트랜잭션이 끝나지 않은 세션, 특정 쿼리에 몰려 있는 패턴입니다.
3. 느린 쿼리와 트랜잭션 범위 확인
이번 장애에서는 단순히 커넥션 수가 부족했던 것이 아니라, 특정 요청에서 DB 커넥션을 오래 점유하는 흐름이 있었습니다. 조회 API 내부에서 여러 테이블을 조인하고, 그 결과를 가공한 뒤 외부 호출까지 같은 흐름 안에서 처리하는 구조였습니다.
문제는 트랜잭션 범위였습니다. DB 작업이 필요한 구간보다 훨씬 넓게 트랜잭션이 잡혀 있었고, 그 사이에 애플리케이션 로직과 외부 연동까지 들어가 있었습니다. 이러면 쿼리 자체가 아주 느리지 않아도 커넥션 반환이 늦어질 수 있습니다.
@Transactional
public OrderResult processOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
PaymentResult paymentResult = paymentClient.approve(order);
order.complete(paymentResult);
return OrderResult.from(order);
}
위 코드는 보기에는 간단하지만, paymentClient.approve()가 외부 API 호출이라면 이야기가 달라집니다. 트랜잭션이 열린 상태에서 외부 API 응답을 기다리면 DB 커넥션을 그 시간만큼 붙잡을 가능성이 있습니다.
이 부분은 개발할 때 놓치기 쉽습니다. 메서드 하나에 @Transactional을 붙이면 코드가 깔끔해 보이지만, 실제로는 DB 커넥션 점유 시간이 길어질 수 있습니다. 트랜잭션은 데이터 정합성을 지키기 위한 도구이지, 서비스 로직 전체를 감싸는 기본값으로 보면 곤란합니다.
DB connection pool 부족 원인은 하나가 아니었다
DB connection pool 부족 장애를 분석해 보면 원인이 하나로 끝나는 경우가 많지 않습니다. 이번에도 커넥션 풀 크기, 트랜잭션 범위, 일부 느린 쿼리, 요청 증가가 겹쳐서 문제가 발생했습니다.
원인 1. 트랜잭션 범위가 너무 넓었다
가장 큰 문제는 트랜잭션 범위였습니다. DB 조회와 저장만 트랜잭션 안에 있어도 되는 흐름인데, 외부 API 호출과 결과 가공까지 같은 트랜잭션에 포함되어 있었습니다.
이 구조에서는 외부 API가 잠깐 느려져도 DB 커넥션 반환이 늦어집니다. 요청 수가 적을 때는 문제가 잘 드러나지 않지만, 동시에 들어오는 요청이 늘어나면 커넥션 풀이 빠르게 고갈됩니다.
원인 2. 일부 조회 쿼리가 오래 걸렸다
두 번째 원인은 일부 조회 쿼리였습니다. 인덱스를 제대로 타지 못하는 조건이 있었고, 특정 파라미터 조합에서 실행 시간이 길어졌습니다. 모든 요청이 느린 것은 아니었기 때문에 처음에는 찾기가 쉽지 않았습니다.
EXPLAIN
SELECT *
FROM orders
WHERE user_id = 10001
AND status = 'PAID'
AND created_at BETWEEN '2026-05-01 00:00:00' AND '2026-05-15 23:59:59'
ORDER BY created_at DESC;
이런 쿼리는 평균 실행 시간만 보면 놓칠 수 있습니다. 장애 상황에서는 평균보다 상위 지연 구간을 봐야 합니다. 일부 쿼리가 커넥션을 오래 잡고 있으면 전체 커넥션 풀이 밀릴 수 있기 때문입니다.
원인 3. 커넥션 풀 설정이 서비스 특성과 맞지 않았다
세 번째는 설정값이었습니다. maximum-pool-size가 무조건 작았다고 보기는 어려웠지만, 요청 특성과 트랜잭션 점유 시간을 고려하면 여유가 크지 않았습니다.
다만 여기서 바로 값을 크게 올리지는 않았습니다. DB max_connections, 다른 애플리케이션 인스턴스 수, 배치 작업 여부, 운영 DB 여유량을 같이 봐야 했습니다. 애플리케이션 인스턴스가 4대이고 각 인스턴스의 pool size가 30이면, 이 서비스 하나만으로 최대 120개의 DB 커넥션을 사용할 수 있습니다.
전체 최대 커넥션 수 = 애플리케이션 인스턴스 수 × maximum-pool-size
예:
4대 × 30 = 최대 120개
이 계산을 하지 않고 서버 한 대의 설정만 보고 pool size를 늘리면 DB 전체 연결 수가 예상보다 빠르게 증가합니다. 팀에서 운영할 때는 이 값을 배포 단위와 함께 관리하는 편이 안전합니다.
대안 비교: 커넥션 풀을 늘릴 것인가, 점유 시간을 줄일 것인가
DB connection pool 부족을 해결할 때는 크게 두 방향이 있습니다. 하나는 커넥션 풀 크기를 늘리는 것이고, 다른 하나는 커넥션을 잡고 있는 시간을 줄이는 것입니다. 둘 중 하나만 정답이라기보다, 장애 원인에 따라 우선순위가 달라집니다.
| 대안 | 장점 | 주의할 점 |
|---|---|---|
| maximum-pool-size 증가 | 일시적인 대기 요청을 줄일 수 있음 | DB 연결 수와 부하가 증가할 수 있음 |
| 트랜잭션 범위 축소 | 커넥션 점유 시간이 줄어듦 | 정합성 기준을 다시 점검해야 함 |
| 느린 쿼리 개선 | DB 처리 시간 자체를 줄일 수 있음 | 인덱스 추가 시 쓰기 성능과 저장 공간도 고려해야 함 |
| API 흐름 분리 | 긴 작업과 DB 작업을 분리할 수 있음 | 상태 관리와 재처리 설계가 필요함 |
이번 상황에서는 커넥션 풀 크기만 늘리는 방식은 보조 수단으로 봤습니다. 근본적으로는 커넥션을 오래 잡고 있는 코드 흐름을 줄이는 것이 먼저였습니다.
DB connection pool 부족 해결 방법
Java DB connection pool 장애를 해결하기 위해 적용한 방법은 크게 세 가지였습니다. 트랜잭션 범위 축소, 쿼리 개선, 그리고 커넥션 풀 설정 조정입니다.
1. 외부 호출을 트랜잭션 밖으로 분리
먼저 외부 API 호출이 트랜잭션 안에 들어가지 않도록 구조를 변경했습니다. DB에서 필요한 데이터를 조회한 뒤 트랜잭션을 종료하고, 외부 호출 이후 다시 필요한 상태 변경만 짧은 트랜잭션으로 처리하는 방식입니다.
public OrderResult processOrder(Long orderId) {
OrderSnapshot snapshot = orderQueryService.getOrderSnapshot(orderId);
PaymentResult paymentResult = paymentClient.approve(snapshot);
return orderCommandService.completeOrder(orderId, paymentResult);
}
@Transactional(readOnly = true)
public OrderSnapshot getOrderSnapshot(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
return OrderSnapshot.from(order);
}
@Transactional
public OrderResult completeOrder(Long orderId, PaymentResult paymentResult) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete(paymentResult);
return OrderResult.from(order);
}
이렇게 변경하면 외부 API를 기다리는 동안 DB 커넥션을 점유하지 않습니다. 코드가 조금 나뉘긴 하지만, 트랜잭션의 의도가 더 명확해집니다. 협업할 때도 어느 구간이 DB 정합성을 책임지는지 구분하기 쉬워집니다.
2. 느린 조회 쿼리 인덱스 개선
다음으로 실행 계획을 확인해 인덱스를 조정했습니다. 자주 사용하는 조회 조건과 정렬 조건을 기준으로 복합 인덱스를 검토했습니다.
CREATE INDEX idx_orders_user_status_created_at
ON orders (user_id, status, created_at);
인덱스는 조회 성능만 보고 추가하면 나중에 부담이 됩니다. 쓰기 작업이 많은 테이블이라면 INSERT, UPDATE 비용도 함께 봐야 합니다. 그래서 장애를 막기 위해 필요한 인덱스인지, 특정 API에만 유효한 임시 대응인지 구분해서 결정하는 편이 좋습니다.
3. HikariCP 설정을 서비스 기준에 맞게 조정
코드와 쿼리를 먼저 정리한 뒤 HikariCP 설정도 조정했습니다. maximum-pool-size는 DB 전체 연결 수와 애플리케이션 인스턴스 수를 함께 계산해 변경했습니다.
spring:
datasource:
hikari:
maximum-pool-size: 40
minimum-idle: 10
connection-timeout: 5000
validation-timeout: 3000
max-lifetime: 1700000
connection-timeout을 너무 길게 두면 장애 감지가 늦어질 수 있습니다. 반대로 너무 짧게 잡으면 잠깐의 부하에도 요청이 쉽게 실패할 수 있습니다. 이 값은 서비스 특성과 재시도 정책을 같이 보고 정해야 합니다.
max-lifetime은 DB나 네트워크 장비의 커넥션 종료 정책보다 짧게 잡는 편이 안전합니다. 서버나 중간 장비가 먼저 커넥션을 끊어버리면 애플리케이션 입장에서는 예기치 않은 연결 오류를 만날 수 있습니다.
적용 후 확인한 지표
DB connection pool 문제를 수정한 뒤에는 단순히 에러가 사라졌는지만 보지 않았습니다. active connection이 계속 max에 붙어 있는지, pending thread가 다시 증가하는지, connection timeout이 발생하는지 확인했습니다.
확인한 지표
- hikaricp.connections.active
- hikaricp.connections.idle
- hikaricp.connections.pending
- hikaricp.connections.timeout
- API 응답 시간
- DB slow query
- MySQL Threads_connected
- MySQL Threads_running
수정 후에는 active connection이 순간적으로 증가하더라도 계속 최대치에 붙어 있지 않았고, pending thread도 안정적으로 내려갔습니다. 특히 외부 API 호출 구간을 트랜잭션 밖으로 분리한 효과가 컸습니다.
장애 대응에서 중요한 것은 조치 후 지표가 어떤 방향으로 바뀌었는지 확인하는 일입니다. 에러 로그가 줄었다는 것만으로는 부족합니다. 커넥션 풀이 여전히 한계에 가까운 상태라면 다음 부하 상황에서 같은 문제가 반복될 수 있습니다.
자주 하는 실수: pool size만 늘리는 대응
Java DB connection pool 부족을 만나면 maximum-pool-size를 늘리는 대응이 가장 빠르게 떠오릅니다. 실제로 단기적으로는 효과가 있을 수 있습니다. 하지만 이것만으로 장애 원인을 해결했다고 보기는 어렵습니다.
커넥션 풀이 부족하다는 것은 커넥션을 기다리는 요청이 많다는 뜻입니다. 그 이유는 요청량이 많아서일 수도 있지만, 커넥션을 너무 오래 잡고 있기 때문일 수도 있습니다. 후자의 경우 pool size를 늘리면 장애가 잠시 늦게 올 뿐, DB 부하가 커지면서 다른 형태로 문제가 나타날 수 있습니다.
그래서 저는 다음 순서로 확인하는 편입니다.
1. 커넥션 풀이 실제로 고갈됐는가?
2. 어떤 요청이 커넥션을 오래 잡고 있는가?
3. 느린 쿼리나 락 대기가 있는가?
4. 트랜잭션 범위가 불필요하게 넓지 않은가?
5. 인스턴스 수를 고려한 전체 DB 연결 수는 적절한가?
6. 그 다음 pool size 조정이 필요한가?
이 순서를 지키면 불필요하게 DB 커넥션을 늘리는 결정을 줄일 수 있습니다. 운영 환경에서는 빠른 복구도 중요하지만, 같은 장애가 반복되지 않도록 원인을 분리해 보는 과정이 더 중요합니다.
장애를 겪고 남긴 운영 기준
이번 DB connection pool 장애 이후에는 몇 가지 기준을 팀 규칙으로 남겼습니다. 코드 리뷰와 운영 지표 양쪽에서 확인할 수 있는 기준이어야 실제로 유지됩니다.
@Transactional은 필요한 범위에만 사용한다
@Transactional은 편리하지만, 메서드 전체에 습관적으로 붙이면 커넥션 점유 시간이 길어질 수 있습니다. 특히 외부 API 호출, 파일 처리, 긴 계산 로직이 트랜잭션 안에 들어가지 않도록 주의해야 합니다.
커넥션 풀 설정은 인스턴스 수와 함께 본다
maximum-pool-size는 서버 한 대 기준 설정처럼 보이지만, 실제 운영에서는 전체 인스턴스 수만큼 곱해집니다. 오토스케일링이 있는 환경이라면 최대 인스턴스 수 기준으로 DB 연결 수를 계산해야 합니다.
pending connection 지표를 알림에 포함한다
API 에러가 발생한 뒤에 보는 것은 늦습니다. pending connection이 증가하는 시점은 장애의 전조로 볼 수 있습니다. 이 지표를 알림에 포함하면 커넥션 풀이 완전히 고갈되기 전에 대응할 여지가 생깁니다.
마무리: DB connection pool 장애는 설정 문제가 아니라 흐름 문제다
Java에서 DB connection pool 부족이 발생하면 설정값부터 보게 됩니다. 하지만 실제 원인은 코드 흐름, 트랜잭션 범위, 쿼리 실행 시간, DB 연결 한계가 함께 얽혀 있는 경우가 많습니다.
이번 경험에서 가장 크게 남은 것은 커넥션 풀은 자원을 아끼기 위한 장치이지, 무한히 요청을 받아내는 버퍼가 아니라는 점입니다. 커넥션을 많이 만드는 것보다 커넥션을 짧게 쓰고 빨리 반환하는 구조가 더 중요합니다.
정리하면 다음과 같습니다.
- DB connection pool 부족은 커넥션 개수만의 문제가 아니다.
- 트랜잭션 범위가 넓으면 커넥션 반환이 늦어진다.
- 외부 API 호출은 가능하면 트랜잭션 밖으로 분리한다.
- 느린 쿼리는 커넥션 풀 전체를 밀리게 만들 수 있다.
- maximum-pool-size는 DB 전체 연결 수 기준으로 계산한다.
- active, idle, pending, timeout 지표를 함께 봐야 한다.
장애 대응은 결국 원인을 분리하는 작업입니다. DB connection pool 부족이라는 메시지를 봤을 때 바로 pool size부터 늘리기보다, 어떤 코드가 커넥션을 오래 잡고 있었는지부터 확인하는 습관이 필요합니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] Hibernate flush 타이밍 때문에 발생한 문제와 실무에서 확인하는 방법 (0) | 2026.05.23 |
|---|---|
| [JAVA] Lock wait timeout exceeded 에러가 발생하는 원인과 해결 방법 (0) | 2026.05.22 |
| [JAVA] JPA N+1 문제 발견하고 해결한 과정: 원인 분석부터 Fetch Join 적용까지 (0) | 2026.05.20 |
| [JAVA] Java Deadlock 발생했을 때 처리 전략: 원인 분석부터 재발 방지까지 (0) | 2026.05.19 |
| [JAVA] Java에서 Too many connections 발생했을 때 대응 방법 (0) | 2026.05.18 |
