본문 바로가기
BackEnd/Project

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

by 개발 Blog 2024. 8. 10.

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

 

지난 시간까지 서비스 로직을 먼저 구현했다.

이번에는 그것에 맞춰 컨트롤러를 구현하는 작업을 진행하고, 구체적인 데이터를 다루는 컨트롤러와 뷰 템플릿을 수정하여 실제 데이터를 전달하고 렌더링하는 작업을 진행한다.

 

게시판 컨트롤러 구현 

기존 코드

package org.example.projectboard.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;

@RequestMapping("/articles")
@Controller
public class ArticleController {

    @GetMapping
    public String articles(ModelMap map) {
        map.addAttribute("articles", List.of());
        return "articles/index";
    }

    @GetMapping("/{articleId}")
    public String article(@PathVariable("articleId") Long articleId, ModelMap map) {
        map.addAttribute("article", "article"); //TODO: 구현할 때 여기를 실제 데이터로 넣어줘야 함
        map.addAttribute("articleComments", List.of());
        return "articles/detail";
    }
}
  • 이 코드에서는 기본적인 페이지를 렌더링하는 컨트롤러가 구현되어 있다.
  • 하지만 아직 실제 데이터를 바인딩하거나 검색 및 페이지네이션 기능이 구현되어 있지 않다.
  • articles 메서드는 기본적인 게시글 리스트 페이지를, article 메서드는 특정 게시글의 상세 페이지를 반환한다.

수정된 코드

이 코드에서는 ArticleService를 통해 데이터를 받아와 페이지에 전달하는 부분이 추가되었다.

package org.example.projectboard.controller;

import lombok.RequiredArgsConstructor;
import org.example.projectboard.domain.type.SearchType;
import org.example.projectboard.dto.response.ArticleResponse;
import org.example.projectboard.dto.response.ArticleWithCommentsResponse;
import org.example.projectboard.service.ArticleService;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@RequiredArgsConstructor
@RequestMapping("/articles")
@Controller
public class ArticleController {

    private final ArticleService articleService;

    @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
    ) {
        map.addAttribute("articles", articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from));
        return "articles/index";
    }

    @GetMapping("/{articleId}")
    public String article(@PathVariable("articleId") Long articleId, ModelMap map) {
        ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId));
        map.addAttribute("article", article);
        map.addAttribute("articleComments", List.of(article.articleCommentsResponse()));
        return "articles/detail";
    }
}

 

  • articles 메서드는 기본 게시글 리스트 페이지를 렌더링한다.
    • searchType과 searchValue를 통해 검색 기능을 지원하며, Pageable을 통해 페이지네이션을 처리한다.
    • 검색 결과로 반환된 Page<ArticleDto>를 ArticleResponse로 변환하여 뷰 템플릿에 전달한다.
  • article 메서드는 특정 게시글의 상세 페이지를 렌더링한다.
    • ArticleWithCommentsResponse 객체를 통해 게시글과 댓글을 포함한 상세 데이터를 전달한다.
    • articleCommentsResponse를 통해 댓글 목록도 함께 전달된다.

컨트롤러 테스트 코드

package org.example.projectboard.controller;

import org.example.projectboard.config.SecurityConfig;
import org.example.projectboard.dto.ArticleWithCommentsDto;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.service.ArticleService;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;
import java.util.Set;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@DisplayName("View 컨트롤러 - 게시글")
@Import(SecurityConfig.class)
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
    private final MockMvc mvc;

    @MockBean private ArticleService articleService;

    ArticleControllerTest(@Autowired MockMvc mvc) {
        this.mvc = mvc;
    }

    @DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
        // given
        given(articleService.searchArticles(eq(null), ArgumentMatchers.eq(null), any(Pageable.class))).willReturn(Page.empty());

        // when & then
        mvc.perform(get("/articles"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("articles/index"))
                .andExpect(model().attributeExists("articles"));
        then(articleService).should().searchArticles(eq(null), ArgumentMatchers.eq(null), any(Pageable.class));
    }

    @DisplayName("[view][GET] 게시글 상세 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
        // given
        Long articleId = 1L;
        given(articleService.getArticle(articleId)).willReturn(createArticleWithCommentsDto());

        // when & then
        mvc.perform(get("/articles/" + articleId))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("articles/detail"))
                .andExpect(model().attributeExists("article"))
                .andExpect(model().attributeExists("articleComments"));
        then(articleService).should().getArticle(articleId);
    }

    ...

    private ArticleWithCommentsDto createArticleWithCommentsDto() {
        return ArticleWithCommentsDto.of(
                1L,
                createUserAccountDto(),
                Set.of(),
                "title",
                "content",
                "#java",
                LocalDateTime.now(),
                "eunchan",
                LocalDateTime.now(),
                "eunchan"
        );
    }

    private UserAccountDto createUserAccountDto() {
        return UserAccountDto.of(
                1L,
                "eunchan",
                "pw",
                "eunchan@mail.com",
                "eunchan",
                "memo",
                LocalDateTime.now(),
                "eunchan",
                LocalDateTime.now(),
                "eunchan"
        );
    }
}

 

  • 게시글 리스트 페이지 테스트 (givenNothing_whenRequestingArticlesView_thenReturnsArticlesView):
    • given 구문에서 articleService.searchArticles()의 반환값을 빈 페이지로 모킹하여, 서비스 호출 시 특정 데이터를 반환하도록 설정하였다.
    • 이후 MockMvc를 사용해 요청을 보내고, 정상적으로 뷰와 모델이 반환되는지 확인한다.
  • 게시글 상세 페이지 테스트 (givenNothing_whenRequestingArticleView_thenReturnsArticleView):
    • given 구문에서 articleService.getArticle()을 호출할 때, ArticleWithCommentsDto를 반환하도록 설정하였다.
    • MockMvc를 통해 /articles/1 요청을 보내고, 상세 페이지가 올바르게 렌더링되며, 필요한 데이터가 모델에 포함되어 있는지 검증한다.

 

 

뷰 템플릿 구현

기존코드

이 코드에서는 게시글 데이터가 하드코딩되어 있으며, 동적으로 데이터가 출력되지 않는다.

<tbody>
    <tr>
        <td>첫 글</td>
        <td>#java</td>
        <td>Eunchan</td>
        <td>2024-08-06</td>
    </tr>
    ...
</tbody>

 

 

수정된 코드

수정된 뷰 템플릿에서는 동적으로 데이터를 받아와 테이블에 출력한다.

<?xml version="1.0"?>
<thlogic>
    <attr sel = "#header" th:replace="header :: header"/>
    <attr sel = "#footer" th:replace="footer :: footer"/>

    <attr sel="#article-table">
        <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>
    </attr>
</thlogic>

 

  • th:each를 사용하여 컨트롤러에서 전달된 articles 데이터를 순회하며 각 게시글의 제목, 해시태그, 작성자, 작성일을 출력한다.
  • th:href를 사용해 각 게시글의 제목에 링크를 걸어, 클릭 시 해당 게시글의 상세 페이지로 이동할 수 있게 한다.
  • #temporals.format을 사용하여 작성일을 포맷팅하여 표시한다.

 

지금까지 코드가 반영된 화면은 다음과 같다. 

 

이번 포스팅에서는 게시판의 컨트롤러와 뷰 템플릿을 구현하며, 서비스 로직과 데이터 바인딩에 대해 자세히 알아보았다.

컨트롤러에서의 데이터 처리와 뷰로의 전달 과정을 통해 실제 애플리케이션이 어떻게 동작하는지 이해할 수 있었다.