최원종의 개발 블로그

V8 게시글 페이징 처리 본문

Spring boot 입문

V8 게시글 페이징 처리

chl6698 2026. 5. 21. 17:29

https://github.com/User20202373/spring_blog_kor_v2

 

GitHub - User20202373/spring_blog_kor_v2

Contribute to User20202373/spring_blog_kor_v2 development by creating an account on GitHub.

github.com



MySQL (limit, offsset 개념 다시 알고 가기)

select * from board_tb limit 2 offset 2;  -- board pk 기준 3, 4 
select * from board_tb limit 2 offset 4;  -- board pk 기준 5, 6 
select * from board_tb limit 2 offset 6;  -- board pk 기준 7, 8
select * from board_tb limit 2 offset 8;  -- board pk 기준 9, 10

-- 산수 : 현 총 게시물 10개 / 한 페이제 게시물 2  ---> 총 페이지 수 5페이지 나옴 
-- limit 는 갯수 , offsset 는 0번 부터 시작 

-- 문제 
--  현 총 게시물 10개 / 한 페이지 보여줄  게시물 3  ---> 총 페이지 수 4페이지 나옴 
-- 3, 3, 3, 1 (페이징 처리 시 올림 처리)

BoardRepository 코드 추가

    // 4. 전체 게시글 조회 + 페이징 처리
    @Query(value = """
        SELECT DISTINCT b FROM Board b JOIN FETCH b.user ORDER BY b.createdAt DESC
    """,
        countQuery = """
            SELECT count(DISTINCT b) FROM Board b
            """)
    Page<Board> findAllWithUserOrderByCreatedAtDesc(Pageable pageable);

BoardService - 게시글 목록 페이징 처리 추가

코드 요약

방어적 아키텍처 코딩 : 사용자의 음수 페이지 요청이나 과도한 대량 데이터 요청(size)을
Math.max/min으로 사전에 차단

Pageable 인터페이스 활용 : 페이지 인덱스, 사이즈, 정렬(createdAt DESC) 조건을
하나로 묶어 SQL의 LIMIT/OFFSET을 자동화

Page<T> 메타데이터 파싱 : 단순 데이터 목록을 넘어 전체 페이지 수,
마지막 페이지 여부 등의 메타데이터를 안정적으로 확보

OSIV false 완벽 대응 : 트랜잭션이 살아있는 서비스 레이어 내에서
페이징 처리된 엔티티 데이터를 화면 전용 PageDTO로 즉시 변환 후 반환

 

핵심 설계 및 기술 포인트 

1. 방어적 코드

int pageIndex = Math.max(0, page - 1);
int validSize = Math.max(1, Math.min(50, size));
문제 상황: 만약 사용자가 주소창에 ?page=-5&size=500000 같은 값을 임의로 조작해서 던지면,
쿼리 엔진에서 문법 에러가 터지거나 한 번에 수십만 건의 데이터를 긁어오느라
서버 메모리가 뻗어버리는 대형 장애가 발생할 수 있음.

개선 효과: Math.max(0, ...)를 통해 최소 페이지를 0번으로 고정하고,
Math.min(50, size)를 통해 아무리 큰 사이즈를 요청해도 한 페이지에 
최대 50개까지만 가져오도록 상한선을 걸어둠.

 

2. 스프링 데이터 표준 인터페이스: Pageable과 Page <T>

Pageable: 쌩 쿼리를 짤 때 매번 계산해야 했던 LIMIT (page * size), size 같은 수식을 
스프링이 대신 계산하도록 함. PageRequest.of() 하나로 페이징 조건을 캡슐화함.

Page<T>: 단순히 목록만 주는 List<T>와 달리,
이 객체는 데이터베이스에 COUNT 쿼리를 함께 날려서 "전체 데이터는 몇 개고, 
그래서 총 몇 페이지까지 만들 수 있는지"에 대한 네비게이션 메타데이터를 통째로 제공함.

