[게시판 프로젝트] 페이지네이션(Pagination) 구현
2024. 8. 26. 19:33ㆍCS/Spring
페이지네이션 기능을 제공하며,
주로 페이지 네비게이션 바에 표시될 페이지 번호들을 계산하는 역할을 수행하는
Spring 서비스 클래스인 PaginationService를 구현하고,
Pagination기능이 적용되어야할 ArticleController클래스의 코드와 뷰도 수정해 봅시다!
PaginationService



클래스 선언부
@Service public class PaginationService {
- @Service: 이 어노테이션은 이 클래스가 서비스 계층의 역할을 수행하며, Spring의 빈(Bean)으로 등록된다는 것을 나타냅니다. 서비스 클래스는 주로 비즈니스 로직을 처리하는 곳입니다.
- public class PaginationService: PaginationService라는 이름의 공개(public) 클래스를 선언합니다.
상수 선언부
private static final int BAR_LENGTH = 5;
- private: 이 상수는 이 클래스 내에서만 접근할 수 있습니다.
- static: 이 상수는 클래스 레벨에서 하나만 존재하며, 모든 인스턴스에서 공유됩니다.
- final: 상수의 값은 변경될 수 없습니다.
- BAR_LENGTH: 페이지 네비게이션 바에 표시될 최대 페이지 번호의 개수를 나타냅니다. 예를 들어, 네비게이션 바에서 최대 5개의 페이지 번호(1, 2, 3, 4, 5)를 표시하도록 설정합니다.
getPaginationBarNumbers 메소드: 페이지 번호 계산
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
- public: 이 메소드는 공개적으로 사용될 수 있습니다.
- List<Integer>: 이 메소드는 Integer 타입의 숫자 목록을 반환합니다. 반환되는 목록은 페이지 네비게이션 바에 표시될 페이지 번호들입니다.
- getPaginationBarNumbers: 페이지 네비게이션 바에 표시될 페이지 번호들을 계산하는 메소드입니다.
- int currentPageNumber: 현재 페이지 번호를 나타내는 정수 값입니다.
- int totalPages: 전체 페이지 수를 나타내는 정수 값입니다.
int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2), 0);
- 네비게이션 바에서 시작할 페이지 번호를 계산합니다.
- currentPageNumber - (BAR_LENGTH / 2): 현재 페이지 번호를 중앙에 위치시키도록 시작 번호를 계산합니다. 예를 들어, 현재 페이지가 3이고 BAR_LENGTH가 5라면, 3 - 2 = 1이 되어, 페이지 바는 1에서 시작합니다.
- Math.max(... , 0): 음수를 방지하기 위해, 계산된 시작 번호가 0보다 작은 경우에는 0으로 설정합니다. 즉, 페이지 번호가 0 미만이 되지 않도록 보호합니다.
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
- 네비게이션 바에서 끝나는 페이지 번호를 계산합니다.
- startNumber + BAR_LENGTH: 시작 번호에서 BAR_LENGTH(5)를 더하여 끝나는 페이지 번호를 계산합니다. 예를 들어, 시작 번호가 1이라면, 끝 번호는 5가 됩니다.
- Math.min(... , totalPages): 계산된 끝 번호가 전체 페이지 수를 초과하지 않도록 보호합니다. 만약 전체 페이지가 4페이지라면, 끝 번호는 4가 됩니다.
return IntStream.range(startNumber, endNumber).boxed().toList();
- IntStream.range(startNumber, endNumber): 시작 번호부터 끝 번호(포함하지 않음)까지의 정수 스트림을 생성합니다. 예를 들어, 시작 번호가 1이고 끝 번호가 5라면, 스트림은 1, 2, 3, 4를 포함합니다.
- .boxed(): 기본형 int 스트림을 Integer 객체 스트림으로 변환합니다.
- .toList(): 스트림을 List<Integer>로 변환하여 반환합니다.
- 최종적으로, 이 메소드는 네비게이션 바에 표시될 페이지 번호들의 리스트를 반환합니다.
currentBarLength 메소드: 네비게이션 바 길이 반환
public int currentBarLength() { return BAR_LENGTH; }
- public int currentBarLength():
- 이 메소드는 네비게이션 바의 길이를 반환합니다. 여기서는 항상 5를 반환합니다. 이 메소드는 바의 길이를 다른 곳에서 참조할 때 사용될 수 있습니다.
PaginationServiceTest
@DisplayName("배즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,classes =PaginationService.class)//test 의 무게를 줆임
class PaginationServiceTest {
private final PaginationService sut;
public PaginationServiceTest(@Autowired PaginationService paginationService) {
this.sut = paginationService;
}
@DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이징 바 리스트를 만들어준다.")
@MethodSource //메소드 주입방식으로 입력
@ParameterizedTest(name = "[{index}] {0}, {1} => {2}")//값을 연속적으로 주입해서 동일한 메소드를 여러번 테스트 하면서 입출력 값을 볼 수 있는 기능 + 포맷 정하기
public void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers(int currentPageNumber, int totalPages, List<Integer> expected/*입력값을 여기 넣을 수 있음*/) throws Exception {
//given
//when
//실제로 받는 값
List<Integer> actual = sut.getPaginationBarNumbers(currentPageNumber, totalPages);
//then
assertThat(actual).isEqualTo(expected);
}
//MethodSource (입력값 메소드)
static Stream<Arguments> givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() {
return Stream.of(
//검증해 보고 싶은 값들의 나열
arguments(0, 13, List.of(0, 1, 2, 3, 4/*스프링 데이터의 페이지 인터페이스에서 제공하는 페이지 넘버(0부터)*/)),
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);
}
}
ArticleController 의존성 주입
before
@RequiredArgsConstructor
@Controller
@RequestMapping("/articles")
public class ArticleController {
private final ArticleService articleService;
After
@RequiredArgsConstructor
@Controller
@RequestMapping("/articles")
public class ArticleController {
private final ArticleService articleService;
private final PaginationService paginationService;
ArticleController 게시글 리스트 페이지 Pagination 구현 코드 추가
before
@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";
}
After
@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));
//페이지네이션 추가 수정
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";
}
뷰 구현 코드 수정 - index.html / index.th.html
before
<div class="row">
<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>
</div>
</main>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header"/>
<attr sel="#footer" th:replace="footer :: footer"/>
<attr sel="#article-table">
After
<div class="row">
<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> <!--for-each 로 치환해서 여러 bar 접근할 수 있도록-->
<li class="page-item"><a class="page-link" href="#">Next</a></li>
</ul>
</nav>
</div>
</main>
<?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="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
</attr>
<attr sel="tbody" th:remove="all-but-first"> <!-- tbody의 첫번째만 남기고 전부 지운다 -->
<attr sel="tr[0]" th:each="article : ${articles}"> <!-- tr의 0번째부터 순회하며 아래 요소에 대한 작업 진행-->
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" /> <!-- a 부분에 다음과 같은 양식으로 하이퍼링크 작성-->
<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>
</attr>
</thlogic>'CS > Spring' 카테고리의 다른 글
| [게시판 프로젝트] 게시글 댓글 구현 - ArticleCommentController 코드 뜯어보며, 댓글 기능 프로세스 이해하기 (0) | 2024.08.26 |
|---|---|
| [게시판 프로젝트] 게시판 검색 구현 (0) | 2024.08.26 |
| [게시판 프로젝트] ArticleRequest와 ArticleResponse를 사용하는 상황의 차이 이해하기, DTO를 따로 만들어 사용하는 이유 (0) | 2024.08.26 |
| [게시판 프로젝트] ArticleController 코드 뜯어 보기를 통해서, 서비스계층과 레포지토리 DTO간 호출/응답 관계 이해하기 (0) | 2024.08.26 |
| [Spring/MVC] Controller, Repository, DTO, Domain, Config의 역할 (0) | 2024.08.26 |