Post

QueryDSL

QueryDSL

1. QueryDSL 설정


📌 의존성 및 Q클래스 생성 설정 (Spring Boot 3.x / Gradle Kotlin DSL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// build.gradle.kts
plugins {
    kotlin("jvm") version "..."
    kotlin("plugin.spring") version "..."
    id("com.ewerk.gradle.plugins.querydsl") version "1.0.10"
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
    annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
}

// Q클래스 생성 경로 설정
val generated = "src/main/generated"

tasks.withType<JavaCompile> {
    options.generatedSourceOutputDirectory.set(file(generated))
}

sourceSets {
    main {
        java.srcDirs(generated)
    }
}

tasks.named("clean") {
    doLast {
        file(generated).deleteRecursively()
    }
}

💡 Spring Boot 3.x는 jakarta 분류자를 사용해야 한다. querydsl-jpa:5.0.0:jakarta에서 :jakarta가 핵심이다. javax 분류자를 사용하면 @Entity를 인식하지 못한다.

📌 Q클래스란

@Entity가 붙은 클래스에 대해 APT(Annotation Processing Tool)가 컴파일 시점에 Q접두사가 붙은 클래스를 자동 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
// 원본 엔티티
@Entity
public class Book { ... }

// 자동 생성되는 Q클래스
public class QBook extends EntityPathBase<Book> {
    public static final QBook book = new QBook("book");
    public final StringPath title = createString("title");
    public final NumberPath<Integer> price = createNumber("price", Integer.class);
    public final QAuthor author = ...;
    // ...
}

Q클래스를 통해 타입 안전한 쿼리 작성이 가능해진다.

📌 JPAQueryFactory Bean 등록

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

2. 기본 쿼리 작성


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Repository
@RequiredArgsConstructor
public class BookQueryRepository {

    private final JPAQueryFactory queryFactory;

    QBook book = QBook.book;
    QAuthor author = QAuthor.author;

    // SELECT * FROM book WHERE category = 'IT'
    public List<Book> findByCategory(String category) {
        return queryFactory
            .selectFrom(book)
            .where(book.category.eq(category))
            .fetch();
    }

    // SELECT * FROM book ORDER BY published_date DESC LIMIT 10 OFFSET 0
    public List<Book> findLatest(int page, int size) {
        return queryFactory
            .selectFrom(book)
            .orderBy(book.publishedDate.desc())
            .offset((long) page * size)
            .limit(size)
            .fetch();
    }
}

📌 결과 조회 메서드

1
2
3
4
queryFactory.selectFrom(book).fetch();           // List 반환. 없으면 빈 리스트
queryFactory.selectFrom(book).fetchOne();        // 단건. 없으면 null, 2건+ 이면 예외
queryFactory.selectFrom(book).fetchFirst();      // LIMIT 1로 첫 번째 결과
queryFactory.selectFrom(book).fetchCount();      // COUNT 쿼리 (Deprecated in 5.x)

💡 fetchResults()fetchCount()는 Hibernate 6.x에서 Deprecated됐다. COUNT 쿼리는 별도로 작성해야 한다.

📌 WHERE 조건 표현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 동등 비교
book.title.eq("스프링 부트")        // title = '스프링 부트'
book.title.ne("스프링 부트")        // title != '스프링 부트'
book.price.gt(10000)               // price > 10000
book.price.goe(10000)              // price >= 10000
book.price.lt(30000)               // price < 30000
book.price.loe(30000)              // price <= 30000
book.price.between(10000, 30000)   // price BETWEEN 10000 AND 30000

// 문자열
book.title.contains("스프링")      // title LIKE '%스프링%'
book.title.startsWith("스프링")    // title LIKE '스프링%'
book.title.endsWith("입문")        // title LIKE '%입문'
book.title.like("%스프링%")        // title LIKE '%스프링%'

// NULL 체크
book.deletedAt.isNull()            // deleted_at IS NULL
book.deletedAt.isNotNull()         // deleted_at IS NOT NULL

// IN
book.category.in("IT", "Science")  // category IN ('IT', 'Science')
book.id.notIn(1L, 2L, 3L)          // id NOT IN (1, 2, 3)

// AND / OR
book.category.eq("IT").and(book.price.lt(20000))
book.category.eq("IT").or(book.category.eq("Science"))

3. 동적 쿼리


QueryDSL에서 동적 쿼리를 작성하는 방법은 두 가지다.

📌 방법 1 — BooleanBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public List<Book> search(String keyword, String category, Integer minPrice, Integer maxPrice) {
    BooleanBuilder builder = new BooleanBuilder();

    if (keyword != null && !keyword.isBlank()) {
        builder.and(book.title.contains(keyword));
    }
    if (category != null) {
        builder.and(book.category.eq(category));
    }
    if (minPrice != null) {
        builder.and(book.price.goe(minPrice));
    }
    if (maxPrice != null) {
        builder.and(book.price.loe(maxPrice));
    }

    return queryFactory
        .selectFrom(book)
        .where(builder)
        .fetch();
}

📌 방법 2 — BooleanExpression (권장)

BooleanExpression을 반환하는 메서드로 조건을 분리한다. null을 반환하면 QueryDSL이 해당 조건을 자동으로 무시한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public List<Book> search(String keyword, String category, Integer minPrice, Integer maxPrice) {
    return queryFactory
        .selectFrom(book)
        .where(
            keywordContains(keyword),
            categoryEq(category),
            priceGoe(minPrice),
            priceLoe(maxPrice),
            book.deletedAt.isNull()
        )
        .fetch();
}

private BooleanExpression keywordContains(String keyword) {
    return (keyword != null && !keyword.isBlank())
        ? book.title.contains(keyword)
        : null;  // null이면 조건 무시
}

private BooleanExpression categoryEq(String category) {
    return category != null ? book.category.eq(category) : null;
}

private BooleanExpression priceGoe(Integer minPrice) {
    return minPrice != null ? book.price.goe(minPrice) : null;
}

private BooleanExpression priceLoe(Integer maxPrice) {
    return maxPrice != null ? book.price.loe(maxPrice) : null;
}

BooleanExpression 방식이 BooleanBuilder보다 나은 이유:

  • 각 조건이 독립적인 메서드로 분리되어 재사용 가능
  • 코드가 더 선언적이고 읽기 쉬움
  • 조건 조합이 where() 안에서 명확하게 드러남

📌 Null-safe 조건 메서드 분리 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 조건 메서드를 별도 클래스로 분리 — 여러 Repository에서 재사용
public class BookConditions {

    public static BooleanExpression titleContains(String title) {
        return StringUtils.hasText(title) ? QBook.book.title.contains(title) : null;
    }

    public static BooleanExpression priceRange(Integer min, Integer max) {
        if (min != null && max != null) return QBook.book.price.between(min, max);
        if (min != null) return QBook.book.price.goe(min);
        if (max != null) return QBook.book.price.loe(max);
        return null;
    }

    public static BooleanExpression notDeleted() {
        return QBook.book.deletedAt.isNull();
    }
}

4. 조인과 서브쿼리


📌 JOIN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// INNER JOIN
queryFactory
    .selectFrom(book)
    .join(book.author, author)
    .where(author.name.eq("김저자"))
    .fetch();

// LEFT JOIN
queryFactory
    .selectFrom(book)
    .leftJoin(book.author, author)
    .fetch();

// FETCH JOIN — 영속성 컨텍스트에 연관 엔티티까지 로딩
queryFactory
    .selectFrom(book)
    .join(book.author, author).fetchJoin()
    .where(book.category.eq("IT"))
    .fetch();

// ON 절 추가
queryFactory
    .selectFrom(book)
    .leftJoin(book.author, author).on(author.name.eq("김저자"))
    .fetch();

📌 서브쿼리 — JPAExpressions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
QBook bookSub = new QBook("bookSub");

// 평균 가격 이상인 도서 조회
queryFactory
    .selectFrom(book)
    .where(book.price.gt(
        JPAExpressions
            .select(bookSub.price.avg())
            .from(bookSub)
    ))
    .fetch();

// 특정 저자의 도서 중 최고가 도서
queryFactory
    .selectFrom(book)
    .where(book.price.eq(
        JPAExpressions
            .select(bookSub.price.max())
            .from(bookSub)
            .where(bookSub.author.name.eq("김저자"))
    ))
    .fetch();

💡 JPQL과 마찬가지로 FROM 절 서브쿼리는 지원하지 않는다. FROM 절 서브쿼리가 필요하면 Native Query 또는 별도 조회 후 애플리케이션에서 조합하는 방식을 사용한다.


5. DTO 조회


📌 Projections.constructor

1
2
3
4
5
6
7
8
9
10
public record BookSummaryDto(String title, int price, String authorName) {}

List<BookSummaryDto> result = queryFactory
    .select(Projections.constructor(BookSummaryDto.class,
        book.title,
        book.price,
        author.name))
    .from(book)
    .join(book.author, author)
    .fetch();

생성자 파라미터 순서와 select 순서가 일치해야 한다.

📌 Projections.fields

1
2
3
4
5
6
7
8
9
// 필드명으로 매핑 (setter 없이 reflection 사용)
List<BookSummaryDto> result = queryFactory
    .select(Projections.fields(BookSummaryDto.class,
        book.title,
        book.price,
        author.name.as("authorName")))  // 필드명이 다르면 as()로 별칭 지정
    .from(book)
    .join(book.author, author)
    .fetch();

📌 Projections.bean

1
2
3
4
5
6
7
8
9
// setter 기반 매핑
List<BookSummaryDto> result = queryFactory
    .select(Projections.bean(BookSummaryDto.class,
        book.title,
        book.price,
        author.name.as("authorName")))
    .from(book)
    .join(book.author, author)
    .fetch();

📌 @QueryProjection (권장)

DTO 생성자에 @QueryProjection을 붙이면 APT가 QBookSummaryDto 클래스를 생성한다. 가장 타입 안전한 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class BookSummaryDto {

    private String title;
    private int price;
    private String authorName;

    @QueryProjection  // Q클래스 생성
    public BookSummaryDto(String title, int price, String authorName) {
        this.title = title;
        this.price = price;
        this.authorName = authorName;
    }
}

// 사용 — 컴파일 타임에 타입 오류 감지
List<BookSummaryDto> result = queryFactory
    .select(new QBookSummaryDto(
        book.title,
        book.price,
        author.name))
    .from(book)
    .join(book.author, author)
    .fetch();

6. 커스텀 Repository 완성


📌 Impl 패턴 + JPAQueryFactory 최종 통합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    QBook book = QBook.book;
    QAuthor author = QAuthor.author;

    @Override
    public Page<BookSummaryDto> searchBooksPaged(
            BookSearchCondition condition, Pageable pageable) {

        List<BookSummaryDto> content = queryFactory
            .select(new QBookSummaryDto(book.title, book.price, author.name))
            .from(book)
            .leftJoin(book.author, author)
            .where(
                keywordContains(condition.getKeyword()),
                categoryEq(condition.getCategory()),
                priceGoe(condition.getMinPrice()),
                priceLoe(condition.getMaxPrice()),
                book.deletedAt.isNull()
            )
            .orderBy(book.publishedDate.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        // fetchResults() Deprecated 대체 — COUNT 쿼리 별도 작성
        Long total = queryFactory
            .select(book.count())
            .from(book)
            .leftJoin(book.author, author)
            .where(
                keywordContains(condition.getKeyword()),
                categoryEq(condition.getCategory()),
                priceGoe(condition.getMinPrice()),
                priceLoe(condition.getMaxPrice()),
                book.deletedAt.isNull()
            )
            .fetchOne();

        return new PageImpl<>(content, pageable, total != null ? total : 0);
    }

    private BooleanExpression keywordContains(String keyword) {
        return StringUtils.hasText(keyword) ? book.title.contains(keyword) : null;
    }
    private BooleanExpression categoryEq(String category) {
        return category != null ? book.category.eq(category) : null;
    }
    private BooleanExpression priceGoe(Integer min) {
        return min != null ? book.price.goe(min) : null;
    }
    private BooleanExpression priceLoe(Integer max) {
        return max != null ? book.price.loe(max) : null;
    }
}

📌 Slice 커서 기반 페이징

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Slice<BookSummaryDto> searchSlice(Long lastId, int size, String category) {
    List<BookSummaryDto> content = queryFactory
        .select(new QBookSummaryDto(book.title, book.price, author.name))
        .from(book)
        .leftJoin(book.author, author)
        .where(
            lastId != null ? book.id.lt(lastId) : null,  // 커서 조건
            categoryEq(category),
            book.deletedAt.isNull()
        )
        .orderBy(book.id.desc())
        .limit(size + 1)  // size + 1로 hasNext 판단
        .fetch();

    boolean hasNext = content.size() > size;
    if (hasNext) content.remove(content.size() - 1);

    return new SliceImpl<>(content, PageRequest.of(0, size), hasNext);
}

7. 정리


개념핵심 요약
Q클래스APT가 @Entity 기반으로 자동 생성. 타입 안전 쿼리의 기반
JPAQueryFactoryQueryDSL 쿼리 실행의 시작점. EntityManager 기반 Bean 등록
BooleanExpressionnull 반환 시 조건 자동 무시. 동적 쿼리의 핵심
fetchJoin()LAZY 연관 엔티티를 한 번에 로딩. N+1 해결
@QueryProjectionDTO 생성자에 붙여 Q클래스 생성. 가장 타입 안전한 Projection
COUNT 별도 작성fetchResults() Deprecated 대응. 데이터 조회와 COUNT 분리
커서 기반 Slice오프셋 페이징 대안. id 기반 커서로 IN Memory 페이징 방지

참고 자료

This post is licensed under CC BY 4.0 by the author.