QueryDSL 동적 쿼리 깔끔하게 짜기 (JPA 3편)

1편에서 JPA 기초, 2편에서 영속성 컨텍스트와 더티체킹을 봤다. 이번엔 QueryDSL 동적 쿼리다.

문자열 JPQL, Criteria API, QueryDSL 동적 쿼리 작성 방식 비교 표

조회 방법 3가지, 뭘 언제 써야 할까

Spring Data JPA 에서 조회 쿼리를 짜는 방법은 크게 셋이다.

메서드 이름 쿼리(findByNameAndEmail)는 코드를 안 짜도 되지만 조건이 많아지면 이름이 감당이 안 된다. 조건 한두 개짜리 단순 조회에 쓴다.

@Query(JPQL) 는 복잡한 JPQL 도 표현할 수 있는 대신 문자열이라 오타나 타입 실수를 컴파일 시점에 못 잡는다. 정적인 조건의 복잡한 쿼리에 쓴다.

QueryDSL 은 자바 코드라 컴파일 시점에 타입 체크가 되고 동적 쿼리에 최적이다. 대신 Q타입 생성이라는 초기 설정이 필요하다.

메서드 이름 쿼리는 조건이 몇 개만 늘어나도 이렇게 감당이 안 된다.

List<Member> findByNameAndEmailAndAgeGreaterThan(String name, String email, int age);

게다가 사용자 입력에 따라 조건이 있을 수도 없을 수도 있는 동적 쿼리는 아예 표현할 방법이 없습니다.

JPQL 을 문자열로 짜는 @Query 도 있지만, 문자열이라 오타나 타입 실수를 컴파일 시점에 못 잡는다. 실행해봐야 에러가 난다.

QueryDSL 은 자바 코드로 쿼리를 짜서 컴파일 시점에 타입 체크가 되고, 조건을 자유롭게 조합하는 동적 쿼리도 자연스럽게 만들어진다.

Q타입이란?

QueryDSL 을 쓰려면 @Entity 마다 대응하는 Q타입(Member → QMember)이 필요하다. 어노테이션 프로세서가 컴파일 시점에 생성하므로 직접 만들 일은 없다.

Gradle 이면 build.gradleannotationProcessor 의존성을 추가하고 compileJava 를 돌리면 build/generated 에 생성된다.

자주 겪는 문제가 “Q타입이 생성이 안 됐는데” 인데, 대부분 Gradle 캐시 문제다. clean 후 다시 빌드하면 해결됩니다.

기본 사용법

@Repository
public class MemberQueryRepository {

    private final JPAQueryFactory queryFactory;

    public MemberQueryRepository(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    public List<Member> findByName(String name) {
        QMember member = QMember.member;

        return queryFactory
                .selectFrom(member)
                .where(member.name.eq(name))
                .fetch();
    }
}

JPAQueryFactory 가 쿼리를 만드는 진입점이고, QMember.member 로 필드에 접근해 where 조건을 자바 코드로 그대로 쓴다.

QueryDSL 동적 쿼리 — 진짜 장점은 여기다

이름이나 이메일 중 값이 있는 것만으로 조건을 걸어 검색한다고 해보자.

public List<Member> search(String name, String email) {
    QMember member = QMember.member;

    return queryFactory
            .selectFrom(member)
            .where(
                    nameEq(name),
                    emailEq(email)
            )
            .fetch();
}

private BooleanExpression nameEq(String name) {
    return name != null ? QMember.member.name.eq(name) : null;
}

private BooleanExpression emailEq(String email) {
    return email != null ? QMember.member.email.eq(email) : null;
}

핵심은 하나다. where() 에 넘긴 조건이 null 이면 QueryDSL 이 그 조건을 무시한다.

name 만 넘어오면 이름 조건만 적용되고 둘 다 넘어오면 둘 다 적용된다.

나가는 SQL 로 확인해봤다. 이름만 주고 나머지는 null 로 둔 채 실행한 로그다.

select m1_0.id, m1_0.age, m1_0.email, m1_0.name, m1_0.team_id
from member m1_0
where m1_0.name=?

null 로 넘긴 조건은 where 에 아예 나타나지 않는다. 1=1 같은 것도 안 끼운다. 문자열로 쿼리를 이어 붙일 때 where 다음에 and 가 먼저 오는 실수를 하는데, 그 문제 자체가 없어진다.

이 패턴이 QueryDSL 을 쓰는 가장 큰 이유다.

실무 팁: 페이징이랑 같이 쓸 때 count 쿼리 최적화

페이징을 구현할 때 fetchResults()PageableExecutionUtils 를 쓰는데, 목록 조회와 별개로 전체 개수를 세는 count 쿼리가 항상 같이 나갑니다.

목록에 조인이 여러 개 붙어 있으면 count 쿼리까지 같은 조인을 다 태운다. 개수만 셀 땐 그 조인이 대부분 불필요하다.

count 쿼리를 별도 메서드로 분리해 조인 없이 가볍게 짜고, PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne) 로 필요할 때만 실행되게 만든다.

정리

  • QueryDSL은 자바 코드로 타입 안전하게 쿼리를 짤 수 있게 해줌
  • 메서드 이름 쿼리 → @Query → QueryDSL 순으로 조건이 복잡해질수록 넘어가는 게 자연스러운 흐름
  • Q타입(예: QMember)으로 필드에 접근하고, JPAQueryFactory로 쿼리를 실행
  • where()에 조건 메서드를 여러 개 넘기고 조건이 없으면 null을 리턴하는 패턴으로 동적 쿼리를 우아하게 처리
  • 페이징 쓸 땐 count 쿼리를 목록 쿼리와 분리해서 불필요한 조인을 빼는 게 성능에 도움됨

다음 편에서는 JPA 쓰다 보면 누구나 한 번은 겪는 N+1 문제를 다뤄볼게요.

참고

JPA 시리즈 전체 보기

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