QueryDSL과 커스텀 Repository
💡 [참고] 기본 개념 관련 시리즈 포스트입니다. 순서대로 읽어보시길 권장합니다.
1. QueryDSL과 커스텀 Repository
1
2
3
4
5
6
7
8
9
JpaRepository만으로는 뭐가 부족한가?
↓
커스텀 Repository 패턴이란 무엇인가?
↓
동적 쿼리를 직접 짜면 왜 힘든가?
↓
QueryDSL이 그 문제를 어떻게 해결하는가?
↓
커스텀 Repository + QueryDSL = 완성된 전체 그림
2. JpaRepository만으로 부족한 경우
JpaRepository의 메서드 이름 쿼리와 @Query는 조건이 고정되어 있을 때 편리하다.
1
2
// 고정 조건 — 항상 category가 있고 항상 price 조건이 있음
List<Book> findByCategoryAndPriceLessThan(String category, int price);
하지만 검색 화면처럼 조건이 선택적인 경우에는 이 방식이 한계에 부딪힌다.
1
2
3
4
5
6
7
사용자가 검색할 수 있는 조건:
- 제목 키워드 (없을 수도 있음)
- 카테고리 (없을 수도 있음)
- 최소 가격 (없을 수도 있음)
- 최대 가격 (없을 수도 있음)
가능한 조합의 수: 2^4 = 16가지
16가지 조합마다 메서드를 만들 수는 없다. 이것이 동적 쿼리(Dynamic Query) 가 필요한 이유다.
3. 동적 쿼리를 직접 짜면 왜 힘든가
동적 쿼리를 가장 원시적인 방법으로 짜면 이렇게 된다.
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
// JPQL을 문자열로 조합 — 실제로 이렇게 짜야 한다
public List<Book> searchBooks(String keyword, String category,
Integer minPrice, Integer maxPrice) {
StringBuilder jpql = new StringBuilder(
"SELECT b FROM Book b WHERE b.deletedAt IS NULL"
);
if (keyword != null && !keyword.isBlank()) {
jpql.append(" AND b.title LIKE :keyword");
}
if (category != null) {
jpql.append(" AND b.category = :category");
}
if (minPrice != null) {
jpql.append(" AND b.price >= :minPrice");
}
if (maxPrice != null) {
jpql.append(" AND b.price <= :maxPrice");
}
jpql.append(" ORDER BY b.publishedDate DESC");
TypedQuery<Book> query = em.createQuery(jpql.toString(), Book.class);
// 파라미터 바인딩도 또 조건 분기
if (keyword != null && !keyword.isBlank()) {
query.setParameter("keyword", "%" + keyword + "%");
}
if (category != null) {
query.setParameter("category", category);
}
// ...
}
문제가 3가지 있다.
문제 1 — 조건 분기가 두 번 나온다. 문자열 조합 때 한 번, 파라미터 바인딩 때 또 한 번. 조건이 10개가 되면 20개의 if 블록이 생긴다.
문제 2 — 오타를 컴파일 타임에 잡을 수 없다. "b.titel" 이라고 써도 컴파일은 된다. 런타임에 실행해봐야 오류를 알 수 있다.
문제 3 — 리팩토링이 어렵다. Book.title 필드명이 name으로 바뀌면 이 문자열을 직접 찾아가서 고쳐야 한다. IDE의 “모든 참조 찾기”가 동작하지 않는다.
이 문제들을 해결하기 위해 QueryDSL이 등장한다.
4. QueryDSL이 해결하는 방식
QueryDSL은 자바 코드로 쿼리를 작성한다. 문자열이 아니라 객체다.
📌 Q클래스 — 엔티티를 자바 객체로 표현한 것
QueryDSL을 설정하면 APT(Annotation Processing Tool)가 @Entity 클래스를 분석해서 Q접두사가 붙은 클래스를 자동으로 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 원본 엔티티
@Entity
public class Book {
private String title;
private int price;
private String category;
private LocalDateTime deletedAt;
}
// APT가 자동 생성하는 Q클래스 (src/main/generated 폴더에 생성됨)
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 StringPath category = createString("category");
public final DateTimePath<LocalDateTime> deletedAt = ...;
}
QBook.book은 Book 엔티티를 자바 객체로 표현한 것이다. book.title, book.price는 단순한 문자열이 아니라 타입 정보를 가진 객체다. book.title.eq("스프링")이라고 쓰면 IDE가 자동완성을 제공하고, title 필드가 없으면 컴파일 오류가 난다.
📌 문자열 조합 vs QueryDSL 비교
1
2
3
4
5
6
7
8
9
10
11
// 문자열 조합 방식 — 같은 조건을 두 번 분기, 오타 위험
if (keyword != null) jpql.append(" AND b.title LIKE :keyword");
// ...
if (keyword != null) query.setParameter("keyword", "%" + keyword + "%");
// QueryDSL 방식 — 조건 하나가 하나의 메서드로 완결
private BooleanExpression keywordContains(String keyword) {
return StringUtils.hasText(keyword)
? book.title.contains(keyword) // LIKE '%keyword%' 자동 생성
: null; // null이면 이 조건 전체가 무시됨
}
null을 반환하면 QueryDSL이 해당 조건을 where()에서 자동으로 제외한다. 이것이 BooleanExpression 패턴의 핵심이다.
5. 그런데 QueryDSL 코드를 어디에 써야 하는가?
여기서 커스텀 Repository 패턴이 필요해진다.
JpaRepository는 인터페이스다. 인터페이스에 QueryDSL 코드를 직접 쓸 수 없다. QueryDSL은 JPAQueryFactory 객체를 사용하는 구현 코드이기 때문이다.
1
2
3
4
5
6
7
// 이렇게 할 수 없다 — 인터페이스에 구현 코드 불가
public interface BookRepository extends JpaRepository<Book, Long> {
// QueryDSL 코드를 여기에? → 불가능
List<Book> searchBooks(...) {
return queryFactory.selectFrom(book)... // 인터페이스에 구현 불가
}
}
해결 방법은 구현체를 별도로 만들고 Spring이 이를 자동으로 연결하도록 하는 것이다. 이것이 커스텀 Repository 패턴이다.
6. 커스텀 Repository + QueryDSL 전체 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────┐
│ 서비스 계층 │
│ private final BookRepository bookRepository; │
│ → bookRepository 하나로 기본 CRUD + QueryDSL 검색 모두 사용 │
└───────────────────────────┬─────────────────────────────────────┘
│ 주입
▼
┌─────────────────────────────────────────────────────────────────┐
│ BookRepository (인터페이스) │
│ extends JpaRepository<Book, Long> ← 기본 CRUD │
│ extends BookRepositoryCustom ← 커스텀 메서드 선언 │
└───────────┬─────────────────────┬───────────────────────────────┘
│ Spring이 자동 연결 │
▼ ▼
┌───────────────────┐ ┌─────────────────────────────────────────┐
│ SimpleJpaRepo... │ │ BookRepositoryImpl (구현체) │
│ (기본 CRUD 구현) │ │ implements BookRepositoryCustom │
│ - save() │ │ private final JPAQueryFactory qf; │
│ - findById() │ │ → QueryDSL 코드가 여기에 작성됨 │
│ - delete() │ └─────────────────────────────────────────┘
└───────────────────┘
핵심은 Spring이 런타임에 BookRepositoryImpl을 자동으로 찾아서 BookRepository에 결합한다는 것이다. 이게 되려면 명명 규칙을 지켜야 한다. 최종 Repository 인터페이스명 + Impl.
7. 코드로 완성하기 — 단계별 구현
📌 Step 0 — JPAQueryFactory Bean 등록
QueryDSL을 쓰려면 JPAQueryFactory가 Spring 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);
}
}
JPAQueryFactory는 내부적으로 EntityManager를 사용한다. 2장에서 배운 것처럼 EntityManager는 ThreadLocal 프록시로 주입되므로 싱글톤 Bean으로 등록해도 스레드 안전하다.
📌 Step 1 — 커스텀 인터페이스 선언
QueryDSL로 구현할 메서드 시그니처만 선언한다. 구현은 Impl에서 한다.
1
2
3
4
5
6
7
8
9
10
// BookRepositoryCustom.java
public interface BookRepositoryCustom {
// 검색 조건이 선택적 → 동적 쿼리가 필요 → QueryDSL로 구현할 것
List<Book> searchBooks(String keyword, String category,
Integer minPrice, Integer maxPrice);
Page<BookSummaryDto> searchBooksPaged(BookSearchCondition condition,
Pageable pageable);
}
📌 Step 2 — 최종 Repository에 상속 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BookRepository.java
public interface BookRepository
extends JpaRepository<Book, Long>, // 기본 CRUD
BookRepositoryCustom { // 커스텀 메서드
// 고정 조건 → 메서드 이름 쿼리로 해결
Optional<Book> findByIsbn(String isbn);
// 복잡한 고정 쿼리 → @Query로 해결
@Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id")
Optional<Book> findWithAuthorById(@Param("id") Long id);
// 동적 조건 → BookRepositoryCustom에 선언, Impl에서 QueryDSL로 구현
}
📌 Step 3 — BookRepositoryImpl 작성 (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
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// BookRepositoryImpl.java
// 명명 규칙: BookRepository + Impl
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepositoryCustom {
private final JPAQueryFactory queryFactory; // Bean 주입
// Q클래스 — 타입 안전한 쿼리 작성의 시작점
private final QBook book = QBook.book;
private final QAuthor author = QAuthor.author;
@Override
public List<Book> searchBooks(String keyword, String category,
Integer minPrice, Integer maxPrice) {
return queryFactory
.selectFrom(book)
.leftJoin(book.author, author).fetchJoin() // N+1 방지
.where(
keywordContains(keyword), // null이면 이 조건 무시
categoryEq(category), // null이면 이 조건 무시
priceGoe(minPrice), // null이면 이 조건 무시
priceLoe(maxPrice), // null이면 이 조건 무시
book.deletedAt.isNull() // 소프트 딜리트 제외 (항상 적용)
)
.orderBy(book.publishedDate.desc())
.fetch();
}
@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();
// COUNT 쿼리 별도 작성 (fetchResults() Deprecated 대응)
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 : 0L);
}
// ──────────────────────────────────────────────────────────
// BooleanExpression 조건 메서드
// null을 반환하면 QueryDSL이 where()에서 해당 조건을 자동으로 제외
// ──────────────────────────────────────────────────────────
private BooleanExpression keywordContains(String keyword) {
return StringUtils.hasText(keyword)
? book.title.contains(keyword) // LIKE '%keyword%'
: 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;
}
}
📌 Step 4 — 서비스에서 사용
서비스는 BookRepository 하나만 주입받는다. 기본 CRUD도, 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
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookService {
private final BookRepository bookRepository; // 하나로 다 됨
// 기본 CRUD — SimpleJpaRepository가 처리
public Optional<Book> findById(Long id) {
return bookRepository.findById(id);
}
// @Query — BookRepository 인터페이스가 처리
public Optional<Book> findWithAuthor(Long id) {
return bookRepository.findWithAuthorById(id);
}
// QueryDSL 동적 검색 — BookRepositoryImpl이 처리
public List<BookResponse> search(String keyword, String category,
Integer minPrice, Integer maxPrice) {
return bookRepository.searchBooks(keyword, category, minPrice, maxPrice)
.stream()
.map(BookResponse::from)
.toList();
}
}
8. 동작 원리 — Spring은 Impl을 어떻게 찾는가
Spring Data JPA는 애플리케이션 시작 시점에 BookRepository 인터페이스를 분석한다. BookRepositoryCustom을 상속하고 있으므로 해당 메서드를 구현한 Bean이 필요하다는 것을 안다. 그때 클래스패스에서 BookRepository + Impl = BookRepositoryImpl이라는 이름의 클래스를 찾는다. 있으면 자동으로 결합한다.
1
2
3
4
5
6
Spring이 BookRepository Bean을 만들 때:
1. BookRepository가 BookRepositoryCustom을 상속함을 확인
2. 클래스패스에서 "BookRepositoryImpl" 검색
3. 발견 → Bean 등록 후 BookRepository에 결합
4. bookRepository.searchBooks() 호출 → BookRepositoryImpl.searchBooks()로 위임
BookRepositoryImpl에 @Repository를 붙이지 않아도 되는 이유가 이 때문이다. Spring이 직접 찾아서 연결하므로 명시적인 Bean 등록이 필요 없다.
9. 문자열 JPQL vs QueryDSL 최종 비교
3장에서 문자열로 짰던 동적 쿼리와 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
31
32
// ❌ 문자열 JPQL — 조건 분기 두 번, 오타 위험, 리팩토링 어려움
public List<Book> searchBooks(String keyword, String category,
Integer minPrice, Integer maxPrice) {
StringBuilder jpql = new StringBuilder("SELECT b FROM Book b WHERE 1=1");
if (keyword != null) jpql.append(" AND b.title LIKE :keyword");
if (category != null) jpql.append(" AND b.category = :category");
if (minPrice != null) jpql.append(" AND b.price >= :minPrice");
if (maxPrice != null) jpql.append(" AND b.price <= :maxPrice");
TypedQuery<Book> query = em.createQuery(jpql.toString(), Book.class);
if (keyword != null) query.setParameter("keyword", "%" + keyword + "%");
if (category != null) query.setParameter("category", category);
if (minPrice != null) query.setParameter("minPrice", minPrice);
if (maxPrice != null) query.setParameter("maxPrice", maxPrice);
return query.getResultList();
}
// ✅ QueryDSL — 조건 분기 한 번, 컴파일 타임 타입 체크, 자동완성 지원
public List<Book> searchBooks(String keyword, String category,
Integer minPrice, Integer maxPrice) {
return queryFactory
.selectFrom(book)
.where(
keywordContains(keyword),
categoryEq(category),
priceGoe(minPrice),
priceLoe(maxPrice)
)
.fetch();
}
// 각 조건은 독립 메서드로 분리 — 재사용, 테스트, 변경이 모두 쉬움
10. 정리 — 전체 흐름 한 장으로
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
동적 쿼리가 필요한 이유
└→ 검색 조건이 선택적 → JpaRepository 메서드 이름 쿼리로 불가
문자열 JPQL로 직접 짜면
└→ 조건 분기 두 번, 오타 위험, 리팩토링 어려움
QueryDSL이 해결하는 방법
└→ Q클래스: 엔티티 필드를 타입 안전한 객체로 표현
└→ BooleanExpression: null 반환 시 조건 자동 무시
QueryDSL 코드를 어디에 두는가?
└→ JpaRepository는 인터페이스 → 구현 코드를 쓸 수 없음
└→ 커스텀 Repository Impl에 작성
커스텀 Repository 패턴
└→ BookRepositoryCustom (인터페이스) : 메서드 시그니처 선언
└→ BookRepositoryImpl (구현체) : QueryDSL 코드 작성
└→ BookRepository : JpaRepository + Custom 동시 상속
└→ Spring이 Impl을 자동으로 찾아 결합 (명명 규칙: ~Impl)
서비스에서는
└→ BookRepository 하나만 주입 → 기본 CRUD + QueryDSL 검색 모두 사용
참고 자료
- 공식문서 - Spring Data JPA Custom Repository: https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.custom-implementations
- 공식문서 - QueryDSL 5.x: http://querydsl.com/static/querydsl/5.0.0/reference/html_single/