[게시판 프로젝트] 게시판 검색 구현

2024. 8. 26. 19:47CS/프로젝트

SearchType enum 클래스


ArticleController - 검색관련 SearchType 데이터 ModelMap에 추가 

Before
@GetMapping//게시글 리스트 페이지 - 정상 호출
    public String articles(
            @RequestParam(required=false)SearchType searchType,
            @RequestParam(required = false) String searchValue,
            @PageableDefault(size=10, sort = "createdAt",direction = Sort.Direction.DESC) Pageable pageable,
            ModelMap map
    ){
        Page<ArticleResponseV1> articles = articleService.searchArticles(searchType,searchValue, pageable).map(ArticleResponseV1::from);
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(),articles.getTotalPages());
        map.addAttribute("articles",articles);
        map.addAttribute("paginationBarNumbers",barNumbers);
        return "articles/index";
    }
After
@GetMapping//게시글 리스트 페이지 - 정상 호출
    public String articles(
            @RequestParam(required=false)SearchType searchType,
            @RequestParam(required = false) String searchValue,
            @PageableDefault(size=10, sort = "createdAt",direction = Sort.Direction.DESC) Pageable pageable,
            ModelMap map
    ){
        Page<ArticleResponseV1> articles = articleService.searchArticles(searchType,searchValue, pageable).map(ArticleResponseV1::from);
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(),articles.getTotalPages());
        map.addAttribute("articles",articles);
        map.addAttribute("paginationBarNumbers",barNumbers);
        map.addAttribute("searchTypes",SearchType.values()); // 검색
        return "articles/index";
    }

뷰 수정 - index.html/ index.th.xm

Before
<div class="row">
        <div class="card card-margin search-form">
            <div class="card-body p-0">
                <form id="search-form">
                    <div class="row">
                        <div class="col-12">
                            <div class="row no-gutters">
<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header"/>
    <attr sel="#footer" th:replace="footer :: footer"/>


    <attr sel="main" th:object="${articles}">
    <attr sel="#article-table">
        <attr sel="thead/tr">
            <attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
            page=${articles.number},
            sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
            <attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
            page=${articles.number},
            sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
            <attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
            page=${articles.number},
            sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
            <attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
            page=${articles.number},
            sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
        </attr>
        <attr sel="tbody" th:remove="all-but-first"> <!-- tbody의 첫번째만 남기고 전부 지운다 -->
            <attr sel="tr[0]" th:each="article : ${articles}"> <!-- tr의 0번째부터 순회하며 아래 요소에 대한 작업 진행-->
                <attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" /> <!-- a 부분에 다음과 같은 양식으로 하이퍼링크 작성-->
                <attr sel="td.hashtag" th:text="${article.hashtag}" />
                <attr sel="td.user-id" th:text="${article.nickname}" />
                <attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
            </attr>
        </attr>
    </attr>

    <attr sel="#pagination">
        <attr sel="li[0]/a"
              th:text="'previous'"
              th:href="@{/articles(page=${articles.number - 1})}"
              th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
        />
        <attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
            <attr sel="a"
                  th:text="${pageNumber + 1}"
                  th:href="@{/articles(page=${pageNumber})}"
                  th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
            />
        </attr>
        <attr sel="li[2]/a"
              th:text="'next'"
              th:href="@{/articles(page=${articles.number + 1})}"
              th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
        />
    </attr>
    </attr>
</thlogic>
After
<div class="row">
        <div class="card card-margin search-form">
            <div class="card-body p-0">
                <form action="/articles" method="get">
                    <div class="row">
                        <div class="col-12">
                            <div class="row no-gutters">
<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header"/>
    <attr sel="#footer" th:replace="footer :: footer"/>


    <attr sel="main" th:object="${articles}">
<!--        search-type-->
        <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.toString == searchType.name)}"
            />
                            <!--param : 현재 GetParameter 에서 searchType 을 꺼내온 것
                            직전 검색 했던 내용은 url Parameter 에 남아있을 것임
                            지금 option dropdown 메뉴의 이름과 같은지 확인 후 true false 넣어주겠다는 뜻
                            즉, 계속 같은 searchType 으로 연속으로 검색하고 싶은 사람을 배려해서 다음 검색 결과에 반영될 수 있도록
                            직전 검색 항목만 true 가 되어있어서 화면에 표현할 수 있음
                            paramMap 에서 꺼낸 searchType 은 enum 이 보장되지 않기 때문에, name 으로 호출 불가해서 toString 으로 호출-->
        </attr>
