Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
Tags
- 컴파일
- Java데이터 타입
- 메서드
- for문
- 인텔리제이 한글 깨짐 해결법
- IntelliJ IDEA
- 상수
- function
- 메서드 오버로딩
- 자바 멀티스레딩
- java변수
- 생성자
- 집합관계
- 반복문
- 형 변환
- Thread
- Java
- continue문
- OPP개념
- JAVA기초
- JAVA객체지향
- 인텔리제이 기초 설정
- multi-threading
- 접근제어지시자
- break문
- 포함관계
- this예약어
- While
- 시스템 환경 변수 편집
- 연관관계
Archives
- Today
- Total
최원종의 개발 블로그
V8 게시글 페이징 처리 본문
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}}'Spring boot 입문' 카테고리의 다른 글
| V10 -1 이미지 업로드와 프로필 화면 (0) | 2026.05.22 |
|---|---|
| V9 게시글 목록 검색 기능 추가 (0) | 2026.05.21 |
| V7-4 댓글 삭제 기능 추가 (0) | 2026.05.21 |
| V7-3 댓글 조회 기능 추가 (0) | 2026.05.21 |
| V7-2 댓글 작성하기 기능 추가 (0) | 2026.05.20 |
