[UMC] QueryDSL를 알아보자

Backend·2025-11-05·by 1000hyehyang

JPA만으로도 기본적인 CRUD는 해결할 수 있다. 그러나 서비스가 확장되고 복잡한 검색 조건이 필요해지면, 결국 직접 쿼리를 작성해야 하는 순간이 온다. 이를 위해 우리는 JPQL을 사용해 왔다. 다만 JPQL은 문자열 기반으로 쿼리를 작성하기 때문에, 오타 하나로 런타임 에러가 발생하고 디버깅 비용이 커진다는 단점이 있다. 메서드 이름 기반 쿼리 역시 일정 수준 이상의 복잡도를 넘어서면 처리하기 어려워진다.

querydsl

이 문제를 해결하기 위해 등장한 것이 QueryDSL이다. QueryDSL은 JPQL/Criteria의 장점을 유지하면서도, 쿼리를 정적 타입 기반으로 작성할 수 있게 하는 내부 DSL이다. 즉, 기존처럼 문자열로 쿼리를 조립하는 방식이 아니라, 컴파일 타임에 검증되는 자바 코드로 쿼리를 작성한다. IDE 자동완성과 함께 타입을 기반으로 빌더 패턴을 활용하기 때문에, 쿼리 작성 과정에서 발생할 수 있는 오류를 사전에 방지한다.

이 지점에서 QueryDSL을 사용하는 이유가 명확해진다. 문자열 기반 쿼리는 오탈자나 필드명 변경처럼 사소한 문제도 컴파일 단계에서 걸러지지 않고 런타임까지 숨어들어 디버깅 비용을 키운다. 반면 QueryDSL은 엔티티에서 생성된 Q-클래스를 통해 도메인 속성에 타입 기반으로 접근하기 때문에, 잘못된 경로나 타입 불일치가 발생하면 컴파일 단계에서 즉시 오류가 발생한다. 그 결과 리팩토링이 쉬워지고, 복잡한 조건 조합이 필요한 동적 where 절 역시 안전하게 구성할 수 있게 된다.

정리하자면, JPA 개발 과정에서 문자열 JPQL이 주는 불안정성과 확장성의 한계로 인해, 자연스럽게 “코드로 쿼리를 작성하는 방식”에 대한 요구가 생겨 QueryDSL이 등장했다.

 

Q클래스는 무엇인고

큐클래스

QueryDSL을 적용하면 빌드 시 엔티티마다 QReview, QStore 같은 Q-클래스(쿼리 타입)가 생성된다. 이 타입은 엔티티 구조를 그대로 반영하며, 각 필드에 대한 정확한 경로와 타입 정보를 가진다. 덕분에 IDE 자동완성으로 조건을 조립할 수 있고, 잘못된 연산이나 존재하지 않는 필드 접근은 컴파일 시점에 바로 잡힌다.

JPQL로는 “문자열로 쿼리 작성 → 실행 → 깨짐 → 수정” 을 해야했다면, QueryDSL로는 “코드로 쿼리 작성 → 컴파일이 걸러줌 → IDE가 도와줌” 으로 바뀐다.

 

Q-클래스는 버전관리(Git)에 올리지 마세요

Q-클래스는 어디까지나 빌드 산출물이다. JDK, Gradle 플러그인, QueryDSL 버전, 그리고 엔티티 구조 변화에 따라 언제든 달라질 수 있다. 이 파일을 VCS에 포함하면 환경 차이로 인해 “내 PC에서는 되는데?” 같은 동료 개발자의 발언을 들을 수 있다.

그래서 보통 build/generated/querydsl처럼 전용 디렉토리를 잡고 .gitignore에 넣는다.

 

QueryDSL에서 동적 쿼리를 만드는 방법

동적 쿼리는 런타임 입력(검색어, 필터, 정렬, 페이징 등)에 따라 필요한 조건만 조립해 실행하는 기법이다. 마켓·검색 화면처럼 조건 조합이 무수히 많이 필요한 영역에서는 사실상 필수다.

문자열로 조립하는 방식(JPQL, MyBatis xml)은 오탈자, 인젝션 위험, 유지보수 비용이 뒤따르지만, QueryDSL은 null 무시 where 가변인자 패턴 + BooleanExpression 헬퍼를 통해 해결한다.

QueryDSL에서 동적 where 조건을 구성할 때는 주로 BooleanBuilder 또는 BooleanExpression 헬퍼를 사용한다. BooleanBuilder는 조건을 순차적으로 쌓아가는 빌더 역할을 하고, BooleanExpression은 조건 단위를 함수로 분리해 재사용하기 좋다.

간단한 검색 API라면 nameContains(String q), ratingGte(Integer r)처럼 nullable 입력을 허용하는 헬퍼를 만들고, where(expr1, expr2, ...) 형태로 한 번에 전달하는 방식이 가독성과 테스트 용이성 모두에서 유리하다. 공식 API 문서에서도 BooleanBuilder를 “Predicate 표현을 점진적으로 합성하는 빌더”로 설명한다.

 

JPA @Query

문자열 기반이라 쿼리가 복잡해질수록 유지보수 난도가 커진다.

@Repository
public interface UserJpaRepository extends JpaRepository<UserEntity, Long> {

    @Query("SELECT u FROM UserEntity u WHERE u.userId = :userId")
    List<UserEntity> findByUserId(String userId);

    @Query(value = "SELECT * FROM TB_USER WHERE user_id = :userId", nativeQuery = true)
    List<UserEntity> selectUserId(@Param("userId") long userId);
}

동적 쿼리 필요 시 문자열 더하기 or if문 증가 → 코드가 더러워지고 수정하기 힘들다

MyBatis

