[QueryDSL] Transform 사용 시 HikariCP Connection Leak 문제 해결
QueryDSL Transform 사용 시 HikariCP Connection Leak 문제
QueryDSL의 transform() 메서드는 쿼리 결과를 그룹화하고 Map으로 변환할 때 매우 유용한 기능입니다. 하지만 부적절하게 사용할 경우 심각한 데이터베이스 커넥션 누수(Connection Leak)를 발생시킬 수 있습니다.
문제 상황
운영 중인 서비스에서 다음과 같은 에러가 발생했습니다:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
DB 커넥션 수는 POD 당 20개(max-connection-pool) 로 충분한 상태였고, 트랜잭션이 길게 잡히는 슬로우 쿼리도 없는 상황이라 아무래도 Connection Leak 이 발생하는 상황이라 생각 했습니다.
원인 분석
QueryDSL의 transform() 메서드 특징
문제의 원인은 QueryDSL의 transform() 메서드의 내부 동작 방식에 있었습니다.
// 문제가 되는 코드 예시 public Map<Long, List<OrderItem>> getOrderItemsByOrderId(List<Long> orderIds) { return queryFactory .selectFrom(orderItem) .where(orderItem.orderId.in(orderIds)) .transform( groupBy(orderItem.orderId) .as(list(orderItem)) ); }
일반적인 QueryDSL 종료 메서드들:
fetch() - 결과 리스트 조회
fetchOne() - 단일 결과 조회
fetchFirst() - 첫 번째 결과 조회
fetchCount() - 카운트 조회
이 메서드들은 모두 queryTerminationMethods에 등록되어 있어 쿼리 실행 후 자동으로 리소스를 정리합니다.
transform()의 다른 점
하지만 transform() 메서드는:
내부적으로 iterate() 메서드를 사용
iterate()는 쿼리 종료 메서드가 아님
JPA EntityManager가 커넥션을 자동으로 반환하지 않음
메서드 실행이 끝나도 커넥션이 계속 점유됨
// QueryDSL 내부 코드 (단순화) public <RT> RT transform(ResultTransformer<RT> transformer) { // iterate()를 사용 - 이것이 문제! return transformer.transform(this.iterate()); }
// Spring 트랜잭션 매니저의 내부 동작 (단순화) try { // 비즈니스 로직 실행 result = method.invoke();
// 트랜잭션 커밋 transactionManager.commit(); } finally { // 정리 작업 - 여기서 커넥션 반환! transactionManager.doCleanupAfterCompletion(); }
EntityManager와 커넥션 해제
doCleanupAfterCompletion()에서 모든 리소스 정리
EntityManager 닫기
데이터베이스 커넥션을 HikariCP 풀에 반환
readOnly = true의 추가 이점
@Transactional(readOnly = true)를 사용하면:
성능 최적화
Hibernate가 Dirty Checking을 하지 않음
스냅샷 저장 안함
플러시 모드를 MANUAL로 설정
명확한 의도 표현
이 메서드가 읽기 전용임을 명시
실수로 데이터를 수정하는 것을 방지
데이터베이스 최적화
일부 데이터베이스는 읽기 전용 트랜잭션을 최적화
Read Replica로 라우팅 가능 (설정에 따라)
대안적인 해결 방법
1. fetch()로 조회 후 애플리케이션에서 그룹화
transform()을 사용하지 않고 애플리케이션 레벨에서 그룹화:
public Map<Long, List<OrderItem>> getOrderItemsByOrderId(List<Long> orderIds) { List<OrderItem> items = queryFactory .selectFrom(orderItem) .where(orderItem.orderId.in(orderIds)) .fetch(); // 일반적인 종료 메서드 사용
@Query(value = """ SELECT o.order_id, o.item_name, o.quantity FROM order_items o WHERE o.order_id IN :orderIds ORDER BY o.order_id """, nativeQuery = true) List<OrderItemProjection> findOrderItemsByOrderIds(@Param("orderIds") List<Long> orderIds);