포스트

Querydsl을 사용하는 이유와 기본 패턴

Querydsl이 필요한 순간

조회 조건이 조금만 복잡해져도 메서드 이름 쿼리나 문자열 JPQL은 금방 읽기 어려워진다. Querydsl은 이 문제를 타입 안전한 쿼리 빌더로 해결한다. 다른 방법과의 비교는 동적 쿼리 정리에 있다.

예를 들어 상태, 키워드, 기간 세 가지 조건을 각각 넣을 수도 있고 뺄 수도 있는 검색 화면이 있다고 하자. Spring Data JPA의 메서드 이름 쿼리로는 조건 조합마다 메서드가 필요하고, 문자열 JPQL로는 if문으로 문자열을 이어 붙이다가 공백이나 and 하나를 빠뜨리기 쉽다. 이런 오류는 실행해 봐야 드러난다. Querydsl은 쿼리를 Java 코드로 조립하므로 같은 실수가 컴파일 단계에서 걸린다.


장점

  • 컴파일 타임에 필드 변경 오류를 발견할 수 있다.
  • IDE 자동완성이 잘 된다.
  • 조건 조합이 명확하다.
  • Repository 구현을 읽기 좋게 유지할 수 있다.

첫 번째 장점은 Q타입 덕분이다. 엔티티의 name 필드를 title로 바꾸면 Q타입도 다시 생성되면서 order.name을 쓰던 코드가 컴파일 오류가 된다. 문자열 JPQL이었다면 그 쿼리를 실행하는 순간에야 알 수 있다.


기본 구조

Querydsl은 JPAAnnotationProcessor가 @Entity 타입마다 생성한 Q타입을 사용한다(Querydsl Reference 2.1).

1
QOrder order = QOrder.order;

이후 레퍼런스가 권하는 JPAQueryFactory로 조건을 조합한다.

1
2
3
4
return queryFactory
    .selectFrom(order)
    .where(order.status.eq(OrderStatus.PAID))
    .fetch();

JPAQueryFactory는 EntityManager를 받아 만든다. Spring에서는 보통 빈으로 하나 등록해 두고 Repository 구현에서 주입받는다.

1
2
3
4
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
    return new JPAQueryFactory(entityManager);
}

결과를 받는 메서드는 용도에 따라 고른다. fetch()는 목록을, fetchOne()은 결과 하나를 돌려준다. 레퍼런스 예제에서 fetchOne은 “단일 원소를 반환하라”는 뜻으로 쓰이므로, 결과가 여러 건일 수 있는 조건에 쓰면 안 된다. 정렬은 orderBy(customer.lastName.asc(), customer.firstName.desc())처럼 여러 개를 이어서 주고, 페이징은 offset과 limit으로 준다.


동적 조건 패턴

가장 많이 쓰는 방식은 BooleanExpression을 메서드로 분리하는 것이다.

1
2
3
private BooleanExpression eqKeyword(String keyword) {
    return hasText(keyword) ? order.name.contains(keyword) : null;
}

where(...)는 null 조건을 건너뛴다(“Skips null arguments”, Querydsl 5.0.0 소스). 그래서 빈 검색 조건은 null을 돌려주는 것만으로 빠진다.

레퍼런스에 따르면 where는 조건을 쉼표로 나열한 가변 인자로 받아도 되고 and로 이어도 된다. 여러 조건을 쉼표로 넘기면 모두 and로 묶인다. 그래서 조건 메서드를 여러 개 만들어 두고 한 번에 넘기면, 검색 쿼리가 “어떤 조건들로 거르는가”를 그대로 읽히는 코드가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public List<Order> search(OrderSearch cond) {
    return queryFactory
        .selectFrom(order)
        .where(
            eqStatus(cond.status()),
            eqKeyword(cond.keyword()),
            orderedAfter(cond.from())
        )
        .orderBy(order.id.desc())
        .fetch();
}

private BooleanExpression eqStatus(OrderStatus status) {
    return status != null ? order.status.eq(status) : null;
}

