Post

JPA Repository

JPA Repository

1. Repository 계층 구조


📌 인터페이스 상속 다이어그램

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
«interface»
Repository<T, ID>
    │  마커 인터페이스 — 메서드 없음. Spring이 스캔 대상을 식별하는 용도
    │
«interface»
CrudRepository<T, ID>
    │  기본 CRUD 제공 (save, findById, delete 등)
    │
«interface»
PagingAndSortingRepository<T, ID>
    │  페이징 + 정렬 추가
    │
«interface»
JpaRepository<T, ID>
    │  JPA 특화 기능 추가 (flush, saveAll, getReferenceById 등)
    │
«class»
SimpleJpaRepository<T, ID>    ← Spring이 런타임에 생성하는 실제 구현체

개발자는 JpaRepository를 상속하는 인터페이스만 선언하면 된다.

1
2
public interface BookRepository extends JpaRepository<Book, Long> {
}

📌 각 계층이 제공하는 메서드

CrudRepository

1
2
3
4
5
6
7
8
9
10
11
<S extends T> S save(S entity);           // 저장 (신규 persist / 기존 merge)
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
Optional<T> findById(ID id);
boolean existsById(ID id);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> ids);
long count();
void deleteById(ID id);
void delete(T entity);
void deleteAll(Iterable<T> entities);
void deleteAll();

PagingAndSortingRepository (CrudRepository에 추가)

1
2
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);

JpaRepository (위 모두에 추가)

1
2
3
4
5
6
7
8
9
10
11
List<T> findAll();
List<T> findAll(Sort sort);
List<T> findAllById(Iterable<ID> ids);
<S extends T> List<S> saveAll(Iterable<S> entities);
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
T getReferenceById(ID id);
void deleteAllInBatch(Iterable<T> entities);
void deleteAllByIdInBatch(Iterable<ID> ids);
void deleteAllInBatch();

💡 deleteAll()은 엔티티를 하나씩 SELECT 후 DELETE한다. 100건이면 SELECT 100번 + DELETE 100번이다. deleteAllInBatch()DELETE FROM book 단일 쿼리 한 방이다. 대량 삭제 시 반드시 InBatch 버전을 써야 한다.

📌 SimpleJpaRepository — 실제 구현체

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Repository
@Transactional(readOnly = true)   // 클래스 레벨: 모든 메서드 기본이 읽기 전용
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

    @Override
    @Transactional  // 쓰기 메서드만 readOnly=false로 재선언
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }
}

읽기 전용 트랜잭션은 Hibernate의 Dirty Checking 스냅샷을 생성하지 않고, 트랜잭션 종료 시 flush를 skip하므로 성능이 좋다.

📌 save()의 isNew() 판단 기준

상황isNew() 결과동작
@Id 필드가 nulltruepersist
@Id 필드에 값이 있음falsemerge
@Version 필드가 nulltruepersist
Persistable 인터페이스 구현직접 정의커스텀

PK를 직접 할당하는 경우 isNew()가 항상 false → merge 호출 문제가 발생한다. Persistable을 구현해서 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Book implements Persistable<String> {

    @Id
    private String isbn;

    @CreatedDate
    private LocalDateTime createdAt;

    @Override
    public String getId() { return isbn; }

    @Override
    public boolean isNew() {
        return createdAt == null;  // createdAt이 null이면 신규로 판단
    }
}

📌 @NoRepositoryBean

중간 계층 공통 인터페이스를 만들 때 사용한다.

1
2
3
4
5
6
7
@NoRepositoryBean
public interface BaseRepository<T, ID> extends JpaRepository<T, ID> {
    List<T> findByDeletedAtIsNull();   // 소프트 딜리트 공통 메서드
}

public interface BookRepository extends BaseRepository<Book, Long> { }
public interface MemberRepository extends BaseRepository<Member, Long> { }

2. 메서드 이름 쿼리 (Derived Query)


Spring Data JPA는 메서드 이름을 파싱해서 JPQL을 자동으로 생성한다. findByTitle이라고 선언하면 SELECT b FROM Book b WHERE b.title = :title 쿼리가 만들어진다.

Spring Data JPA가 애플리케이션 시작 시점에 인터페이스 메서드를 전부 파싱해서 쿼리를 검증한다. 필드명이 잘못되었거나 타입이 맞지 않으면 애플리케이션 구동 실패로 즉시 오류를 알 수 있다.

📌 접두사 키워드

