본문 바로가기
BackEnd/Project

[Board] Ch03. 게시판 검색 구현 - 해시태그

by 개발 Blog 2024. 8. 11.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

해시태그 검색 구현

이번 포스팅에서는 게시판 검색 기능 중 해시태그 검색을 구현한 내용을 다룬다. 기본적인 검색 기능 외에도 해시태그를 이용한 검색 기능을 추가하여, 사용자들이 특정 해시태그를 통해 관련된 게시글을 더욱 쉽게 찾을 수 있도록 개선했다. 코드 수정 전후와 함께 추가된 코드들에 대해 상세히 설명한다.

1. ArticleController에 해시태그 검색 기능 추가

기존 ArticleController 코드

기존의 ArticleController는 게시글 목록 조회와 상세 조회 기능을 제공했으며, 제목, 내용, 사용자 ID, 닉네임을 기준으로 검색할 수 있었다. 하지만 해시태그 검색 기능은 포함되지 않았다.

@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<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::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";
}

 

수정 후 ArticleController 코드

새롭게 추가된 searchHashtag 메서드를 통해 해시태그를 이용한 게시글 검색이 가능하게 되었다.

이 기능을 통해 특정 해시태그로 게시글을 검색하고, 관련된 모든 게시글을 반환받을 수 있다.

@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("searchType", SearchType.HASHTAG);

    return "articles/search-hashtag";
}

searchHashtag 메서드

  • 해시태그 검색 요청을 처리한다.
  • 사용자가 입력한 searchValue를 통해 해시태그로 검색된 게시글 목록을 반환한다.
  • 결과로 반환된 articles, hashtags, paginationBarNumbers 등을 뷰에 전달한다.
  • searchType으로 HASHTAG를 지정하여 검색 타입을 명확히 구분한다.

2. ArticleRepository에 해시태그 관련 기능 추가

기존 ArticleRepository 코드

기존 ArticleRepository는 제목, 내용, 사용자 ID, 닉네임을 기준으로 게시글을 검색할 수 있었다.

하지만 해시태그 검색에 대한 구현이 되어 있지 않았다.

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

 

수정 후 ArticleRepository 코드

해시태그 검색을 위한 메서드와 함께, 게시글에서 사용된 모든 해시태그를 조회할 수 있는 기능을 추가했다.

@RepositoryRestResource
public interface ArticleRepository extends
        JpaRepository<Article, Long>,
        ArticleRepositoryCustom,
        QuerydslPredicateExecutor<Article>,
        QuerydslBinderCustomizer<QArticle>{
  • 기존 리포지토리에 ArticleRepositoryCustom 인터페이스를 추가하여, 커스텀 쿼리 메서드를 사용할 수 있도록 했다.
  • 이를 통해 고유한 해시태그 목록을 조회할 수 있다.

3. ArticleRepositoryCustom 인터페이스 추가

public interface ArticleRepositoryCustom {
    List<String> findAllDistinctHashtags();
}
  • findAllDistinctHashtags 메서드
    • 게시글에 사용된 모든 고유한 해시태그를 조회하는 메서드이다.
    • 이를 통해 게시판에서 사용된 모든 해시태그를 리스트로 가져올 수 있다.

4. ArticleRepositoryCustomImpl 구현체 추가

package org.example.projectboard.repository.querydsl;

import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.QArticle;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import java.util.List;

public class ArticleRepositoryCustomImpl extends QuerydslRepositorySupport implements ArticleRepositoryCustom {

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

    @Override
    public List<String> findAllDistinctHashtags() {
        QArticle article = QArticle.article;

        return from(article)
                .distinct()
                .select(article.hashtag)
                .where(article.hashtag.isNotNull())
                .fetch();
    }
 }
  • findAllDistinctHashtags 구현
    • Querydsl을 사용해 모든 게시글에서 고유한 해시태그를 조회하는 쿼리를 작성했다.
    • 이 쿼리는 hashtag가 null이 아닌 경우에만 해시태그를 선택해 반환한다.

5. ArticleService에 해시태그 검색 기능 추가

기존 ArticleService 코드

기존 ArticleService는 제목, 내용, 사용자 ID, 닉네임을 기준으로 게시글을 검색할 수 있었다.

@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);
    };
}

 

수정 후 ArticleService 코드

이제 해시태그를 통한 게시글 검색 기능이 추가되었다. 또한, 모든 해시태그를 조회할 수 있는 기능도 함께 구현되었다.

public class ArticleService {
    private final ArticleRepository articleRepository;

    @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);
        };
    }
    
	...
    
    @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);
    }

    public List<String> getHashtags() {
        return articleRepository.findAllDistinctHashtags();
    }
}
  • searchArticlesViaHashtag 메서드
    • 특정 해시태그로 게시글을 검색해 해당 페이지의 게시글을 반환하는 메서드이다.
    • 해시태그가 입력되지 않은 경우 빈 페이지를 반환한다.
  • getHashtags 메서드
    • 리포지토리에서 모든 고유한 해시태그를 조회해 리스트로 반환한다.
    • 이를 통해 뷰에서 사용자가 선택할 수 있는 해시태그 목록을 제공한다.

6. 해시태그 검색 페이지 구현

기존 해시태그 검색 페이지:

기존에 해시태그 검색을 위한 search-hashtag.html 파일은 단순한 페이지였으며, 실제 검색 기능을 지원하지 않았다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
게시글 해시태그 검색
</body>
</html>

 

수정 후 해시태그 검색 페이지

해시태그 검색 페이지가 완전히 구현되었다. 이제 사용자는 특정 해시태그를 클릭하여 해당 해시태그와 관련된 게시글을 확인할 수 있다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="Eunchan Kim">
    <title>해시태그 검색</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
    <link href="/css/articles/table-header.css" rel="stylesheet">