<!--        search-value-->
        <attr sel="#search-value" th:value="${param.searchValue}" />
                            <!--직전에 검색했던 검색어를 유지하기 위해서-->


<!--        article-table-->
        <attr sel="#article-table">
            <attr sel="thead/tr">
                <attr sel="th.title/a" th:text="'제목'" 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}
        )}"/>
                <attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
            page=${articles.number},
            sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : ''),
            searchType=${param.searchType},
            searchValue=${param.searchValue}
        )}"/>
                <attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
            page=${articles.number},
            sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : ''),
            searchType=${param.searchType},
            searchValue=${param.searchValue}
        )}"/>
                <attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
            page=${articles.number},
            sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : ''),
            searchType=${param.searchType},
            searchValue=${param.searchValue}
        )}"/>
            </attr>


            <attr sel="tbody" th:remove="all-but-first"> <!-- tbody의 첫번째만 남기고 전부 지운다 -->
                <attr sel="tr[0]" th:each="article : ${articles}"> <!-- tr의 0번째부터 순회하며 아래 요소에 대한 작업 진행-->
                    <attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" /> <!-- a 부분에 다음과 같은 양식으로 하이퍼링크 작성-->
                    <attr sel="td.hashtag" th:text="${article.hashtag}" />
                    <attr sel="td.user-id" th:text="${article.nickname}" />
                    <attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
                </attr>
            </attr>
        </attr>

<!--        페이지네이션-->
        <attr sel="#pagination">
            <attr sel="li[0]/a"
                  th:text="'previous'"
                  th:href="@{/articles(page=${articles.number - 1},searchType=${param.searchType}, searchValue=${param.searchValue})}"
                  th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
            />
            <attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
                <attr sel="a"
                      th:text="${pageNumber+1}"
                      th:href="@{/articles(page=${pageNumber},searchType=${param.searchType}, searchValue=${param.searchValue})}"
                      th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
                />
            </attr>
            <attr sel="li[2]/a"
                  th:text="'next'"
                  th:href="@{/articles(page=${articles.number + 1},searchType=${param.searchType}, searchValue=${param.searchValue})}"
                  th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
            />
        </attr>
    </attr>
</thlogic>

index.th.html 코드 분석

1. 파일의 구조
<?xml version="1.0"?> <thlogic> ... </thlogic>
  • <?xml version="1.0"?>: 이 파일이 XML 형식으로 작성되었음을 나타냅니다.
  • <thlogic>: 이 태그는 실제로 Thymeleaf에서 사용되지 않지만, 여기서는 코드 블록을 감싸기 위한 용도로 사용된 것으로 보입니다. 실제 Thymeleaf 템플릿 파일에서는 <html> 태그로 시작하게 됩니다.

 

2. 헤더와 푸터 삽입
<attr sel="#header" th:replace="header :: header"/>
<attr sel="#footer" th:replace="footer :: footer"/>
  • <attr sel="#header"  <attr sel="#footer": 이 태그들은 헤더와 푸터의 HTML 요소를 선택하고, 해당 위치에 다른 템플릿 파일(예: header.html, footer.html)의 특정 부분을 삽입하는 역할을 합니다.
  • th:replace="header :: header": header.html 파일 내에 있는 header라는 이름의 fragment(조각)를 현재 위치에 삽입합니다.
  • th:replace="footer :: footer": footer.html 파일 내의 footer라는 이름의 fragment를 현재 위치에 삽입합니다.

 

3. 주 콘텐츠 영역 설정
<attr sel="main" th:object="${articles}">
  • <attr sel="main": <main> 태그를 선택하고, 해당 영역에서 작업을 수행합니다.
  • th:object="${articles}": articles라는 객체를 이 영역에 바인딩하여 사용할 수 있게 합니다. 이 객체는 게시글 목록을 포함하고 있을 것입니다.

 

4. 검색 타입 드롭다운 메뉴
<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.toString == searchType.name)}"
    />
