escape-room

[QueryDSL/JPA] 동적 쿼리 작성하기 (Join 사용 X)

기매_ 2023. 5. 19. 12:46

만들고자 하는 기능 : [GET] 카페 검색 (지역, 키워드 검색 동적 쿼리)

 

API 요청 예시 및 설명

 

https://{{host}}/cafe/search?loc=홍대&keyword=비밀

 : 지역이 홍대이면서 방탈출 카페 이름에 비밀이 포함되는 카페 list 반환 
https://{{host}}/cafe/search?loc=홍대&loc=건대&keyword=비밀  

 : 지역이 홍대 또는 건대이면서 카페 이름에 비밀이 포함되는 list 반환
https://{{host}}/cafe/search

 : 검색 조건이 없으므로 전체 list 반환


코드 (Github)

https://github.com/maemae22/escape-room/pull/23

 

feat: #19 - [GET] 카페 검색 (지역, 키워드 검색 동적 쿼리) 개발 (/cafe/search) by maemae22 · Pull Request #23

feat: #19 - JPAQueryFactory를 Spring Bean으로 등록 feat: #19 - CafeDTO에 @QueryProjection 추가 DTO로 조회하기 위하여 @QueryProjection 추가 (프로젝션과 결과 반환) 컴파일러로 타입 체크 가능 (컴파일 에러 캐치 가

github.com


1. JPAQueryFactory를 Spring Bean으로 등록

필수는 아니지만 (Spring Bean으로 등록하지 않고 하는 방법 아래에서 설명) 이게 더 편한 것 같아 나는 Spring Bean으로 등록해서 사용했다 !

@SpringBootApplication
public class EscapeRoomApplication {

    public static void main(String[] args) {
        SpringApplication.run(EscapeRoomApplication.class, args);
    }

    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

2. 사용할 DTO에 @QueryProjection 어노테이션 추가 (프로젝션과 결과 반환)

결과를 DTO로 반환할 때의 방법으로

Projection.bean(), fields(), constructor() - 프로퍼티 접근, 필드 직접 접근, 생성자 사용

와 같은 다른 방법도 있지만 @QueryProjection을 사용하였다

 

@QueryProjection 특징

장점 : 컴파일러로 타입 체크 가능 (컴파일 에러 캐치 가능)
단점 : DTO에 QueryDSL 어노테이션을 유지해야함, DTO까지 Q 파일을 생성해야함

 

@Data
public class CafeDTO {

    private Long cafeId;
    private String name;
    private String phoneNumber;
    private String bhours;
    private String address;
    private String domain;
    private String location;

    @QueryProjection
    public CafeDTO(Long cafeId, String name, String phoneNumber, String bhours, String address, String domain, String location) {
        this.cafeId = cafeId;
        this.name = name;
        this.phoneNumber = phoneNumber;
        this.bhours = bhours;
        this.address = address;
        this.domain = domain;
        this.location = location;
    }
}

3. Repository 작성 : Where 다중 파라미터 사용으로 카페 검색 동적 쿼리 작성

(1) CafeRepositoryCustom 생성 (interface)

public interface CafeRepositoryCustom {

    Page<CafeDTO> cafeSearchPage(List<String> loc, String keyword, Pageable pageable);
}

 

(2) CafeRepository에서 CafeRepositoryCustom 상속 받기

public interface CafeRepository extends JpaRepository<Cafe, Long>, CafeRepositoryCustom {
}

 

(3) CafeRepositoryCustom을 구현한 CafeRepositoryCustomImpl 생성 및 QueryDSL을 활용하여 동적 쿼리 코드 작성 (Where 다중 파라미터 사용)

public class CafeRepositoryCustomImpl implements CafeRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public CafeRepositoryCustomImpl(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    @Override
    public Page<CafeDTO> cafeSearchPage(List<String> loc, String keyword, Pageable pageable) {

        QueryResults<CafeDTO> searchResults = queryFactory
                .select(new QCafeDTO(cafe.id, cafe.name, cafe.phoneNumber,
                        cafe.bhours, cafe.address, cafe.domain, cafe.location))
                .from(cafe)
                .where(locIn(loc), keywordContaining(keyword))
                .orderBy(cafe.location.asc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<CafeDTO> contents = searchResults.getResults();
        long total = searchResults.getTotal();

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

    private BooleanExpression locIn(List<String> loc) {
        return loc == null ? null : ( !loc.isEmpty() ? cafe.location.in(loc) : null );
    }

    private BooleanExpression keywordContaining(String keyword) {
        return hasText(keyword) ? cafe.name.contains(keyword) : null;
    }
}

 

 

만약 1번의 JPAQueryFactory를 Spring Bean으로 등록하는 과정을 생략하였다면,

public class CafeRepositoryCustomImpl implements CafeRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    // JPAQueryFactory가 Spring Bean으로 등록되어있을 경우
//    public CafeRepositoryCustomImpl(JPAQueryFactory queryFactory) {
//        this.queryFactory = queryFactory;
//    }

    // JPAQueryFactory가 Spring Bean으로 등록되어있지 않을 경우
    public CafeRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

이렇게 작성하면 된다 !

 

동적 쿼리는 Where 다중 파라미터를 사용하여 작성하였다.

- locIn 함수 : 파라미터로 받은 loc(List<String>)이 null이거나 isEmpty()가 아닐 경우, location in ('loc1', 'loc2')으로 where 검색 조건 추가됨

- keywordContaining 함수 : 파라미터로 받은 keyword가 빈 문자열(혹은 null)이 아닐 경우, name like '%keyword%' 으로 where 검색 조건 추가됨

 

null 을 반환할 경우 따로 where 절이 추가되지 않는다. 따라서 둘 다 null로 들어왔을 경우 전체 검색을 하게 된다 !

 


- 또한 orderBy를 통해서 location으로 ASC 정렬하여 반환하게 하였다.


4. Service 작성

@Service
@RequiredArgsConstructor
public class CafeService {

    private final CafeRepository cafeRepository;

    public Page<CafeDTO> cafeSearchList(List<String> loc, String keyword, Pageable pageable) {
        return cafeRepository.cafeSearchPage(loc, keyword, pageable);
    }
}

5. Controller 작성

@RestController
@RequiredArgsConstructor
public class CafeController {

    private final CafeService cafeService;

    @GetMapping("/cafe/search")
    public Result cafeSearchList(@RequestParam(required = false) List<String> loc,
                                 @RequestParam(required = false) String keyword,
                                 Pageable pageable) {
        Page<CafeDTO> cafeSearchList = cafeService.cafeSearchList(loc, keyword, pageable);
        return new Result(cafeSearchList);
    }

    @Data
    @AllArgsConstructor
    static class Result<T> {
        private T data;
    }
}

 

- List<String> loc와 String keyword를 @RequestParam을 통해 파라미터로 받을 수 있음
- @RequestParam의 required 속성을 false로 설정함 (파라미터로 안 들어오는 경우도 있기 때문)
- Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있게 함 (확장성 증가)


결과

http://localhost:8080/cafe/search?keyword=넥스트&loc=신촌&loc=강남

성공 !!!! ㅎㅎ


프로젝트에 QueryDSL으로 동적 쿼리를 작성해본 것은 처음이여서 정리해봤다.

다음에는 Join을 사용하여 동적 쿼리를 작성하는 방법을 포스팅할 예정 !

거의 비슷하지만 count Query를 따로 작성하는 점 등 소소한 변화가 있다 ,, !