도전 기능 Lv 3-10. QueryDSL 을 사용하여 검색 기능 만들기
📋 요구사항
- 검색 조건
(1) 검색 키워드로 일정의 제목을 검색할 수 있어요. 제목은 부분적으로 일치해도 검색이 가능해요.
(2) 일정의 생성일 범위로 검색할 수 있어요. 일정을 생성일 최신순으로 정렬해주세요.
(3) 담당자의 닉네임으로도 검색이 가능해요. 닉네임은 부분적으로 일치해도 검색이 가능해요.
- 검색 결과
(1) 일정에 대한 모든 정보가 아닌, 제목만 넣어주세요.
(2) 해당 일정의 담당자 수를 넣어주세요.
(3) 해당 일정의 총 댓글 개수를 넣어주세요.
(4) 검색 결과는 페이징 처리되어 반환되도록 합니다.
💥 Trouble 1. 검색 결과에 담당자가 있는 일정만 필터링돼서 조회된다.
키워드없이 검색했을 경우, 모든 Todo가 조회되어야 한다.
총 17개의 일정이 조회되어야 하지만 13개(: totalElements) 의 일정만 조회되었다. 왜 13개만 조회된걸까?
모든 일정 중에서 담당자가 등록되어 있는 일정 13개(중복 포함)만 조회되었기 때문이다.
TodoCustomRepositoryImpl.java
public Page<TodoSearchResponse.Dto> search(Dto requestDto, Pageable pageable) {
List<TodoSearchResponse.Dto> todos =
queryFactory
.select(
Projections.constructor(
TodoSearchResponse.Dto.class,
todo,
// 담당자 수
select(Wildcard.count).from(manager).where(todo.id.eq(manager.todo.id)),
// 댓글 개수
select(Wildcard.count).from(comment).where(todo.id.eq(comment.todo.id))
)
)
.from(todo)
.join(todo.managers, manager).fetchJoin()
.join(manager.user, user).fetchJoin()
// 검색 조건: 제목, 생성일, 담당자 닉네임
.where(
titleContains(requestDto.title()),
createdDateBetween(requestDto.startDate(), requestDto.endDate()),
nicknameContains(requestDto.nickname())
)
.orderBy(todo.createdAt.desc()) // 생성일 최신순으로 정렬
.limit(pageable.getPageSize()) // 1페이지당 몇 개까지 받아올건지
.offset(pageable.getOffset()) // 몇 번째 페이지에서부터 갖고올건지
.fetch();
// 생략
QueryDSL에서 todo - manager - user 연관관계가 join()으로 되어있다.
여기서, join() 은 기본적으로 INNER JOIN (내부조인) 역할을 한다.
innerJoin() 과 같이 명시적으로 INNER JOIN을 하게해주는 메서드가 따로 있지만, 일반 join()도 기본값이 INNER JOIN 이라는 것을 알았다.
INNER JOIN
.join(todo.managers, manager).fetchJoin()
.join(manager.user, user).fetchJoin()
- 양쪽 테이블에 매칭되는 데이터만 조회된다.
- 어떤 유저가 매니저로 등록되어 있으며, 담당하는 일정이 있을 때 해당 일정만 결과로 조회된다.
그렇다면 담당자 등록 여부 관계없이 모든 일정이 조회되려면 어떻게 해야 할까?
LEFT JOIN 을 사용하면 담당자가 없는 일정도 조회가 된다.
LEFT JOIN
.leftJoin(todo.managers, manager).fetchJoin()
.leftJoin(manager.user, user).fetchJoin()
조인 방식 | 조회 대상 | NULL값 포함 여부 |
INNER JOIN | 두 테이블 모두 매칭되는 데이터만 조회 | 포함 X |
LEFT JOIN | 왼쪽 테이블의 모든 데이터 조회 + 오른쪽 테이블의 매칭되는 데이터 | 오른쪽이 없으면 NULL |
- leftJoin(조인대상, 별칭)
- todo와 manager, manager와 user 두 테이블 모두 .leftJoin()을 해주어야 담당자가 없는 일정도 조회된다.
또한, 닉네임 키워드가 없을 시에는 null 을 반환하여 where 조건에 영향이 가지않도록 한다.
// 담당자 닉네임으로 일정 검색
private BooleanExpression nicknameContains(String nickname) {
if(nickname == null || nickname.isBlank()) {
return null;
}
return todo.id.eq(manager.todo.id) // 어떤 일정? ↓매니저가 담당하는 일정의 아이디와 일치하는 일정
.and(manager.user.id.eq(user.id)) // 어떤 매니저? ↓유저의 아이디와 일치하는 매니저
.and(user.nickname.contains(nickname)); // 어떤 유저? 닉네임이 포함된 유저
}
💥 Trouble 2. 담당자가 여러명인 일정은 담당자 수만큼 중복돼서 결과로 나온다.
Trouble 1번의 결과로 일정 17개가 아닌 19개가 나왔다.
담당자가 3명인 일정(3번 아이디)이 담당자 수 만큼 결과로 조회되었기 때문이다. (17+2)
FETCH JOIN
- @OneToMany 관계의 엔티티를 fetchJoin을 사용하여 조회하면 데이터가 중복될 수 있다. (중복된 객체 반환)
- Lazy Loading 문제 해결
- N+1 문제 방지
fetchJoin()을 사용하면 데이터 중복 문제가 발생할 수 있다는 것을 알게되어 fetchJoin()을 지워보았지만 계속 중복된 일정이 조회되었다. 그래서 중복을 제거하는 메서드에 대해 알아보았다.
DISTINCT
public Page<TodoSearchResponse.Dto> search(Dto requestDto, Pageable pageable) {
List<TodoSearchResponse.Dto> todos =
queryFactory
.select(
Projections.constructor(
TodoSearchResponse.Dto.class,
todo,
// 담당자 수
select(Wildcard.count).from(manager).where(todo.id.eq(manager.todo.id)),
// 댓글 개수
select(Wildcard.count).from(comment).where(todo.id.eq(comment.todo.id))
)
)
.distinct() // 일정 중복 제거 (ex.담당자가 여러 명일 경우) 단, 성능이 떨어질 수 있다.
.from(todo)
// 닉네임 검색 외에는 담당자가 없는 일정도 결과에 나와야함. -> leftJoin
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
// 검색 조건: 제목, 생성일, 담당자 닉네임
.where(
titleContains(requestDto.title()),
createdDateBetween(requestDto.startDate(), requestDto.endDate()),
nicknameContains(requestDto.nickname())
)
.orderBy(todo.createdAt.desc()) // 생성일 최신순으로 정렬
.limit(pageable.getPageSize()) // 1페이지당 몇 개까지 받아올건지
.offset(pageable.getOffset()) // 몇 번째 페이지에서부터 갖고올건지
.fetch();
- SQL의 SELECT DISTINCT() 역할
- 중복된 todo 데이터를 제거하여 출력
- 일부 경우 성능이 떨어질 수 있다.
GROUP BY
public Page<TodoSearchResponse.Dto> search(Dto requestDto, Pageable pageable) {
List<TodoSearchResponse.Dto> todos =
queryFactory
.select(
Projections.constructor(
TodoSearchResponse.Dto.class,
todo,
// 담당자 수
select(Wildcard.count).from(manager).where(todo.id.eq(manager.todo.id)),
// 댓글 개수
select(Wildcard.count).from(comment).where(todo.id.eq(comment.todo.id))
)
)
.from(todo)
// 닉네임 검색 외에는 담당자가 없는 일정도 결과에 나와야함. -> leftJoin
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
// 검색 조건: 제목, 생성일, 담당자 닉네임
.where(
titleContains(requestDto.title()),
createdDateBetween(requestDto.startDate(), requestDto.endDate()),
nicknameContains(requestDto.nickname())
)
.groupBy(todo.id) // 일정 중복 제거 (ex.담당자가 여러 명일 경우)
.orderBy(todo.createdAt.desc()) // 생성일 최신순으로 정렬
.limit(pageable.getPageSize()) // 1페이지당 몇 개까지 받아올건지
.offset(pageable.getOffset()) // 몇 번째 페이지에서부터 갖고올건지
.fetch();
- todo.id를 기준으로 그룹화하여 중복을 제거
- 집계 함수와 함께 사용 가능
- 쿼리 구조가 조금 더 복잡해질 수 있다.
🌕 결과
join을 leftJoin으로 변경한 후, fetchJoin()을 제거하고 groupBy()를 추가하였더니 일정이 중복되지 않고 담당자가 없는 일정까지 잘 조회되는 걸 확인할 수 있다.
*fetchJoin()을 지우지 않으면 계속 중복된 일정이 조회된다. 이 부분에 대해서는 더 알아봐야 할 것 같다..
'TIL > Trouble Shooting' 카테고리의 다른 글
[AWS] Elastic Beanstalk 배포 후 502 에러 발생 (0) | 2025.02.08 |
---|---|
Spring 심화 개인 과제 트러블 슈팅 (0) | 2025.01.06 |
Spring 일정 관리 앱 Develop 트러블 슈팅 (1) | 2024.12.19 |
Spring - 일정 관리 앱 만들기 과제 트러블 슈팅 (0) | 2024.12.10 |
Java 키오스크 과제 - 챌린지 Lv1 트러블 슈팅 (1) | 2024.11.28 |