본문 바로가기
Spring

[Jpa/QueryDsl] QueryDsl 설정 및 조회 기능 구현 (+동적 정렬)

by seeker00 2025. 2. 16.

querydsl을 활용한 기본적인 조회 기능 구현에 대해서 기록해보고자 한다.
이전에 한 번 업무에서 querydsl을 간단히 다루는 경험을 한 적이 있는데, 그게 벌써 약 2년 전 일이 되었다.
그런 만큼, querydsl 활용이 조금 걱정되었는데, 다행히 전에 구현했던 기억이 조금은 남아 있어서 + 간단한 조회 기능이라 수월하게 작업을 해보았다. Springboot 버전 3이 되면서 querydsl 연동도 간결해져서 작업하는데 수월했다.

QueryDsl

  • 정적 타입을 이용해 sql 과 같은 쿼리를 생성할 수 있도록 도와주는 오픈소스 프레임워크
  • 쿼리를 문자열로 작성하는 것이 아닌 프레임워크에서 제공하는 API를 활용해 코드 형태로 작성해 쿼리를 생성해주므로, 타입 안정성이 보장되고 런타임 에러를 방지할 수 있다.
    • 다른 말로는 type-safe(컴파일시 에러 체크 가능)하다.
  • 동적으로 쿼리 작성을 가능하게 도와준다.

(이하의 설정 및 코드는 스프링 부트 3.4.2 버전 기준임)

설정하기

  • 우선, 라이브러리를 추가해준 다음, QueryDsl Config 코드를 작성해준다.

1. gradle 설정

    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"

2. Config 코드 설정

@Configuration
public class QueryDslConfig {

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

활용

  • 활용 전에 gradle의 complieJava 명령을 실행하여, Qtype 코드를 생성해준다.
    • gradle.build 파일에 따로 디렉토리 설정을 해주지 않았다면, 기본적으로 build/generated/sources/annotationProcessor/...(이하 생략) 내부에 Qtype 코드가 생성된다.

1. 준비단계

  • ReviewRepositoryCustom 인터페이스를 생성하고, ReviewRepository 인터페이스가 확장하도록 한다.
// ReviewRepository.java
@Repository
public interface ReviewRepository extends JpaRepository<Review, UUID>, ReviewRepositoryCustom {
}

// ReviewRepositoryCustom.java
public interface ReviewRepositoryCustom {

    Page<Review> searchReviewByCondition(ReviewSearchCondition condition, Pageable pageable);
}

활용 코드

  • ReviewRepositoryCustom 인터페이스를 구현한다.
  • PageableExecutionUtils 에 대해
    • PageImpl을 사용할 때 보다 성능 최적화를 할 수 있다.
    • PageableExecutionUtils 를 사용해 값을 반환하면 content(조회 데이터)와 pageable의 total size를 확인하여, 페이지의 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작거나, 마지막 페이지일 때 카운트 쿼리를 실행하지 않는다.
    • 참고 url : https://junior-datalist.tistory.com/342
@RequiredArgsConstructor
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Review> searchReviewByCondition(ReviewSearchCondition condition, Pageable pageable) {

        List<Review> content = queryFactory.select(review)
                .from(review)
                .where(
                        betweenPeriod(condition.getStartCreatedAt(), condition.getEndCreatedAt()),
                        reviewScoreEq(condition.getScore())
                )
                .orderBy(createOrderSpecifiers(pageable.getSort()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(review.count())
                .from(review)
                .where(
                        betweenPeriod(condition.getEndCreatedAt(), condition.getEndCreatedAt()),
                        reviewScoreEq(condition.getScore())
                );

        return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne());

    }

    private BooleanExpression reviewScoreEq(Integer score) {

        if (score == null) return null;
        return review.reviewScore.eq(score);
    }

    private BooleanExpression betweenPeriod(LocalDateTime startCreatedAt, LocalDateTime endCreatedAt) {

        if (startCreatedAt == null || endCreatedAt == null)  return null;
        if (startCreatedAt.isAfter(endCreatedAt)) throw new CustomApiException(ReviewException.INVALID_PERIOD);
        return review.createdAt.between(startCreatedAt, endCreatedAt);
    }

    // 동적으로 정렬 기준 생성
    private OrderSpecifier[] createOrderSpecifiers(Sort sort) {

        // 스트림 활용한 방식
        return sort.stream()
                .filter(order -> List.of("score", "createdAt").contains(order.getProperty()))
                .map(order -> {
                    Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
                    switch(order.getProperty()) {
                        case "score":
                            return new OrderSpecifier(direction, review.reviewScore);
                        case "createdAt":
                            return new OrderSpecifier(direction, review.createdAt);
                    }
                    return null;
                })
                .toArray(OrderSpecifier[]::new);

// for 문 활용한 방식               
//        List<OrderSpecifier> orderSpecifiers = new ArrayList<>();
//
//        // 생성 시간, 별점 기준으로만 정렬
//        for(Sort.Order order : sort) {
//            Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
//            switch(order.getProperty()) {
//                case "score":
//                    orderSpecifiers.add(new OrderSpecifier(direction, review.reviewScore));
//                case "createdAt":
//                    orderSpecifiers.add(new OrderSpecifier(direction, review.createdAt));
//            }
//        }
//        return orderSpecifiers.toArray(new OrderSpecifier[0]);
    }
}

또 다른 방식의 동적 정렬 생성

  • 인터넷에 보면, EntityPathBase 와 같은 path를 활용해서 정렬 옵션을 파싱하는 것을 볼 수 있다.
    • 그러나, 이 방식을 활용하는 경우엔 제약 조건이 없어서 사용자가 입력하는 데이터를 모두 처리하려고 한다.
    • 즉, 존재하지 않는 컬럼으로 정렬을 요청하는 경우, 500 에러가 발생하게 된다.
    • 조회 쿼리에서 모든 컬럼을 대상으로 정렬을 수행해 줄 필요도 없으므로 아래의 코드는 사용하지 않기로 결정했다.
    private OrderSpecifier[] createOrderSpecifiers(Sort sort, EntityPathBase base) {

        return sort.stream()
                .map(item -> {
                    Order order = item.getDirection() == Sort.Direction.ASC ? Order.ASC : Order.DESC;
                    Path<Object> path = Expressions.path(Object.class, base, item.getProperty());
                    return new OrderSpecifier(order, path);
                })
                .toArray(OrderSpecifier[]::new);
    }
  • 이하 발생 예외
  • 2025-02-16T16:41:14.611+09:00 WARN 29492 --- [order-management-platform] [nio-8080-exec-1] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'di' of 'com.ioteam.order_management_platform.review.entity.Review' [select review<EOL>from Review review<EOL>where review.createdAt between ?1 and ?2<EOL>order by review.di desc]]