BoardService코드

 /**
     * 게시글 목록 조회 페이징 처리 
     * OSIV false 환경 대응 - 응답 DTO 설계
     */
    public BoardResponse.PageDTO 게시글목록(int page, int size) {

        // 화면 기준에서 넘어 오는 값 (사용자에게 보여지는 기준) 0이 아니라 1부터 시작
        // 반면 Spring Data JPA 기분으로 offset 0번 부터 시작이다.
        // 사용자가 일부 음수값을 넣어라도 기본 값 0으로 셋팅될 수 있도록 방어적 코드 작성 필수!
        int pageIndex = Math.max(0, page - 1);
        //  사용자가 임의로 많은 값을 던지는 것 방지
        int validSize = Math.max(1, Math.min(50, size));

        // Pageable 이란?
        // 어떤 페이지를(pageIndex), 몇 개씩(validSize), 어떤 정렬(sort) 가져올지를
        // 한 묶으로 표현하는 Spring Data 표준 페이징 인터페이스 이다.
        // 즉 Repository 에 Pageable 객체를 넘기면 자동으로 Limit, Offset 만들어 준다.
        Sort sort = Sort.by(Sort.Direction.DESC, "createdAt");
        Pageable pageable = PageRequest.of(pageIndex, validSize, sort);

        // Page<T> 이란?
        // 조회된 데이터 한 페이지와 페이징 관련된 메타 데이터를 한꺼번에 담아 주는 객체이다.
        // - getContent() : 현재 페이지의 데이터 목록
        // - getNumbers() : 현재 페이지 번호 (0번부터 시작 함)
        // - getTotalElements() : 전체 항목 수
        // - getTotalPage() : 전체 페이지 수
        // - isFrist() / isLast() : 첫 페이지/마지막 페이지 여부 boolean()
        Page<Board> boardPage = boardRepository.findAllWithUserOrderByCreatedAtDesc(pageable);
        // DTO 변환해서 컨트롤러로 내려 줌 (OSIV 대응)
        return new BoardResponse.PageDTO(boardPage);
    }

BoardResponse 

코드 요약

뷰(View) 친화적 구조 설계 : 산술 계산이 불가능한 Mustache 엔진을 위해 이전/다음 페이지 번호를
백엔드에서 미리 계산(prevPage, nextPage)하여 전달

Stream API를 통한 캡슐화 : page.getContent()의 엔티티 리스트를 영속성 컨텍스트가 닫히기 전 
순수 ListDTO 리스트로 깔끔하게 변환(OSIV false 대응)

화면 인덱스 격차 해소 : JPA의 0 기반 인덱스 시스템을 
사용자 친화적인 1 기반 인덱스(currentPage = getNumber() + 1)로 매끄럽게 보정

유동적 페이지 바(Window) 구현 : Math.max와 Math.min을 활용하여
현재 페이지 기준 앞뒤 2개(최대 5개)의 페이지 번호 구역을 유동적으로 계산

PageItem 컴포넌트 분리 : 페이지 번호와 함께 현재 활성화 상태(active) 여부를
boolean 필드로 묶어 화면단의 'active' CSS 클래스 바인딩을 자동화

 

핵심 설계 및 리팩토링 포인트

1. 백엔드 연산 제어

설계 목적: 타임리프(Thymeleaf) 같은 엔진과 달리 머스태치는 변수의 덧셈, 뺄셈 기능이 없기 때문.
// 템플릿이 산술 계산을 못하기 때문에 여기서 계산해서 내려 줌
this.prevPage = this.first ? this.currentPage : this.currentPage - 1;
this.nextPage = this.last ? this.currentPage : this.currentPage + 1;

 

 

2. 현재 페이지 기준 유동적인 페이지 번호 윈도우(start, end) 처리

int start = Math.max(1, this.currentPage - 2);
int end = Math.min(this.totalPages, this.currentPage + 2);
동작 원리: 전체 페이지가 100페이지일 때 하단에 1부터 100까지 숫자가 다 나올 수가 없음 .

효과: 현재 위치가 5페이지면 [3, 4, 5, 6, 7]이 나오고, 1페이지면 음수로 가지 않게 
Math.max로 방어해 [1, 2, 3]만 나오도록 유동적 슬라이딩 윈도우를 구현. 

예시
 // 페이지 번호 윈도우 : 현재 페이지 기준 앞뒤 2페이지 (최대 5개)
            // 예1 : 현재 페이지 5 ---> [ 3, 4, 5, 6, 7 ]
            // 예2 : 현재 페이지 1 ---> [1, 2, 3]
            // 예3 : 현재 페이지 5, 총 페이지 5 일 경우 [3, 4, 5]

