※ 이 포스트는 스프링 실습 과정에서 작성하기 때문에 정보가 부정확할 수 있는 부분이 있습니다.
따라서 참고만 해주시고 틀린 부분이 있을 경우 알려주시면 감사하겠습니다.
프로젝트 진행 중 게시판에 페이징 기능을 구현하다가 포스트 안에서 언급하고 넘어가기보다는 정리해서 글 하나로 남겨두는 것이 낫다고 생각해서 작성하게 됐습니다.
게시판 페이징 방식은 엄청나게 다양하지만 결국 구조는 거의 다 비슷하기 때문에 원리 위주로 포스팅해볼까 합니다.
개발 환경
JAVA 1.8, Spring 2.4 IntelliJ
Thymeleaf
* 참고로 stream, JPA 등의 설명을 제외하면 다른 언어를 쓰시더라도 크게 복잡하지 않도록 작성하려 해봤습니다.
* Spring 개발 중이시면 이 포스트에 나오는 코드의 경우 예제를 위해 View > Controller > Repository > Entity의 프로젝트에 사용하기 부적절한 의존 구조를 따르기에 실제 코드의 경우 project 카테고리의 포스트를 보시면 됩니다.
1. 페이징 원리 이해
2. 자신이 구현할 페이징 기능 정의
3. Java 코드 작성(Spring이 아닌거 쓰시는 분은 그냥 모델 쪽 코드 작성이라고 이해하셔도 됩니다.)
4. Html 코드 작성(포스트에선 Thymleaf 사용)
5 실제 View 확인
1. 페이징 원리 이해
# 일단 이 글을 보시는 분이라면 페이징이 뭔지는 다들 알 것이고 궁금한 건 어떻게 구현하느냐? 이것일테니 원리부터 빨리 짚고갑시다. 페이징을 구현하려면 요소에 뭐가 있는지는 알아야겠죠? 일단 설명 전에 페이징에서 가장 중요한 점 몇 개를 짚고 가겠습니다. 인용문에서 설명한 내용만 잘 활용하시면 페이징은 금방 하실겁니다. 다음 내용을 잘 모르셔도 포스트를 쭉 읽으시면 설명이 다 나와 있으니 괜찮습니다.
* 페이지와 블럭의 총 개수는 (하위 요소의 총 개수 / 하위 요소의 최대 제한 개수)를 올림한 값
(block의 하위 요소는 page, page의 하위 요소는 posts)
* 현재 내 창에서 맨 위 글을 현재 Index로 놓으면 (현재 Index) = (현재 page-1) * 총 페이지의 개수
* 페이징 시 내가 정해야할 것은 현재 창 내 최대 게시글, page 수 두가지
* 계산시 필요한 현재 위치한 페이지는 request 요청으로 서버에 알아서 입력됨
아래 박스에 있는 요소들을 우선 이해하셔야 합니다.
각 요소 별로 컴퓨터에 넘겨줘야 하는 정보
posts : 총 게시글의 수, 현재 index
page : 총 페이지 수, 한 페이지 안에서 보여줄 게시글의 수, 현재 page
block : 총 블럭 수, 현재 창에서 보여줄 최대 블럭의 수, 현재 block
- block 요소가 최상위 요소이며 한 block 안의 1~5는 page 요소가 됩니다. 예시를 들어 DB내의 게시글 151개를 페이징 구현을 통해 view로 보여주고싶다고 하면
- 한 페이지 안에서 보여줄 게시글의 수는 10개로 결정
- 총 페이지 수는 16개 [ ceil(151 / 10) ] *ceil은 올림
- 현재 창에서 보여줄 최대 블럭의 수는 5개로 결정
- 총 블럭 수는 4개 [ ceil(16 / 5) ]
그리고 현재 index, 현재 page는 뭐하러 알아야하나?싶을 수 있는데 현재 Index는 이 후 다시 설명할 것이고 현재 페이지는 현재 창의 몇번째 블럭을 보여줄 지 결정하기 위해 알아야합니다.
만약 게시글 페이지 URL이 http://localhost:8080/posts-list일때 2page의 No. 141 게시글이 보고 싶다면 첫번째 block에 있는 3버튼을 눌렀을 것이고 이동된 브라우저의 URL이 다음과 같을겁니다.
http://localhost:8080/posts-list/?page=3
이 URL의 ? 뒤에 붙는 문장을 '쿼리 스트링'이라고 하며 json 등의 key-value(자바에선 Map, 파이썬에선 딕셔너리 등)를 알고 계시다면 key값인 page는 쿼리 파라미터 value인 3은 값이 되는 걸 아실겁니다.
block의 처음, 이전, page, 다음, 끝 버튼을 누르면 이동되는 URL에는 이동되는 page값이 쿼리 스트링으로 늘 붙게되며 우리는 이 쿼리스트링을 view와 서버단에서 매핑해주면 됩니다. 이렇게되면 현재 page 값은 브라우저에서 알아서 전송되고 2번 항목 사양만 우리가 정해주면 충분합니다.
2. 자신이 구현할 페이징 기능 정의
# 자신이 정해야 할 사양은 다음과 같습니다.
- 한 페이지 안에서 보여줄 게시글의 수
- 현재 창에서 보여줄 최대 블럭의 수
- 게시글 출력 순서(최신 글을 기준으로 오름차순인지 내림차순인지)
나머지의 경우 코드를 제대로 짜면 알아서 계산됩니다.
3. JAVA 코드 작성
# 원래는 게시글이 이미 DB에 존재해야 정상이지만 쉽게 post 클래스도 적겠습니다. 자바를 잘 모르시면 괄호 안을 읽으시면 됩니다.
- Posts 엔티티 클래스(게시글), PostsRepository(게시글을 DB에 저장) 생성
- Pagination 클래스(자신이 정한 사양 + 사양에 따라 계산될 다른 변수들 포함) 생성 후 필드(요소값들) 작성
- Pagination 클래스 내에 필드 값 할당(위에서 정한 사양에 따라 값을 정해줌)
- PostsRepository에 페이징 관련 메서드 추가(할당한 값들과 DB의 게시글 수를 가지고 중간 연산)
- HomeController 클래스 생성 후 view template 매핑(연산 된 결과를 실제 창에 반영)
1.Posts 엔티티 클래스(게시글), PostsRepository(게시글을 DB에 저장) 생성
// Posts
@Getter
@Setter
@Entity
public class Board {
@Id @GeneratedValue
private String id; // id
private String title; // 제목
private String author; // 작성자
}
// PostsRepository
@Repository
public class PostsRepository {
@PersistenceContext
private EntityManager em;
}
자바 유저 기준으로 JPA를 잘 모르시면 엔티티와 repository가 생소하실 수도 있는데 MyBatis등의 쿼리매퍼에서 DAO랑 같다고 생각하시면되고 그것도 모르신다면 그냥 DB에 쿼리 날려서 테이블을 만들어 주는 것이라고 아시면 되고 다른 언어를 쓰시더라도 DB에 게시글 데이터만 만들면 똑같습니다. getter stter는 롬복을 썼는데 자바 유저라면 이 정돈 아시리라 생각합니다.
2. Pagination 클래스(자신이 정한 사양 + 사양에 따라 계산될 다른 변수들 포함) 생성 후 필드(요소값들) 작성
@Getter
@Setter
public class Pagination {
/** 한 페이지 안에서 보여줄 게시글의 수 (내가 정한 사양) **/
private int pageSize = 10;
/** 현재 창에서 보여줄 최대 블럭의 수 (내가 정한 사양) **/
private int blockSize = 5;
/** 현재 페이지 (자동) **/
private int page = 1;
/** 현재 블럭 (자동) **/
private int block = 1;
/** 총 게시글의 수 (DB에서 가져올 값) **/
private int totalListCnt;
/** 총 페이지의 수 (자동) **/
private int totalPageCnt;
/** 총 블럭의 수 (자동) **/
private int totalBlockCnt;
/** 블럭 시작 페이지 (내가 정한 사양) **/
private int startPage = 1;
/** 블럭 마지막 페이지 (내가 정한 사양) **/
private int endPage = 1;
/** 현재index (자동) **/
private int startIndex = 0;
/** 이전 블럭의 마지막 페이지 (자동) **/
private int prevBlockPage;
/** 다음 블럭의 시작 페이지 (자동) **/
private int nextBlockPage;
}
설명은 주석으로 대신합니다. 주석의 (내가 정한 사양)이 위에 설명한 내용이니 이해하기 무리 없으리라 생각합니다. 자바를 모르시는 경우 객체 신경쓰지마시고 그냥 페이징에 필요한 요소 값들을 서버 단에서 선언해준 것이라고 생각하시면 됩니다.
3. Pagination 클래스 내에 필드 값 할당(위에서 정한 사양에 따라 값을 정해줌)
일단 아래 class를 보시기 전에 식을 보다가 성립이 안되는 경우가 존재하기에 이게 맞나?하며 고민하실 분들이 반드시 있을거라 생각합니다. 그런 분들은 코드 밑으로 좀 내려서 검증에 관련된 글을 읽어주시면 됩니다.
public class Pagination {
...
// 해당 메서드는 Param으로 전체 게시글의 수와 페이지를 받습니다
public Pagination(int totalListCnt, int page) {
/** 현재 페이지 (Controller에서 url을 통해 @requestparam으로 받을 예정) **/
setPage(page);
/** 총 게시글의 수 (Controller에서 repository를 통해 관련 메서드로 받을 예정) **/
setTotalListCnt(totalListCnt);
/** 총 페이지의 수 **/
setTotalPageCnt((int) Math.ceil(totalListCnt * 1.0 / pageSize));
/** 총 블럭의 수 **/
setTotalBlockCnt((int) Math.ceil(totalPageCnt * 1.0 / blockSize));
/** 현재 창에 나오는 블럭 **/
setBlock((int) Math.ceil((page * 1.0)/blockSize));
/** 블럭 시작 페이지 **/
setStartPage((block - 1) * blockSize + 1);
/** 블럭 마지막 페이지 **/
setEndPage(startPage + blockSize - 1);
/** 이전 블럭(클릭 시, 이전 블럭 마지막 페이지) **/
setPrevBlockPage((block * blockSize) - blockSize);
/** 다음 블럭(클릭 시, 다음 블럭 첫번째 페이지) **/
setNextBlockPage((block * blockSize) + 1);
/** 현재 index **/
setStartIndex((page-1) * pageSize);
/* validation(글의 개수가 1개 이상) */
if(endPage > totalPageCnt){this.endPage = totalPageCnt;}
if(prevBlockPage < 1) {this.prevBlockPage = 1;}
if(nextBlockPage > totalPageCnt) {nextBlockPage = totalPageCnt;}
/* validation(글의 개수가 0) */
if (totalListCnt == 0) {
setEndPage(1);
setNextBlockPage(1);
setTotalPageCnt(1);
}
}
주석으로 대부분 설명이 되지만 이 부분에서 몇 가지 짚을게 있습니다.
일단 총 페이지 수를 구할 때(int) Math.ceil(totalListCnt * 1.0 / pageSize) 로 되어 있는데 1.0을 곱하는건 계산 결과를 실수로 만들기 위해서입니다. 기본적으로 올림은 실수 밖에 안되니까요. 이 결과를 다시 (int)로 형변환합니다.
현재 index의 경우 살펴보면 예시에서 봤던 총 게시글 151개를 가지고 생각하면 현재 전체 글 151개, 전체 페이지 16개, 전체 블럭 2개입니다. 대부분의 게시판은 이렇게 최신글이 맨 앞에 나오도록 역순으로 표시될텐데 이때 내가 보는 페이지가 2번째 페이지라면 아마 No가 141로 되어 있을겁니다. 그런데 Index는 일단 역순이 아니고 시작을 1이 아닌 0부터 시작하기때문에 저 141번째 글의 실제 Index는 10입니다. 첫 페이지 글인 151~142번 글의 인덱스가 0~9인걸 생각하면 당연한 결과입니다. 따라서 startIndex는 (page-1) * pageSize가 맞습니다.
마지막으로 현재 Pagination을 생성하면 게산되는 과정은 게시글의 수가 0 이상인 모든 정수에 대하여 만족하는 식이 아닙니다.
1. 게시글이 100개 미만일 경우 EndPage가 계산과 상관없이 10으로 고정됩니다.
3. 게시글이 0개인 경우 거의 대부분의 계산 결과가 틀립니다.
또한 계산 식은 다음과 같은 오류가 존재합니다.
1. 이전을 누를 경우 넘겨지는 값인 PreBlockPage는 현재 블럭이 1일 때, 0이라는 값이 나옵니다.
(preBlockPage는 반드시 1보다 커야하는 값입니다. 이전을 눌렀는데 0이면 안되겠죠)
2. 다음을 누를 경우 넘겨지는 값인 NextBlockPage는 최소값이 11입니다.
(글의 개수가 100개 이상일때는 문제없지만 그 아래라면 NextBlockPage=totalPageCnt가 되어야 합니다)
일반적이라면 이 많은 오류를 내포하는 계산식을 써선 안될 것입니다. 하지만 정확한 계산식을 넣으려고 if문을 넣기 시작하면 코드가 매우 지저분해집니다. 그렇기 때문에 최대한 간결한 코드를 유지하기 위해 특정 경우 오류가 존재할 수 있지만 일반적인 상황에서 대부분 성립하는 계산식을 생성자에 반영하고 위에 언급한 특정 상황에서의 오류들은 validation(검증) 메서드로 처리할 것입니다.
검증 순서는 다음과 같습니다.
(게시글이 1 이상) 1. EndPage<=totalPageCnt 2. PreBlockPage>=1 3. NextBlockPage<=totalPageCnt
(게시글이 0) 4. totalPageCnt=1, EndPage=1, nextBlockPage=1
이 순서를 지켜 검증하면 모든 경우에서 페이징 기능이 정상 작동합니다. 위에 코드에 이 과정이 있으니 코드와 비교해 보시길 바랍니다.
자바를 모르시는 경우 해당 코드를 설명하자면 자바에선 객체를 생성할 때 메소드를 정해줄 수 있고 생성 메소드를 통해 페이징 계산과 검증 기능을 부여하여 다른 클래스에서 pagination 객체를 생성할 때 자동으로 메소드가 실행되어 호출됩니다. 객체를 사용하지 않는 언어라면 pagination 관련 메서드 작성시 해당 계산 및 검증 내용 코드를 같이 작성해주시면되고 굳이 검증을 사용하지 않으시고 if (totalListCnt<100)같은 조건문을 통해 올바른 계산 과정을 적으셔도 될 것 같네요.
4. PostsRepository에 페이징 관련 메서드 추가(할당한 값들과 DB의 게시글 수를 가지고 중간 연산)
@Repository
public class PostsRepository {
@PersistenceContext
private EntityManager em;
public int findAllCnt() {
return ((Number) em.createQuery("select count(*) from Posts")
.getSingleResult()).intValue();
}
public List<Board> findListPaging(int startIndex, int pageSize) {
return em.createQuery("select p from Posts p", Post.class)
.setFirstResult(startIndex)
.setMaxResults(pageSize)
.getResultList();
}
}
해당 코드의 경우 repository에서 쿼리를 이용해 관련 메서드를 만들었습니다. 하지만 service 클래스를 사용하시거나 기존 repository 메서드를 이용해서도 해당 기능을 똑같이 구현할 수 있습니다. findAllCnt()의 경우 게시글의 총 개수를 구하는 메서드고 findListPaging는 현재 창에서 보여질 게시글 리스트를 (예시의 경우 2page의 141~132번째 글) 생성하는 메서드입니다.
findAllCnt()의 경우 저처럼 그냥 repository를 인터페이스로 쓰시고 service를 사용하시는 분들을 위한 코드를 사용하셔서 다음과 같이 쓰셔도 됩니다.(이 다음 순서가 바로 HomeController입니다)
// HomeController
int totalListCnt = PostsRepository.findAllCnt();
코드의 메소드를 따로 만들지 않고 자주쓰는 findAllDesc()를 사용해 다음과 같이 작성할 수 있습니다.
// HomeController
int totalListCnt = postsService.findAllDesc().size();
findListPaging은 저처럼 Service단에서 stream 연산을 이용할 수 있습니다. 밑 연산의 내용은 1 게시글 전체 리스트를 생성한 후 2 현재 페이지 이전 게시글을 전부 리스트에서 제외 3 현재 페이지의 10개 빼고 이후 게시글도 리스트에서 제외 과정을 stream 연산으로 진행한겁니다.
public class PostsService {
private final PostsRepository postsRepository;
...
@Transactional
public List<Posts> findListpaging(int startindex, int pagesize) {
return postsRepository.findAllDesc().stream()
.skip(startindex)
.limit(pagesize)
.collect(Collectors.toList());
}
해당 코드들은 예시에 불과하고 그냥 4번의 핵심은 1 총 게시글의 개수를 DB에서 불러옴 2 미리 정해진 두개의 사양을 가지고 view에 표시할 게시글 리스트를 불러옴 이 두가지만 코드로 작성되면 충분합니다.
자바를 모르신다면 그냥 위 두가지 과정을 함수로 만드시면 됩니다.
5. HomeController 클래스 생성 후 view template 매핑(연산 된 결과를 실제 창에 반영)
// HomeController
@GetMapping("/")
// 1 @RequstParam
public String home(Model model, @RequestParam(defaultValue = "1") int page) {
// 2 총 게시물 수
int totalListCnt = postsRepository.findAllCnt();
// 3 생성인자로 총 게시물 수, 현재 페이지를 전달
Pagination pagination = new Pagination(totalListCnt, page);
// 4 DB select start index
int startIndex = pagination.getStartIndex();
// 5 페이지 당 보여지는 게시글의 최대 개수
int pageSize = pagination.getPageSize();
// 6
List<Posts> postsList = postsRepository.findListPaging(startIndex, pageSize);
// 7
model.addAttribute("boardList", boardList);
model.addAttribute("pagination", pagination);
return "index";
}
컨트롤러에서 진행되는 흐름은 다음과 같습니다. 마찬가지로 자바 모르시면 괄호 읽으시면 됩니다.
- @RequestParam으로 현재 페이지 정보를 Param으로 받음(현재 페이지 정보를 서버로 전달만 하면 됨)
- DB에서 총 게시글의 개수를 불러옴(앞서 4번에서 메서드로 만들며 설명함)
- pagination 객체를 생성 > param으로 totalListCnt, page를 전달해서 객체의 필드 값 모두 초기화(1, 2에서 구한 값을 이전 과정에서 정의한 변수, 함수들에 대입해주면 됩니다)
- pagination 객체의 메서드가 startIndex를 반환((page-1) * pageSize 여기에 값 입력하시면 됩니다)
- pagination 객체의 필드 pageSize 값 반환(객체에 있어서 그렇지 그냥 아까 맨처음 정한 pageSize입니다.)
- postsRepository의 findListPaging() 메서드가 view에 표시할 게시글 리스트를 startIndexpageSize에 맞춰 반환(앞서 4번에서 메서드로 만들며 설명함)
- model에 값을 담아서 view로 전달(model에 안 담아도되4. Html 코드 작성(포스트에선 Thymleaf 사용)
4. Html 코드 작성(포스트에선 Thymleaf 사용)
<!DOCTYPE html>
<html lang="ko"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
...
</head>
<body>
<table class="table table-striped">
<thead class="thead-dark">
<tr>
<th scope="col" style="width: 10%">no</th>
<th scope="col">제목</th>
<th scope="col" style="width: 15%">작성자</th>
</tr>
</thead>
<tbody>
<tr th:each="posts : ${postsList}">
<th scope="row" th:text="${postsStat.index + 1}">1</th>
<td th:text="${posts.title}"></td>
<td th:text="${posts.user}"></td>
</tr>
</tbody>
</table>
// paging button
<nav aria-label="Page navigation example ">
<ul class="pagination">
<li class="page-item">
<a class="page-link" th:href="@{/?page=1}" aria-label="Previous">
<span aria-hidden="true">처음</span>
</a>
</li>
<li class="page-item">
<a class="page-link" th:href="@{/?page={page} (page = ${pagination.prevBlockPage})}" aria-label="Previous">
<span aria-hidden="true">이전</span>
</a>
</li>
<th:block th:with="start = ${pagination.startPage}, end = ${pagination.endPage}">
<li class="page-item"
th:with="start = ${pagination.startPage}, end = ${pagination.endPage}"
th:each="pageButton : ${#numbers.sequence(start, end)}">
<a class="page-link" th:href="@{/?page={page} (page = ${pageButton})}" th:text=${pageButton}></a>
</li>
</th:block>
<li class="page-item">
<a class="page-link" th:href="@{?page={page} (page = ${pagination.nextBlockPage})}" aria-label="Next">
<span aria-hidden="true">다음</span>
</a>
</li>
<li class="page-item">
<a class="page-link" th:href="@{?page={page} (page = ${pagination.totalPageCnt})}" aria-label="Previous">
<span aria-hidden="true">끝</span>
</a>
</li>
</ul>
</nav>
</body>
</html>
타임리프를 안 쓰시는 분들은 그냥 view 자신에 맞게 만드셔서 서버 값만 전달해주면 동일합니다.
5 실제 View 확인
페이징의 경우 원리는 비슷하지만 구현은 위에 소개한 방식이 아니더라도 다양한 바리에이션이 존재하니 궁금하시면 다른 포스트를 보셔도됩니다. 위에 소개한 코드는 제가 페이징 관련 포스트들을 찾아보다가 비교적 이해하기 쉬운 아래 링크의 포스트를 참고했습니다. Spring을 사용하시는 입장에서 위의 코드는 설명 있어서는 좋은 코드지만 프로젝트 적용에 있어 좋은 코드라 보기는 어렵기에 프로젝트에 직접 페이징을 적용하시는 것을 보시려면 Project 카테고리의 글을 보시면 보다 자세히 나와 있습니다.
refer
'Spring' 카테고리의 다른 글
Spring_정리5_Spring의 구조 훑어보기_FrontController (0) | 2022.05.09 |
---|---|
Spring_정리4_Spring의 구조 훑어보기_MVC (0) | 2022.05.08 |
Spring_정리3_Spring의 구조 훑어보기_서블릿 (0) | 2022.05.05 |
Spring_정리2_Spring 이전 JAVA 웹 개발의 역사 훑어보기 (0) | 2022.04.29 |
Spring_2_Spring Validation 기초 (0) | 2022.04.28 |