private BooleanExpression orderedAfter(LocalDateTime from) {
    return from != null ? order.orderedAt.goe(from) : null;
}

한 가지 주의할 점은 모든 조건이 null이면 where에 아무것도 남지 않아 전체 조회가 된다는 것이다. 검색 화면에서 조건 없이 조회할 수 있게 둘지, 최소 하나는 요구할지를 API 계약으로 정해 두는 편이 안전하다.


DTO로 바로 조회하기

엔티티 전체가 아니라 화면에 필요한 컬럼만 가져오려면 프로젝션을 쓴다. 레퍼런스 3.2절은 생성자를 이용하는 Projections.constructor와, 생성자에 @QueryProjection을 붙여 Q타입처럼 생성되는 클래스를 쓰는 방법을 소개한다(Querydsl Reference 3.2).

1
2
3
4
5
List<OrderSummary> result = queryFactory
    .select(Projections.constructor(OrderSummary.class, order.id, order.name, order.status))
    .from(order)
    .where(order.status.eq(OrderStatus.PAID))
    .fetch();

Projections.constructor는 인자 순서와 타입이 생성자와 맞는지를 실행 시점에야 확인한다. @QueryProjection은 생성자 시그니처로 Q 클래스를 만들어 컴파일 시점에 검사되지만, DTO가 Querydsl 애너테이션에 의존하게 된다. 어느 쪽을 택할지는 DTO를 어느 계층에서 쓰는지에 따라 정한다.


언제 특히 유용한가

  • 검색 조건이 3개 이상으로 늘어날 때
  • 조인과 정렬이 복잡할 때
  • Repository 메서드 이름이 과도하게 길어질 때
  • 커스텀 조회 전용 Repository가 필요할 때

주의할 점

  • Querydsl을 도입해도 인덱스 전략이 해결되는 것은 아니다.
  • 복잡한 조회는 결국 실행 계획을 확인해야 한다.
  • 카운트 쿼리와 본문 쿼리를 분리해야 할 때가 많다.

세 번째 항목은 Querydsl 5.0에서 API 차원의 변화로도 드러났다. 5.0.0의 AbstractJPAQuery에서 전체 건수를 함께 돌려주던 fetchResults()와 fetchCount()는 @Deprecated가 붙었다(Querydsl 5.0.0 AbstractJPAQuery 소스). 소스 주석은 그 이유를 설명한다. COUNT(DISTINCT a, b, c)는 대부분의 SQL 방언에서는 유효하지만 JPQL에서는 유효하지 않아서, group by가 여러 개이거나 having이 있는 쿼리의 카운트는 만들 수 없다. 그래서 이런 경우에는 fetch()의 결과 크기를 세는 방식으로 대신하며, 결과가 크면 심각한 성능 저하가 생길 수 있다고 적혀 있다.

그래서 페이징 조회는 본문 쿼리와 카운트 쿼리를 따로 작성하는 것이 일반적이다. Spring Data의 PageableExecutionUtils.getPage를 함께 쓰면, 결과 크기와 Pageable만으로 전체 건수를 알 수 있는 경우(예를 들어 마지막 페이지라서 결과가 페이지 크기보다 적을 때) 카운트 쿼리를 생략한다(PageableExecutionUtils Javadoc).

1
2
3
4
5
6
7
8
9
10
11
12
13
List<Order> content = queryFactory
    .selectFrom(order)
    .where(eqStatus(cond.status()))
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();

JPAQuery<Long> countQuery = queryFactory
    .select(order.count())
    .from(order)
    .where(eqStatus(cond.status()));

return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);

카운트 쿼리에는 본문 쿼리의 정렬이나 결과에 영향을 주지 않는 조인이 필요 없으므로, 따로 쓰면서 더 가볍게 만들 수 있다. 페이징 설계 전반은 페이지네이션 설계에 정리했다.


정리

Querydsl은 쿼리를 표현하는 방식을 바꿀 뿐 DB가 실행하는 방식은 그대로다. 조건 조합이 많아지는 시점부터 투자할 가치가 있다.

참고한 자료외부 출처 5

외부 출처

Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다