1
2
3
4
5
6
List<Book> findByCategory(String category);       // 일반 조회
Optional<Book> findByIsbn(String isbn);           // Optional — 단건 조회
long countByCategory(String category);            // 카운트
boolean existsByIsbn(String isbn);                // 존재 여부
void deleteByIsbn(String isbn);                   // 삭제
long deleteByCategory(String category);           // 삭제된 건수 반환

💡 deleteBy는 내부적으로 SELECT 후 하나씩 DELETE한다. 대량 삭제는 @Modifying + @Query가 훨씬 효율적이다.

📌 조건 키워드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 비교
List<Book> findByCategoryNot(String category);               // category != ?
List<Book> findByPriceBetween(int min, int max);             // price BETWEEN ? AND ?
List<Book> findByPriceLessThan(int price);                   // price < ?
List<Book> findByPriceGreaterThanEqual(int price);           // price >= ?
List<Book> findByDeletedAtIsNull();                          // deleted_at IS NULL
List<Book> findByActiveTrue();                               // active = true

// 문자열
List<Book> findByTitleLike(String pattern);                  // title LIKE ?
List<Book> findByTitleContaining(String keyword);            // title LIKE %?%
List<Book> findByTitleStartingWith(String prefix);           // title LIKE ?%
List<Book> findByTitleContainingIgnoreCase(String keyword);  // 대소문자 무시

// 컬렉션
List<Book> findByCategoryIn(List<String> categories);        // category IN (?)
List<Book> findByCategoryNotIn(List<String> categories);     // category NOT IN (?)

// 복합 조건
List<Book> findByTitleAndCategory(String title, String category);
List<Book> findByTitleOrCategory(String title, String category);

📌 정렬과 조회 수 제한

1
2
3
4
5
6
7
8
9
10
11
12
// 메서드명에 OrderBy 포함
List<Book> findByAuthorOrderByPublishedDateDesc(String author);

// Sort 파라미터 — 동적 정렬
List<Book> findByCategory(String category, Sort sort);

// 조회 수 제한
Optional<Book> findFirstByOrderByPublishedDateDesc();
List<Book> findTop3ByCategoryOrderByPriceAsc(String category);

// 중복 제거
List<Book> findDistinctByCategory(String category);

📌 반환 타입

1
2
3
4
5
6
List<Book>          findByCategory(String category);
Optional<Book>      findByIsbn(String isbn);
Page<Book>          findByCategory(String category, Pageable pageable);
Slice<Book>         findByCategory(String category, Pageable pageable);
long                countByCategory(String category);
boolean             existsByIsbn(String isbn);

📌 한계와 전환 기준

상황이유
조건이 3개 이상메서드명 가독성 붕괴
JOIN이 필요한 경우메서드 이름으로 JOIN 표현 불가
동적 조건고정된 메서드명으로 동적 처리 불가
서브쿼리, 집계 함수지원 안 됨

동적 조건이 필요하면 @Query + JPQL, 더 복잡하면 QueryDSL로 간다.


3. @Query 어노테이션 — JPQL과 벌크 연산


📌 @Query 기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// JPQL은 테이블명/컬럼명이 아닌 엔티티명/필드명을 기준으로 작성한다
@Query("SELECT b FROM Book b WHERE b.title LIKE %:keyword% AND b.deletedAt IS NULL")
List<Book> searchByKeyword(@Param("keyword") String keyword);

// JOIN 포함 쿼리
@Query("""
        SELECT b FROM Book b
        JOIN b.author a
        WHERE a.name = :authorName
        AND b.price BETWEEN :minPrice AND :maxPrice
        ORDER BY b.publishedDate DESC
        """)
List<Book> findByAuthorAndPriceRange(
        @Param("authorName") String authorName,
        @Param("minPrice") int minPrice,
        @Param("maxPrice") int maxPrice
);

파라미터 바인딩은 :param 이름 기반이 권장된다. ?1, ?2 위치 기반은 파라미터 순서가 바뀌면 버그가 생기므로 사용하지 않는 것이 낫다.

📌 nativeQuery

1
2
3
4
5
@Query(
    value = "SELECT * FROM book WHERE category = :category AND deleted_at IS NULL LIMIT :limit",
    nativeQuery = true
)
List<Book> findByCategoryNative(@Param("category") String category, @Param("limit") int limit);

네이티브 쿼리에서 Pageable을 쓰려면 countQuery를 반드시 명시해야 한다.

1
2
3
4
5
6
@Query(
    value = "SELECT * FROM book WHERE category = :category AND deleted_at IS NULL",
    countQuery = "SELECT COUNT(*) FROM book WHERE category = :category AND deleted_at IS NULL",
    nativeQuery = true
)
Page<Book> findByCategoryNativePaged(@Param("category") String category, Pageable pageable);