</head>

<body>
<header id="header">
    헤더 삽입부
    <hr>
</header>

<main class="container">
    <header class="py-5 text-center">
        <h1>Hashtags</h1>
    </header>

    <section class="row">
        <div id="hashtags" class="col-9 d-flex flex-wrap justify-content-evenly">
            <div class="p-2">
                <h2 class="text-center lh-lg font-monospace"><a href="#">#java</a></h2>
            </div>
        </div>
    </section>

    <hr>

    <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">Eunchan</td>
            <td class="created-at"><time>2024-01-01</time></td>
        </tr>
        <tr>
            <td>두번째글</td>
            <td>본문</td>
            <td>Eunchan</td>
            <td><time>2024-01-02</time></td>
        </tr>
        <tr>
            <td>세번째글</td>
            <td>본문</td>
            <td>Eunchan</td>
            <td><time>2024-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-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>
  • 해시태그를 선택하면 관련 게시글이 검색되는 화면을 구현했다.
  • 상단에는 해시태그 목록이 표시되고, 하단에는 검색된 게시글이 테이블 형식으로 나타난다.

5. search-hashtag.th.xml 파일 추가

해시태그 검색 기능과 관련된 로직을 Thymeleaf 템플릿 엔진을 사용해 구현했다.

<?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="#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="ul">
                <attr sel="li[0]/a"
                      th:text="'previous'"
                      th:href="@{/articles(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(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(page=${articles.number + 1}, searchType=${searchType.name}, searchValue=${param.searchValue})}"
                      th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
                />
            </attr>
        </attr>
    </attr>
</thlogic>
  • Thymeleaf 템플릿 파일로, 해시태그 검색 화면에서 필요한 동적 데이터를 처리한다.
  • hashtags 리스트와 articles를 받아 화면에 렌더링 하며, 해시태그와 게시글 제목 등을 링크로 연결해 사용자가 선택할 수 있도록 했다.

6. 해시태그 검색 기능 테스트

해시태그 검색 기능이 정상적으로 동작하는지 확인하기 위해 다양한 테스트를 진행했다.

 

ArticleController 테스트

이 테스트 메서드는 /articles/search-hashtag 경로로 GET 요청이 들어왔을 때 해시태그 검색 페이지가 정상적으로 반환되는지를 확인한다.

@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(articleService.getHashtags()).willReturn(hashtags);
    given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(1, 2, 3, 4, 5));

    // when & then
    mvc.perform(get("/articles/search-hashtag"))
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(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(articleService.getHashtags()).willReturn(hashtags);
    given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(1, 2, 3, 4, 5));

    // when & then
    mvc.perform(
            get("/articles/search-hashtag")
                    .queryParam("searchValue", hashtag)
    )
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(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());
}
  • given 절에서는 articleService와 paginationService의 메서드 호출 결과를 미리 지정해 주고, 해시태그 리스트와 빈 페이지를 설정했다.
  • mvc.perform을 통해 실제 요청을 모방하며, 결과로 HTTP 상태 코드 200(OK), 뷰 이름 articles/search-hashtag, 그리고 모델에 필요한 데이터들이 포함되어 있는지를 확인한다.
  • then 절에서는 articleService와 paginationService의 메서드가 제대로 호출되었는지 검증한다.

이 테스트는 사용자가 아무런 해시태그를 입력하지 않은 경우에도, 검색 페이지가 정상적으로 출력되는지를 보장한다.

 

ArticleService 테스트

이 테스트 메서드는 사용자가 특정 해시태그(예: #java)를 입력하고, 해당 해시태그로 검색했을 때, 검색 결과 페이지가 정상적으로 반환되는지를 확인한다.

@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();
}

@DisplayName("게시글을 해시태그 검색하면, 게시글 페이지를 반환한다.")
@Test
void givenHashtag_whenSearchingArticlesViaHashtag_thenReturnsArticlePage() {
    // 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);
}
  • given 절에서는 articleService와 paginationService의 메서드 호출 결과를 설정했다. 특히, 해시태그가 포함된 검색 결과를 확인하기 위해 빈 페이지와 해시태그 리스트를 설정했다.
  • mvc.perform에서는 검색어를 쿼리 파라미터로 포함해 GET 요청을 수행하고, 이 요청에 대해 HTTP 상태 코드 200, 뷰 이름 articles/search-hashtag, 그리고 모델의 적절한 데이터를 확인한다.
  • then 절에서는 articleService와 paginationService가 기대대로 호출되었는지를 검증한다.

이 테스트는 사용자가 특정 해시태그를 입력한 경우, 올바른 결과 페이지가 반환되는지를 확인하여 해시태그 검색 기능이 의도대로 동작하는지를 보장한다.

전체 테스트를 돌려보면, Disabled 상태인 테스트를 제외하고 모든 테스트가 성공적으로 통과한 것을 확인할 수 있다. 이는 프로젝트가 거의 완성 단계에 있다는 것을 의미한다.

 

이후, 웹 브라우저에서 http://localhost:8080/articles/search-hashtag로 접속해보면, 해시태그 검색 페이지가 의도한 대로 잘 표시되는 것을 볼 수 있다.

 

이로써 해시태그 검색 기능 구현이 성공적으로 완료되었다. 프로젝트의 핵심 기능들이 모두 안정적으로 동작하는 것을 확인했으며, 이를 통해 사용자에게 더욱 편리한 검색 환경을 제공할 수 있게 되었다. 이제 이 작업을 깃허브에 푸시하여 최종적으로 프로젝트를 마무리한다. 앞으로도 프로젝트를 지속적으로 개선하고 유지보수하며, 사용자 경험을 향상시킬 수 있도록 노력할 것이다.