본문 바로가기
프로젝트/Recipository

[Dev] 23.02.22. 게시글 검색 기능

by 규글 2023. 2. 22.

 이리저리 작업하다가 게시글 페이징 작업이 마지막이라고 생각했는데, 생각해보니 검색 기능을 작업하지 않았다는 것이 생각났다. 이번 게시글에는 검색 기능을 작업했던 내용을 기록해보려고 한다.

 

 

작업 아이디어

 아이디어는 특별하지 않다. Client에서 html form tag를 통해 보내는 request parameter를 활용할 것이며, 보내는 request parameter의 값에 따라 동작하는 query를 다르게 하도록 하는 것이다.

 

작업

navibar.html

  <div class="col" style="padding-top: 85px;">
    <form class="search_form" action="/page/1" method="get">
      <div class="row">
        <div class="col">
          <select class="form-select text-center" name="type" id="type" style="width: 130px;">
            <option value="title" th:selected="${searchData == null} 
                                              or ${searchData.type} == 'title'">제목</option>
            <option value="writer" th:selected="${searchData != null} 
                                              and ${searchData.type} == 'writer'">작성자</option>
            <option value="content" th:selected="${searchData != null} 
                                                and ${searchData.type} == 'content'">내용</option>
          </select>
        </div>
        <div class="col-7">
          <label for="keyword" style="display: none;"></label>
          <input type="text" class="form-control text-center"
                 name="keyword" id="keyword" placeholder="검색어를 입력하세요."
                 th:value="${searchData != null and searchData.keyword != null} 
                            ? ${searchData.keyword} : ''">
        </div>
        <div class="col">
          <button class="btn btn-light col" type="submit" id="searchBtn">검색</button>
        </div>
      </div>
    </form>
  </div>

 보통은 client 에 대한 작업 이미지 설명은 거의 하지 않았던 것 같은데, 이번에는 하나 기록할 것이 있어서 이미지를 가져왔다. 필자는 검색 keyword를 입력하고 검색을 요청한 뒤 그에 대한 응답을 받을 때, 검색 시 선택했던 검색 type과 검색 시 입력했던 keyword에 대한 data를 그대로 받아 client에 유지하고 싶어서 data를 담을 바로 이어지는 SearchDto를 작성했다. 그리고 검색과 관련된 index page에 대해서 data를 유지할 수 있도록 받은 SearchDto를 그대로 response 하도록 했는데, 문제는 검색창이 존재하는 navigation bar 가 index page에만 존재하는 것이 아니라는 것이다.

 

 거의 어디에서나 볼 수 있는 navigation의 검색창 때문에 단순히 검색한 data를 받아 thymeleaf 로 할당해주면, 게시글을 확인하거나 마이 페이지로의 이동 요청 등의 경우에는 controller에서 검색 data를 받도록 하지 않았기 때문에 오류가 발생한다.

 이에 대한 해결책으로 여러가지 방식을 생각해볼 수 있겠다.

  • 필요한 모든 요청에 대한 controller에서 모두 searchDto를 return 하도록 한다.
  • 필요한 요청에 대해 interceptor를 만들어서, 특정 요청에 대해서만 searchDto를 return 하도록 한다.
  • Client에서 경우에 따라 출력되는 항목을 다르게 하도록 한다.

 

 필요한 모든 요청에 대해 controller에서 SearchDto를 response 하도록 하는 것은, 검색한 항목이 없는데도 response 하도록 해야하는 경우가 있기도 하다.(ex - 마이 페이지, 게시글 확인) 또한 검색창이 존재하는 모든 경로 요청에 대해 계속해서 SearchDto를 response 하도록 해야하며, 혹시라도 변경되는 경우 고쳐야 할 부분이 많아질 수도 있겠다.

 그래서 생각한 것이 두 번째인 interceptor를 활용하는 것이었다. 특정 요청에 대해 postHandle method를 활용하여 필요할 때마다 response 하는 것이었는데, 동일한 이유로 굳이 필요한 항목인가 하는 생각이 들었다.

 그래서 아예 client 쪽에서 thymeleaf를 활용하기로 했다. 검색을 하지 않은 경우에는 따로 SearchDto를 통해 data를 받는 과정이 없기 때문에 null 값이 될 것이므로, 이를 가지고 thymeleaf 를 활용하면 될 것 같았다.

 

 이미지의 붉게 표시한 부분을 위주로 보면 되겠다. 검색 시에는 searchData가 존재하고, 그에 맞는 값을 먼저 할당해주면 된다. 하지만 검색과 관련된 요청이 아닌 경우라면 searchData는 null 값을 가진다. 따라서 검색 관련 요청이 아닌 경우를 생각하여 searchData가 null일 때 어떻게 해야할 지를 추가적으로 작성해준 것이다.

 

