@Override
public Page<TodoQueryDslResponse> searchTodos(
String title, String nickname,
LocalDateTime start, LocalDateTime end, Pageable pageable) {
List<TodoQueryDslResponse> content = queryFactory
.select(new QTodoQueryDslResponse(
todo.title,
manager.id.countDistinct(),
select(comment.id.count())
.from(comment)
.where(comment.todo.eq(todo))
))
.from(todo)
.leftJoin(todo.managers, manager) //담당자
.leftJoin(manager.user, user) //담당자의 user (닉네임)
.where(
titleContains(title),
managerNicknameContains(nickname),
createdAfter(start),
createdBefore(end)
)
.groupBy(todo.id, todo.title)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
//countQuery: 닉네임 검색이 없으면 JOIN 생략 가능
JPAQuery<Long> countBase = queryFactory
.select(todo.id.countDistinct())
.from(todo);
if (StringUtils.hasText(nickname)) {
countBase.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user);
}
Long total = countBase
.where(
titleContains(title),
managerNicknameContains(nickname),
createdAfter(start),
createdBefore(end)
)
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0 : total);
}
//동적 조건 메서드
private BooleanExpression titleContains(String t) {
return (t == null || t.isBlank()) ? null : todo.title.containsIgnoreCase(t);
}
private BooleanExpression managerNicknameContains(String n) {
return StringUtils.hasText(n) ? user.nickname.containsIgnoreCase(n) : null;
}
private BooleanExpression createdAfter(LocalDateTime from) {
return from == null ? null : todo.createdAt.goe(from);
}
private BooleanExpression createdBefore(LocalDateTime to) {
return to == null ? null : todo.createdAt.loe(to);
}
카운트 쿼리에서 닉네임 조건이 없을 땐 JOIN을 건너뛰어 성능 개선했습니다.
처음엔 todo가 생성될 때 manager가 본인으로 지정되기 때문에 manager를 innerjoin처리, user또한 innerjoin처리 해줬었습니다.
그런데 가만히 생각해보니 로직이 변경될 가능성이나, manager를 맡은 user가 탈퇴했을 때 null값이 들어갈 가능성이 있다고 판단하여 leftjoin을 사용해주었습니다.
처음에는 todo.user.nickname으로 닉네임을 조회했으나 담당자 닉네임 검색을 요구했으니 manager.user.nickname을 조회하도록 고쳤습니다.
처음에는 LEFT JOIN managers m LEFT JOIN comments c를 한 뒤 GROUP BY로 묶어주어 COUNT(DISTINCT)처리해줬었습니다.
이 때 옵티마이저가 두 테이블을 동시에 조인해야 해서 메모리/정렬/네트워크 비용이 커지는 문제가 있었습니다.
한 Todo에 담당자·댓글이 많아지면 manager × comment 조인으로 결과 row 수가 커질 수 있음을 판단해
댓글 수를 서브쿼리로 가져오도록 했습니다.
담당자 조인은 닉네임 조건, 담당자 수 집계 등에 필요하다고 판단해 그대로 조인했고
댓글은 1:N이 더 커질 확률이 높아 서브쿼리로 빼주었습니다.
Querydsl은 문자열 JPQL 대비 Q클래스와 자바 체이닝으로 인해 컴파일 타임에 필드 오타나 타입 불일치를 잡아줄 수 있다는 장점이 있습니다.
일관성을 유지하기 위해, 프로젝션 또한 컴파일 타입에서 검증 가능한 QDto 생성자 프로젝션을 사용했습니다.
엔티티를 직접 조회하지 않고 필요한 필드만 projection하여 N+1 문제를 우회할 수 있었고, 네트워크 페이로드와 메모리 사용량도 줄일 수 있었습니다.