[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() 메서드는:

  1. 내부적으로 iterate() 메서드를 사용
  2. iterate()는 쿼리 종료 메서드가 아님
  3. JPA EntityManager가 커넥션을 자동으로 반환하지 않음
  4. 메서드 실행이 끝나도 커넥션이 계속 점유됨
// QueryDSL 내부 코드 (단순화)
public <RT> RT transform(ResultTransformer<RT> transformer) {
// iterate()를 사용 - 이것이 문제!
return transformer.transform(this.iterate());
}

커넥션 누수 시나리오

[요청 1] transform() 호출 → 커넥션 1 획득 → 쿼리 실행 → 커넥션 반환 안됨 ❌
[요청 2] transform() 호출 → 커넥션 2 획득 → 쿼리 실행 → 커넥션 반환 안됨 ❌
[요청 3] transform() 호출 → 커넥션 3 획득 → 쿼리 실행 → 커넥션 반환 안됨 ❌
...
[요청 N] transform() 호출 → 사용 가능한 커넥션 없음 → 30초 대기 → 타임아웃 💥

해결 방법

해결책: @Transactional(readOnly = true) 추가

가장 간단하고 확실한 해결책은 @Transactional 어노테이션을 추가하는 것입니다.

@Transactional(readOnly = true)
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))
);
}

왜 @Transactional이 문제를 해결하는가?

@Transactional 어노테이션을 추가하면:

  1. Spring의 트랜잭션 관리 활성화

    • JpaTransactionManager가 메서드 실행을 관리
  2. 메서드 완료 시 정리 작업 보장

    // Spring 트랜잭션 매니저의 내부 동작 (단순화)
    try {
    // 비즈니스 로직 실행
    result = method.invoke();

    // 트랜잭션 커밋
    transactionManager.commit();
    } finally {
    // 정리 작업 - 여기서 커넥션 반환!
    transactionManager.doCleanupAfterCompletion();
    }
  3. EntityManager와 커넥션 해제

    • doCleanupAfterCompletion()에서 모든 리소스 정리
    • EntityManager 닫기
    • 데이터베이스 커넥션을 HikariCP 풀에 반환

readOnly = true의 추가 이점

@Transactional(readOnly = true)를 사용하면:

  1. 성능 최적화

    • Hibernate가 Dirty Checking을 하지 않음
    • 스냅샷 저장 안함
    • 플러시 모드를 MANUAL로 설정
  2. 명확한 의도 표현

    • 이 메서드가 읽기 전용임을 명시
    • 실수로 데이터를 수정하는 것을 방지
  3. 데이터베이스 최적화

    • 일부 데이터베이스는 읽기 전용 트랜잭션을 최적화
    • 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(); // 일반적인 종료 메서드 사용

// Java Stream으로 그룹화
return items.stream()
.collect(Collectors.groupingBy(OrderItem::getOrderId));
}

장점:

  • 커넥션 누수 위험 없음
  • @Transactional 없이도 안전

단점:

  • 애플리케이션 메모리에서 그룹화 처리
  • 대용량 데이터에서는 메모리 부담

2. 네이티브 쿼리 사용

복잡한 그룹화가 필요한 경우 네이티브 쿼리 고려:

@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);

// 애플리케이션에서 그룹화
return items.stream()
.collect(Collectors.groupingBy(OrderItemProjection::getOrderId));

3. Projection과 Tuple 사용

필요한 컬럼만 조회하여 메모리 효율성 향상:

@Transactional(readOnly = true)
public Map<Long, List<OrderItemDto>> getOrderItemsByOrderId(List<Long> orderIds) {
return queryFactory
.select(
orderItem.orderId,
orderItem.itemName,
orderItem.quantity
)
.from(orderItem)
.where(orderItem.orderId.in(orderIds))
.fetch() // fetch() 사용
.stream()
.map(tuple -> new OrderItemDto(
tuple.get(orderItem.orderId),
tuple.get(orderItem.itemName),
tuple.get(orderItem.quantity)
))
.collect(Collectors.groupingBy(OrderItemDto::getOrderId));
}

실전 예시: 환불 처리

실제 프로덕션 환경에서 발생했던 환불 처리 코드를 개선한 예시입니다.

문제가 있던 코드

@Service
public class RefundService {

// @Transactional 없음 - 문제 발생!
public void processRefund(Long orderId) {
// 1. 주문 아이템 조회 - transform() 사용
Map<Long, List<OrderItem>> orderItems = queryFactory
.selectFrom(orderItem)
.where(orderItem.orderId.eq(orderId))
.transform(
groupBy(orderItem.productId)
.as(list(orderItem))
); // 커넥션 반환 안됨!

// 2. 환불 처리 로직
orderItems.forEach((productId, items) -> {
// 환불 처리...
});

// 3. 환불 이력 저장
saveRefundHistory(orderId, orderItems);
}
}

문제점:

  • 동시에 여러 환불 요청이 들어오면 커넥션 고갈
  • 환불 이력 저장이 실패하는 현상 발생

개선된 코드

