문제 상황
HikariPool-1 - Connection is not available, request timed out after 30000ms (total=10, active=10, idle=0, waiting=0)
→ 현재 HikariCP 커넥션 풀에 여유 있는 커넥션이 없어서 DB 연결을 못 하고 30초가 지나 타임아웃이 발생
HikariCP 커넥션 풀 발생시 보통 예상되는 원인 분석
- 최대 커넥션 수 초과 (maximumPoolSize)
- active=10, idle=0 → 총 10개 커넥션이 모두 사용 중이고, 대기 중인 커넥션이 없음.
- waiting=0 → 요청 대기열도 없는 상태에서 바로 실패.
- 트랜잭션이 반환되지 않고 커넥션이 회수되지 않는 경우
- 커넥션 누수가 발생했거나, 비효율적인 쿼리나 무한 루프/지연 등으로 커넥션이 오랜 시간 잡혀 있음.
- DB에 슬로우 쿼리 존재.
- 비동기 작업에서 커넥션을 획득하려고 하다가 실패
- 특히 @Async, 스케줄러 등에서 JPA를 직접 사용할 때 발생 가능.
@Async 로직에서 Dead lock 발생 가능성을 체크해
이 문제는 @Async를 포함하고 있는 SSE 로직을 추가한 뒤로 발생하기 시작했으니 그 로직을 검토한다.
logging.level.com.zaxxer.hikari=DEBUG
우선 위와 같이 hikari 디버깅 설정
Test 1
👉 maximum-pool-size를 1로 설정
- SSE 연결 sse/subscribe → 성공
- SSE 연결한 상태에서 “시그널 보내기 API” 요청 → 30초 후 실패
HikariPool-1 - Connection is not available, request timed out after 30005ms (total=1, active=1, idle=0, waiting=0)
HikariPool-1 - Fill pool skipped, pool has sufficient level or currently being filled.
- 현재 maximum-pool-size를 1로 설정한 상태에서 위와 같이 에러가 발생한다는 것은 “시그널 보내기 API”가 한 트렌젝션 내에서 2개의 커넥션 수를 가지고 있다는 말이다. 하지만 요청이 롤백 됐음에도 불구하고 active=1 가 남아 있는 이상한 현상이 발생했다.
⭐️ 원래 정상적인 흐름이라면 HikariCP에서 Sub Transaction에서 Dead lock이 발생할 경우에 30초 경과하고 connection.close() 실행하고 커넥션을 connection pool에 반납하고, SQLTransientConnectionException으로 인해 Sub Transaction이 Rollback 된다.
그래서 Sub Transaction의 Rollback으로 인해 Root Transaction이 rollbackOnly = true가 되며 Root Transaction이 롤백 된다.
Rollback 됨과 동시에 Root Transaction용 Connection은 다시 Pool에 반납해야하는데 그 과정이 없고 계속 반납하지 않고 있다.
요약: 서브 트랜잭션 실패 후, Root 트랜잭션이 rollbackOnly 가 되었지만 여전히 커넥션을 점유하고 있는 상태이다. 즉, PoolStats에서 계속 active=1 상태가 유지되고 있음 → 커넥션이 반납되지 않음.
🤔 하지만 의문점이 하나가 생겼다.
이 문제점을 본 서버가 아닌 테스트 서버에서 프론트엔드 개발자 혼자서 SSE 연동하다가 발생했다.
“시그널 보내기 API” 요청 Transaction에서 필요한 커넥션수가 2인데 기본값인 maximum-pool-size: 10로 설정해놔서 “시그널 보내기 API”를 동시에 10명에서 요청해야 위에서 언급된 원인으로 Dead lock이 발생하겠지만 현재 상황에서는 그렇지 않고 프론트엔드 개발자가 테스트 서버에서 “시그널 보내기 API” 요청을 순차적으로 1개씩 요청해서 다른 원인을 찾아봐야 한다.
Test 2
👉 maximum-pool-size를 1로 설정
- “시그널 보내기 API” 실행하기 전에 SSE 연결 API sse/subscribe만 요청하고 로그를 확인했더니
HikariPool-1 - Pool stats (total=1, active=1, idle=0, waiting=0)
HikariPool-1 - Fill pool skipped, pool has sufficient level or currently being filled.
헐.. 이게 뭐야 왜 active가 1이지?
👉 maximum-pool-size를 10로 설정
- SSE 연결 API sse/subscribe 요청하여 SSE 연결 맺기: 2번
HikariPool-1 - Pool stats (total=10, active=2, idle=1, waiting=0)
- SSE 연결 API sse/subscribe 요청하여 SSE 연결 맺기: 3번
HikariPool-1 - Pool stats (total=10, active=3, idle=0, waiting=0)
⭐️ sse/subscribe로 연결을 맺는 로직에서 DB 커넥션을 사용하고 반납하지 않는 부분이 있는 것으로 추정
SSE 로직을 추가한 뒤로 추가된 @Async 메서드가 아닌 sse 연결을 맺는 로직 자체가 원인이었다.
Test1에서 추측했던 Root 트랜잭션의 커넥션 미반납이 결국에 Root 트랜잭션이 커넥션을 반납하지 않았던 게 아니라 sse/subscribe의 커넥션이 계속 반납되고 있지 않았다.
애초에 “시그널 보내기 API” 로직에서 필요한 커넥션 수가 2개가 아니라 1개였다는 말이다. 즉 Sub Transaction이 없고 Root Transaction만 존재했다는 구조.
커넥션을 사용하고 반납하지 않는 원인 찾기
- subscribe 메서드
@Transactional
public SseEmitter subscribe(Long userId) {
boolean exists = userRepository.existsById(userId); 👈👈👈
if (!exists) throw new UserNotFoundException();
if (emitters.containsKey(userId)) {
emitters.get(userId).complete();
emitters.remove(userId);
}
SseEmitter emitter = new SseEmitter(TIMEOUT);
emitters.put(userId, emitter);
emitter.onCompletion(() -> {
log.info("SSE 연결 종료: userId={}", userId);
emitters.remove(userId);
});
emitter.onTimeout(() -> {
log.info("SSE 타임아웃 발생: userId={}", userId);
emitter.complete();
emitters.remove(userId);
});
sendToClient(userId, SseEventName.PING.getValue(), "connect success");
log.warn("connect success: userId={}", userId);
return emitter;
}
sse/subscribe API 요청 시 실행되는 subscribe 메서드 내에서 DB에 쿼리 날리고 있는 부분이 다음과 같다
boolean exists = userRepository.existsById(userId); 👈👈👈
if (!exists) throw new UserNotFoundException();
select count(*) from user where id=?
(이 코드 부분을 없애고 실행해본 결과: 문제가 발생하지 않았음)
👉 이 과정에서 쿼리를 날리기 위해 커넥션을 사용하고 반납을 하지 않는 문제가 발생하는 것으로 명확해졌다.
왜 커넥션을 사용하고 반납을 하지 않는가?
subscribe Service 메서드가 비즈니스 로직의 다른 Service 메서드들이랑 다른 점은 subscribe 메서드는 SseEmitter를 반환하는 메서드이다. 즉, SSE 연결을 맺기 위해 사용되는 메서드.
그럼 왜 이 메서드 내에서 커넥션을 반환하지 않을까?
간략하게 @Transactional과 SseEmitter의 동작방식을 알아보면
- @Transactional의 동작 방식
- 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
- Service에서 @Transactional을 사용할 경우, 해당 코드 내의 method를 호출할 때 영속성 컨텍스트가 생긴다는 뜻이다.
- 영속성 컨텍스트는 프록시가 트랜잭션을 시작할 때 생겨나고, 메서드가 종료되어 프록시가 트랜잭션을 커밋할 경우 영속성 컨텍스트가 flush되면서 해당 내용이 반영된다. 이후 영속성 컨텍스트 역시 종료된다.
- 영속성 컨텍스트는 사용자의 요청 시점에서 생성되지만, 데이터를 쓰거나 수정할 수 있는 트랜잭션은 비즈니스 계층에서만 사용할 수 있도록 트랜잭션이 일어난다.
- SseEmitter의 동작 특성
- SseEmitter는 클라이언트와의 연결을 지속적으로 유지하는 비동기 스트리밍 구조이다.
- subscribe() 메서드가 리턴되더라도, 응답은 완료되지 않고 서버와 클라이언트의 연결이 열린 채 유지된다.
⭐️ 이 두 가지가 충돌하면?
@Transactional에 의해 커넥션이 확보되었는데, 메서드가 끝나도 SseEmitter 연결이 열려 있으므로 트랜잭션이 종료되지 않고 커넥션이 반환되지 않음.
구체적으로 설명하자면
SSE 통신을 하는 동안은 HTTP Connection이 계속 열려있다. 만약 SSE 연결 응답 API에서 JPA를 사용하고 open-in-view 속성을 true로 설정했다면, HTTP Connection이 열려있는 동안 DB Connection도 같이 열려있게 된다.
즉 DB Connection Pool에서 최대 10개의 Connection을 사용할 수 있다면, 10명의 클라이언트가 SSE 연결 요청을 하는 순간 DB 커넥션도 고갈되게 된다.
그래서 이 경우 open-in-view 설정을 반드시 false로 설정해야 한다! 하지만 open-in-view 설정을 false로 했을 때 발생할 수 있는 문제점이 있을까 먼저 알아보면
Open-Session-In-View = false
- OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어두는 기능이다.
- 영속성 컨텍스트가 유지되면 엔티티도 영속 상태로 유지된다.
- 뷰까지 영속성 컨텍스트가 살아있다면 뷰에서도 지연 로딩을 사용할 수 있다.
Open-Session-In-View를 false로 설정하게 되면 다음과 같이 바뀐다.
트랜잭션을 종료할 때 영속성 컨텍스트 또한 닫힌다. 그러므로 Service에서 끝나기 때문에 영속성 컨텍스트가 Transaction 범위 바깥인 Controller에서 Lazy loading 을 시도하면 에러가 발생한다.
영속성 컨텍스트가 닫혔다면 Lazy loading 또한 할 수 없다.
예) Registry와 Comment의 1:N 관계에서 게시글 페이지에서 Registry를 가져올 때는 Comment는 지연로딩이기 때문에 영속성 컨텍스트에 proxy 객체만 있고 실제 객체는 없다.
여기서 Service가 Controller로 가게 되면 영속성 컨텍스트는 닫히게 되고 view에서 comment를 호출할 경우 Controller는 comment에 접근하고 싶어도 영속성 컨텍스트가 이미 닫혀있어서 error를 내게 된다.
- LazyInitializationException 발생
Open-Session-In-View = false 적용 후 검증
spring.jpa.open-in-view : false로 설정하면 Service가 Controller로 가게 되면 영속성 컨텍스트는 닫히게 되니까 Controller 메서드에서 Lazy loading 을 시도하면 에러가 발생함으로 전체 로직에서 그렇게 동작하는 API가 있는지 점검했다.
다행히 전체 시스템에서는 모든 Lazy loading이 Service 메서드에서 끝나서 spring.jpa.open-in-view : false 설정을 안전하게 도입해도 되는 상황이었다.
spring.jpa.open-in-view : false 설정 후, SSE sse/subscribe 로 인한 커넥션 미반납 문제는 즉시 해결되었다. 즉, subscribe Service 메서드가 끝나고 흐름이 다시 컨트롤러로 넘어가는 순간에 트랜잭션이 종료되고 영속성 컨텍스트 또한 닫히게 되면서 빌렸던 커넥션이 반환된다.
👉 maximum-pool-size를 10로 설정
- SSE 연결 API sse/subscribe 요청하여 SSE 연결 맺기: 3번
HikariPool-1 - Pool stats (total=10, active=0, idle=1, waiting=0)
참고 자료
HikariCP Dead lock에서 벗어나기 (실전편) | 우아한형제들 기술블로그
1부 HikariCP Dead lock에서 벗어나기 (이론편)은 잘 보셨나요? 2부 HikariCP Dead lock에서 벗어나기 (실전편)에서는 실제 장애 사례를 기반으로 장애 원인을 설명하고 해결 사례를 공유하고자 합니다. 그
techblog.woowahan.com
Sse 문제점
처음에 실시간 알림을 구현하기 위해 여기저기 글들을 보았다.
haedal-uni.github.io
끝.
'카카오테크 부트캠프' 카테고리의 다른 글
| SSE 도입 후 궁금증 완전 해소 – Polling 한계 극복부터 Nginx 설정, Tomcat 병목까지 총정리 (1) | 2025.06.29 |
|---|---|
| MVP 이후의 코드 품질 개선 전략 — 리팩토링 (0) | 2025.06.21 |
| Java의 record 타입 — DTO에 적합한 이유와 실제 적용기 (1) | 2025.06.10 |
| Spring Security + SSE 사용 중, 권한 문제로 Access Denied 예외가 발생 (0) | 2025.05.26 |
| boolean 필드가 JSON에서 read로 직렬화되는 이유 (0) | 2025.05.25 |