게시판 만들기 - 게시판 검색 구현 - 해시태그

정영찬·2022년 8월 30일
1

프로젝트 실습

목록 보기
40/60
post-thumbnail

검색 기능 자체는 구현했지만. 특별함을 위해서 해시태그 만을 위한 검색페이지를 만든다

서비스 구현

테스트

검색어 없이 검색할 경우, 빈페이지 반환

@DisplayName("검색어 없이 게시글을 해시태그로 검색하면, 빈 페이지를 반환한다.")
    @Test
    void givenNoSearchParameters_whenSearchingArticlesViaHashtag_thenReturnsEmptyPage() {
        // Given
        Pageable pageable = Pageable.ofSize(20);

        // When
        Page<ArticleDto> articles = sut.searchArticlesViaHashtag(null,pageable);
        // Then
        assertThat(articles).isEqualTo(Page.empty(pageable));
        then(articleRepository).shouldHaveNoInteractions();
    }

해시태그 만으로 검색을 하기 때문에 searchArticles가 아닌 다른 메소드를 작성해줘야한다.
따라서 searchArticlesViaHastag 메소드를 만든다.

해시태그로 검색하면, 게시글 페이지를 반환한다.

 @DisplayName("게시글을 해시태그로 검색하면, 게시글 페이지를 반환한다.")
    @Test
    void givenHashtag_whenSearchingArticlesViaHashtag_thenReturnsArticlesPage() {
        // Given
        String hashtag = "#java";
        Pageable pageable = Pageable.ofSize(20);
        given(articleRepository.findByHashtag(hashtag, pageable)).willReturn(Page.empty(pageable));

        // When
        Page<ArticleDto> articles = sut.searchArticlesViaHashtag(hashtag,pageable);
        // Then
        assertThat(articles).isEqualTo(Page.empty(pageable));
        then(articleRepository).should().findByHashtag(hashtag,pageable);
    }

전체 게시글이 보유한 해시태그를 리스트로 반환

  @DisplayName("해시태그를 조회하면, 유니크 해시태그 리스트를 반환한다.")
    @Test
    void givenNothing_whenCalling_thenReturnsHashTags(){
        // Given
       List<String> expectedHashtags = List.of("#java","#spring","#boot");
       given(articleRepository.findAllDistinctHashtags()).willReturn(expectedHashtags);

        // When
        List<String> actualHashtags = sut.getHashtags();
        // Then

        assertThat(actualHashtags).isEqualTo(expectedHashtags);
        then(articleRepository).should().findAllDistinctHashtags();
    }

먼저 예상되는 해시태그 리스트의 형태를 정의하고, 해당 리스트를 리턴해주는 메소드인 findAllDistinctHashtags작성한다.

getHashtags()를 통해 나타난 리스트를 actualHashtags로 정의하고 해당 값을 expectedHashtags와 비교한다.

쿼리 출력결과가 도메인이 아닌 문자열을 필요로 한다. 스프링 데이터 jpa의 쿼리메소드는 도메인으로 출력을 만들어준다. 이를위해서 사용하는 것이 querydsl이다.

repository 경로에서 querydsl 패키지를 만들고 내부에 ArticleRepositoryCustom, ArticleRepositoryCustomImpl을 생성한다. Impl파일은 querydsl이 인식하는 파일이다. 따라서 이름을 함부로 바꾸면 안됨.

public interface ArticleRepositoryCustom {
    List<String> findAllDistinctHashtags();
}

public class ArticleRepositoryCustomImpl extends QuerydslRepositorySupport implements ArticleRepositoryCustom {

    public ArticleRepositoryCustomImpl() {
        super(Article.class);
    }

    @Override
    public List<String> findAllDistinctHashtags() {
        QArticle article = QArticle.article;
        JPQLQuery<String> query = from(article)
                .distinct()
                .select(article.hashtag)
                .where(article.hashtag.isNotNull());

        return query.fetch();

    }
}

그리고 ArticleRepository와 연동을 시켜야 하므로 extends에 ArticleRepositoryCustom을 추가해줘야한다.

