페지이 기능, 페이징, 정렬까지 구현했고, 다음에는 게시판 검색 기능을 구현한다.
ArticleControllerTest에서 게시글 리스트 페이지를 검색어와 함께 호출하는 테스트를 작성한다.
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 검색어와 함께 호출")
@Test
public void givenSearchKeyword_whenSearchingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
SearchType searchType = SearchType.TITLE;
String searchValue = "title";
given(articleService.searchArticles(eq(searchType), eq(searchValue),any(Pageable.class))).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(anyInt(),anyInt())).willReturn(List.of(0,1,2,3,4));
// When & Then
mvc.perform(get("/articles")
.queryParam("searchType",searchType.name())
.queryParam("searchValue",searchValue)
)
.andExpect(status().isOk()) // 정상 호출
.andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
.andExpect(view().name("articles/index")) // 뷰의 존재여부 검사
.andExpect(model().attributeExists("articles")) // 뷰에 모델 어트리뷰트로 넣어준 데이터존재 여부 검사
.andExpect(model().attributeExists("searchTypes"));
then(articleService).should().searchArticles(eq(searchType), eq(searchValue),any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(),anyInt());
}
주어진 검색 타입은 일단 TITLE로만 검색하게 하고,
페이지네이션 어트리뷰트 검사 항목은 이미 다른 테스트에서 진행하고 있기 때문에 제거하고, ' SearchType'
항목이 어트리뷰트로 보내졌는지에대한 검사를 진행한다. 컨트롤러에 아직 searchType을 추가하지 않았으므로 아직 통과되지 않음
컨트롤러에 SearchType와 searchType는 이전에 작성을 해놨기 때문에 따로 작성할 필요는 없으며 addAttribute로 작성하면 끝!
map.addAttribute("searchTypes",searchType.values());
테스트 통과 확인
index.html 에 검색바 항목의 목업 내용을 치환하기 위해서 select컴포넌트에 id와 name을 추가한다. 물론 검색어를 입력하는 input 에도 id와 name을 부여한다.
<select class="form-control" id="search-type" name="searchType">
<input type="text" placeholder="검색어..." class="form-control" id="search-value"
name="searchValue">
상위에 from 컴포넌트에 action 과 method를 작성한다.
<form action="/articles" method="get">
검색을 실행한뒤 결과는 articles에 나타나야하므로 /articles, 그에 대한 메소드는 검색이기 때문에 get으로 충분하다.
index.th.xml 에 현재 검색어를 받는 로직이 없기 때문에 내용을 추가한다.
index.th.xml
<attr sel="#search-type" th:remove="all-but-first">
<attr sel="option[0]"
th:each="searchType : ${searchTypes}"
th:value="${searchType.name}"
th:text="${searchType.description}"
th:selected="${param.searchType != null && (param.searchType.name == searchType.name)}"
/>
</attr>
검색바 에서 검색 타입을 고르는 선택지 컴포넌트를 select 했고, 하나를 제외한 나머지를 제거한 다음에 반복문을 이용해서 구현했다. 이때 searchTypes 어트리뷰트에서 받아온 값의 수만큼 반복하여 구현하지만 현재 searchType은
public enum SearchType {
TITLE, CONTENT, ID, NICKNAME, HASHTAG
}
이렇게 영어로만 표현되어있어서 만약 이대로 뷰에 적용된다면 영어로 나타나는데, 만약 한글로 변형하고 싶으면 여기에 값을 추가하면 된다.
public enum SearchType {
TITLE("제목"),
CONTENT("내용"),
ID("유저 ID"),
NICKNAME("닉네임"),
HASHTAG("해시태그");
@Getter private final String description;
SearchType(String description) {
this.description = description;
}
}
description의 값을 담아서 생성자로 만들고, Getter를 붙여서 해당 값을 가져올수 있게 만들었다. 이렇게 해서 th:text
에다가 searchType.description을 불러오면 각각의 타입에 해당하는 description
을 불러오는 것이다.
마지막으로 selected 설정을 추가한 것은 사용자가 유저 id로 검색을 실행한 다음에도 검색바에 선택된 옵션이 그대로 유지되게 해서 선택한 검색타입을 지속적으로 검색을 하고 싶을때 불편하지 않게 하기 위해 작성했다.
여기서 param은 thymeleaf에서 준 것으로 현재 getParameter 를 의미하고, 거기서 searchType값을 가져온 것이고, 직전까지 검색했던 내용은 url의 getParameter에 남아있을 것이다(만약 검색을 하지 않았다면 비어있을 테고). 해당 값을 가져와서, 현재 사용자가 선택한 검색옵션과 비교했을 때 일치할 경우에 true값을 내보내며 이는 사용자가 검색을 진행한 다음에도 이전에 선택한 검색 타입이 그대로 유지된 채로 검색을 진행할수 있게 되는 것이다.
검색 선택창은 이렇게 구현했고, 검색내용을 입력창도 설정을 추가해야한다.
<attr sel="#search-value" th:value="${param.searchValue}"/>
리컴파일하고 검색을 실행했으나 오류가 나타남.
검색 타입을 선택하는 코드에서 th:selected
에 있는 param.searchType.name이 아닌 toString 으로 작성해준다. param 맵에서 꺼낸 searchType는 enum이 보장되지 않는다. param의 geniric 타입이 오브젝트이기 때문이므로 enum이 확정된 상태가 아니므로 name을 호출할 수가 없는 것이다.
이렇게 수정해서 검색을 진행하는데 검색을 해도 계속 아무것도 나타나지 않는 현상이 일어났다.
콘솔에서도 오류가 나타나지 않았고, 어트리뷰트가 받아오는 값에 오타가 있는지 확인했으나 없었다. 그렇다면 무슨 이유인지 몰라도 검색어로 전송되는 값이 null이 되버린다는 것이다. 이를 해결하기 위해서 searchValue
가 어떻게 결정되는지 확인하기위해 추적했다.
컨트롤러에서 searchValue가 선언되어있고, Page<ArticleRespone>
articles에서 해당값을 파라미터로 한 searchArticles 메소드의 리턴값을 어트리뷰트로 추가했다.
searchArticles로 가보니 searchKeyword가 존재할때의 switch 문의 return 값이 Page.empty()로 된 것을 확인했다. 서비스 테스트의 통과를 위해서 이전에 아무 값이나 리턴이 되게 설정했었는데 그걸 지우지 않은 것이다.(에휴...)
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword == null || searchKeyword.isBlank()) {
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
switch (searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID ->
articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME ->
articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from);
};
return Page.empty();
}
아무튼 return 값을 지우고 switch 자체를 return 으로 해주면 case마다 다른 리턴값이 적용된다.
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword == null || searchKeyword.isBlank()) {
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
return switch (searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID ->
articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME ->
articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from);
};
}
또는 switch문을 이렇게 바꿔도 동작한다.
Page<ArticleDto> answer = switch (searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID ->
articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME ->
articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from);
};
return answer;
근데 굳이 이럴 필요 없이 개선된 switch 문 방식으로 첫번째 방법을 사용하는게 편하다.
항상 문제가 발생했을때, 차근차근 분석을 해야 문제를 해결할수 있다는 것을 느낀다. 원래 전송되어야 할 값이 그렇지 않다면, 그 값이 어떤 방법으로 오는지를 생각해서 추적하여 문제를 해결하는 능력이 정말 중요한것 같다.
아무튼 다시 검색을 실행하면?
오케이 검색 기능이 잘 동작한다!!
그러면 끝인가요?
아뇨
육안으로 봤을 때는 제대로 돌아가는 것 같지만, 검색을 하고 난 결과물의 정렬을 할수 없는 것을 발견했다. 예를 들어서 해시태그로 검색을 한 결과를 제목을 기준으로 정렬을 하고싶었는데
제목을 클릭하고 나면 검색 결과는 사라지고 처음에 나타나는 게시글 목록이 정렬된 상태가 되버린다.
해결 방법은 정렬기준이 되는 항목들의 href를 수정해주면 된다.
th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"
쿼리 파라미터에 searchType 와 searchValue값을 넣어주면 된다. 물론 정렬 기준이 되는 항목마다 각각 쿼리 파라미터를 집어넣는것을 잊지 말것
이제 검색을 진행한 상태에서도 정렬이 이루어진다.
초기화 현상이 일어나는 것은 정렬 뿐만이 아니었고, 검색 결과가 나온 상태에서 페이지넘버 버튼을 클릭하면 검색 하기 전의 페이지로 돌아온다.
이것도 쿼리 파라미터를 추가해주면 된다. 페이지네이션 기능을 부여한 컴포넌트 #pagination에서 각 li 요소의 href에 쿼리 파라미터를 추가한다.
th:href="@{/articles(page=${articles.number - 1},searchType=${param.searchType},searchValue=${param.searchValue})}"
이전 문제 해결에서도 작성했지만, 각각의 li에 전부 쿼리 파라미터를 추가해야한다는 것을 잊지말자.
이제 기본적인 검색 기능은 구현이 완료 되었다