nativeQuery는 DB에 종속된다. 이식성이 중요하면 JPQL을 쓰고, DB 전용 함수(MATCH AGAINST, 쿼리 힌트 등)가 꼭 필요한 경우에만 선택한다.

📌 @Modifying — 벌크 연산

@Query로 UPDATE/DELETE를 실행하면 @Modifying이 없으면 InvalidDataAccessApiUsageException이 발생한다.

1
2
3
4
5
6
7
8
9
@Modifying
@Transactional
@Query("UPDATE Book b SET b.price = b.price * :rate WHERE b.category = :category")
int bulkUpdatePrice(@Param("rate") double rate, @Param("category") String category);

@Modifying
@Transactional
@Query("DELETE FROM Book b WHERE b.deletedAt < :cutoff")
int deleteOldRecords(@Param("cutoff") LocalDateTime cutoff);

📌 clearAutomatically — 영속성 컨텍스트 동기화

벌크 연산의 핵심 함정이다. 벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 직접 반영된다. 이후 1차 캐시에서 조회하면 변경 전 값을 반환하는 불일치 문제가 생긴다.

1
2
3
4
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("UPDATE Book b SET b.price = b.price * :rate WHERE b.category = :category")
int bulkUpdatePrice(@Param("rate") double rate, @Param("category") String category);
  • clearAutomatically = true: 벌크 연산 실행 후 영속성 컨텍스트를 자동으로 clear한다.
  • flushAutomatically = true: 벌크 연산 전에 flush를 수행해서 미반영 변경사항을 먼저 DB에 내보낸다.

📌 실전 벌크 연산 패턴

1
2
3
4
5
6
7
8
9
10
11
12
// 소프트 딜리트
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Book b SET b.deletedAt = :now WHERE b.id IN :ids")
int softDeleteByIds(@Param("ids") List<Long> ids, @Param("now") LocalDateTime now);

// 상태 일괄 변경
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE LoanRecord lr SET lr.status = 'OVERDUE' " +
       "WHERE lr.dueDate < :today AND lr.status = 'ACTIVE'")
int markOverdueLoans(@Param("today") LocalDate today);

📌 @Query vs 메서드 이름 쿼리 선택 기준

상황선택
단순 조건 1~2개, 조인 없음메서드 이름 쿼리
조건 3개 이상@Query
JOIN 포함@Query
UPDATE / DELETE@Query + @Modifying
동적 조건QueryDSL
DB 전용 함수 필요@Query(nativeQuery=true)

4. 페이징과 정렬


📌 Pageable과 PageRequest

1
2
3
4
5
6
7
Pageable pageable = PageRequest.of(0, 10);  // 페이지 번호는 0부터 시작

Pageable pageable = PageRequest.of(0, 10, Sort.by("publishedDate").descending());

Pageable pageable = PageRequest.of(0, 10,
    Sort.by(Sort.Order.desc("publishedDate"), Sort.Order.asc("title"))
);

💡 페이지 번호는 0부터 시작한다. 클라이언트에서 1 기반으로 받아서 -1해야 하는 경우가 많다.

📌 Page — 전체 건수 포함

Page 객체가 제공하는 정보:

1
2
3
4
5
6
7
8
9
10
page.getContent();           // 현재 페이지 데이터 List<Book>
page.getTotalElements();     // 전체 데이터 건수 (COUNT 쿼리 결과)
page.getTotalPages();        // 전체 페이지 수
page.getNumber();            // 현재 페이지 번호 (0부터)
page.getSize();              // 페이지 크기
page.getNumberOfElements();  // 현재 페이지에 있는 데이터 수
page.hasNext();              // 다음 페이지 존재 여부
page.hasPrevious();          // 이전 페이지 존재 여부
page.isFirst();
page.isLast();

Page는 내부에서 데이터 조회 쿼리와 COUNT 쿼리 2번이 실행된다.

1
2
3
// map()으로 타입 변환 — Page<Book> → Page<BookDto>
Page<BookDto> result = bookRepository.findAll(pageable)
    .map(book -> new BookDto(book.getTitle(), book.getPrice()));

📌 Slice — COUNT 없는 경량 페이징

구분PageSlice
COUNT 쿼리실행실행 안 함
전체 건수알 수 있음알 수 없음
hasNext()지원지원
적합한 UI페이지 네비게이션무한 스크롤, 더보기

