본문 바로가기
BackEnd/Project

[Board] Ch03. 게시판 페이지 기능 구현(1)

by 개발 Blog 2024. 8. 9.

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

 

서비스 로직 구현

지난 시간에 작성한 테스트 코드를 기반으로, 서비스 로직을 구현한다.

각 기능(조회, 생성, 삭제)에 대한 테스트를 통과하기 위해 필요한 로직을 순차적으로 추가할 것이다.

 

ArticleRepository

ArticleRepository에서는 게시글의 다양한 속성을 기반으로 검색할 수 있도록 메서드를 추가한다.

특히 ContentContaining 메서드는 부분 검색을 가능하게 하여, 사용자가 입력한 키워드가 포함된 내용을 검색할 수 있도록 한다.

이를 통해 더 유연한 검색 기능을 제공할 수 있게 한다.

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

    Page<Article> findByTitleContaining(String title, Pageable pageable);
    Page<Article> findByContentContaining(String content, Pageable pageable);
    Page<Article> findByUserAccount_UserIdContaining(String userId, Pageable pageable);
    Page<Article> findByUserAccount_NicknameContaining(String nickname, Pageable pageable);
    Page<Article> findByHashtag(String hashtag, Pageable pageable);

    @Override
    default void customize(QuerydslBindings bindings, QArticle root){
        ...
    }
}

 

ArticleService

ArticleService에서는 테스트에 기반한 로직 구현을 시작했다.

 

1. 게시글을 검색하는 테스트

테스트코드

@DisplayName("검색어 없이 게시글을 검색하면, 게시글 페이지를 반환한다.")
@Test
void givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
    // given
    Pageable pageable = Pageable.ofSize(20);
    given(articleRepository.findAll(pageable)).willReturn(Page.empty());

    // when
    Page<ArticleDto> articles = sut.searchArticles(null, null, pageable);

    // then
    assertThat(articles).isEmpty();
    then(articleRepository).should().findAll(pageable);
}

 

서비스 코드

@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);
    };
}
  • 검색어가 없는 경우
    • 이 경우 모든 게시글을 검색해 반환해야 한다. 이때 DTO와 mapper를 사용한 것이 큰 도움이 된다.
    • Page 객체 안의 내용을 map으로 변환하여 ArticleDto를 추가함으로써, 서비스는 도메인 코드와 ArticleDto만 알게 된다.
    • 즉, 서비스는 컨트롤러의 자세한 구현을 알 필요 없이, 오직 도메인과 DTO 간의 매핑만을 처리하게 된다.
  • 검색어가 있는 경우
    • SearchType에 따라 각각 다른 쿼리를 실행하도록 switch 문을 사용했다.
    • 예를 들어, SearchType.TITLE의 경우, findByTitleContaining 메서드를 호출하여 검색어가 포함된 제목을 가진 게시글을 검색하고, 그 결과를 ArticleDto로 변환해 반환한다.
    • 검색어가 주어졌을 때, SearchType에서 만든 ENUM을 활용하여 서로 다른 쿼리를 생성한다.
    • 예를 들어, 제목, 아이디 등 다양한 속성에 대해 검색 쿼리를 각각 호출하게 된다.
    • 이로 인해 검색 기능이 보다 유연해졌으며, 다양한 조건에 맞는 검색이 가능해졌다.
    • 해시태그 검색 시 #은 일일이 넣기 귀찮으니 자동으로 넣어준다.
    • 하지만 이렇게 하면 #을 일부러 넣었을 때 두 개 들어가는 단점이 있지만 우선은 리팩토링 요소로 남겨두고 넘어간다.

게시글 페이지를 반환하는 기능 구현을 완료했다.

 

2. 특정 게시글을 조회하는 테스트

테스트 코드

@DisplayName("게시글을 조회하면, 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticle_thenReturnsArticle() {
    // given
    Long articleId = 1L;
    Article article = createArticle();
    given(articleRepository.findById(articleId)).willReturn(Optional.of(article));

    // when
    ArticleWithCommentsDto dto = sut.getArticle(articleId);

    // then
    assertThat(dto)
            .hasFieldOrPropertyWithValue("title", article.getTitle())
            .hasFieldOrPropertyWithValue("content", article.getContent())
            .hasFieldOrPropertyWithValue("hashtag", article.getHashtag());
    then(articleRepository).should().findById(articleId);
}

 