BoardResponse.java 설계 코드

    // 페이징 DTO 설계
    // Page<Board> page --> 우리 사용할 DTO 클래스 단순히 변환 하는 작업 및 편의 기능 추가
    @Data
    public static class PageDTO {
        private List<ListDTO> list;
        private int currentPage;
        private int size;
        private int totalPages;
        private long totalElements;
        private boolean first;
        private boolean last;
        private int prevPage;
        private int nextPage;
        private List<PageItem> pageItemNumbers;

        public PageDTO(Page<Board> page) {
            // List<Board> --> List<ListDTO> 형태로 변환 작업
            this.list = page.getContent().stream()
                    .map(board -> new ListDTO(board))
                    .toList();
            // 사전 지식 :  page.getNumber() <-- 0 번 부터 시작 함.
            // 화면에서는 변수 this.currentPage <-- 1번 부터 보여 줘야 함.
            this.currentPage = page.getNumber()  + 1;
            this.size = page.getSize(); // 현재 default 값은 2 임 (수정 가능)
            this.totalPages = page.getTotalPages();
            this.totalElements = page.getTotalElements(); // long로 설계 되어 있음
            this.first = page.isFirst();
            this.last = page.isLast();

            // 이전/다음 페이지 번호 ( 템플릿이 산술 계산을 못하기 때문에 여기서 계산해서 내려 줌)
            this.prevPage = this.first ? this.currentPage : this.currentPage - 1;
            this.nextPage = this.last ? this.currentPage : this.currentPage + 1;

            // 페이지 번호 윈도우 : 현재 페이지 기준 앞뒤 2페이지 (최대 5개)
            // 예1 : 현재 페이지 5 ---> [ 3, 4, 5, 6, 7 ]
            // 예2 : 현재 페이지 1 ---> [1, 2, 3]
            int start = Math.max(1, this.currentPage - 2);
            // 예3 : 현재 페이지 5, 총 페이지 5 일 경우 [3, 4, 5]
            int end = Math.min(this.totalPages, this.currentPage + 2);

            // 빈 List 먼저 생성 후 값 할당
            this.pageItemNumbers = new ArrayList<>();
            for(int i = start; i <= end; i++) {
                boolean isActive = (i == this.currentPage);
                this.pageItemNumbers.add(new PageItem(i, isActive));
            }
        }

    } // end of PageDTO

    @Data
    public static class PageItem {
        private int number;
        private boolean active;

        public PageItem(int number, boolean active) {
            this.number = number;
            this.active = active;
        }
    }

BoardController -  게시글 목록 화면 요청 page, size 키 값 추가 (defaultValue 확인)

다중 URL 매핑 처리 : @GetMapping({"/board/list", "/"}) 배열 구조를 활용해 
메인 페이지와 리스트 페이지를 하나의 메서드로 통합 제어

@RequestParam 옵션 전술적 활용 : 쿼리 스트링(?page=1&size=5) 형태로 들어오는 파라미터를
자바 객체(Integer)로 자동 바인딩

defaultValue를 통한 예외 방어 : 사용자가 주소창에 파라미터 없이 접근하더라도
기본값(1페이지, 5개씩)으로 세팅되어 NullPointerException 원천 차단

화면 데이터 탑재 및 SSR 반환 : 가공 완료된 고도화 PageDTO를 Model에 실어
머스태치(board/list) 뷰 엔진으로 안전하게 전달

BoardController 코드

    /**
     * 게시글 목록 화면 요청
     * 주소설계 : http://localhost:8080/
     */
    // 페이징 처리 주소설계 : http://localhost:8080/?page=1&size=2
    // 페이징 처리 주소설계 : http://localhost:8080/ <--- defaultValue 로 동작
    // @RequestParam(name= "page") 필수 값 처리
    @GetMapping({"/board/list", "/"})
    public String list(Model model,
                       @RequestParam(name = "page", defaultValue = "1") Integer page,
                       @RequestParam(name = "size", defaultValue = "5") Integer size) {

        BoardResponse.PageDTO boardPage = boardService.게시글목록(page, size);
        model.addAttribute("boardPage", boardPage);
        return "board/list";
    }

board/list.mustache 코드

{{> layout/header}}
<div class="container p-5 flex-grow-1">
    {{#boardPage.list}}
        <div class="card mb-3">
            <div class="card-body">
                <h4 class="card-title mb-3">{{title}}</h4>
                <!--  머스태치 문법은 getter 메서드를 가지고 오고 get은 탈락 가능 -->
                <div> 작성자 : {{username}}  | 작성일 : {{createdAt}}</div>
                <a href="/board/{{id}}" class="btn btn-primary">상세보기</a>
            </div>
        </div>
    {{/boardPage.list}}

    <!-- 페이지 네비게이션  시작 -->
    <ul class="pagination d-flex justify-content-center">
        <!--  이전 페이지 버튼 (첫 페이지 disabled 처리)       -->
        <li class="page-item {{#boardPage.first}} disabled {{/boardPage.first}} ">
            <a class="page-link " href="/?page={{boardPage.prevPage}}&size={{boardPage.size}}">Previous</a>
        </li>

        <!--  페이지 번호 윈도우 (편재 페이지 기준 앞뒤 2페이지 최대 5개   -->
        {{#boardPage.pageItemNumbers}}
        <li class="page-item {{#active}} active {{/active}}">
            <a class="page-link" href="/?page={{number}}&size={{boardPage.size}}">{{number}}</a>
        </li>
        {{/boardPage.pageItemNumbers}}


        <li class="page-item {{#boardPage.last}} disabled {{/boardPage.last}}  ">
            <a class="page-link" href="/?page={{boardPage.nextPage}}&size={{boardPage.size}}">Next</a>
        </li>
    </ul>
    <!-- 페이지 네비게이션  종료 -->

</div>

{{> layout/footer}}