Slice는 내부적으로 size + 1개를 조회해서 다음 데이터가 있는지 확인한다. COUNT 쿼리 없이 hasNext()를 구현하는 방식이다.

📌 countQuery 분리 — JOIN 포함 시 최적화

JOIN이 포함된 @QueryPageable을 적용하면 자동 COUNT 쿼리에 불필요한 JOIN이 포함된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Query(
    value = """
            SELECT b FROM Book b
            JOIN FETCH b.author a
            WHERE a.name = :authorName
            """,
    countQuery = """
            SELECT COUNT(b) FROM Book b
            JOIN b.author a
            WHERE a.name = :authorName
            """
)
Page<Book> findByAuthorName(@Param("authorName") String authorName, Pageable pageable);

📌 동적 정렬

1
2
3
4
5
List<Book> findByCategory(String category, Sort sort);

List<Book> books = bookRepository.findByCategory("IT",
    Sort.by(Sort.Order.desc("publishedDate"), Sort.Order.asc("price"))
);

외부 입력값으로 정렬 필드를 받을 때는 화이트리스트로 검증해서 SQL injection을 방지한다.

1
2
3
4
5
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
    "title", "price", "publishedDate", "createdAt"
);

String safeSortBy = ALLOWED_SORT_FIELDS.contains(sortBy) ? sortBy : "createdAt";

📌 페이징 성능 최적화 팁

오프셋 페이징은 뒤 페이지로 갈수록 느려진다. LIMIT 20 OFFSET 20000이면 DB는 20001개를 읽고 앞 20000개를 버린다. 대량 데이터에서는 커서 기반 페이징이 대안이다.

1
2
@Query("SELECT b FROM Book b WHERE b.id < :lastId ORDER BY b.id DESC")
List<Book> findNextPage(@Param("lastId") Long lastId, Pageable pageable);

페이징 조회에서 컬렉션 fetch join + 페이징을 함께 쓰면 Hibernate가 In Memory 페이징(전체 조회 후 메모리에서 페이징)을 수행한다. @BatchSize 또는 default_batch_fetch_size 사용으로 해결한다.


5. Projection — 필요한 필드만 조회


Projection은 필요한 필드만 선택적으로 조회해서 데이터 전송량과 메모리 사용을 줄이는 기법이다.

방식특징권장 상황
인터페이스 기반프록시 객체 생성, SpEL 지원빠른 선언, 간단한 가공
DTO(클래스) 기반실제 객체 생성, 명시적일반적인 DTO 변환 (권장)
동적 Projection런타임에 타입 결정다양한 반환 타입이 필요한 경우
@QueryProjection컴파일 타임 타입 안전QueryDSL과 연동 시

📌 인터페이스 기반 Projection

1
2
3
4
5
6
public interface BookSummary {
    String getTitle();
    int getPrice();
}

List<BookSummary> findByCategory(String category);

실행 쿼리: SELECT b.title, b.price FROM book b WHERE b.category = 'IT';

중첩 Projection으로 연관 엔티티 필드도 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
public interface BookWithAuthor {
    String getTitle();
    int getPrice();
    AuthorInfo getAuthor();

    interface AuthorInfo {
        String getName();
        String getEmail();
    }
}

@Value 어노테이션으로 SpEL 표현식을 사용할 수 있지만, SpEL이 있으면 최적화 SELECT가 동작하지 않고 전체 엔티티를 로드한다.

📌 DTO 기반 Projection — 권장

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

@Query("""
    SELECT new com.example.dto.BookSummaryDto(b.title, b.price, a.name)
    FROM Book b
    JOIN b.author a
    WHERE b.category = :category
    """)
List<BookSummaryDto> findSummaryByCategory(@Param("category") String category);

인터페이스 기반과 달리 실제 객체를 직접 생성하므로 프록시 오버헤드가 없다. new 표현식에 들어가는 생성자 파라미터 순서와 DTO 생성자 파라미터 순서가 반드시 일치해야 한다.

📌 동적 Projection

1
2
3
4
<T> List<T> findByCategory(String category, Class<T> type);

List<BookSummary> summaries = bookRepository.findByCategory("IT", BookSummary.class);
List<Book> books = bookRepository.findByCategory("IT", Book.class);

📌 @QueryProjection — QueryDSL과 통합 시 타입 안전 Projection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BookSummaryDto {
    @QueryProjection
    public BookSummaryDto(String title, int price, String authorName) {
        this.title = title;
        this.price = price;
        this.authorName = authorName;
    }
}

