[게시판 프로젝트] 페이지네이션(Pagination) 구현

2024. 8. 26. 19:33CS/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>