공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
게시판 페이징 내비게이션 구현
이번 포스팅에서는 게시판 페이지에 페이징 내비게이션바를 추가하고 이를 구현하는 방법을 소개한다.
중앙에 위치한 숫자가 현재 페이지의 중간을 잡도록 구현할 것이다.
예를 들어, 1, 2, 3, 4, 5 페이지가 있을 때 현재 보고 있는 페이지는 3페이지로 설정하고,
4페이지를 클릭하면 2, 3, 4, 5, 6이 표시되도록 구현한다.
이번 작업은 Spring Service Bean을 통해 페이지 번호 리스트를 생성하여 뷰에서 렌더링 하는 방식으로 구현한다.
수정 전후의 코드를 비교하며 어떤 부분이 개선되었는지 살펴보겠다.
1. ArticleController.java 수정 전후 비교
수정 전 코드
@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";
}
}
- 수정 전의 ArticleController에서는 페이징 기능이 없었다.
- 게시글 리스트를 가져오는 articles() 메서드에서는 단순히 ArticleService에서 검색된 결과를 모델에 담아 반환할 뿐이었다.
- 하지만 페이징 네비게이션을 위해서는 추가적인 처리가 필요하다.
수정 후 코드
수정된 코드에서는 PaginationService를 추가하여 페이징 바 숫자를 계산하는 로직을 분리했다. articles() 메서드에서 PaginationService를 사용해 현재 페이지와 총 페이지 수를 기반으로 페이징 바 숫자 리스트를 생성한다. 이 리스트는 paginationBarNumbers라는 이름으로 모델에 추가되어 뷰에서 사용된다. 이렇게 함으로써 페이징 바가 동적으로 변화하도록 만들 수 있다.
@RequiredArgsConstructor
@RequestMapping("/articles")
@Controller
public class ArticleController {
private final ArticleService articleService;
private final PaginationService paginationService;
@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);
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";
}
}
- PaginationService: 이 서비스는 페이징 로직을 처리하기 위해 추가되었다. 이를 통해 페이징 관련 로직이 컨트롤러에서 분리되어 코드의 가독성과 유지보수성을 높였다.
- articles(): 이 메서드는 검색 및 페이징 기능을 통해 게시글 리스트를 가져와 articles 모델에 담고, 페이징 바에 표시할 숫자 리스트를 paginationBarNumbers 모델에 담아 뷰로 전달한다.
- @RequestParam: 사용자가 검색 옵션을 제공하지 않아도 괜찮도록 required = false로 설정했다. 사용자가 원하는 페이지와 정렬 순서를 지정할 수 있다.
- PageableDefault: 기본 페이징 설정으로 페이지 크기를 10, 정렬 기준을 createdAt(생성일자) 내림차순으로 설정했다.
ArticleControllerTest.java 수정 전후 비교
수정 전 코드
@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);
}
}
- 수정 전의 테스트 코드에서는 페이징 기능을 테스트하는 코드가 없었다.
- 단순히 게시글 리스트와 상세 페이지를 정상 호출하는지 테스트하는 정도였다.
수정 후 코드
수정된 테스트 코드에서는 PaginationService의 기능을 테스트하기 위해 MockBean으로 추가했다. 페이징 바 리스트를 정상적으로 반환하고 있는지 확인하기 위해 paginationService.getPaginationBarNumbers() 메서드의 동작을 모킹하여 검증한다. 또한, 페이징과 정렬 기능을 테스트하는 givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() 메서드를 추가하여, 페이지 내비게이션 바와 관련된 테스트를 강화했다.
@DisplayName("View 컨트롤러 - 게시글")
@Import(SecurityConfig.class)
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
private final MockMvc mvc;
@MockBean private ArticleService articleService;
@MockBean private PaginationService paginationService;
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());
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0, 1, 2, 3, 4));
// when & then
mvc.perform(get("/articles"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attributeExists("paginationBarNumbers"));
then(articleService).should().searchArticles(eq(null), ArgumentMatchers.eq(null), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬 기능")
@Test
void givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() throws Exception {
// Given
String sortName = "title";
String direction = "desc";
int pageNumber = 0;
int pageSize = 5;
Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Order.desc(sortName)));
List<Integer> barNumbers = List.of(1, 2, 3, 4, 5);
given(articleService.searchArticles(null, null, pageable)).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages())).willReturn(barNumbers);
// When & Then
mvc.perform(
get("/articles")
.queryParam("page", String.valueOf(pageNumber))
.queryParam("size", String.valueOf(pageSize))
.queryParam("sort", sortName + "," + direction)
)
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attribute("paginationBarNumbers", barNumbers));
then(articleService).should().searchArticles(null, null, pageable);
then(paginationService).should().getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages());
}
@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);
}
}
- @MockBean: 페이징 로직을 테스트하기 위해 PaginationService를 MockBean으로 추가했다. 이를 통해 실제 서비스의 동작을 시뮬레이션하고 테스트할 수 있다.
- articles() 테스트 메서드: 기존에 없던 paginationService.getPaginationBarNumbers() 메서드의 호출이 추가되었다. 페이징 바 숫자 리스트를 정상적으로 가져오는지 테스트한다.
- givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() 메서드: 새로운 테스트 메서드로, 페이징 및 정렬 기능이 제대로 작동하는지 검증한다. 사용자가 페이지와 정렬 옵션을 제공했을 때, ArticleService와 PaginationService가 올바르게 호출되는지 확인한다.

