TIL

[250331 TIL] JPAQueryFactory 실전 적용

도원좀비 2025. 3. 31. 20:38

지난 시간 동안 공부 JPAQueryFactory를 사용한 동적 검색 쿼리 작성

 

1️⃣ 구현한 기능 요약

  • 키워드 기반 일정 검색 (제목, 내용, 작성자 이름에 포함된 항목)
  • Page<SchedulePageResponseDto> 형태로 페이징 처리
  • BooleanBuilder를 활용한 조건 동적 구성
BooleanBuilder condition = new BooleanBuilder();
if(keyword != null && !keyword.isBlank()) {
    condition.and(
        schedule.title.containsIgnoreCase(keyword)
            .or(schedule.contents.containsIgnoreCase(keyword))
            .or(author.name.containsIgnoreCase(keyword))
    );
}

2️⃣ 전체 쿼리 예시 (ScheduleQueryRepositoryImpl)

더보기

(QuerydslConfig.java)

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory queryFactory() {
        return new JPAQueryFactory(em);
    }
}

 

(ScheduleQueryRepository.java)

/**
 * 일정(Schedule) ScheduleRepository에 대한 QueryDSL리포지토리 인터페이스입니다.
 */
public interface ScheduleQueryRepository {
    Page<SchedulePageResponseDto> searchSchedulesByKeyword(String keyword, Pageable pageable);

}

 

(ScheduleQueryRepositoryImpl.java)

/**
 * QueryDSL + JPAQueryFactory 리포지토리
 */
@RequiredArgsConstructor
public class ScheduleQueryRepositoryImpl implements ScheduleQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    /**
     *
     * @param keyword  검색어 (제목, 내용, 작성자 이름에 대해 부분 일치 검색)
     * @param pageable 페이지 번호, 사이즈 등의 페이징 정보
     * @return SchedulePageResponseDto 형태의 페이징된 일정 목록
     */
    @Override
    public Page<SchedulePageResponseDto> searchSchedulesByKeyword(String keyword, Pageable pageable) {

        QSchedule schedule = QSchedule.schedule;
        QAuthor author = QAuthor.author;
        QComment comment = QComment.comment;

        // 동적 쿼리 where 조건 생성을 위한 BooleanBuilder
        BooleanBuilder condition = new BooleanBuilder();

        // 검색어가 있을 때, 제목/내용/작성자 이름에서 해당 키워드 포함 여부 검사
        if(keyword != null && !keyword.isBlank()) {
            condition.and(
                    schedule.title.containsIgnoreCase(keyword)
                            .or(schedule.contents.containsIgnoreCase(keyword))
                            .or(author.name.containsIgnoreCase(keyword))
            );
        }

        // 실제 페이징된 결과 목록 조회 (select 절에는 DTO로 바로 매핑)
        List<SchedulePageResponseDto> content = jpaQueryFactory
                .select(Projections.constructor(SchedulePageResponseDto.class,
                        schedule.title,               // 일정 제목
                        schedule.contents,            // 일정 내용
                        author.name,                  // 작성자 이름
                        comment.countDistinct(),      // 댓글 수 (중복 제거)
                        schedule.createdDate,         // 생성일
                        schedule.updatedDate          // 수정일
                ))
                .from(schedule)                         // FROM schedule
                .join(schedule.author, author)          // INNER JOIN author
                .leftJoin(comment).on(comment.schedule.eq(schedule))    // 레프트 조인
                .where(condition)                       // 동적 where 조건 적용
                .groupBy(schedule)                      // COUNT 사용을 위한 그룹핑
                .offset(pageable.getOffset())           // 몇 번째부터 가져올지 (페이지 시작 인덱스)
                .limit(pageable.getPageSize())          // 한 페이지에 몇 개 가져올지
                .orderBy(schedule.updatedDate.desc())   // 최신순 정렬
                .fetch();                               // 결과 리스트로 반환

        // 전체 개수 조회 (페이징의 total count를 위함)
        long total = Optional.ofNullable(
                jpaQueryFactory
                        .select(schedule.count())     // 총 개수 select
                        .from(schedule)
                        .join(schedule.author, author)
                        .where(condition)             // 동일한 where 조건 적용
                        .fetchOne()                   // 단일 결과 조회
        ).orElse(0L);                           // 결과가 null일 경우 0으로 처리 (NPE 방지)


        return new PageImpl<>(content, pageable, total);
    }
}

 

(ScheduleService.java)

    /**
     * 키워드와 페이징 처리된 일정 목록을 조회
     *
     * @param keyword   검색 키워드
     * @param pageable  페이징 정보
     * @return          일정 페이지 응답 DTO
     */
    Page<SchedulePageResponseDto> searchSchedulesByKeyword(String keyword, Pageable pageable);

}

 

(ScheduleServiceImpl.java)

@Service
@RequiredArgsConstructor
public class ScheduleServiceImpl implements ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final AuthorRepository authorRepository;
    
    
    /**
     * 키워드와 페이징 처리된 일정 목록을 조회
     *
     * @param keyword   검색 키워드
     * @param pageable  페이징 정보
     * @return          일정 페이지 응답 DTO
     */
    @Override
    public Page<SchedulePageResponseDto> searchSchedulesByKeyword(String keyword, Pageable pageable) {
        return scheduleRepository.searchSchedulesByKeyword(keyword, pageable);
    }
}

 

(ScheduleController.java)

@RestController
@RequiredArgsConstructor
@RequestMapping("/schedules")
public class ScheduleController {

    private final ScheduleService scheduleService;

    /**
     * 일정 키워드 검색 조회
     *
     * @param keyword   검색 조건
     * @param page      조회할 페이지 번호 (기본값: 0)
     * @param size      한 페이지당 항목 수 (기본값: 10)
     * @return          검색 조건에 맞는 페이징된 일정 목록과 200(OK) 응답
     */
    @GetMapping("/search")
    public ResponseEntity<Page<SchedulePageResponseDto>> searchSchedulesByKeyword(
            @RequestParam(required = false) String keyword,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updateDate"));
        Page<SchedulePageResponseDto> schedulePageResponseDto = scheduleService.searchSchedulesByKeyword(keyword, pageable);
        return new ResponseEntity<>(schedulePageResponseDto, HttpStatus.OK);
    }

}

 

 🚨 에러 정리

❗️ 원인 : Q타입(QSchedule)이 생성 경로를 인식하지 않음

💡 해결 방법 : build.gradle에 Q타입 생성 경로 추가

// Q파일 인식 경로 추가
sourceSets {
    main.java.srcDirs += 'build/generated/sources/annotationProcessor/java/main'
}

 

❗️ 원인 :  테스트 실행 시 BeanDefinitionOverrideException

  • 테스트 클래스(SchedulerJpaApplicationTests)에 @EnableJpaAuditing을 추가했는데,
  • 본 애플리케이션 설정에도 이미 @EnableJpaAuditing이 있어서 중복 선언으로 충돌 발생

💡 해결 방법 : @EnableJpaAuditing 중복 선언 되어 빈 중복 에러 발생

// ✅ 테스트 클래스에서 @EnableJpaAuditing 제거
@SpringBootTest
class SchedulerJpaApplicationTests {
    @Test
    void contextLoads() {
    }
}