프로젝트를 진행하면서 강의 때 잠깐 사용해 본 Query DSL과 Slice를 사용해 보기로 했다. 지금 프로젝트에서 매장을 검색해야 하는데 검색과 카테고리 선택 후 목록을 보여주는 부분이 전부 따로 만들기에는 기능상 굉장히 흡사해서 계속 새로운 내용을 추가하는 것보다는 동적으로 쿼리를 작성해보고자 했다.
Controller
@Builder
@Getter
public class StoreSearchCondition {
Integer categoryId;
String storeName;
String regionCode;
public void setRegionCode(String regionCode) {
this.regionCode = regionCode;
}
}
@Slf4j
@RequestMapping("/stores")
@RequiredArgsConstructor
@RestController
public class StoreController {
private final StoreService storeService;
/**
* 카테고리로 매장 검색, 쿼리 파라미터에 page, size, sort 포함
* ex) stores?page=1&size=10&sort=deliveryFees,asc&storeName=test1&categoryId=2
*/
@GetMapping
public List<ResponseStore> findAll(Authentication authentication,
@ModelAttribute StoreSearchCondition searchCond,
@PageableDefault(sort = "name", direction = ASC) Pageable pageable) {
String regionCode = ((UserAuthentication) authentication).getRegionCode();
searchCond.setRegionCode(regionCode);
return storeService.findAll(searchCond, pageable).getContent();
}
}
컨트롤러에서는 Security 필터에서 Security Context에 저장한 유저 정보를 가지고 온다. 그리고 쿼리 파라미터로 들어오는 값들을 StoreSearchCondition이라는 객체에 매핑하고, page에 관련된 쿼리 파라미터를 받아오기 위해 Pageable 객체를 파라미터로 받았다.
UserAuthentication은 regionCode가 필요하기 때문에 UsernamePasswordAuthentication 클래스를 상속받아 해당 멤버 변수를 추가해 주었다.
검색 조건을 하나의 객체로 넘기기 위해 regionCode를 searchCond에 추가해 준다.
Service
@RequiredArgsConstructor
@Service
public class StoreServiceImpl implements StoreService {
private final StoreRepository storeRepository;
@Override
public Slice<ResponseStore> findAll(StoreSearchCondition searchCond, Pageable pageable) {
Slice<Store> foundStores = storeRepository.findAll(searchCond, pageable);
return foundStores.map(StoreMapper.INSTANCE::toResponseStore);
}
}
서비스 계층에서는 나중에 응답을 MapStruct로 ResponseStore로 변환시키는 부분 빼고 나머지는 StoreRespository에 위임한다.
여기서 주의할 점은 Slice의 내용은 스트림으로 변환을 하고 다시 Slice로 변환할 수가 없다. 그래서 Slice에서 제공하는 map() 메서드를 이용해서 변환해 주었다.
Repository
@Slf4j
@Repository
@Transactional
public class StoreRepositoryImpl implements StoreRepository {
private final EntityManager entityManager;
private final JPAQueryFactory queryFactory;
public StoreRepositoryImpl(EntityManager entityManager) {
this.entityManager = entityManager;
this.queryFactory = new JPAQueryFactory(entityManager);
}
@Override
public Slice<Store> findAll(StoreSearchCondition searchCond, Pageable pageable) {
// 검색 조건 설정
BooleanBuilder builder = getCondResult(searchCond);
List<Store> result = queryFactory.select(store)
.from(store)
.where(builder)
.orderBy(getOrderSpecifiers(pageable))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 슬라이스로 변환
return toSlice(result, pageable);
}
private static BooleanBuilder getCondResult(StoreSearchCondition searchCondition) {
log.info(searchCondition.toString());
BooleanBuilder builder = new BooleanBuilder();
String regionCode = searchCondition.getRegionCode();
Integer categoryId = searchCondition.getCategoryId();
String storeName = searchCondition.getStoreName();
// 법정동 코드 검색 조건
if (StringUtils.hasText(regionCode)) {
builder.and(store.regionCode.like(regionCode.substring(0, 5) + "%"));
}
// 카테고리별 검색 조건
if (categoryId != null) {
builder.and(store.category.id.eq(categoryId));
}
// 매장명 검색 조건
if (StringUtils.hasText(storeName)) {
builder.and(store.name.like("%" + storeName + "%"));
}
return builder;
}
private static Slice<Store> toSlice(List<Store> result, Pageable pageable) {
int pageSize = pageable.getPageSize();
boolean hasNext = false;
// 다음 슬라이스 있는지 확인
if (result.size() > pageSize) {
hasNext = true;
result.remove(pageSize);
}
return new SliceImpl<>(result, pageable, hasNext);
}
// 정렬 조건 획득
private static OrderSpecifier[] getOrderSpecifiers(Pageable pageable) {
List<OrderSpecifier> orderList = new ArrayList<>();
if (!isEmpty(pageable.getSort())) {
for (Sort.Order order : pageable.getSort()) {
Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
switch (order.getProperty()) {
case "name":
orderList.add(new OrderSpecifier<>(direction, store.name));
break;
case "deliveryFees":
orderList.add(new OrderSpecifier<>(direction, store.deliveryFees));
default:
break;
}
}
}
return orderList.toArray(OrderSpecifier[]::new);
}
}
내용이 조금 많은데 하나씩 살펴보자.
@Override
public Slice<Store> findAll(StoreSearchCondition searchCond, Pageable pageable) {
// 검색 조건 설정
BooleanBuilder builder = getCondResult(searchCond);
List<Store> result = queryFactory.select(store)
.from(store)
.where(builder)
.orderBy(getOrderSpecifiers(pageable))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 슬라이스로 변환
return toSlice(result, pageable);
}
결국 흐름은 위 메서드에서 시작되고 반환된다. BooleanBuilder 검색 조건을 반환하는 getCondResult()라는 메서드를 따로 만들었다.
private static BooleanBuilder getCondResult(StoreSearchCondition searchCondition) {
log.info(searchCondition.toString());
BooleanBuilder builder = new BooleanBuilder();
String regionCode = searchCondition.getRegionCode();
Integer categoryId = searchCondition.getCategoryId();
String storeName = searchCondition.getStoreName();
// 법정동 코드 검색 조건
if (StringUtils.hasText(regionCode)) {
builder.and(store.regionCode.like(regionCode.substring(0, 5) + "%"));
}
// 카테고리별 검색 조건
if (categoryId != null) {
builder.and(store.category.id.eq(categoryId));
}
// 매장명 검색 조건
if (StringUtils.hasText(storeName)) {
builder.and(store.name.like("%" + storeName + "%"));
}
return builder;
}
해당 메서드에서는 Pageable을 제외한 검색 조건들을 모두 가져와 가져온 내용이 있으면 and로 조건을 추가한다.
동적으로 where절에 and를 추가한다고 보면 된다.
// 정렬 조건 획득
private static OrderSpecifier[] getOrderSpecifiers(Pageable pageable) {
List<OrderSpecifier> orderList = new ArrayList<>();
if (!isEmpty(pageable.getSort())) {
for (Sort.Order order : pageable.getSort()) {
Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
switch (order.getProperty()) {
case "name":
orderList.add(new OrderSpecifier<>(direction, store.name));
break;
case "deliveryFees":
orderList.add(new OrderSpecifier<>(direction, store.deliveryFees));
default:
break;
}
}
}
return orderList.toArray(OrderSpecifier[]::new);
}
getOrderSpecifiers() 이 메서드는 쿼리 파라미터로 지정했던 Pageable의 정렬 정보를 가져와 정렬 설정을 한다.
나는 name과 deliveryFees 두 필드에 대해 오름차순, 내림차순을 할 수 있도록 하였다.
private static Slice<Store> toSlice(List<Store> result, Pageable pageable) {
int pageSize = pageable.getPageSize();
boolean hasNext = false;
// 다음 슬라이스 있는지 확인
if (result.size() > pageSize) {
hasNext = true;
result.remove(pageSize);
}
return new SliceImpl<>(result, pageable, hasNext);
}
Slice 데이터를 확인해서 마지막인지 아닌지 검사를 하는 세팅이 있는 부분이다.
[QueryDsl] Page, Slice (페이지네이션, 무한 스크롤)
개요 페이지네이션과 무한 스크롤은 사용자가 컨텐츠를 조작할 때 중요한 역할을 합니다. 사용자가 특정 정보를 탐색하는 과정에 영향을 미치기 때문에 UX에서 매우 중요한 요소라고 볼 수 있습
rachel0115.tistory.com
위 블로그에 설명이 잘 되어 있어서 도움을 많이 받았다.
테스트하기
페이지 정보나 검색 조건이 쿼리 파라미터에 붙기 때문에 약간 복잡할 수도 있다.
page=1&size=10&sort=deliveryFees,desc&storeName=test1&categoryId=1
이런 식으로 페이징이나 검색 조건 등, 여러 조건을 지정할 수 있다.

원래는 뭔가 복잡하게 밑에 더 떠야 하는데 불필요한 정보가 많아서 그냥 Controller에서 List로 변경해 응답하도록 했다.


기대한 대로 잘 동작한다..!
'공부 > JPA' 카테고리의 다른 글
| [JPA] 한 번 조회하는데 쿼리가 여러 번 실행될 때 (0) | 2024.10.08 |
|---|---|
| [JPA] JPA 테스트 중 available: expected at least 1 bean which qualifies as autowire candidate. 에러 해결하기 (0) | 2024.05.08 |
| [JPA] 엔티티 그래프(EntityGraph) 사용하기 (0) | 2024.03.11 |
| [JPA] 스프링 OSIV (Open Session In View) 사용하기 (0) | 2024.03.02 |
| [JPA] LazyInitializationException 해결하기 (0) | 2024.02.28 |