QueryDSL, OrderSpecifier을 이용하여 정렬하기

sunny·2023년 9월 22일
1

배달의 민족 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을 사용하는 이유에 대해 먼저 알아보자!

  • 문자가 아닌 코드로 쿼리를 작성할 수 있어 컴파일 시점에 문법 오류를 확인할 수 있다.
  • 인텔리제이와 같은 IDE의 자동 완성 기능의 도움을 받을 수 있다.
  • 복잡한 쿼리나 동적 쿼리 작성이 편리하다.
  • 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
  • JPQL 문법과 유사한 형태로 작성할 수 있어 쉽게 적응할 수 있다.

여튼 이러한 이유들로 QueryDSL을 사용한다고 한다!


QueryDSL 작성해보자~~😽

1. QueryDSL 설정하기

현재 우리 프로젝트의 스펙은 다음과 같다.

  • springboot 3.1.3
  • java 17
  • gradle
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가 생성된 것을 확인할 수 있다!!

2. Repsitory 작성

repository 파일은 총 3가지를 생성해야 한다.
1. ItemRepository.java

public interface ItemRepository extends JpaRepository<Item, Long>, ItemRepositoryCustom {

}
  1. ItemRepositoryCustom.java
public interface ItemRepositoryCustom {

    List<Item> findNewItemsOrderBy(Long lastIdx, Long lastItemId, ItemSortType sortType,
        Pageable pageable);
}
  1. ItemRepositoryImpl.java
@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 코드를 작성해 주면 된다!


그럼 QueryDSL 코드를 자세하게 뜯어보자!!!!!!!

신상품 조회는 다음과 조건을 만족해야 한다.

1. sortType을 받아 5가지의 정렬 기준 중 하나로 정렬한다.
2. pageNumber 대신 마지막으로 조회한 아이템의 아이디와 값(가격 높은 순 -> 가격, 할인순 -> 할인율 등)을 받아 페이징 기능을 구현한다.

그래서 OrderSpecifier를 이용해서 정렬 기준을 나누고 페이지네이션을 위한 having에 들어갈 condition을 구해야 한다.

OrderSpecifier

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마다 다르기 때문에 함수로 분리해 주었다!

findNewItemsOrderBy()

@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();
    }

0개의 댓글