public interface ArticleRepository extends
        JpaRepository<Article, Long>,
        ArticleRepositoryCustom,
        QuerydslPredicateExecutor<Article>,
        QuerydslBinderCustomizer<QArticle> {

서비스

getHashtags

ArticleRepository에서 findAllDistinctHashtags()를 호출한다

  public List<String> getHashtags() {
       return articleRepository.findAllDistinctHashtags();
    }

searchArticlesViaHashtag

만약 hastg의 값이 null이거나 공백일 경우, 빈 페이지를 리턴하고, 그 외에는 findByHashtag를 호출하여 리턴한다.

  @Transactional(readOnly = true)
    public Page<ArticleDto> searchArticlesViaHashtag(String hashtag, Pageable pageable) {
        if (hashtag == null || hashtag.isBlank()){
            return Page.empty(pageable);
        }
        return articleRepository.findByHashtag(hashtag,pageable).map(ArticleDto::from);
    }

컨트롤러 구현

매핑

해시태그를 검색어로 하는 검색페이지를 따로 만들어야 하므로 매핑을 하나 더 추가한다.

 @GetMapping("/search-hashtag")
    public String searchHashtag(
            @RequestParam(required = false) String searchValue,
            @PageableDefault(size = 10, sort ="createdAt", direction = Sort.Direction.DESC) Pageable pageable,
            ModelMap map
        ) {
        return"articles/search-hashtag";
    }

테스트

이전에 Disabled 처리한 게시글 해시태그 검색 페이지 정상 호출 테스트를 해제하고 구현을 시작한다.

게시글 해시태그 검색 페이지 정상호출

 @DisplayName("[view][GET] 게시글 해시태그 검색 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticleSearchHashtagView_thenReturnsArticleHashtagSearchView() throws Exception {
        // Given
        given(articleService.searchArticlesViaHashtag(eq(null),any(Pageable.class))).willReturn(Page.empty());
        // When & Then
        mvc.perform(get("/articles/search-hashtag"))
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/search-hashtag")) // 뷰의 존재여부 검사
                .andExpect(model().attribute("articles",Page.empty()))
                .andExpect(model().attributeExists("hashtags"))
                .andExpect(model().attributeExists("paginationBarNumbers"));
        then(articleService).should().searchArticlesViaHashtag(eq(null),any(Pageable.class));
    }

given으로 서비스에서 searchArticlesViahashtag를 통해 빈 페이지가 리턴 되어야 한다는 명시를 해주고 그에대한 호출 여부와 hashtags``paginationBarNumbers 어트리뷰트의 추가 여부를 확인하는 테스트이다.

게시글 해시태그 검색 페이지 - 정상 호출, 해시태그 입력한 경우

사용자가 해시태그를 입력한 경우의 테스트를 구현한다. 테스트 데이터 해시태그를 정의하고 queryParam을 통해서 searchValue 값을 hashtag로 정의한다.

 @DisplayName("[view][GET] 게시글 해시태그 검색 페이지 - 정상 호출, 해시태그 입력")
    @Test
    public void givenHashtag_whenRequestingArticleSearchHashtagView_thenReturnsArticleSearchHashtagView() throws Exception {
        // Given
        String hashtag = "#java";
        given(articleService.searchArticlesViaHashtag(eq(null),any(Pageable.class))).willReturn(Page.empty());
        // When & Then
        mvc.perform(get("/articles/search-hashtag")
                        .queryParam("searchValue",hashtag)
                )
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/search-hashtag")) // 뷰의 존재여부 검사
                .andExpect(model().attribute("articles",Page.empty()))
                .andExpect(model().attributeExists("hashtags"))
                .andExpect(model().attributeExists("paginationBarNumbers"));
        then(articleService).should().searchArticlesViaHashtag(eq(null),any(Pageable.class));
    }

컨트롤러 내용 추가

테스트 내용을 만족시키기 위해서는 hashtags와 paginationBarNumbers 어트리뷰트를 추가해야하며, hashtags 어트리뷰트의 값은 articleService에서 getHashtags()메소드를 호출해서 리턴된 리스트이다.

 @GetMapping("/search-hashtag")
    public String searchHashtag(
            @RequestParam(required = false) String searchValue,
            @PageableDefault(size = 10, sort ="createdAt", direction = Sort.Direction.DESC) Pageable pageable,
            ModelMap map
        ) {
        Page<ArticleResponse> articles =  articleService.searchArticlesViaHashtag(searchValue,pageable).map(ArticleResponse::from);
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(),articles.getTotalPages());
        List<String> hashtags = articleService.getHashtags();
        map.addAttribute("articles", articles);
        map.addAttribute("hashtags", hashtags);
        map.addAttribute("paginationBarNumbers",barNumbers);
//        map.addAttribute("searchTypes",SearchType.values());

        return"articles/search-hashtag";
    }