// QueryDSL에서 사용
List<BookSummaryDto> result = queryFactory
    .select(new QBookSummaryDto(book.title, book.price, author.name))
    .from(book)
    .join(book.author, author)
    .where(book.category.eq("IT"))
    .fetch();

6. 커스텀 Repository — QueryDSL 연동을 위한 확장 패턴


JpaRepository의 메서드 이름 쿼리와 @Query로 해결할 수 없는 경우 — 동적 조건이 많거나, QueryDSL 같은 외부 라이브러리를 써야 하거나, 복잡한 집계 쿼리가 필요한 경우 — 커스텀 Repository 패턴을 사용한다.

📌 전체 구조

1
2
3
4
5
BookRepository                    ← 개발자가 주입받아 사용하는 최종 인터페이스
├── extends JpaRepository         ← 기본 CRUD + 페이징
└── extends BookRepositoryCustom  ← 커스텀 메서드 선언 (인터페이스)
                                        ↑ implements
                                  BookRepositoryImpl  ← 실제 구현체 (Impl 명명 규칙)

Spring Data JPA가 BookRepository의 Bean을 만들 때, BookRepositoryImpl을 찾아서 자동으로 결합한다.

📌 Step 1 — 커스텀 인터페이스 선언

1
2
3
4
5
public interface BookRepositoryCustom {
    List<Book> searchBooks(String keyword, String category, Integer minPrice, Integer maxPrice);
    Page<Book> searchBooksPaged(BookSearchCondition condition, Pageable pageable);
    Map<String, Long> countByCategory();
}

📌 Step 2 — 최종 Repository에 상속 추가

1
2
3
4
5
6
7
8
public interface BookRepository
        extends JpaRepository<Book, Long>, BookRepositoryCustom {

    List<Book> findByAuthor_Name(String authorName);

    @Query("SELECT b FROM Book b WHERE b.isbn = :isbn AND b.deletedAt IS NULL")
    Optional<Book> findByIsbnActive(@Param("isbn") String isbn);
}

📌 Step 3 — Impl 구현체 작성

명명 규칙: 최종 Repository 인터페이스명 + "Impl". Spring Data JPA가 이 명명 규칙으로 자동 탐지하고 결합한다. @Repository를 붙이지 않아도 자동으로 Bean 등록된다.

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
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepositoryCustom {

    private final EntityManager em;

    @Override
    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);
        if (minPrice != null)                       query.setParameter("minPrice", minPrice);
        if (maxPrice != null)                       query.setParameter("maxPrice", maxPrice);

        return query.getResultList();
    }

    @Override
    public Map<String, Long> countByCategory() {
        List<Object[]> results = em.createQuery(
            "SELECT b.category, COUNT(b) FROM Book b GROUP BY b.category",
            Object[].class
        ).getResultList();

        return results.stream()
            .collect(Collectors.toMap(
                row -> (String) row[0],
                row -> (Long) row[1]
            ));
    }
}

💡 StringBuilder로 JPQL을 조합하는 방식은 조건이 많아질수록 코드가 복잡해진다. 실무에서는 이 패턴을 QueryDSL로 대체한다.

📌 QueryDSL 연동을 위한 Impl 뼈대

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
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    QBook book = QBook.book;
    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()
            .where(
                keywordContains(keyword),
                categoryEq(category),
                priceGoe(minPrice),
                priceLoe(maxPrice),
                book.deletedAt.isNull()
            )
            .orderBy(book.publishedDate.desc())
            .fetch();
    }

    // BooleanExpression 반환 — null이면 해당 조건 무시 (동적 쿼리 핵심)
    private BooleanExpression keywordContains(String keyword) {
        return (keyword != null && !keyword.isBlank()) ? book.title.contains(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;
    }
}

BooleanExpressionnull을 반환하면 QueryDSL이 해당 조건을 자동으로 무시한다. StringBuilder로 조합하는 것보다 훨씬 깔끔하다.

📌 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);
    }
}

📌 서비스에서의 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
@RequiredArgsConstructor
public class BookService {

    // BookRepository 하나만 주입받으면 JpaRepository + Custom 메서드 모두 사용 가능
    private final BookRepository bookRepository;

    public List<BookSummaryDto> search(BookSearchCondition condition) {
        List<Book> books = bookRepository.searchBooks(
            condition.getKeyword(),
            condition.getCategory(),
            condition.getMinPrice(),
            condition.getMaxPrice()
        );
        return books.stream()
            .map(BookSummaryDto::from)
            .toList();
    }
}

참고 자료

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