Today's Codekata
# Second Highest Salary
SELECT MAX(salary) AS SecondHighestSalary
FROM Employee
WHERE salary < (
SELECT MAX(salary) FROM Employee
);
# Delete Duplicate Emails
DELETE p1
FROM Person p1
JOIN Person p2 ON p1.email = p2.email
AND p1.id > p2.id;
# Patients With a Condition
SELECT patient_id, patient_name, conditions
FROM Patients
WHERE conditions LIKE "DIAB1%" OR conditions LIKE "% DIAB1%";
Today I Learned
Spring 플러스 주차 개인 과제
#QueryDSL로 일정 검색 기능 구현하기
이번에 구현해본 기능은 일정(Todo)을 다양한 조건으로 검색할 수 있는 API이다.
단순히 제목으로만 검색하는 것이 아니라, 생성일 범위, 담당자 닉네임 등 복합적인 조건으로 검색할 수 있도록 만들어야했다.
목표
제목, 생성일, 담당자 닉네임으로 검색 가능
검색 결과에 일정 제목, 담당자 수, 댓글 수 포함
최신순 정렬 및 페이징 처리
테스트 코드로 기능 검증
구현
1. 동적 조건 처리 - BooleanBuilder
검색 조건은 사용자가 선택적으로 입력할 수 있기 때문에, 조건을 동적으로 조합할 수 있어야 한다.
이를 위해 QueryDSL의 `BooleanBuilder`를 사용했다.
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(title)) {
builder.and(todo.title.contains(title));
}
if (startDate != null && endDate != null) {
builder.and(todo.createdAt.between(startDate.atStartOfDay(), endDate.atTime(23, 59, 59)));
}
if (StringUtils.hasText(nickname)) {
builder.and(user.nickname.contains(nickname));
}
이렇게 하면 조건이 하나도 없을 수도 있고, 세 가지가 모두 있을 수도 있는 상황을 유연하게 처리할 수 있다.
`hasText()`는 단순히 null 체크뿐 아니라 공백 문자열까지 걸러준다. `" "`처럼 공백만 있는 값도 false로 처리된다.
2. DTO 매핑 - Projections.constructor()
검색 결과는 `TodoSimpleResponse`라는 DTO로 반환되며, 다음과 같은 필드를 포함한다.
[ 일정 ID / 일정 제목 / 담당자 수 / 댓글 수 ]
.select(Projections.constructor(
TodoSimpleResponse.class,
todo.id,
todo.title,
manager.countDistinct(),
comment.countDistinct()
))
지금 코드에서는 모든 필드를 매핑했지만,
`Projections.constructor()`를 사용하면 필요한 필드만 매핑할 수 있어 엔티티 전체를 조회하지 않아도 된다.
3. 조인 및 그룹핑
담당자와 댓글 수를 구하기 위해 각각 `Manager`, `Comment` 엔티티와 조인하고, 일정별로 그룹핑을 적용했다.
.from(todo)
.leftJoin(manager).on(manager.todo.eq(todo))
.leftJoin(comment).on(comment.todo.eq(todo))
.leftJoin(manager.user, user)
.where(builder)
.groupBy(todo.id)
`on()` 절을 사용하면 조인 조건을 명시적으로 지정할 수 있다.
불필요한 조인을 방지하고, 원하는 조건에 맞는 데이터만 가져올 수 있어 성능 최적화에 도움이 된다.
이번 검색 조건은 `todo.user`(일정 작성자)의 닉네임이 아닌 담당자의 닉네임을 기준으로 하기 때문에,
`user.nickname.contains(nickname)` 조건이 제대로 작동하려면 `manager.user`(일정 담당자)와 `user`를 조인해줘야했다.
4. 페이징 및 정렬
최신순으로 정렬하고, Pageable을 활용해 페이징 처리했다.
`PageableExecutionUtils.getPage()`를 사용하면 count 쿼리를 효율적으로 처리할 수 있다.
.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(todos, pageable, countQuery::fetchOne);
테스트 코드
기능이 제대로 동작하는지 확인하기 위해 테스트 코드를 작성했다.
15개의 일정 데이터를 저장하고 제목과 닉네임, 날짜 조건으로 검색했을 때 기대한 결과가 나오는지 검증한다.
@Test
void findByQuery_조건_기반_검색() {
// given
User user = new User("email", "password", "nickname", UserRole.ROLE_USER);
userRepository.save(user);
for (int i = 0; i < 15; i++) {
Todo todo = new Todo((i + 1) + ". title", "contents", "weather", user);
ReflectionTestUtils.setField(todo, "createdAt", LocalDateTime.now().minusHours(1));
todoRepository.save(todo);
Manager manager = new Manager(user, todo);
managerRepository.save(manager);
}
// when
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<TodoSimpleResponse> todos = todoRepository.findByQuery(
pageable,
"title",
LocalDate.now().minusDays(1),
LocalDate.now(),
"nick"
);
// then
assertThat(todos.getTotalElements()).isEqualTo(15);
assertThat(todos.getTotalPages()).isEqualTo(2);
assertThat(todos.getContent().get(0).getTitle()).isEqualTo("15. title");
assertThat(todos.getContent())
.allMatch(todo -> todo.getTitle().contains("title"));
}
#마치며
이번 기능 구현을 통해 QueryDSL의 동적 조건 처리와 DTO 매핑, 페이징 처리까지 한 번에 경험할 수 있었다.
특히 BooleanBuilder를 활용하면 다양한 조건을 유연하게 조합할 수 있어, 실무에서도 매우 유용하게 쓰일 수 있을 것 같다.