뷰생성 및 기능 부여

디커플드 로직에 내용을 추가한다. search-hashtag.html에 내용을 추가해서 게시글 해시태그 검색 페이지를 만든다. index.html과 구조는 거의 비슷할것 같아서 index.html 내용을 복사해서 수정했다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>해시태그 검색</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="author" content="YeongChan Jeong">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/articles/table-header.css">
</head>
<body>

<header id="header">
    헤더 삽입부
    <hr>
</header>
<main class="container">
    <header class="py-5 text-center">
        <h1>Hashtags</h1>
    </header>

    <section class="row d-flex justify-content-center">
        <div id="hashtags" class="col-9 d-flex flex-wrap justify-content-evenly">
            <div class="p-2">
                <h2 class="text-center 1h-lg font-monospace"><a href="#">#java</a></h2>
            </div>
        </div>
    </section>
    <table class="table" id="article-table">
        <thead>
        <tr>
            <th class="title col-6"><a>제목</a></th>
            <th class="content col-4"><a>본문</a></th>
            <th class="user-id"><a>작성자</a></th>
            <th class="created-at"><a>작성일</a></th>
        </tr>
        </thead>
        <tbody>
        <tr>
            <td class="title"><a>첫글</a></td>
            <td class="content"><span class="d-inline-block text-truncate" style="max-width:300px;">본문</span></td>
            <td class="user-id">Jyc</td>
            <td class="created-at">
                <time>2022-01-01</time>
            </td>
        </tr>
        <tr>
            <td>두번째글</td>
            <td>#spring</td>
            <td>Uno</td>
            <td>
                <time>2022-01-02</time>
            </td>
        </tr>
        <tr>
            <td>세번째글</td>
            <td>#java</td>
            <td>Uno</td>
            <td>
                <time>2022-01-03</time>
            </td>
        </tr>
        </tbody>
    </table>


    <nav id="pagination" aria-label="Page navigation">
        <ul class="pagination justify-content-center">
            <li class="page-item"><a class="page-link" href="#">Previous</a></li>
            <li class="page-item"><a class="page-link" href="#">1</a></li>
            <li class="page-item"><a class="page-link" href="#">Next</a></li>
        </ul>
    </nav>


</main>
<footer id="footer">
    <hr>
    푸터 삽입부
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
        crossorigin="anonymous"></script>
</body>
</html>

index.html에 있는 내용에서 검색바,글쓰기 버튼 컴포넌트는 필요하지 않아서 제거하고 테이블 헤드의 항목또한 해시태그 대신 본문으로 대체했다.

컨트롤러 테스트 내용 수정

디커플드 로직에 기능을 구현한다. 해시태그의 값을 서버에서 가져오게 하기 위해서 컨트롤러에 어트리뷰트를 하나 더 추가했다.

map.addAttribute("searchType",SearchType.HASHTAG);

컨트롤러 테스트에 페이지네이션과 해시태그에 관련된 내용을 작성하지 않아서 추가했다.

 @DisplayName("[view][GET] 게시글 해시태그 검색 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticleSearchHashtagView_thenReturnsArticleSearchHashtagView() throws Exception {
        // Given
        List<String> hashtags = List.of("#java","#spring","boot");
        given(articleService.searchArticlesViaHashtag(eq(null),any(Pageable.class))).willReturn(Page.empty());
        given(paginationService.getPaginationBarNumbers(anyInt(),anyInt())).willReturn(List.of(1,2,3,4,5));
        given(articleService.getHashtags()).willReturn(hashtags);
        // When & Then
        mvc.perform(get("/articles/search-hashtag"))
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/search-hashtag")) // 뷰의 존재여부 검사
                .andExpect(model().attribute("articles",Page.empty()))
                .andExpect(model().attribute("hashtags",hashtags))
                .andExpect(model().attributeExists("paginationBarNumbers"))
                .andExpect(model().attribute("searchType",SearchType.HASHTAG));
        then(articleService).should().searchArticlesViaHashtag(eq(null),any(Pageable.class));
        then(articleService).should().getHashtags();
        then(paginationService).should().getPaginationBarNumbers(anyInt(),anyInt());
    }

    @DisplayName("[view][GET] 게시글 해시태그 검색 페이지 - 정상 호출, 해시태그 입력")
    @Test
    public void givenHashtag_whenRequestingArticleSearchHashtagView_thenReturnsArticleSearchHashtagView() throws Exception {
        // Given
        String hashtag = "#java";
        List<String> hashtags = List.of("#java","#spring","boot");
        given(articleService.searchArticlesViaHashtag(eq(hashtag),any(Pageable.class))).willReturn(Page.empty());
        given(paginationService.getPaginationBarNumbers(anyInt(),anyInt())).willReturn(List.of(1,2,3,4,5));
        given(articleService.getHashtags()).willReturn(hashtags);
        // When & Then
        mvc.perform(get("/articles/search-hashtag")
                        .queryParam("searchValue",hashtag)
                )
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/search-hashtag")) // 뷰의 존재여부 검사
                .andExpect(model().attribute("articles",Page.empty()))
                .andExpect(model().attribute("hashtags",hashtags))
                .andExpect(model().attributeExists("paginationBarNumbers"))
                .andExpect(model().attribute("searchType",SearchType.HASHTAG));
        then(articleService).should().searchArticlesViaHashtag(eq(hashtag),any(Pageable.class));
        then(articleService).should().getHashtags();
        then(paginationService).should().getPaginationBarNumbers(anyInt(),anyInt());
    }

