옌의 로그

Querydsl transform() 사용 시 발생할 수 있는 커넥션 누수 이슈 본문

스터디/스프링

Querydsl transform() 사용 시 발생할 수 있는 커넥션 누수 이슈

dev-yen 2025. 10. 3. 14:56

조회쿼리 작성시 연관관계를 갖는 경우, transform을 처음에 사용하려했었는데,
이 때 발견한 커넥션 leack 문제에 대해 포스팅하고자 한다.

 

 

Querydsl transform()이란?

transform()은 Querydsl에서 그룹핑된 데이터를 원하는 구조로 변환할 때 사용하는 메서드이다.

일반적인 fetch()는 단순히 List<Tuple> 형태로 데이터를 반환하지만,
transform()은 결과를 Map이나 DTO 등으로 그룹핑 및 가공해서 반환할 수 있게 해준다.

groupBy().transform() 형태로 많이 사용됨

 

예를 들어, 아래처럼 1:N 관계의 데이터를 조회할 때

  • 하나의 작가가 여러 권의 책을 갖고 있을 때
  • 하나의 주문에 여러 주문상품이 연결되어 있을 때
  • 하나의 게시글에 여러 댓글이 달릴 때 등등

 

@Entity
public class Author {
    @Id Long id;
    String name;

    @OneToMany(mappedBy = "author")
    List<Book> books;
}

@Entity
public class Book {
    @Id Long id;
    String title;

    @ManyToOne
    Author author;
}

 

 

transform() 사용 예

Map<String, List<String>> result = queryFactory
    .select(author.name, book.title)
    .from(book)
    .join(book.author, author)
    .transform(
        groupBy(author.name)
        .as(list(book.title))
    );

 

결과

{
    "한강": ["소년이 온다", "채식주의자"],
    "정유정": ["7년의 밤", "완전한 행복"],
    ...
}

 

 

DTO로 변환

List<AuthorWithBooksDto> result = queryFactory
    .select(author.id, author.name, book.title)
    .from(book)
    .join(book.author, author)
    .transform(
        groupBy(author.id)
        .list(Projections.constructor(
            AuthorWithBooksDto.class,
            author.id,
            author.name,
            list(book.title)
        ))
    );
public class AuthorWithBooksDto {
    Long authorId;
    String authorName;
    List<String> bookTitles;

    public AuthorWithBooksDto(Long authorId, String authorName, List<String> bookTitles) {
        this.authorId = authorId;
        this.authorName = authorName;
        this.bookTitles = bookTitles;
    }
}

 

 

 

 

Connection leack 발생

transform()은 내부적으로 ResultSet을 lazy하게 순회
  • == 프록시처럼 순차적으로 돌면서 결과를 처리하는 것 
  • 즉, 커넥션이 열려 있는 상태에서 모든 데이터를 다 순회하기 전까지는 커넥션이 반납되지 않는다 (헐;)

 

(원인)

  • Querydsl 내부적으로 transform() 은 HibernateHandler#iterate(...) 를 통해 실행
  • 이때 Hibernate의 Query.scroll(ScrollMode) 을 호출하면서 새로운 커넥션을 획득
  • 문제는 이 커넥션을 반환하는 종료(close) 처리 로직이 transform() 경로에는 명시적으로 포함되지 않는다는 것
  • 반면, fetch(), fetchOne(), fetchResults() 같은 메서드는 JPA의 getResultList(), getSingleResult() 를 호출하는데, 이들은 커넥션 반환을 트리거하는 종료 메서드(termination methods) 에 포함되어 있어 문제가 발생하지 않는다

 

즉, transform() 은 Querydsl/JPA 통합 계층에서 “쿼리를 종료하는 메서드”로 인식되지 않아 커넥션 반환이 누락된다

더보기

transform() 내부 동작 흐름

  1. query.transform(...) 호출
  2. FetchableQueryBase / AbstractJPAQuery 에서 실행 경로 진입
  3. 내부적으로 HibernateHandler#iterate(...) 실행
  4. Query.scroll(ScrollMode) 호출 → ScrollableResults 획득 (커넥션 점유 시작)
  5. ScrollableResultsIterator 로 감싸 결과 변환 진행
  6. 커넥션을 닫는 명시적 호출이 누락됨 → 커넥션 반환 불가
  7. 트랜잭션이 없는 경우, 커넥션이 풀에서 IN_USE 상태로 방치됨

 

 

따라서, @Transactional(readOnly = true) 붙여서 커넥션을 트랜잭션으로 관리해야함

  • 스프링은 메서드에 트랜잭션 설정이 없으면 명시적으로 커넥션 관리하지 않음
    → 커넥션이 자동 반납되지 않음
  • transform()은 커넥션을 오래 붙잡고 있음 → 누수 발생

 

 

1:N 응답 데이터 조회 해결 방법

1. @Transactional(readOnly = true) 명시하기

@Transactional(readOnly = true)
public Map<Long, List<BookDto>> findBooks() {
    return queryFactory
        .select(...)
        .from(book)
        .join(...)
        .transform(...);
}
  • readOnly = true (트랜잭션은 읽기전용으로 작동)
  • Spring이 커넥션을 조기에 반납할 수 있음
  • transform()을 사용해도 커넥션 누수 없이 안전

 

2. fetch() + 직접 DTO 조립하기

List<Tuple> tuples = queryFactory
    .select(author.id, book.title)
    .from(book)
    .join(book.author, author)
    .fetch();

Map<Long, List<String>> grouped = new LinkedHashMap<>();
for (Tuple tuple : tuples) {
    Long authorId = tuple.get(author.id);
    String bookTitle = tuple.get(book.title);
    grouped.computeIfAbsent(authorId, k -> new ArrayList<>()).add(bookTitle);
}
  • 모든 데이터를 한 번에 가져옴 → 커넥션 즉시 반납
  • 수동 조립이 필요하긴 하지만 커넥션 누수 X
  • HikariCP 6.x 환경에서도 완전 호환

 

 

 

HikariCP 6.x + Querydsl 5.x 에서는 transform() 사용 주의

  • HikariCP 6부터는 Statement/ResultSet이 명확히 닫히지 않으면 예외 발생
  • transform() 내부 구현이 auto-closeable을 완전히 보장하지 않기 때문에 예외 발생 가능
  • 일부 버전에선 transform()이 사실상 Deprecated 취급

 

 

 

마무리하며 .. . .

사실, 처음엔 transform을 쓸 생각도 못한게, 현재 플젝에서 hikaricp 6+ 버전을 쓰고 있는데, transform이 deprecated 되서 써지지도 않았다. 그래서, hikaricp 버전 다운해서 쓰려고 찾아보다가, transform의 커넥션 누수 문제를 확인하게 되었다.

트래픽이 많은 경우가 아니라면, transform을 사용하면 좋을 것 같고(물론 트랜잭션으로 커넥션 누수를 방지해야한다)

그게 아니라면, fetch() + DTO 조립을 활용하는게 장기적으로 옳은 방향인 것 같다.

Comments