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]]
'Spring' 카테고리의 다른 글
QueryDsl의 Projections 활용 (0) | 2025.02.26 |
---|---|
스프링 로깅 Spring Logging (0) | 2025.02.24 |
[Springboot]@WebMvcTest 사용 중 security csrf 오류 해결 (2) | 2023.11.26 |
Spring Security 기본 (0) | 2023.10.05 |
[SpringBoot]로그백 필터 활용하기 (0) | 2023.09.25 |