</attr>
 
  • th:remove="all-but-first": 선택된 요소(즉, #search-type)의 자식 요소 중 첫 번째 요소를 제외한 모든 요소를 제거합니다.
  • th:each="searchType : ${searchTypes}": searchTypes 리스트를 반복(iterate)하면서, 각각의 searchType객체에 대해 <option> 태그를 생성합니다.
  • th:value="${searchType.name}": 각 searchType 객체의 name 속성을 <option> 태그의 value로 설정합니다.
  • th:text="${searchType.description}": 각 searchType 객체의 description 속성을 <option> 태그의 텍스트로 설정합니다.
  • th:selected="${param.searchType != null && (param.searchType.toString == searchType.name)}": 현재 선택된 검색 타입과 일치하는 경우, 해당 <option>을 선택된 상태로 표시합니다.

 

5. 검색어 입력 필드
<attr sel="#search-value" th:value="${param.searchValue}" />
  • th:value="${param.searchValue}": 검색어 입력 필드에 이전에 입력된 검색어(searchValue)를 유지시킵니다.

 

6. 게시글 테이블
<attr sel="#article-table">
    <attr sel="thead/tr">
        <attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(...)"/>
        <attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(...)"/>
        <attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(...)"/>
        <attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(...)"/>
    </attr>
  • <attr sel="thead/tr">: 테이블 헤더에서 <tr> 태그를 선택하여 내부의 <th> 태그들에 대한 작업을 수행합니다.
  • th:text="'제목'": 해당 <th> 태그의 텍스트를 "제목"으로 설정합니다.
  • th:href="@{/articles(...)}": 테이블 헤더의 각 항목(제목, 해시태그, 작성자, 작성일)에 대해 정렬을 수행할 수 있도록 링크를 설정합니다. 각 링크는 현재 페이지와 검색 조건을 유지한 채로 정렬 기준만 변경합니다.

 

7. 게시글 목록 표시
<attr sel="tbody" th:remove="all-but-first">
    <attr sel="tr[0]" th:each="article : ${articles}">
        <attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
        <attr sel="td.hashtag" th:text="${article.hashtag}" />
        <attr sel="td.user-id" th:text="${article.nickname}" />
        <attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
    </attr>
</attr>
  • th:remove="all-but-first": <tbody>의 첫 번째 행을 제외한 나머지 행을 제거합니다. 이후 첫 번째 행을 템플릿으로 사용하여 반복 렌더링됩니다.
  • th:each="article : ${articles}": articles 리스트를 반복하면서 각 article 객체에 대해 <tr> 태그를 생성합니다.
  • th:text="${article.title}"  th:href="@{'/articles/' + ${article.id}}": 각 게시글의 제목과 링크를 설정합니다.
  • th:text="${article.hashtag}": 각 게시글의 해시태그를 표시합니다.
  • th:text="${article.nickname}": 각 게시글의 작성자 닉네임을 표시합니다.
  • th:datetime="${article.createdAt}"  th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}": 각 게시글의 작성일을 형식화하여 표시합니다.

 

8. 페이지네이션
<attr sel="#pagination">
    <attr sel="li[0]/a"
          th:text="'previous'"
          th:href="@{/articles(page=${articles.number - 1},searchType=${param.searchType}, searchValue=${param.searchValue})}"
          th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
    />
    <attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
        <attr sel="a"
              th:text="${pageNumber+1}"
              th:href="@{/articles(page=${pageNumber},searchType=${param.searchType}, searchValue=${param.searchValue})}"
              th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
        />
    </attr>
    <attr sel="li[2]/a"
          th:text="'next'"
          th:href="@{/articles(page=${articles.number + 1},searchType=${param.searchType}, searchValue=${param.searchValue})}"
          th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
    />
</attr>
  • <attr sel="li[0]/a": 이전 페이지 링크를 설정합니다. 현재 페이지가 첫 페이지인 경우 링크를 비활성화(disabled)합니다.
  • <attr sel="li[1]" th:each="pageNumber : ${paginationBarNumbers}": 페이지 번호를 반복하여 각 페이지 번호에 대한 링크를 생성합니다. 현재 페이지 번호는 비활성화(disabled)합니다.
  • <attr sel="li[2]/a": 다음 페이지 링크를 설정합니다. 현재 페이지가 마지막 페이지인 경우 링크를 비활성화(disabled)합니다.