1. QueryDSL 을 사용하여 검색 기능 만들기
(1) 문제 요구 사항
(2) TodoSearchResponse DTO 생성
package org.example.expert.domain.todo.dto.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class TodoSearchResponse {
private String title;
private long managerCount;
private long commentCount;
}
이 DTO는 일정의 제목, 담당자의 수, 댓글의 수를 필드로 가지고 있습니다.
어노테이션을 활용하여 객체 생성을 용이하게 하였습니다.
🔍 @NoArgsConstructor
기본 생성자를 자동으로 생성하여,
빈 객체를 생성하거나 프록시 라이브러리(예: Hibernate 등)에서 객체를 생성할 때 유용
🔍 @AllArgsConstructor
모든 필드를 인자로 받는 생성자를 자동으로 생성하여,
필요한 값들을 한 번에 전달해 객체를 초기화할 수 있도록 도와줍니다.
(3) 컨트롤러에 API 추가
@GetMapping("todos/search")
public ResponseEntity<Page<TodoSearchResponse>> searchTodos(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String nickname,
@RequestParam(required = false) LocalDateTime startDate,
@RequestParam(required = false) LocalDateTime endDate
) {
return ResponseEntity.ok(todoService.searchTodos(page, size, keyword, nickname, startDate, endDate));
}
이 API는 GET 요청을 통해 일정 검색 결과를 페이징 처리하여 반환합니다.
클라이언트는 페이지 번호(page)와 페이지 크기(size) 외에도,
제목에 포함된 키워드, 담당자의 닉네임, 시작일과 종료일의 검색 조건을 전달할 수 있으며,
이 인자들을 바탕으로 조건에 맞는 결과를 동적으로 조회합니다.
(4) 서비스에 메소드 추가
@Transactional(readOnly = true)
public Page<TodoSearchResponse> searchTodos(int page, int size, String keyword, String nickname, LocalDateTime startDate, LocalDateTime endDate) {
Pageable pageable = PageRequest.of(page - 1, size);
if (startDate == null) {
startDate = LocalDateTime.of(1, 1, 1, 0, 0);
}
if (endDate == null) {
endDate = LocalDateTime.of(9999, 12, 31, 23, 59);
}
return todoRepository.searchTodos(pageable, keyword, nickname, startDate, endDate);
}
startDate와 endDate가 null인 경우, 각각 최소 날짜와 최대 날짜 값을 할당하여
Repository에 항상 유효한 날짜 범위를 전달함으로써
데이터베이스 조회 시 null 값으로 인한 오류를 방지하도록 하였습니다.
(5) QueryDSL 작성하기
1️⃣ 동적으로 검색 조건 구성하기
🔍 BooleanBuilder란?
QueryDSL에서 동적으로 검색 조건을 구성할 수 있도록 도와주는 유틸리티 클래스입니다.
예를 들어, 사용자가 입력한 키워드나 날짜 범위와 같이 조건이 선택적일 경우,
해당 조건이 존재할 때만 추가할 수 있어 유연하게 쿼리를 구성할 수 있습니다.
내부적으로 여러 개의 검색 조건을 저장 한 후,
and()나 or()메소드를 사용하여 하나의 복합 조건으로 결합할 수 있습니다.
최종적으로 QueryDSL의 where()절에 전달하여 원하는 데이터를 필터링할 수 있습니다.
[BooleanBuilder로 조건 설정]
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(keyword)) {
builder.and(todo.title.contains(keyword));
}
if (StringUtils.hasText(nickname)) {
builder.and(todo.user.nickname.contains(nickname));
}
builder.and(todo.createdAt.between(startDate, endDate));
builder를 초기화 한 후, 입력된 keyword와 nickname에 대해 값이 존재한다면,
각각 제목과 사용자의 닉네임에 대한 조건을 추가하였습니다.
날짜 범위 조건은 서비스 계층에서 null일 경우 기본값이 할당되므로 별도의 if 조건 없이 항상 적용됩니다.
2️⃣ QueryDSL을 활용하여 쿼리 작성하기
🔍 프로젝션(Projection)이란?
엔티티 전체가 아닌 특정 컬럼들 또는 계산된 값만 선택하여 DB에서 조회하는 방법입니다.
프로젝션을 사용하면 조회 성능을 최적화할 수 있고,
불필요한 데이터를 로드하지 않아 메모리 사용을 줄일 수 있습니다.
[프로젝션 기능을 사용하여 작성한 QueryDSL 코드]
List<TodoSearchResponse> todoList = jqf
.select(
Projections.fields(TodoSearchResponse.class,
todo.title.as("title"),
manager.id.countDistinct().as("managerCount"),
comment.id.countDistinct().as("commentCount")
)
)
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(todo.comments, comment)
.where(builder)
.groupBy(todo.id)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
QueryDSL의 프로젝션 기능을 사용하여,
데이터베이스에서 조회한 결과를 필요한 필드만 추출하여 DTO에 매핑하였습니다.
🤔 리스트 조회 후 Page 객체 생성 이유
QueryDSL은 직접 Page 타입을 반환하는 메서드를 제공하지 않기 때문에,쿼리 결과를 리스트로 조회합니다.이후 전체 건수를 별도로 계산하고,
Spring Data의 PageImpl을 사용해리스트, 페이징 정보, 총 건수를 기반으로Page 객체를 생성하여 반환합니다.
이 방식으로 현재 페이지 데이터와 전체 건수를 모두 포함하는페이징 처리 결과를 구성할 수 있습니다.
3️⃣ 총 건수 조회 쿼리
Long total = jqf
.select(todo.count())
.from(todo)
.where(builder)
.fetchOne();
조건(builder)를 적용한 후, Todo엔티티의 총 건수를 조회합니다.
이는 페이징 처리를 위한 쿼리로, 필터링된 전체 데이터 수를 알아내어 페이징에 활용됩니다.
4️⃣ 페이지 객체 생성 및 반환
return new PageImpl<>(todoList, pageable, total != null ? total : 0);
조회된 todoList와 페이징 정보, 전체 건수를 이용하여,
SpringData의 PageImpl 객체를 생성하고 반환합니다.
5️⃣ 전체 코드
@Override
public Page<TodoSearchResponse> searchTodos(Pageable pageable, String keyword, String nickname, LocalDateTime startDate, LocalDateTime endDate) {
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(keyword)) {
builder.and(todo.title.contains(keyword));
}
if (StringUtils.hasText(nickname)) {
builder.and(todo.user.nickname.contains(nickname));
}
builder.and(todo.createdAt.between(startDate, endDate));
List<TodoSearchResponse> todoList = jqf
.select(
Projections.fields(TodoSearchResponse.class,
todo.title.as("title"),
manager.id.countDistinct().as("managerCount"),
comment.id.countDistinct().as("commentCount")
)
)
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(todo.comments, comment)
.where(builder)
.groupBy(todo.id)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = jqf
.select(todo.count())
.from(todo)
.where(builder)
.fetchOne();
return new PageImpl<>(todoList, pageable, total != null ? total : 0);
}
2. 의문점
(1) 3LayeredArchitecture 위반이지 않은가?
🤔 프로젝션을 사용하여 필요한 컬럼만 조회하고,
리포지토리 계층에서 직접 DTO를 반환하는 방식은
3LayeredArchitecture 위반하는 것이 아닐까?
DTO(Data Transfer Object)는 서비스 계층과 컨트롤러 계층 사이에서 데이터를 전달하기 위한 수단으로 주로 사용됩니다.
하지만, 쿼리 최적화를 위해 필요한 컬럼만 조회하는 경우,
리포지토리 계층에서 직접 DTO를 반환하는 것도 흔히 사용되는 기법입니다.
이는 성능 향상과 불필요한 데이터 로드를 방지하는 데 효과적입니다.
엄격하게 3계층 아키텍처를 준수하고 싶다면,
컨트롤러-서비스 DTO와 서비스-리포지토리 DTO를 분리하여 구현하는 방식도 고려할 수 있습니다.
이렇게 하면 각 계층이 독자적인 DTO를 사용하여 책임을 더욱 명확하게 분리할 수 있습니다.
(2) 왜 fetchJoin 을 사용하지 않아도 N+1문제가 발생하지 않는가?
🤔 연관관계의 엔티티가 존재하기 때문에
fetchJoin을 사용하지않으면 N+1문제가 발생할 거 같은데,
왜 이 쿼리에서는 N+1 문제가 왜 발생하지 않을까?
fetchJoin 연관된 전체 엔티티를 조회할 때 N+1 문제를 예방하기 위해서 유용하지만,
이 쿼리에서는 Projections.fields를 사용하여 필요한 컬럼만 조회하고 DTO에 매핑합니다.
따라서, 전체 엔티티를 로딩하지 않고, 조인은 집계 및 조건 처리에만 사용되므로,
lazy로딩으로 인한 추가 쿼리가 발생하지 않아 N+1 문제도 발생하지 않습니다.
3. 추가 적용 사항: QueryDSL의 Pagination 성능 개선
오늘 구현한 QueryDSL에서는 새로운 PageImpl 객체를 반환하기 위해 별도의 count 쿼리를 실행하였습니다.
팀 활동 중 공부한 내용을 공유하면서,
한 분이 적용한 `PageableExecutionUtils.getPage()`를 사용한 것을 보았습니다.
이 방법을 사용하면, 결과 리스트의 크기와 전체 건수를 기준으로,
첫 번째 페이지나 마지막 페이지를 조회할 때, 불필요한 count 쿼리 실행을 생략할 수 있습니다.
추후 해당 방식을 적용하여 성능 개선을 해보도록 하겠습니다.