@Service
@RequiredArgsConstructor
public class RefundService {

private final JPAQueryFactory queryFactory;
private final RefundHistoryRepository refundHistoryRepository;

@Transactional // 추가!
public void processRefund(Long orderId) {
// 1. 주문 아이템 조회 - transform() 사용해도 안전
Map<Long, List<OrderItem>> orderItemsByProduct = queryFactory
.selectFrom(orderItem)
.where(orderItem.orderId.eq(orderId))
.transform(
groupBy(orderItem.productId)
.as(list(orderItem))
); // 메서드 종료 시 커넥션 자동 반환

// 2. 환불 처리 로직
List<RefundHistory> histories = orderItemsByProduct.entrySet()
.stream()
.map(entry -> processRefundForProduct(entry.getKey(), entry.getValue()))
.toList();

// 3. 환불 이력 일괄 저장 (같은 트랜잭션)
refundHistoryRepository.saveAll(histories);
}

private RefundHistory processRefundForProduct(Long productId, List<OrderItem> items) {
int totalAmount = items.stream()
.mapToInt(OrderItem::getAmount)
.sum();

return RefundHistory.builder()
.productId(productId)
.itemCount(items.size())
.totalAmount(totalAmount)
.build();
}
}

개선 효과:

  • 커넥션 누수 완전 해결
  • 환불 처리와 이력 저장이 하나의 트랜잭션으로 묶임
  • 원자성 보장 (전체 성공 또는 전체 롤백)

모니터링 및 탐지

HikariCP 메트릭 설정

커넥션 풀 상태를 모니터링하여 문제를 조기에 발견:

# application.yml
spring:
datasource:
hikari:
# 커넥션 풀 설정
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000

# 커넥션 누수 탐지
leak-detection-threshold: 60000 # 60초

# 메트릭 활성화
register-mbeans: true

management:
endpoints:
web:
exposure:
include: metrics, health
metrics:
export:
prometheus:
enabled: true

커넥션 풀 상태 모니터링

@RestController
@RequiredArgsConstructor
public class MonitoringController {

private final HikariDataSource dataSource;

@GetMapping("/admin/hikari-stats")
public Map<String, Object> getHikariStats() {
HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean();

return Map.of(
"activeConnections", poolMXBean.getActiveConnections(),
"idleConnections", poolMXBean.getIdleConnections(),
"totalConnections", poolMXBean.getTotalConnections(),
"threadsAwaitingConnection", poolMXBean.getThreadsAwaitingConnection()
);
}
}

경고 알림 설정

Prometheus + Grafana를 사용한 알림 설정:

# prometheus-alert.yml
groups:
- name: hikari_connection_pool
interval: 30s
rules:
- alert: HikariConnectionPoolExhausted
expr: hikari_pool_total_connections >= hikari_pool_max_connections * 0.9
for: 1m
labels:
severity: warning
annotations:
summary: "HikariCP connection pool nearly exhausted"
description: "Pool usage is at {{ $value }}%"

- alert: HikariConnectionLeakSuspected
expr: hikari_pool_active_connections > 0 and rate(hikari_pool_active_connections[5m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Possible connection leak detected"

체크리스트

QueryDSL을 사용할 때 확인해야 할 사항들:

  • transform() 메서드를 사용하는 모든 메서드에 @Transactional 추가
  • readOnly = true 설정으로 읽기 전용 명시
  • HikariCP leak detection 설정 활성화
  • 커넥션 풀 메트릭 모니터링 구성
  • 대용량 데이터 처리 시 페이징 고려
  • 테스트 환경에서 동시성 테스트 수행

교훈과 베스트 프랙티스

1. QueryDSL 메서드 이해

// ✅ 안전 - 자동 리소스 정리
queryFactory.selectFrom(entity).fetch()
queryFactory.selectFrom(entity).fetchOne()
queryFactory.selectFrom(entity).fetchFirst()

// ⚠️ 주의 필요 - @Transactional 필수
queryFactory.selectFrom(entity).transform(...)
queryFactory.selectFrom(entity).iterate()

2. 트랜잭션 경계 명확히

// ❌ 나쁜 예
public void someMethod() {
// 트랜잭션 없이 transform() 사용
Map<Long, List<Entity>> map = queryFactory
.selectFrom(entity)
.transform(groupBy(entity.id).as(list(entity)));
}

// ✅ 좋은 예
@Transactional(readOnly = true)
public void someMethod() {
Map<Long, List<Entity>> map = queryFactory
.selectFrom(entity)
.transform(groupBy(entity.id).as(list(entity)));
}

3. 적절한 커넥션 풀 크기 설정

// HikariCP 권장 공식
connections = ((core_count * 2) + effective_spindle_count)

// 예시: 4코어 CPU, SSD 사용
// connections = (4 * 2) + 1 = 9
// 여유를 두어 10~20 설정
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10

4. 페이징 처리

대용량 데이터를 처리할 때는 페이징 고려:

@Transactional(readOnly = true)
public Map<Long, List<OrderItem>> getOrderItemsByOrderIdWithPaging(
List<Long> orderIds,
int pageSize
) {
Map<Long, List<OrderItem>> result = new HashMap<>();

for (int i = 0; i < orderIds.size(); i += pageSize) {
List<Long> batch = orderIds.subList(
i,
Math.min(i + pageSize, orderIds.size())
);

Map<Long, List<OrderItem>> batchResult = queryFactory
.selectFrom(orderItem)
.where(orderItem.orderId.in(batch))
.transform(groupBy(orderItem.orderId).as(list(orderItem)));

result.putAll(batchResult);
}

return result;
}
Share