PaginationService 클래스 추가
PaginationService는 새로운 서비스 클래스이다. 이 클래스는 페이지 네비게이션 바를 생성하는 역할을 한다. getPaginationBarNumbers() 메서드는 현재 페이지와 총 페이지 수를 받아, 중앙에 현재 페이지가 위치하도록 페이징 바를 계산해준다. 이 서비스는 스프링 서비스 빈으로 등록되어 다른 클래스에서 의존성 주입을 통해 사용할 수 있다.
package org.example.projectboard.service;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.IntStream;
@Service
public class PaginationService {
private static final int BAR_LENGTH = 5;
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
int startNumber = Math.max(currentPageNumber- (BAR_LENGTH / 2), 0);
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
return IntStream.range(startNumber, endNumber).boxed().toList();
}
public int currentBarLength() {
return BAR_LENGTH;
}
}
- BAR_LENGTH는 페이징 바의 길이로, 5로 설정했다. 이 길이는 사용자에게 보여질 페이징 바의 숫자 수를 결정한다.
- getPaginationBarNumbers() 메서드는 현재 페이지 번호와 총 페이지 수를 받아, 페이징 바에 표시될 숫자 리스트를 반환한다. 예를 들어, 현재 페이지가 3이고, 총페이지가 13이라면 [1, 2, 3, 4, 5]와 같은 리스트를 반환한다.
- currentBarLength() 메서드는 페이징 바의 길이를 반환한다. 이 길이는 서비스나 테스트에서 필요할 때 유용하게 사용할 수 있다.
PaginationServiceTest 클래스 추가
이 클래스는 PaginationService의 비즈니스 로직을 검증하는 테스트 클래스이다.
package org.example.projectboard.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.params.provider.Arguments.*;
@DisplayName("비즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class)
class PaginationServiceTest {
private final PaginationService sut; // 테스트 대상인 PaginationService
PaginationServiceTest(@Autowired PaginationService paginationService) {
this.sut = paginationService;
}
@DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이징 바 리스트를 만들어준다.")
@MethodSource
@ParameterizedTest(name = "[{index}] 현재 페이지 : {0}, 총 페이지 : {1} => {2}")
void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers(int currentPageNumber, int totalPages, List<Integer> expected) {
// given
// when
List<Integer> actual = sut.getPaginationBarNumbers(currentPageNumber, totalPages);
// then
assertThat(actual).isEqualTo(expected);
}
static Stream<Arguments> givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() {
return Stream.of(
arguments(0, 13, List.of(0, 1, 2, 3, 4)),
arguments(1, 13, List.of(0, 1, 2, 3, 4)),
arguments(2, 13, List.of(0, 1, 2, 3, 4)),
arguments(3, 13, List.of(1, 2, 3, 4, 5)),
arguments(4, 13, List.of(2, 3, 4, 5, 6)),
arguments(5, 13, List.of(3, 4, 5, 6, 7)),
arguments(6, 13, List.of(4, 5, 6, 7, 8)),
arguments(10, 13, List.of(8, 9, 10, 11, 12)),
arguments(11, 13, List.of(9, 10, 11 ,12)),
arguments(12, 13, List.of(10, 11, 12))
);
}
@DisplayName("현재 설정되어 있는 페이지네이션 바의 길이를 알려준다.")
@Test
void givenNothing_whenCalling_thenReturnsCurrentBarLength() {
// Given
// When
int barLength = sut.currentBarLength();
// Then
assertThat(barLength).isEqualTo(5);
}
}
- PaginationServiceTest 클래스의 생성자에서 PaginationService를 주입받아 sut(System Under Test, 테스트 대상)으로 설정했다.
- givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() 메서드는 파라미터화된 테스트로, 다양한 현재 페이지와 총 페이지 수에 대해 페이징 바 숫자 리스트가 올바르게 생성되는지 확인한다.
- givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() 메서드의 MethodSource를 통해 다양한 테스트 케이스를 제공하며, 각 케이스는 예상되는 결과와 비교해 검증한다.
- givenNothing_whenCalling_thenReturnsCurrentBarLength() 메서드는 currentBarLength() 메서드가 올바른 값을 반환하는지 검증하는 테스트다.
index.html 수정 후
<nav id="pagination" aria-label="Page navigation example">
<ul class="pagination justify-content-center">
<li class="page-item disabled"><a class="page-link">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>
- 수정된 부분은 <nav> 태그에 id="pagination" 속성이 추가된 것뿐이다.
- 이 id는 index.th.xml에서 해당 요소를 선택하여 동적으로 페이지 네비게이션 바를 생성하는 데 사용된다.
- 이로써 페이지 번호와 이전/다음 버튼을 서버에서 제공하는 데이터에 따라 동적으로 구성할 수 있다.
index.th.xml 수정 후
#pagination 부분 추가: 이 부분은 페이지 네비게이션 바를 동적으로 구성한다.
<?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>
<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>
</thlogic>
- li[0]/a: "Previous" 버튼을 생성한다. 현재 페이지가 첫 페이지라면 disabled 클래스를 추가하여 클릭할 수 없게 만든다.
- li[1]: 각 페이지 번호 버튼을 생성한다. PaginationService에서 받은 paginationBarNumbers를 기반으로, 각각의 번호에 대해 버튼을 생성한다. 현재 페이지인 경우 disabled 클래스를 추가하여 강조한다.
- li[2]/a: "Next" 버튼을 생성한다. 현재 페이지가 마지막 페이지라면 disabled 클래스를 추가하여 클릭할 수 없게 만든다.
- th:href: 버튼 클릭 시 이동할 URL을 동적으로 생성한다. 예를 들어, "Previous" 버튼은 이전 페이지로, "Next" 버튼은 다음 페이지로 이동하도록 설정된다.
페이지네이션 기능이 잘 되는 것을 확인할 수 있다.
이번 작업을 통해 게시판 서비스에 필수적인 페이징 기능을 성공적으로 구현했다. 이제 사용자는 게시글 목록을 페이지별로 손쉽게 탐색할 수 있으며, 페이지 네비게이션 바는 동적으로 중앙 페이지를 기준으로 표시된다. 페이징 바의 구현은 PaginationService를 사용하여 각 페이지의 범위를 계산하고, 이를 뷰에 반영하여 페이지 이동이 직관적으로 이루어지도록 설계되었다. 이로써 게시판의 사용자 경험이 한층 향상되었으며, 프로젝트의 완성도가 높아졌다. 앞으로도 지속적인 개선을 통해 더욱 안정적이고 사용하기 편리한 게시판 서비스를 개발하는 데 집중할 예정이다.
'BackEnd > Project' 카테고리의 다른 글
[Board] Ch03. 게시판 정렬 구현 (0) | 2024.08.10 |
---|---|
[Board] Ch03. 게시글 페이징 구현 (0) | 2024.08.10 |
[Board] Ch03. 게시글 페이지 기능 구현 (0) | 2024.08.10 |
[Board] Ch03. 게시판 페이지 기능 구현(2) (0) | 2024.08.10 |
[Board] Ch03. 게시판 페이지 기능 구현(1) (0) | 2024.08.09 |