이제 search-hashtag.th.xml을 생성해서 기능을 search-hashtag 뷰에 기능을 부여한다.

<?xml version="1.0"?>
<thlogic xmlns:th="http://www.thymeleaf.org">
    <attr sel="#header" th:replace="header :: header"/>
    <attr sel="#footer" th:replace="footer :: footer"/>
    <attr sel="main" th:object="${articles}">

        <attr sel="#hashtags" th:remove="all-but-first">
            <attr sel="div" th:each="hashtag : ${hashtags}">
                <attr sel="a" th:class="'text-reset'" th:text="${hashtag}" th:href="@{/articles/search-hashtag(
                    page=${param.page},
                    sort=${param.sort},
                    searchType=${searchType.name},
                    searchValue=${hashtag}
                )}"/>
            </attr>
        </attr>

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

            <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.content/span" th:text="${article.content}"/>
                    <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/search-hashtag(page=${articles.number - 1},searchType=${searchType.name},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/search-hashtag(page=${pageNumber},searchType=${searchType.name},searchValue=${param.searchValue})}"
                      th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
                />
            </attr>
            <attr sel="li[2]/a"
                  th:text="'next'"
                  th:href="@{/articles/search-hashtag(page=${articles.number + 1},searchType=${searchType.name},searchValue=${param.searchValue})}"
                  th:class="'page-link' + (${articles.number} >= ${articles.totalPages -1} ? ' disabled' : '')"
            />

        </attr>

    </attr>
</thlogic>

index 페이지의 디커플드 로직과 다른점이 있다면 검색바를 제거하고, 검색의 결과를 표시해주는 항목을 제목,본문,작성자,작성일 이렇게 4가지로 나눴고, 본문의 경우, 내용이 너무 길면 결과 목록이 두꺼워지므로, 일부만 표시해주고 나타나게 설정 했다.
여기서 searchType의 값은 이미 hashtag로 고정 되어있기 때문에 어트리뷰트에서 searchType으로 SearchType의 HASHTAG로 고정을 시켜놓았다.
정렬 항목,페이징 버튼의 href도 /articles 가 아니라 /articles/search-hashtag 로 변경했다.

이제 어플리케이션을 실행 시킨 다음에 컨트롤러 매핑에서 설정한 경로 articles/search-hashtag 로 이동해보면?

현재 모든 게시글들이 보유하고 있는 해시태그의 종류가 나열되어있고 해당 해시태그들을 클릭하면 그에 맞는 결과가 나타나게 된다.

상세 게시글 안들어가지는 오류 해결

index.th.xml에서 tbody의 title/a 의 href 링크를 잘못 설정했었다.

<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/+ ${article.id}}"/>

잚 보면 '/articles/ 라고 되어있는데, 작은 따옴표 마무리를 하지 않아서 링크 값이 이상했던것이었다... '/articles/'로 고치면 상세 게시글이 나타난다. 게시글 페이지만 구현해놓고 들어가보지 않아서 몰랐었는데 안들어가지길래 찾아봤더니 이런 실수가 있었다 ㅜㅜ

해시태그 기능 부여 과정은 여기서 끝이다.

profile
개발자 꿈나무

0개의 댓글