1. QueryDsl 이란 무엇일까?
Spring 프로젝트를 진행하다보면 Spring Data JPA가 기본적으로 제공해주는 메서드를 사용하면 기본적인 CRUD 작업을 손쉽게 진행할 수 있습니다. 하지만 복잡한 조건의 데이터가 필요할 경우에는 JPQL 같은 방법을 이용해 쿼리를 작성하게 됩니다. 이 때 문법적인 오류나, 오타 등이 존재하는 경우 런타임 시점에 확인할 수 있고, 가독성이 떨어지는 문제들이 발생하게 됩니다.
이 때 QueryDsl을 사용하게 되면 이러한 문제들을 어느정도 해결할 수 있으며 아래와 같은 장점들을 갖추고 있습니다.
QueryDsl? 정적 타입을 이용해 SQL 등의 쿼리를 생성할 수 있도록 해주는 프레임워크
- 문자가 아닌 자바 코드로 쿼리를 작성하기 때문에 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
- 동적인 쿼리 작성이 편리하다.
- 쿼리 작성시 메서드 추출 등을 통해 재사용할 수 있다.
- IDE의 자동 완성의 도움을 받을 수 있다.
지금부터는 본격적으로 QueryDsl을 활용해 카테고리별 조회 기능을 구현해보도록 하겠습니다.
2. Gradle 설정 추가
각자의 환경에 맞는 설정법을 찾아 Gradle 설정을 추가해줍니다.
implementation 'com.querydsl:querydsl-jpa'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
그 후 gradle에서 compileJava를 실행시켜주면 Entity로 등록한 클래스들이 Q접두사가 붙은 형태로 생성된 것을 확인할 수 있습니다. 이러한 클래스를 Q클래스 or Q타입이라고 합니다. QueryDSL은 해당 클래스를 기반으로 쿼리 메서드를 실행하게 됩니다. 따라서 타입 불일치의 걱정없이 사용할 수 있다는 장점이 있습니다.
3. 카테고리 구성과 목표
현재 계층형으로 카테고리가 구성되어 있는데, 카테고리를 통해 티켓을 조회하면 아래와 같은 조회 결과가 나오도록 구현해보겠습니다.
1. 부모 카테고리로 조회할 경우
=> 서브카테고리에 해당하는 티켓까지 모두 조회한다.
(스포츠 카테고리를 선택할 경우 야구에 해당하는 티켓도 모두 조회한다)
2. 서브 카테고리로 조회할 경우
=> 서브카테고리에 해당하는 티켓만 조회한다.
4. QueryDsl 이용한 카테고리 조회
Spring Data JPA는 JpaRepository를 상속한 Repository에서 Custom Repository 기능을 사용할 수 있는 기능을 제공합니다.
QueryDslConfig.java
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
해당 Config파일을 만들어 프로젝트 전역에서 QueryDSL을 작성할 수 있도록 Bean으로 등록해줍니다.
TicketCustomRepository.java
public interface TicketCustomRepository {
Page<TicketPageResponse> findTicketsByCategoryId();
}
커스텀 인터페이스에 해당 메서드를 정의합니다.
TicketCustomRepositoryImpl.java
public class TicketCustomRepositoryImpl implements TicketCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public TicketCustomRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Page<TicketPageResponse> findTicketsByCategoryId(Long categoryId, Pageable pageable) {
QCategory category = QCategory.category;
QTicket qTicket = QTicket.ticket;
List<Long> categoryIds = jpaQueryFactory
.select(category.id)
.from(category)
.where(category.parent.id.eq(categoryId).or(category.id.eq(categoryId)))
.fetch();
List<TicketPageResponse> ticketPageResponse = jpaQueryFactory
.select(new QTicketPageResponse(
qTicket.id,
qTicket.title,
qTicket.content,
qTicket.runningTime,
qTicket.openDate,
qTicket.endDate,
qTicket.startDate
))
.from(qTicket)
.where(qTicket.category.id.in(categoryIds))
.orderBy(qTicket.id.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = jpaQueryFactory
.select(qTicket.count())
.from(qTicket)
.where(qTicket.category.id.in(categoryIds));
return PageableExecutionUtils.getPage(ticketPageResponse, pageable, countQuery::fetchOne);
}
}
커스텀 인터페이스 구현하는 클래스에 QueryDsl을 작성해 줍니다. 빌더 패턴으로 작성할 수 있어 편리하고, 타입에 대한 안정성을 높일 수 있습니다. (이 때 해당 구현 클래스의 이름은 Impl로 끝나야 합니다.)
+ 추가) 리팩토링 후(조건별 페이징)(이거는 무시하셔도 됩니다)
public class TicketCustomRepositoryImpl implements TicketCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public TicketCustomRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Page<TicketPageResponse> findFilteredTickets(LocalDate startDate, LocalDate endDate, Long categoryId, Pageable pageable) {
QTicket qTicket = QTicket.ticket;
Predicate condition = createCondition(qTicket, startDate, endDate, categoryId);
List<TicketPageResponse> ticketPageResponse = findTicketPageResponse(qTicket, pageable, condition);
JPAQuery<Long> countQuery = createCountQuery(qTicket, condition);
return PageableExecutionUtils.getPage(ticketPageResponse, pageable, countQuery::fetchOne);
}
private Predicate createCondition(QTicket qTicket, LocalDate startDate, LocalDate endDate, Long categoryId) {
BooleanExpression condition = qTicket.isNotNull(); // 초기 조건 설정
QCategory qCategory = QCategory.category;
if (startDate != null && endDate != null) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
condition = condition.and(qTicket.startDate.between(startDateTime, endDateTime));
}
if (categoryId != null) {
List<Long> categoryIds = fetchCategoryIdsByParentId(categoryId, qCategory);
condition = condition.and(qTicket.category.id.in(categoryIds));
}
return condition;
}
private List<TicketPageResponse> findTicketPageResponse(QTicket qTicket, Pageable pageable, Predicate condition) {
return jpaQueryFactory
.select(new QTicketPageResponse(
qTicket.id,
qTicket.title,
qTicket.content,
qTicket.runningTime,
qTicket.openDate,
qTicket.endDate,
qTicket.startDate
))
.from(qTicket)
.where(condition)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(qTicket.startDate.asc())
.fetch();
}
private JPAQuery<Long> createCountQuery(QTicket qTicket, Predicate condition) {
return jpaQueryFactory
.select(qTicket.count())
.from(qTicket)
.where(condition);
}
private List<Long> fetchCategoryIdsByParentId(Long categoryId, QCategory category) {
return jpaQueryFactory
.select(category.id)
.from(category)
.where(category.parent.id.eq(categoryId).or(category.id.eq(categoryId)))
.fetch();
}
}
@QueryProjection
@QueryProjection
public TicketPageResponse(Long id,
String title,
String content,
String runningTime,
LocalDateTime openDate,
LocalDateTime endDate,
LocalDateTime startDate) {
this.id = id;
this.title = title;
this.content = content;
this.runningTime = runningTime;
this.openDate = openDate;
this.endDate = endDate;
this.startDate = startDate;
}
TicketPageReponse의 생성자에 @QueryProjection을 이용하면 TicketPageResponse를 기반으로 생성된
QTicketPageResponse를 이용하게 되고 아래와 같은 장점이 있습니다.
- 타입 세이프티(Type Safety): @QueryProjection을 사용하면 컴파일 시점에 타입 검사를 통해 오류를 잡을 수 있다.
- 편리한 DTO 생성: @QueryProjection을 이용하면 쿼리 결과를 직접 TicketPageResponse 객체로 변환할 수 있다.
- 가독성 향상: QueryDSL 쿼리에서 QTicketPageResponse를 직접 사용할 수 있어 쿼리와 결과 매핑을 명확히 할 수 있다.
TicketRepository
public interface TicketRepository extends JpaRepository<Ticket, Long>, TicketCustomRepository {
}
TicketCustomRepository를 상속해줌으로써 TicketRepository에서 TicketCustomRepositoryImpl의 QueryDSL 메서드를 자동으로 사용할 수 있게 됩니다.
5. 결과 확인
부모 카테고리로 조회한 경우
서브 카테고리로 조회한 경우
부모카테고리로 조회할 경우 서브카테고리에 해당하는 모든 티켓을, 서브카테고리로 조회할 경우 서브카테고리에 해당하는 티켓만 조회되는 것을 볼 수 있습니다.
'Backend > 프로젝트' 카테고리의 다른 글
Redis 분산 락(Distribution Lock)으로 동시성 문제 해결하기(+ AOP) (0) | 2024.08.18 |
---|---|
스프링에서 동시성 문제 해결하기(낙관적 락, 비관적 락) (0) | 2024.08.14 |
Github Actions를 이용한 CI 구성 (0) | 2024.07.10 |
Spring Security + jwt + OAuth2(소셜 로그인 2편) (0) | 2024.06.30 |
OAuth란 무엇인가? (소셜 로그인 1편) (0) | 2024.06.17 |