조건이 존재할 때만 WHERE 절을 붙인다. 런타임 조건에 따라 SQL을 동적으로 생성한다.

<select id="selectBoardList" resultType="boardDto">
    SELECT *
    FROM tb_board
    WHERE 1=1
    <if test="title != null">
        AND title = #{title}
    </if>
    <if test="content != null">
        AND content = #{content}
    </if>
</select>

유연하지만 문자열 관리가 부담스럽고 SQL 인젝션 대비 로직 필요하다.

QueryDSL

QueryDSL은 where(expr1, expr2, ...)에서 null 조건은 자동으로 제외된다.

즉, 조건을 함수로 분리하고 null을 반환하면? → where절에서 빠짐

@Override
public List<BoardEntity> selectBoardList(BoardDto dto) {
    return queryFactory
        .select(board)
        .from(board)
        .where(
            eqTitle(dto.getTitle()),
            eqContent(dto.getContent())
        )
        .orderBy(board.boardSq.desc())
        .fetch();
}

private BooleanExpression eqTitle(String title) {
    return hasText(title) ? board.title.eq(title) : null;
}

private BooleanExpression eqContent(String content) {
    return hasText(content) ? board.content.eq(content) : null;
}

 

DTO - 생성자, 필드, 빈, 그리고 @QueryProjection

API 응답을 위해서는 결국 SQL 결과를 DTO로 매핑해서 반환해야 한다. 응답에 필요한 필드만 뽑아서 DTO로 만들고 반환하는데, 이 과정을 Projection이라고 부른다.

QueryDSL에서는 이 프로젝션을 위해 네 가지 방식을 제공한다. constructor, fields, bean, 그리고 @QueryProjection. 요건 상황에 따라 장단점이 조금씩 다르다.

@Data
@AllArgsConstructor
public class ReviewDto {
    private Long id;
    private String storeName;
    private Integer rating;
    private LocalDateTime createdAt;
}

요런 DTO가 있다고 해보자.

1) Projections.constructor

.select(Projections.constructor(
    ReviewDto.class,
    review.id,
    review.store.name,
    review.rating,
    review.createdAt
))

생성자 파라미터 순서와 타입이 정확히 맞아야 한다.

즉, DTO 생성자가 (Long, String, Integer, LocalDateTime) 이런 순서라면 쿼리에서도 딱 그 순서대로 값을 넘겨야 한다. 한 글자라도 타입이 어긋나거나 순서가 바뀌면 런타임에서 바로 터진다.

불변 DTO와 궁합이 좋다.

2) Projections.fields

.select(Projections.fields(
    ReviewDto.class,
    review.id,
    review.store.name.as("storeName"),
    review.rating,
    review.createdAt
))

필드명만 잘 맞추면 자동으로 DTO 필드에 값이 채워진다.

다만 setter가 없어도 동작할 수 있지만, private final이면 불가능하다. 그래서 record나 완전 불변 DTO하고는 잘 안 맞는 방식이다. 필드명 바꾸면 매핑도 바로 깨지기 때문에 편하지만 느슨하고 깨지기 쉬운 방식이다.

3) Projections.bean

.select(Projections.bean(
    ReviewDto.class,
    review.id,
    review.store.name.as("storeName"),
    review.rating,
    review.createdAt
))

내부적으로 setter를 호출한다. 필드가 많을 때 setter 방식이 유연하지만 DTO에 setter 필요해서 불변 DTO와 맞지 않다.

  1. @QueryProjection
public class ReviewDto {

    @QueryProjection
    public ReviewDto(Long id, String storeName, Integer rating, LocalDateTime createdAt) {
        ...
    }
}

DTO에 애노테이션을 붙여준다.

.select(new QReviewDto(
    review.id,
    review.store.name,
    review.rating,
    review.createdAt
))

@QueryProjection은 컴파일 타임 검증이 가능해 대규모 리팩토링에 강하지만 DTO가 QueryDSL에 의존한다는 트레이드오프가 생긴다.

 

연관 데이터를 조회하다 보면, DB는 기본적으로 “평평한 로우”만 준다. 예를 들어 회원과 주문을 JOIN한다고 해보자. 철수가 햄버거와 피자를 주문했다면, DB는 이렇게 응답한다.

member order
철수 햄버거
철수 피자

하지만 우리가 API로 내보내고 싶은 데이터 구조는 이게 아니다. 보통은 “철수라는 회원이 있고, 그 아래 주문 리스트가 있다”는 계층 구조를 원한다.

{
  "name": "철수",
  "orders": [
    { "name": "햄버거" },
    { "name": "피자" }
  ]
}

이 간극을 메꾸는 게 바로 중첩 DTO다. DB는 flat하게 주는데, 우리는 nested하게 만들고 싶은 상황! 여기서 QueryDSL의 groupBy().as()를 사용할 수 있다.

QueryDSL은 transform()을 통해, DB에서 가져온 flat 데이터를 자바 코드 안에서 다시 계층형으로 재조립할 수 있다.

.transform(groupBy(member.id).as(
   new MemberDto(
      member.id,
      member.name,
      list(new OrderDto(order.id, order.name))
   )
));

DB 레벨에서 GROUP BY를 하는 게 아니다. 쿼리를 날려서 로우들을 받아온 다음에, 자바 메모리 안에서 값들을 묶어주는 작업이다.

Fetch Join은 연관 데이터를 한 번에 가져오는 좋은 도구지만, OneToMany에서 페이징이 섞이면 row 수가 기하급수적으로 증가하면서 성능이 급격히 나빠진다. 결국 컬렉션 fetch join 대신 페이지는 별도 쿼리, 연관 데이터는 batch-size + DTO 투사 + groupBy(transform) 조합으로 처리하자.

 

댓글 로딩 중...