서비스 코드

@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticle(Long articleId) {
    return articleRepository.findById(articleId)
            .map(ArticleWithCommentsDto::from)
            .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
  • 이 테스트는 특정 ID로 게시글을 조회할 때, 해당 게시글이 올바르게 반환되는지 확인한다.
  • 서비스 로직에서는 articleRepository.findById(articleId)를 사용해 해당 ID의 게시글을 찾고, 이를 ArticleWithCommentsDto로 변환하여 반환한다.
  • 만약 게시글이 존재하지 않으면 EntityNotFoundException을 던진다.

특정 게시글을 조회하는 기능 구현을 완료했다.

 

3. 게시글을 생성하는 테스트

테스트코드

@DisplayName("게시글 정보를 입력하면, 게시글을 생성한다.")
@Test
void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
    // given
    ArticleDto dto = createArticleDto();
    given(articleRepository.save(any(Article.class))).willReturn(createArticle());

    // when
    sut.saveArticle(dto);

    // then
    then(articleRepository).should().save(any(Article.class));
}

 

서비스 코드

public void saveArticle(ArticleDto dto) {
    articleRepository.save(dto.toEntity());
}
  • 이 테스트는 입력된 게시글 정보를 기반으로 새 게시글이 생성되는지 확인한다.
  • 서비스 로직에서는 ArticleDto를 Article 엔티티로 변환한 후, 이를 articleRepository.save()를 통해 저장한다.
  • 이 과정을 통해 새로운 게시글이 데이터베이스에 정상적으로 저장되는지 확인할 수 있다.

게시글을 생성하는 기능 구현을 완료했다.

 

4. 게시글을 수정하는 테스트

테스트 코드

@DisplayName("게시글의 수정 정보를 입력하면, 게시글을 수정한다.")
@Test
void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
    // given
    Article article = createArticle();
    ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");
    given(articleRepository.getReferenceById(dto.id())).willReturn(article);

    // when
    sut.updateArticle(dto);

    // then
    assertThat(article)
            .hasFieldOrPropertyWithValue("title", dto.title())
            .hasFieldOrPropertyWithValue("content", dto.content())
            .hasFieldOrPropertyWithValue("hashtag", dto.hashtag());
    then(articleRepository).should().getReferenceById(dto.id());
}

 

서비스 코드

public void updateArticle(ArticleDto dto) {
    try {
        Article article = articleRepository.getReferenceById(dto.id());
        if (dto.title() != null) {article.setTitle(dto.title());}
        if (dto.content() != null) {article.setContent(dto.content());}
        article.setHashtag(dto.hashtag());
    } catch(EntityNotFoundException e) {
        log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다. - dto : {}", dto);
    }
}
  • 이 테스트는 게시글 수정 정보를 입력했을 때, 해당 게시글이 올바르게 수정되는지 확인한다.
  • 서비스 로직에서는 getReferenceById()를 통해 수정할 게시글을 가져오고, 입력된 ArticleDto에 있는 값으로 해당 게시글의 필드를 업데이트한다.
  • 만약 게시글이 존재하지 않으면 EntityNotFoundException이 발생하고, 이 경우 로그를 남긴다.

게시글을 수정하는 기능 구현을 완료했다.

 

4. 게시글을 삭제하는 테스트

테스트 코드

@DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다.")
@Test
void givenArticleId_whenDeletingArticle_thenDeletesArticle() {
    // given
    Long articleId = 1L;
    willDoNothing().given(articleRepository).deleteById(articleId);

    // when
    sut.deleteArticle(1L);

    // then
    then(articleRepository).should().deleteById(articleId);
}

 

서비스 코드

public void deleteArticle(long articleId) {
    articleRepository.deleteById(articleId);
}
  • 이 테스트는 특정 ID의 게시글을 삭제할 때, 해당 게시글이 데이터베이스에서 삭제되는지 확인한다.
  • 서비스 로직에서는 articleRepository.deleteById() 메서드를 사용해 해당 ID의 게시글을 삭제한다.
  • 이 과정을 통해 삭제 기능이 정상적으로 작동하는지 검증할 수 있다.

이렇게 해서 서비스 로직까지 구현이 완료되었다.

 

위와 같이, 각 테스트 케이스에 맞춰 서비스 로직을 구현하고, 테스트를 통해 해당 로직이 의도한 대로 동작하는지 확인했다.