트래픽이 갑자기 몰리던 날, 서비스가 사실상 멈췄습니다. HikariCP 풀을 보니 Active는 최대치로 꽉 차 있었고, Waiting도 특정 값에서 더 늘지도 줄지도 않은 채 그대로 유지되고 있었습니다. Waiting이 그 값 이상으로 늘지 않은 건 HikariCP에 대기 상한이 있어서가 아니라, WAS의 최대 스레드 수만큼만 요청이 동시에 커넥션을 기다릴 수 있었기 때문으로 보입니다.
처음엔 단순히 “트래픽이 늘어서 커넥션이 부족한 거겠지”라고 생각했습니다. 그런데 이상했습니다. 커넥션 풀 사이즈를 넉넉히 늘려도 문제였고, 무엇보다 커넥션이 하나도 반납되지 않고 그대로 멈춰 있다는 게 이상했습니다. 트래픽이 빠지면 자연스럽게 풀렸어야 할 상황인데, 그러지 않았거든요.
배경: 멀티 테넌트를 위한 TransactionManager 빈 수동 등록
저희 서비스는 멀티 테넌트 구조를 지원하기 위해 AbstractRoutingDataSource를 사용하고 있습니다. 요청의 테넌트 정보에 따라 실제로 연결할 데이터소스를 런타임에 결정하는 구조입니다.
이 라우팅 데이터소스를 트랜잭션 관리 범위 안에서 쓰기 위해, AbstractRoutingDataSource를 감싸는 TransactionManager를 직접 Bean으로 등록해서 사용하고 있었습니다. Spring Boot가 기본으로 만들어주는 JpaTransactionManager를 그대로 쓰지 않은 겁니다.
여기에 더해 같은 코드베이스 안에서 JPA(Hibernate)와 MyBatis를 함께 쓰고 있었다는 점도 이번 이슈와 맞물린 부분이었습니다.
여기까지는 특별히 문제될 게 없어 보였습니다. 트랜잭션도 잘 걸렸고, 커밋/롤백도 정상적으로 동작했으니까요.
원인 분석: 커넥션이 반납되지 않은 이유
장애 당시 상황을 다시 정리하면 이랬습니다.
- 커넥션 풀 사이즈: 20
- 장애 시점 동시 요청 수: 100~300건
풀 사이즈보다 요청이 훨씬 많았으니 대기가 생기는 것 자체는 이상하지 않습니다. 그런데 대기 중인 요청들이 시간이 지나도 전혀 처리되지 않는다는 점이 핵심이었습니다. 정상적인 상황이라면 앞선 요청이 끝나면서 커넥션을 반납하고, 대기하던 요청이 순차적으로 처리돼야 합니다.
문제가 되던 요청 흐름을 하나씩 되짚어보다가, 단순 조회 메서드 하나에 @Transactional(readOnly=true)가 붙어 있는 걸 발견했습니다. “설마 이것 때문에?” 싶어서 제거하고 재현 테스트를 해봤더니 문제가 사라졌고, 다시 붙였더니 그대로 재현됐습니다.
원인은 이랬습니다.
문제가 되던 흐름은 상위 메서드에서 Hibernate(JPA) 쪽 조회를 먼저 호출하고, 그다음에 MyBatis Mapper로 또 다른 조회를 실행하는 구조였습니다. @Transactional(readOnly=true)는 Hibernate 쪽 호출에만 붙어 있었고, MyBatis 호출은 그 트랜잭션 밖에서 별도로 실행됐습니다.
저희는 spring.jpa.open-in-view를 별도로 꺼두지 않은 상태였습니다(Spring Boot 기본값 true). 이 설정 때문에 Hibernate 세션과 그 밑에 물려 있는 커넥션은 @Transactional 메서드가 끝나 트랜잭션이 커밋된 뒤에도 바로 반납되지 않고, 요청 전체가 끝날 때까지 계속 열려 있는 상태로 남아 있었습니다.
그 상태에서 뒤이어 실행되는 MyBatis Mapper 호출은 이미 열려 있는 Hibernate 커넥션과 무관하게 커넥션 풀에서 새 커넥션을 하나 더 꺼내와 자신의 조회에 사용했습니다. 결과적으로 조회 요청 하나가 응답이 끝날 때까지 커넥션을 2개씩 물고 있었던 겁니다.
// 문제가 됐던 코드
@Service
public class OrderQueryFacade {
private final OrderJpaService orderJpaService; // Hibernate(JPA)
private final OrderQueryMapper orderQueryMapper; // MyBatis
public OrderDetailResponse getOrderDetail(Long orderId) {
Order order = orderJpaService.findOrder(orderId);
// → 여기서 커넥션 1 사용. OSIV 때문에 이 메서드가 끝나도 반납되지 않고 요청이 끝날 때까지 유지됨
List<OrderItemSummary> items = orderQueryMapper.selectItemSummaries(orderId);
// → 커넥션 1과 무관하게 커넥션 2를 새로 획득
return OrderDetailResponse.of(order, items);
}
}
@Service
public class OrderJpaService {
private final OrderRepository orderRepository;
@Transactional(readOnly = true)
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
}
getOrderDetail()은 조회 요청 하나를 처리할 뿐인데, orderJpaService.findOrder()를 거치며 열린 Hibernate 커넥션이 OSIV 때문에 요청이 끝날 때까지 살아있고, 그 뒤에 실행되는 MyBatis 호출이 별도 커넥션을 하나 더 열면서 총 2개의 커넥션을 동시에 물게 됩니다. 평소라면 커넥션이 금방 반납되니 별문제가 안 됩니다. 하지만 동시 요청이 몰려 풀에 있는 커넥션 20개가 전부 “각 요청의 Hibernate 커넥션”으로 소진되고 나면, MyBatis 쪽에서 필요한 두 번째 커넥션을 내어줄 여유분이 풀에 하나도 남지 않습니다. 결국 20개 요청 모두 MyBatis Mapper 호출 지점에서 두 번째 커넥션을 기다리며 멈추고, 그 요청들의 응답이 끝나야 반납될 Hibernate 커넥션도 반납되지 않는 상태가 되어버린 겁니다.
숫자로 보면 왜 이게 데드락으로 이어졌는지 명확해집니다.
- 풀 사이즈 20 → 요청 20개가 각각 Hibernate 커넥션(첫 번째)을 잡는 순간 Active 20, 풀 소진
- 이 20개 요청은 전부 MyBatis 커넥션(두 번째)을 기다리는 중 (Waiting)
- 하지만 풀에 남은 커넥션이 없으니 아무도 두 번째 커넥션을 받지 못함
- 두 번째 커넥션을 받아야 트랜잭션이 끝나고 Hibernate 커넥션도 반납되는데, 그럴 방법이 없음
즉, 서로가 서로의 커넥션 반납을 기다리며 아무도 앞으로 나아가지 못하는 셀프 데드락이었습니다. 정확히는 완전히 무한정 멈춰 있던 건 아닙니다. HikariCP의 connectionTimeout(기본 30초)이 지나면 대기하던 요청은 커넥션 획득 실패 예외를 던지며 풀려나긴 합니다. 하지만 그 자리를 대체하는 새 요청도 곧바로 같은 패턴에 걸려버렸기 때문에, 트래픽이 빠지기 전까지는 실질적으로 서비스가 마비된 것과 다름없었습니다.
해결
가장 먼저 한 일은 문제가 되는 흐름에서 불필요한 @Transactional(readOnly=true)를 제거하는 것이었습니다. 원래 단순 조회라 트랜잭션이 꼭 필요한 메서드도 아니었기 때문에, 영향 범위가 적은 즉시 조치로 선택했습니다. @Transactional을 걷어내니 Hibernate가 커넥션을 조회 시점에만 짧게 사용하고 바로 반납하게 되어, OSIV로 인해 요청이 끝날 때까지 붙들려 있던 커넥션도 함께 사라졌습니다.
// 수정 후
@Service
public class OrderJpaService {
private final OrderRepository orderRepository;
// @Transactional(readOnly = true) 제거
// → OSIV로 인해 커넥션이 요청 끝까지 유지되는 대신, 조회 시점에만 짧게 쓰이고 바로 반납됨
public Order findOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
}
근본적으로는 Hibernate와 MyBatis를 함께 쓰는 라우팅 데이터소스 환경 전반의 트랜잭션 관리 체계를 JpaTransactionManager 기반으로 정리할 계획입니다. 다만 이 부분은 트랜잭션 매니저 자체를 교체하는 만큼 영향 범위가 커서, 충분한 테스트를 거친 뒤 별도로 적용할 예정입니다.
정리
- 당장 급한 경우: 문제가 되는 요청 흐름에서 불필요하게 걸려 있는
@Transactional을 찾아 제거하는 것만으로도 커넥션 2배 소비 문제를 막을 수 있습니다. - OSIV(
spring.jpa.open-in-view)를 켜둔 채 Hibernate와 다른 데이터 접근 기술(MyBatis, JdbcTemplate 등)을 함께 쓰는 경우:@Transactional이 한 번이라도 걸렸던 요청은 Hibernate 커넥션이 응답이 끝날 때까지 유지될 수 있다는 점을 염두에 둬야 합니다. - 구조적으로 해결하고 싶은 경우: 멀티 테넌트 라우팅 데이터소스를 쓰더라도, JPA와 MyBatis처럼 서로 다른 데이터 접근 기술을 함께 쓴다면
JpaTransactionManager를 통해 둘이 커넥션을 공유할 수 있는 구조로 맞춰야 합니다.
커넥션 풀 사이즈만 믿고 있었는데, 실제로는 요청 하나가 커넥션을 몇 개나 쓰는지가 훨씬 중요한 변수였습니다. 트랜잭션 매니저를 직접 등록해서 쓰고 있다면, 특히 JPA와 MyBatis처럼 서로 다른 기술을 함께 쓰는 코드베이스에서는 한 번쯤 이 부분을 의심해볼 만한 것 같습니다.
