배달의 민족 B마트를 클론 코딩하는 프로젝트에서 신상품과 인기상품을 조회하는데 총 5가지 정렬을 추가해야 했다! 더군다나.. 페이지네이션까지...
정렬 기준은 총 5가지였다!
1. 최신순
2. 가격 높은 순
3. 가격 낮은 순
4. 할인순
5. 인기순
QueryDSL을 도입하기 전에는 5가지의 정렬과 신상품이라는 조건을 모두 만족시키기 위해서 5개의 JPA 메서드를 이용했다.
인기 상품 조회는 더 끔찍하다..
서비스단은 다음과 같다!
// ItemService.java
@Transactional(readOnly = true)
public FindItemsResponse findNewItems(FindNewItemsCommand findNewItemsCommand) {
List<Item> items = findNewItemsFrom(findNewItemsCommand);
return FindItemsResponse.from(items);
}
private List<Item> findNewItemsFrom(FindNewItemsCommand findNewItemsCommand) {
LocalDateTime createdAt = LocalDateTime.now()
.minus(NEW_PRODUCT_REFERENCE_WEEK, ChronoUnit.WEEKS);
return switch (findNewItemsCommand.sortType()) {
case NEW ->
itemRepository.findByCreatedAtAfterAndItemIdLessThanOrderByCreatedAtDesc(createdAt,
findNewItemsCommand.lastIdx(), findNewItemsCommand.pageRequest());
case HIGHEST_AMOUNT ->
itemRepository.findByCreatedAtAfterAndPriceLessThanOrderByPriceDescItemIdDesc(
createdAt, findNewItemsCommand.lastIdx().intValue(),
findNewItemsCommand.pageRequest());
case LOWEST_AMOUNT ->
itemRepository.findByCreatedAtAfterAndPriceGreaterThanOrderByPriceAscItemIdDesc(
createdAt, findNewItemsCommand.lastIdx().intValue(),
findNewItemsCommand.pageRequest());
case DISCOUNT ->
itemRepository.findByCreatedAtAfterAndDiscountLessThanOrderByDiscountDescItemIdDesc(
createdAt, findNewItemsCommand.lastIdx().intValue(),
findNewItemsCommand.pageRequest());
default -> {
int lastIdx = findNewItemsCommand.lastIdx().intValue();
if (findNewItemsCommand.lastIdx() != Long.parseLong(
String.valueOf(Integer.MAX_VALUE))) {
lastIdx = orderItemRepository.countByOrderItemId(findNewItemsCommand.lastIdx()).intValue();
}
yield itemRepository.findNewItemOrderByOrders(createdAt, lastIdx,
findNewItemsCommand.pageRequest());
}
};
}
이제 이러한 것들을 QueryDSL을 이용하여 (나름) 깔끔하게 바꿔보자!
그 전에 QueryDSL을 사용하는 이유에 대해 먼저 알아보자!
여튼 이러한 이유들로 QueryDSL을 사용한다고 한다!
현재 우리 프로젝트의 스펙은 다음과 같다.
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
dependencies {
//querydsl 설정 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
// Querydsl 설정부
def generated = 'src/main/generated'
// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
// java source set 에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += [generated]
}
// gradle clean 시에 QClass 디렉토리 삭제
clean {
delete file(generated)
}
위와 같이 build.gradle을 작성해 주면 된다!
이후 build-clean
-> build
를 진행하면 QClass가 생성된 것을 확인할 수 있다!!
repository 파일은 총 3가지를 생성해야 한다.
1. ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long>, ItemRepositoryCustom {
}
public interface ItemRepositoryCustom {
List<Item> findNewItemsOrderBy(Long lastIdx, Long lastItemId, ItemSortType sortType,
Pageable pageable);
}
@Repository
@RequiredArgsConstructor
public class ItemRepositoryImpl implements ItemRepositoryCustom {
private final JPAQueryFactory queryFactory;
private static final int NEW_PRODUCT_REFERENCE_TIME = 2;
@Override
public List<Item> findNewItemsOrderBy(Long lastIdx, Long lastItemId, ItemSortType sortType,
Pageable pageable) {
OrderSpecifier orderSpecifier = createOrderSpecifier(sortType);
Predicate predicate = item.createdAt.after(
LocalDateTime.now().minus(NEW_PRODUCT_REFERENCE_TIME, ChronoUnit.WEEKS));
return queryFactory
.selectFrom(item)
.leftJoin(item.reviews, review)
.leftJoin(item.likeItems, likeItem)
.leftJoin(item.orderItems, orderItem)
.where(predicate)
.groupBy(item)
.having(
getHavingCondition(lastIdx, lastItemId, sortType)
)
.orderBy(orderSpecifier, item.itemId.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
private Predicate getHavingCondition(Long lastIdx, Long lastItemId, ItemSortType sortType) {
return switch (sortType) {
case NEW -> item.itemId.lt(lastIdx);
case HIGHEST_AMOUNT -> item.price.lt(lastIdx)
.or(item.price.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
case LOWEST_AMOUNT -> item.price.gt(lastIdx)
.or(item.price.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
case DISCOUNT -> item.discount.lt(lastIdx)
.or(item.discount.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
default -> JPAExpressions.select(orderItem.quantity.longValue().sum().coalesce(0L))
.from(orderItem)
.where(orderItem.item.eq(item))
.lt(lastIdx);
};
}
private OrderSpecifier createOrderSpecifier(ItemSortType sortType) {
return switch (sortType) {
case NEW -> new OrderSpecifier<>(Order.DESC, item.itemId);
case HIGHEST_AMOUNT -> new OrderSpecifier<>(Order.DESC, item.price);
case LOWEST_AMOUNT -> new OrderSpecifier<>(Order.ASC, item.price);
case DISCOUNT -> new OrderSpecifier<>(Order.DESC, item.discount);
default -> new OrderSpecifier<>(Order.DESC, item.orderItems.any().quantity.sum());
};
}
}
위의 ItemRepositroyImpl.java
에서 QueryDSL 코드를 작성해 주면 된다!
신상품 조회는 다음과 조건을 만족해야 한다.
1. sortType을 받아 5가지의 정렬 기준 중 하나로 정렬한다.
2. pageNumber 대신 마지막으로 조회한 아이템의 아이디와 값(가격 높은 순 -> 가격, 할인순 -> 할인율 등)을 받아 페이징 기능을 구현한다.
그래서 OrderSpecifier를 이용해서 정렬 기준을 나누고 페이지네이션을 위한 having에 들어갈 condition을 구해야 한다.
private OrderSpecifier createOrderSpecifier(ItemSortType sortType) {
return switch (sortType) {
case NEW -> new OrderSpecifier<>(Order.DESC, item.itemId);
case HIGHEST_AMOUNT -> new OrderSpecifier<>(Order.DESC, item.price);
case LOWEST_AMOUNT -> new OrderSpecifier<>(Order.ASC, item.price);
case DISCOUNT -> new OrderSpecifier<>(Order.DESC, item.discount);
default -> new OrderSpecifier<>(Order.DESC, item.orderItems.any().quantity.sum());
};
}
각 sortType에 맞는 정렬 기준의 OrderSpecifier 객체를 반환하는 함수를 작성해 주었다!
case HIGHEST_AMOUNT -> new OrderSpecifier<>(Order.DESC, item.price);
위는 item의 price 컬럼을 기준으로 정렬하되 내림차순을 따른다는 뜻이다!
만약 정렬 조건을 여러개 하고 싶다면 List Type의 OrderSpecifier를 사용하면 된다!
private Predicate getHavingCondition(Long lastIdx, Long lastItemId, ItemSortType sortType) {
return switch (sortType) {
case NEW -> item.itemId.lt(lastIdx);
case HIGHEST_AMOUNT -> item.price.lt(lastIdx)
.or(item.price.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
case LOWEST_AMOUNT -> item.price.gt(lastIdx)
.or(item.price.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
case DISCOUNT -> item.discount.lt(lastIdx)
.or(item.discount.eq(lastIdx.intValue()).and(item.itemId.gt(lastItemId)));
default -> JPAExpressions.select(orderItem.quantity.longValue().sum().coalesce(0L))
.from(orderItem)
.where(orderItem.item.eq(item))
.lt(lastIdx);
};
}
만약 가격이 낮은 순으로 정렬을 한다고 하면, 마지막으로 조회한 아이템의 가격보단 비싼 상품들이 와야 한다.
이러한 조건들 또한 sortType마다 다르기 때문에 함수로 분리해 주었다!
@Override
public List<Item> findNewItemsOrderBy(Long lastIdx, Long lastItemId, ItemSortType sortType,
Pageable pageable) {
OrderSpecifier orderSpecifier = createOrderSpecifier(sortType);
Predicate predicate = item.createdAt.after(
LocalDateTime.now().minus(NEW_PRODUCT_REFERENCE_TIME, ChronoUnit.WEEKS));
return queryFactory
.selectFrom(item)
.leftJoin(item.reviews, review) // 아이템과 연관된 review들도 함게 가져온다!
.leftJoin(item.likeItems, likeItem)
.leftJoin(item.orderItems, orderItem)
.where(predicate) // 위의 신상품 조건
.groupBy(item)
.having( // 페이징 기능을 위한 조건
getHavingCondition(lastIdx, lastItemId, sortType)
)
.orderBy(orderSpecifier, item.itemId.asc()) // 정렬
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}