부트캠프에서 프로젝트를 진행할 당시, 검색 기능을 구현하는데 코드가 상당히 더러웠던 경험이 있다.
public MultiResponseDto readFilteredCocktails(String email, String category, String tag, String sortValue) {
Sort sort = setSort(sortValue);
if (isNotSelectCategoryAndTag(category, tag)) {
List<Cocktail> cocktails = cocktailQueryService.readAllCocktails(sort);
log.info("# CocktailService#readFilterdCocktails 성공");
return createCocktailsSimpleMultiResponseDtos(email, cocktails);
}
if (isNotSelectCategory(category)) {
return filterByTagCocktailsSimpleResponse(email, tag, sort);
}
if (isNotSelectTag(tag)) {
return filterByCategoryCocktailsSimpleResponse(email, category, sort);
}
return filterByTagsAndCategoryCocktails(email, category, tag, sort);
}
어... 그... 네.... ㅋㅋ;;
태그나 카테고리 등을 선택해서 검색할 수 있었는데, 얘네가 동적으로 들어왔다. 그러니까, 카테고리를 선택할 수도 안 할 수도 있고, 태그를 선택할 수도 안 할 수도 있었다. 아니면 아예 전부 선택하지 않을 수도 있었다.
이번에 검색 기능을 구현하기 위해서 Querydsl을 처음으로 적용해보고자 한다. 이번 기능을 구현하면서 위의 코드를 개선해볼 수는 없을까, 좀 더 좋은 코드를 작성할 수는 없을까 싶어 구글에 검색해보니 Querydsl을 사용하신 분이 계셨다.
한 번 정도는 사용해보고 싶었고, 그래서 이번 검색 기능에 적용해보려고 한다.
일단은 검색의 로직부터 정의해야겠다.
- 검색은 제목, 내용, 작성자로 가능하다. 별도의 정렬은 지원하지 않는다.
- HTTP GET("/free-board/search?title=*&content=*&writer=*&page=*)의 Request를 FreeBoardController#getFilteredFreeBoardPage(@RequestParam(name = "title", required = false) String title, ... , int pageNumber) 메서드에서 받는다. 해당 메서드는 FreeBoardService#getFilteredFreeBoardList()를 호출하고 호출한 결과를 ResponseEntity<List<FreeBoardDto.Response>>로 반환한다. 이 때, HTTP 상태 코드는 200 OK이다.
- FreeBoardService#getFilteredFreeBoardList(); 는 List<FreeBoardDto.Response>를 반환한다. FreeBoardQueryService#readFilteredEntityPage()를 호출해 List<FreeBoard>를 받아오고 각 요소들을 FreeBoardConverter를 통해 Response DTO로 변환한 List를 반환한다.
- FreeBoardQueryService#readFilteredEntityPage()는 FreeBoardRepository#findFilteredFreeBoards()를 호출해 Page를 받아오고 그 결과를 반환한다.
우리가 테스트 해볼 부분은 2, 4번이 되시겠다. 우선은 4번에 대한 테스트를 먼저 만들어보자.
@Test
@DisplayName("검색 조건으로 지정한 게시글들을 읽어온다.")
void readFilteredFreeBoardsTest(){
int fakePageNumber = 1;
String fakeTitle = "title";
String fakeContent = "content";
String fakeWriter = "writer";
Page<FreeBoard> fakePage = new PageImpl<>(List.of(
FreeBoard.builder().title("1").content("1").build(),
FreeBoard.builder().title("2").content("2").build(),
FreeBoard.builder().title("3").content("3").build(),
FreeBoard.builder().title("4").content("4").build(),
FreeBoard.builder().title("5").content("5").build()
));
given((freeBoardRepository.findFilteredFreeBoards(anyString(), anyString(), anyString(), any()))).willReturn(fakePage);
//when
List<FreeBoard> foundList = freeBoardQueryService.readFilteredEntityPage(fakeTitle, fakeContent, fakeWriter, fakePageNumber);
//then
assertThat(foundList).isEqualTo(fakePage.getContent());
}
사실 조건 검색이라는게, 이 페이징에서 온거거든요... 직전에 만들었던 페이징과 거의 다를게 없다. 다만 검색 조건이 추가되었을 뿐이다. 이제 readFilteredEntityPage()를 구현하러 가보자.
@Transactional(readOnly = true)
public List<FreeBoard> readFilteredEntityPage(String title, String content, String writer, int pageIndex) {
}
내용도 페이징과 별다를 것 없을 것이다. Pageable을 만들고 레파지토리에 검색조건과 함께 넣어주면 된다. 여기서 Querydsl을 사용해야 한다. Querydsl을 적용한 과정은 링크에 남겨놓는다. 혹시 읽고 계신 분이 있다면, 잠깐 읽고 와주시면 감사하겠다. 사실 이번 기능 구현의 핵심이 Querydsl을 사용하는 것이기 때문에 꼭 한 번씩들 봐주십쇼...
... 그리고 짜잔!
@Transactional(readOnly = true)
public List<FreeBoard> readFilteredEntityPage(String title, String content, String writer, int pageIndex) {
Pageable pageable = Pageable.ofSize(PAGE_SIZE.getValue()).withPage(pageIndex);
Page<FreeBoard>freeBoardPage = freeBoardRepository.findFilteredFreeBoards(title, content, writer, pageable);
return freeBoardPage.getContent();
}
레파지토리에서 Page를 읽어오고 여기서 List를 뽑아서 반환한다. 페이징과 한치의 오차가 없다. 테스트를 수행하면 통과함을 확인할 수 있다.
이제 controller에 기능을 구현하러 가보자. 먼저 테스트를 만들어야겠다.
@Test
@DisplayName("게시글 검색 요청 테스트")
void getFilteredFreeBoardPageTest() throws Exception {
// given
int fakePage = 1;
String fakeTitle = "title";
String fakeContent = "content";
String fakeWriter = "writer";
List<FreeBoardDto.Response> fakeResponseList = List.of(
FreeBoardDto.Response.builder().boardId(1).title("1").content("1").createdAt("1").modifiedAt("1").build(),
FreeBoardDto.Response.builder().boardId(2).title("2").content("2").createdAt("2").modifiedAt("2").build(),
FreeBoardDto.Response.builder().boardId(3).title("3").content("3").createdAt("3").modifiedAt("3").build(),
FreeBoardDto.Response.builder().boardId(4).title("4").content("4").createdAt("4").modifiedAt("4").build(),
FreeBoardDto.Response.builder().boardId(5).title("5").content("5").createdAt("5").modifiedAt("5").build(),
FreeBoardDto.Response.builder().boardId(5).title("6").content("6").createdAt("6").modifiedAt("6").build()
);
given(freeBoardService.getFilteredFreeBoardList(anyString(), anyString(), anyString(), anyInt())).willReturn(fakeResponseList);
// when // then
for (int i = 0; i < fakeResponseList.size(); i++) {
mockMvc.perform(
get("/free-board/search?title={title}&content={content}&writer={writer}&page={page}", fakeTitle, fakeContent, fakeWriter, fakePage)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
).andExpect(jsonPath("$", hasSize(fakeResponseList.size())))
.andExpect(jsonPath("$[%d].boardId", i).value(fakeResponseList.get(i).boardId))
.andExpect(jsonPath("$[%d].title", i).value(fakeResponseList.get(i).title))
.andExpect(jsonPath("$[%d].content", i).value(fakeResponseList.get(i).content))
.andExpect(jsonPath("$[%d].createdAt", i).value(fakeResponseList.get(i).createdAt))
.andExpect(jsonPath("$[%d].modifiedAt", i).value(fakeResponseList.get(i).modifiedAt));
}
mockMvc.perform(
get("/free-board/search?title={title}&content={content}&writer={writer}&page={page}", fakeTitle, fakeContent, fakeWriter, fakePage)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andDo(document(
"get-filtered-paged-free-board",
getRequestPreProcessor(),
getResponsePreProcessor(),
requestParameters(
parameterWithName("title").description("글 제목"),
parameterWithName("content").description("글 내용"),
parameterWithName("writer").description("작성자 이름"),
parameterWithName("page").description("페이지 번호")
),
responseFields(
List.of(
fieldWithPath("[].boardId").type(JsonFieldType.NUMBER).description("게시글 식별자"),
fieldWithPath("[].title").type(JsonFieldType.STRING).description("제목"),
fieldWithPath("[].content").type(JsonFieldType.STRING).description("내용"),
fieldWithPath("[].createdAt").type(JsonFieldType.STRING).description("작성일"),
fieldWithPath("[].modifiedAt").type(JsonFieldType.STRING).description("수정일")
)
)
));
}
마찬가지로 페이징 조회와 거의 완전 똑같다. requestParameters()에 조금 더 많은 파라미터가 추가되었을 뿐이다. Controller에 메서드를 구현하는 것도 같다.
@GetMapping("/search")
public ResponseEntity<List<FreeBoardDto.Response>> getFilteredFreeBoardPage(@RequestParam(name = "title", required = false) String title,
@RequestParam(name = "content", required = false) String content,
@RequestParam(name = "writer", required = false) String writer,
@RequestParam(name = "page", defaultValue = "1") int pageNumber) {
List<FreeBoardDto.Response> responseDtoList = freeBoardService.getFilteredFreeBoardList(title, content, writer, pageNumber);
return new ResponseEntity<>(responseDtoList, HttpStatus.OK);
}
테스트를 돌려보면 통과함을 확인할 수 있다.
이번 기능 구현은 Querydsl을 아주 간단하게나마 접해볼 수 있었다는 점에서 뿌듯하다. 하나 생각해볼 점은 굳이 전체 페이지를 보여주는 것과 검색 페이지를 나눠야 할까?이다. 둘이 너무나도 비슷한 부분이 많다. 그냥 전부 null 때리고 조회하는게 페이지 조회가 아닌가 싶기도 하고... 테스트 해보고 통합하던가 해야겠다.
전체 코드는 링크에서 확인할 수 있다.
'Programming > TDD Project' 카테고리의 다른 글
Pull Request 020. 회원 가입 및 로그인 기능 TDD로 구현하기 - 회원 가입 Service 계층 구현 (1) | 2023.12.04 |
---|---|
Pull Request 019. 페이징 처리 시 페이지 정보도 Response에 함께 담아주기 (0) | 2023.12.01 |
Pull Request 017. 자유 게시글 여러 개 조회 기능 TDD로 구현하기 (0) | 2023.11.29 |
Pull Request 016. Base Entity를 통해 데이터 생성일 / 수정일 설정하기 - 제어하기 어려운 코드 개선, static 메서드 mocking 테스트 (1) | 2023.11.28 |
Pull Request 015. API 문서화 적용하기 - Spring Rest Docs (2) | 2023.11.28 |
댓글