Today I Learned
검색 API 설계와 QueryDSL 활용
1. 검색 API 설계
처음 검색 기능을 만들 때 가장 먼저 고민한 건 검색 조건이 없을 때 어떻게 동작해야 하는가였다.
예를 들어 키워드, 날짜 범위 등 조건이 모두 비어 있으면 전체 데이터를 반환해야 할까? 아니면 예외를 던져야 할까?
내가 선택한 방식
- 조건이 없으면 전체 데이터를 페이징하여 반환
- 단, Pageable은 반드시 적용해서 성능 문제를 방지
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(condition.keyword())) {
builder.and(...);
}
if (condition.fromDate() != null) {
builder.and(...);
}
if (condition.toDate() != null) {
builder.and(...);
}
return queryFactory.selectFrom(match)
.where(builder)
.orderBy(match.startTime.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
2. 프로젝션 DTO와 응답 DTO
처음엔 필드가 동일해서 하나로 써도 되지 않을까 싶었으나,
실무에서는 역할에 따라 DTO를 분리하는 것이 일반적이라는 걸 알게 됐다.
| 프로젝션 DTO | 응답 DTO |
| DB 조회 최적화용 | API 응답 표현용 |
| QueryDSL에서 사용 | Controller에서 사용 |
// 프로젝션 DTO
public record MatchProjection(Long matchId, String matchName, LocalDateTime startTime) {}
// 응답 DTO
public record MatchResponse(Long matchId, String matchName, LocalDateTime startTime) {
public static MatchResponse from(MatchProjection projection) {
return new MatchResponse(
projection.matchId(),
projection.matchName(),
projection.startTime()
);
}
}
QueryDSL로 조회한 결과를 MatchResponse로 변환
Page<MatchProjection> projections = matchRepository.searchByCondition(condition, pageable);
return projections.map(MatchResponse::from);
필드가 같아 하나의 DTO로 처리해도 동작하지만, 역할 분리를 통해 유지보수성과 명확성을 높일 수 있다.
3. 성능 최적화: PageableExecutionUtils 활용
검색 결과가 많을 때 countQuery를 따로 분리해서 성능을 최적화할 수 있다.
이때 PageableExecutionUtils.getPage(...)를 같이 사용하면, countQuery가 필요한 경우에만 실행된다.
List content = queryFactory
.select(Projections.constructor(MatchProjection.class, ...))
.from(match)
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery countQuery = queryFactory
.select(match.count())
.from(match)
.where(builder);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
4. 테스트 코드
검색 기능은 조건이 다양하기 때문에 테스트가 중요하다고 생각했다.
Repository
@Test
@DisplayName("조건 검색 성공")
void searchByCondition() {
// given
Match match1 = Match.builder()
.matchName("LCK")
.teamA("T1")
.teamB("GEN.G")
.startTime(LocalDateTime.of(2025, 10, 15, 0, 0))
.build();
Match match2 = Match.builder()
.matchName("LCK")
.teamA("KT")
.teamB("DRX")
.startTime(LocalDateTime.of(2025, 10, 21, 0, 0))
.build();
matchRepository.saveAll(List.of(match1, match2));
MatchSearchCondition condition = new MatchSearchCondition(
"LCK",
LocalDateTime.of(2025, 10, 10, 0, 0),
LocalDateTime.of(2025, 10, 20, 0, 0)
);
// when
Page<MatchProjection> result = matchRepository.searchByCondition(condition, PageRequest.of(0, 10));
// then
assertThat(result.getTotalElements()).isEqualTo(1);
assertThat(result.getContent().get(0).teamB()).isEqualTo("GEN.G");
}
@Test
@DisplayName("정렬 순서 검증 - 경기 시작 시간 가까운순, 경기 이름 가나다순")
void searchByCondition_orderBy() {
// given
Match match1 = Match.builder()
.matchName("CCC")
.teamA("TeamA")
.teamB("TeamB")
.startTime(LocalDateTime.of(2025, 10, 20, 0, 0))
.build();
Match match2 = Match.builder()
.matchName("BBB")
.teamA("TeamA")
.teamB("TeamB")
.startTime(LocalDateTime.of(2025, 10, 19, 0, 0))
.build();
Match match3 = Match.builder()
.matchName("AAA")
.teamA("TeamA")
.teamB("TeamB")
.startTime(LocalDateTime.of(2025, 10, 20, 0, 0))
.build();
matchRepository.saveAll(List.of(match1, match2, match3));
MatchSearchCondition condition = new MatchSearchCondition("", null, null);
// when
Page<MatchProjection> result = matchRepository.searchByCondition(condition, PageRequest.of(0, 10));
// then
assertThat(result.getTotalElements()).isEqualTo(3);
assertThat(result.getContent().get(0).matchName()).isEqualTo("BBB");
assertThat(result.getContent().get(1).matchName()).isEqualTo("AAA");
}
Service
@Test
@DisplayName("경기 검색 성공")
void searchMatches() {
// given
MatchSearchCondition condition = new MatchSearchCondition("T", null, null);
Pageable pageable = PageRequest.of(0, 10);
Match match1 = Match.builder()
.matchName("LCK")
.teamA("T1")
.teamB("GEN.G")
.startTime(LocalDateTime.now().plusDays(1))
.build();
Match match2 = Match.builder()
.matchName("LCK")
.teamA("KT")
.teamB("DRX")
.startTime(LocalDateTime.now().plusDays(2))
.build();
MatchProjection response1 = MatchProjection.from(match1);
MatchProjection response2 = MatchProjection.from(match2);
Page<MatchProjection> matches = new PageImpl<>(List.of(response1, response2), pageable, 1);
when(matchRepository.searchByCondition(condition, pageable)).thenReturn(matches);
// when
Page<MatchResponse> result = matchService.searchMatches(condition, pageable);
// then
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent().get(0).teamA()).isEqualTo("T1");
assertThat(result.getContent().get(1).teamA()).isEqualTo("KT");
}
Controller
@Test
@DisplayName("POST /matches/search - 경기 검색 성공")
void searchMatches() throws Exception {
// given
MatchSearchCondition condition = new MatchSearchCondition("", null, null);
Pageable pageable = PageRequest.of(0, 10);
PageImpl<MatchResponse> responsePage = new PageImpl<>(List.of(response), pageable, 1);
when(matchService.searchMatches(condition, pageable)).thenReturn(responsePage);
ObjectMapper objectMapper = new ObjectMapper();
String requestBody = objectMapper.writeValueAsString(condition);
// when & then
mockMvc.perform(post("/api/v1/matches/search")
.param("page", "0")
.param("size", "10")
.contentType("application/json")
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content[0].matchId").value(1))
.andExpect(jsonPath("$.data.content[0].teamB").value("GEN.G")
);
}
검색 조건과 정렬, DTO 변환, API 응답 구조까지
Repository–Service–Controller 단위로 나눠 테스트하며 기능의 정확성과 안정성을 확인했다.
마치며
이번 검색 API 구현을 통해 배운 건 단순히 기능을 만드는 게 아니라, 역할을 분리하고, 성능을 고려하고, 유지보수를 생각하는 설계가 중요하다는 것이었다. 아직 많이 배우는 중이지만, 하나씩 고민하고 정리해보는 과정이 정말 큰 도움이 된다.