SearchDto.java

package com.example.recipository.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SearchDto {
    private String type = "";
    private String keyword = "";
}

 검색 시 선택한 검색 type, 검색 시 입력한 keyword에 대한 data를 담을 DTO이다. 대표적으로 index page의 경우 아무 것도 할당되지 않아서 두 field가 null이 되는데, 검색 관련 요청 시 두 field가 null이 되는 경우는 없으므로 기본값을 공백으로 초기화 했다. (select는 적어도 하나 선택된 상황이고, 검색어는 아무것도 작성하지 않으면 공백이어야 한다.)

 

PageController.java

    // index page + 출력할 게시글 목록 with pagination
    @GetMapping(value = {"/", "/page/{pageNum}"})
    public ModelAndView main(@PathVariable(value = "pageNum", required = false) Integer pageNum,
                             SearchDto searchDto){
        if(pageNum == null){
            pageNum = 1;
        }

        ModelAndView mView = new ModelAndView();

        Map<String, Object> map = recipeService.getRecipeList(pageNum, searchDto);

        mView.addObject("recipeList", map.get("recipeDtoList"));
        mView.addObject("pagination", map.get("pageDto"));
        mView.addObject("searchData", searchDto);
        mView.setViewName("index");

        return mView;
    }

 Client로부터의 request parameter를 SearchDto를 활용해서 data를 받도록 하고, 그것을 그대로 response 하도록 했다. 그리고 이 parameter 들을 활용하기 위해서 service 로 넘겨주었다.

 

RecipeServiceImpl.java

        // Data를 조회하여 response 할 RecipeDto로 변환
        Page<Recipe> recipeList = null;
        switch (searchDto.getType()) {
            case "":
            case "title":
                recipeList = recipeRepository.getTitleAllWithPagination(pageable, searchDto.getKeyword());
                break;
            case "writer":
                recipeList = recipeRepository.getWriterAllWithPagination(pageable, searchDto.getKeyword());
                break;
            case "content":
                recipeList = recipeRepository.getContentAllWithPagination(pageable, searchDto.getKeyword());
                break;
        }
        List<RecipeDto> recipeDtoList = new ArrayList<>();
        recipeList.forEach(tmp -> {
            recipeDtoList.add(tmp.toDto());
        });

 SearchDto 의 type field의 값에 따라 다른 query가 동작할 수 있도록 경우의 수를 나누어서 서로 다른 repository method가 동작하도록 했다. 처음에 필자는 if 와 else if 를 활용해서 작성하고 있었는데, swtich - case 는 어떻냐고 intelliJ가 제안해줘서 그렇게 바꿔보았다. Case가 공백인 "" 인 경우는 index page의 경우이다.

 

RecipeRepository.java

    // 제목으로 검색 + pagination
    @Query(value = "select r from Recipe r join fetch r.member where r.title like %:keyword%",
            countQuery = "select count(r) from Recipe r")
    Page<Recipe> getTitleAllWithPagination(Pageable pageable, @Param("keyword") String keyword);

    // 작성자로 검색 + pagination
    @Query(value = "select r from Recipe r join fetch r.member m where m.name like %:keyword%",
            countQuery = "select count(r) from Recipe r")
    Page<Recipe> getWriterAllWithPagination(Pageable pageable, @Param("keyword") String keyword);

    // 내용으로 검색 + pagination
    @Query(value = "select r from Recipe r join fetch r.member where r.content like %:keyword%",
            countQuery = "select count(r) from Recipe r")
    Page<Recipe> getContentAllWithPagination(Pageable pageable, @Param("keyword") String keyword);

 Service logic에서 SearchDto의 type field 값에 따라 동작할 repository method 이다. 각각의 경우에 맞게 where 절에서 포함할 keyword 가 어떤 값에 대응하게 할 것인지 구분했다. 하나의 method로 통일하는 방법은 없을까 생각해봤지만, 찾지 못했기 때문에 구분한 것이다.

 

 

 이렇게 게시글 검색 기능에 대한 작업까지 마무리 했다. 이제 포트폴리오에 최소한의 작업 이미지를 캡쳐하기 위해서는 이미지를 저장하고, 출력하도록 그 경로에 대한 임시 작업을 해야한다. 임시 작업인 이유는 천천히 다른 server로 올릴 때 다른 방식을 사용하는 것으로 알고 있으며, 기존에 존재하는 image의 경우가 누락된다는 글을 본 기억이 있기 때문이다. 얼른 다음 작업을 해보도록 하겠다.

 

Reference

댓글