✔️ Q클래스란?
다 캡쳐하진 못했지만, Todo의 필드가 QTodo에서도 보이는 것을 알 수 있다.
Todo 엔티티 | QTodo |
---|---|
![]() | ![]() |
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
def generated = 'src/main/generated'
tasks.withType(JavaCompile).configureEach {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
clean {
delete file(generated)
}
.gitignore
에 다음과 같이 등록해 놓는다. QueryDSL의 라이브러리 버전에 따라 생성되는 Q클래스 생김새가 달라질 수 있어 추가 해놓는 것이 좋다.###QueryDSL###
/src/main/generated/
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
public interface TodoRepositoryQuery {
Optional<Todo> findByIdWithUser(Long todoId);
}
Impl
이라는 postfix를 반드시 붙여 구현한다.@RequiredArgsConstructor
public class TodoRepositoryQueryImpl implements TodoRepositoryQuery {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
return Optional.ofNullable(jpaQueryFactory.selectFrom(todo)
.where(todo.id.eq(todoId))
.leftJoin(todo.user)
.fetchJoin()
.fetchOne());
}
}
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryQuery {
//...중략
}
영상 자료에 있는 코드를 다 가져오진 않고 내가 현재 작성 중인 쿼리로 대체하겠다. 검색 조건은 다음과 같다.
처음에는 findByTitle()
처럼 검색 조건을 따로따로 받아 만드려고 하다가, 검색 API를 하나로 통일하고 파라미터를 추가해 동적 쿼리를 만드는 것이 나은 것 같아 바꿨다. 한편으로는 실무에서 지금보다 검색 조건이 훨씬 많은 경우에도 계속 파라미터만 추가하는 방식으로 구현할까?🤔 라는 궁금증이 들었다.
응답 DTO로 TodoSearchResponse
를 새로 만들어 반환하도록 했다. 해당 DTO에는 제목, 담당자 수, 댓글 개수를 담는다.
@GetMapping("/todos/search")
public ResponseEntity<Page<TodoSearchResponse>> searchTodos(
@AuthenticationPrincipal AuthUser authUser,
@RequestParam(required = false) String title,
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate,
@RequestParam(required = false) String nickName,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(todoService.searchTodos(authUser, title, startDate, endDate, nickName, page, size));
}
@Override
public Page<TodoSearchResponse> searchTodos(
String title, LocalDate startDate, LocalDate endDate,
String nickName, Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if (title != null && !title.isEmpty()) {
builder.and(todo.title.containsIgnoreCase(title));
}
if (startDate != null && endDate != null) {
builder.and(todo.createdAt.between(startDate.atStartOfDay(), endDate.atTime(23, 59, 59)));
}
if (nickName != null && !nickName.isEmpty()) {
builder.and(todo.user.nickName.containsIgnoreCase(nickName));
}
List<TodoSearchResponse> results = jpaQueryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
todo.managerCount,
todo.commentCount
))
.from(todo)
.where(builder)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = jpaQueryFactory
.select(todo.count())
.from(todo)
.where(builder);
return PageableExecutionUtils.getPage(results, pageable, countQuery::fetchOne);
}
유저와 함께 일정을 검색하는 기능은 기존 쿼리를 QueryDSL로 바꿔야 했다. 기존 JQPL 쿼리는 다음과 같다.
다음은 QueryDSL로 바꾼 코드다. SQL에 익숙하다면 읽기에 문제가 없을 것이다. 참고로, todo
와 user
는 Q클래스에서 import 해온 것이다.
selectFrom(todo)
: Todo 엔티티를 기준으로 조회한다..leftJoin(todo.user, user)
: 일정에 해당하는 User 정보를 함께 조회한다..fetchJoin()
: N+1 문제를 해결할 수 있다..fetchOne()
: 단일 엔티티를 반환한다.✔️ fetchOne()
vs fetch()
vs fetchFirst()
차이
fetchOne()
: 하나의 결과를 가져오고 싶을 때 사용.fetch()
: 리스트 형태와 같이 여러 개의 결과를 가져오고 싶을 때 사용.fetchFirst()
: limit(1).fetchOne()
과 같은 결과를 낼 때 사용.fetchOne()
은 결과가 유일할 때 사용하고, fetchFirst()
는 여러 개 중 하나만 가져올 때 사용.BooleanBuilder를 사용해 여러 개의 조건을 동적으로 추가했다. 따라서 제목과 생성일을 동시에 필터링해 검색하거나 닉네임과 생성일을 기준으로 검색하는 등의 동작이 가능하다. BooleanBuilder에 대한 자세한 내용은 아래에서 다룬다.
우선 BooleanBuilder를 먼저 생성하고, 제목이 비어있지 않은 경우에만 builder에 and()
연산자를 사용해 AND 조건을 추가한다. 따라서 모든 파라미터가 들어온다면 제목, 생성일, 닉네임을 모두 충족하는 일정을 검색하게 된다.
containsIgnoreCase()
: 대소문자 상관없이 특정 문자열을 포함하고 있으면 treu를 반환한다.createdAT 필드가 startDate와 endDate 사이에 포함되는 데이터를 필터링한다. /todos/search?startDate=2025-03-10&endDate=2025-03-12&page&size
와 같이 호출할 경우, createdAt이 2025-03-10 00:00:00 이상이고 2025-03-12 23:59:59 이하인 데이터를 조회한다.
.atStartOfDay()
: 해당 날짜의 00:00:00(자정)으로 변환한다..atTime(23, 59, 59)
: 해당 날짜의 23:59:59 (하루의 마지막 순간)으로 변환한다.제목 검색과 마찬가지로 키워드가 부분적으로 일치해도 검색이 가능하다.
.select(Projections.constructor(TodoTitleResponse.class, todo.title))
: 여기서 Projections.constructor()
은 DTO로 데이터 매핑 시 사용하는 QueryDSL의 기능이다. TodoTitleResponse
DTO를 활용해 todo.title
값을 매핑하고 있다..where(builder)
: 동적 검색 조건을 적용한다..offset(pageable.getOffset())
: 조회할 데이터의 시작 위치(몇 개를 건너뛸지)를 설정한다..limit(pageable.getPageSize())
: 가져올 최대 개수를 pageable.getPageSize()
로 설정한다.fetch()
: 리스트 형태로 조회한다.PageableExecutionUtils.getPage()
: results.size() < pageable.getPageSize()라면 countQuery 실행 없이 페이징 정보를 생성한다.✔️ PageableExecutionUtils란?
위 코드를 PageImpl로 바꿔보면 다음과 같다. 항상 countQuery를 실행하므로 불필요한 쿼리를 실행할 가능성이 높다.
long total = countQuery.fetchOne();
return new PageImpl<>(titleList, pageable, total);
PageableExecutionUtils은 countQuery 실행을 지연시켜 꼭 필요할 때만 실행되도록 최적화하는 방식이다. 데이터의 개수가 적을 경우 countQuery 실행을 생략해 성능을 향상시킨다.
return PageableExecutionUtils.getPage(titleList, pageable, countQuery::fetchOne);
countQuery를 생략 가능한 경우는 다음과 같다.
제목으로 검색 | 생성일 범위로 검색 | 닉네임으로 검색 |
---|---|---|
![]() | ![]() | ![]() |
생성일 + 닉네임 검색 | 생성일 + 제목 검색 | 제목 + 닉네임 검색 |
---|---|---|
![]() | ![]() | ![]() |
동적 쿼리는 사용자의 요구에 따라 변하는 쿼리를 말한다. 동적 쿼리를 알기위해서는 먼저 BooleanExpression에 대해 알아야 한다. 아래 내용은 우테톡 영상으로 보고 간단하게 정리한 내용이다.
.from(store)
.where(store.name.eq("덮밥"))
.where(expression1, expression2)
에서 ,
는 AND
와 같은 의미를 지닌다. .from(store)
.where(expression1, expression2)
private BooleanExpression expression1(final String name) {
return null이 아닌 expression;
}
private BooleanExpression expression2(final String name) {
return null이 아닌 expression;
}
.from(store)
.where(expression1, expression2)
private BooleanExpression expression1(final String name) {
if (name == null) return null;
return null이 아닌 expression;
}
private BooleanExpression expression2(final String name) {
return null이 아닌 expression;
}
이렇게 BooleanExpression을 활용해 다음과 같이 동적 쿼리를 작성할 수 있다. BooleanExpression(혹은 null)을 반환하는 matchesCondition 메서드를 having절에 넣어줌으로써 동적으로 having 조건을 설정할 수 있다.
public List<Store> findPopularStoreByTotalRateOrOrderWithMin(final String type, final String min) {
return jpaQueryFactory.selectFrom(store)
.leftJoin(store.orders, order)
.leftJoin(order.review, review)
.groupBy(store.id)
.having(matchesCondition(type, min))
.fetch();
}
private BooleanExpression matchesCondition(final String type, final String min) {
if (type.equals("orderCount")) {
return filterByOrderCount(min);
}
if (type.equals("rate")) {
return filterByRate(min);
}
return null;
}
BooleanBuilder는 동적 쿼리를 작성할 때 유용하게 사용되는 클래스다. 검색 조건이 유동적으로 바뀌거나 여러 개의 조건을 조합해야 할 때 주로 사용된다.
따라서 위에 작성한 검색 기능에 적합하다고 판단되어 BooleanBuilder를 사용하였다. BooleanExpression 예시 코드처럼 type에 따라 하나의 조건만 적용된다면 BooleanExpression이 더 적합할 수 있겠지만, 여러 개의 조건을 조합할 때는 BooleanBuilder가 더 좋지 않을까 생각한다.
그래서 BooleanExpression과 BooleanBuilder 차이를 한마디로 정리하면 뭘까? 검색하다 발견한 김영한님의 답변으로 간단하게 설명할 수 있을 것 같다.
QueryDSL의 Projections는 DTO로 원하는 필드만 선택하여 조회할 때 사용된다. 엔티티를 그대로 조회하면 불필요한 필드까지 가져오게 되지만, Projections를 활용하면 필요한 데이터만 가져올 수 있다.
Projections.constructor()
은 DTO의 생성자를 활용해 필드를 매핑하는 방식이다.위에서 검색 기능을 구현할 때 사용한 방식이다.
List<TodoSearchResponse> results = jpaQueryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
todo.managerCount,
todo.commentCount
))
.from(todo)
.fetch();
Projections.bean()
은 Setter를 활용해 필드를 매핑하는 방식이다. 필드명이 일치하면 자동 매핑된다. Setter를 활용한 매핑이라 불변 객체에는 적합하지 않다.
.select(Projections.bean(TodoTitleResponse.class, todo.title.as("title")))
Expressions.as()
를 사용해야 한다.Projections.fields()
는 DTO의 필드명과 일치하는 값이 자동 매핑된다. 기본 생성자가 있어야 한다.
List<TodoSearchResponse> results = jpaQueryFactory
.select(Projections.fields(
TodoSearchResponse.class,
todo.title,
todo.managerCount,
todo.commentCount
))
.from(todo)
.fetch();
Expressions.as()
를 사용해야 한다.DTO 생성자에 @QueryProjection
을 붙이면 QueryDSL이 자동으로 해당 DTO의 Q클래스를 생성한다.
@Getter
public class TodoTitleResponse {
private String title;
@QueryProjection // QueryDSL에서 생성자 기반 매핑을 지원하도록 설정
public TodoTitleResponse(String title) {
this.title = title;
}
}
✔️ @QueryProjection 장단점
📚 참고 자료