Categories
웹개발/Java
JSP pagination page link 만들기, 게시판 페이지 번호 처리 기준
게시판 목록에서 페이지 번호가 한 칸씩 밀리거나 이전 다음 버튼이 엉뚱한 곳으로 가면 먼저 JSP pagination page link 계산 기준을 확인해야 한다. 이 글은 totalCount, currentPage, pageSize, blockSize로 페이지 번호를 만들고 JSP에서 링크로 출력하는 기준을 정리한다.
페이징은 화면 장식처럼 보이지만 실제로는 서버 계산, SQL 조회 범위, JSP 링크, 검색 조건 유지가 같이 맞아야 동작한다. 한 곳만 틀려도 첫 페이지는 정상인데 마지막 페이지에서 깨지거나, 검색 후 2페이지로 가면 조건이 사라지는 문제가 생긴다.
핵심 결론: JSP pagination은 전체 건수와 현재 페이지로 서버에서 페이지 블록을 계산하고, JSP는 계산된 값만 기준으로 링크를 출력하는 구조가 안정적이다.
확인 순서: totalCount 계산 → currentPage 보정 → startPage/endPage 계산 → 이전 다음 버튼 조건 → 검색 조건 유지.
JSP pagination은 어디서 계산해야 하나
초급 개발자가 페이징을 만들 때 자주 하는 실수는 JSP 안에서 모든 계산을 하려는 것이다. JSP에서 totalCount, currentPage, endPage를 직접 계산하기 시작하면 화면 코드가 복잡해지고, 다른 목록 화면에서 재사용하기 어렵다.
실무에서는 보통 Controller나 Service에서 페이징 정보를 계산해 JSP로 넘긴다. JSP는 그 결과를 forEach로 출력하고, 링크에 필요한 파라미터만 붙인다. 이렇게 해야 게시판, 공지사항, 관리자 목록처럼 여러 화면에서 같은 기준을 유지할 수 있다.
Spring MVC 프로젝트에서 JSP로 화면을 넘기고 정적 리소스 경로까지 함께 다루는 구조라면 MVC 설정 흐름도 같이 확인해 두면 좋다. 페이징 자체와 별개로 ViewResolver, resources 경로가 틀리면 JSP는 열리는데 CSS나 링크가 어긋나는 문제가 생긴다.
페이징 계산에 필요한 값
기본 변수 정리
게시판 페이징에서 최소로 필요한 값은 아래와 같다.
| 이름 | 의미 | 예시 |
|---|---|---|
| totalCount | 전체 게시글 수 | 153 |
| currentPage | 현재 페이지 번호 | 7 |
| pageSize | 한 페이지에 보여 줄 글 수 | 10 |
| blockSize | 한 번에 보여 줄 페이지 번호 개수 | 5 또는 10 |
| totalPage | 전체 페이지 수 | 16 |
| startPage | 현재 블록의 시작 페이지 | 6 |
| endPage | 현재 블록의 끝 페이지 | 10 |
여기서 가장 중요한 값은 currentPage다. 사용자가 URL에 ?page=0, ?page=-1, ?page=99999를 직접 넣을 수 있기 때문에 서버에서 반드시 보정해야 한다.
totalPage 계산
전체 페이지 수는 전체 건수를 페이지 크기로 나누고 올림 처리한다.
int totalPage = (int) Math.ceil((double) totalCount / pageSize);
다만 totalCount가 0일 때를 어떻게 볼지는 프로젝트 기준을 정해야 한다. 검색 결과가 없으면 totalPage를 0으로 둘 수도 있고, 화면 처리를 단순하게 하려고 1로 보정할 수도 있다. 어느 쪽이든 JSP와 Controller가 같은 기준을 써야 한다.
Pagination 객체 만들기
계산 결과를 담는 클래스
페이징 계산 결과를 Map으로 넘길 수도 있지만, 필드가 늘어나면 클래스로 만드는 편이 읽기 쉽다.
public class Pagination {
private int totalCount;
private int currentPage;
private int pageSize;
private int blockSize;
private int totalPage;
private int startPage;
private int endPage;
private boolean hasPrev;
private boolean hasNext;
// getter, setter 생략
}
필드 이름은 팀 컨벤션에 맞추면 된다. 중요한 것은 JSP가 계산식을 다시 만들지 않고, 이 객체의 값만 출력하도록 하는 것이다.
페이지 번호 계산 코드
아래는 기본적인 페이징 계산 예시다.
public Pagination buildPagination(int totalCount, int currentPage, int pageSize, int blockSize) {
Pagination pagination = new Pagination();
int totalPage = (int) Math.ceil((double) totalCount / pageSize);
if (totalPage == 0) {
totalPage = 1;
}
if (currentPage < 1) {
currentPage = 1;
}
if (currentPage > totalPage) {
currentPage = totalPage;
}
int currentBlock = (int) Math.ceil((double) currentPage / blockSize);
int startPage = (currentBlock - 1) * blockSize + 1;
int endPage = Math.min(startPage + blockSize - 1, totalPage);
pagination.setTotalCount(totalCount);
pagination.setCurrentPage(currentPage);
pagination.setPageSize(pageSize);
pagination.setBlockSize(blockSize);
pagination.setTotalPage(totalPage);
pagination.setStartPage(startPage);
pagination.setEndPage(endPage);
pagination.setHasPrev(startPage > 1);
pagination.setHasNext(endPage < totalPage);
return pagination;
}
이 계산식에서 hasPrev는 이전 페이지가 아니라 이전 블록이 있는지를 뜻한다. 페이지 번호 1~10을 보여 주고 있을 때 이전 블록은 없고, 11~20을 보여 줄 때 이전 블록이 생긴다.
목록 조회 범위 계산
Oracle ROWNUM 방식
DB에서 가져올 데이터 범위도 currentPage와 pageSize로 계산한다. Oracle 11g 이하나 레거시 프로젝트에서는 ROWNUM 방식이 자주 보인다.
int startRow = (currentPage - 1) * pageSize + 1;
int endRow = currentPage * pageSize;
SELECT *
FROM (
SELECT ROWNUM rn, A.*
FROM (
SELECT board_id, title, writer, created_at
FROM board
ORDER BY board_id DESC
) A
WHERE ROWNUM <= #{endRow}
)
WHERE rn >= #{startRow}
중요한 점은 정렬이 먼저 안정적으로 잡혀야 한다는 것이다. ORDER BY가 없거나 중복 정렬 기준만 있으면 페이지를 넘길 때 같은 글이 중복으로 보이거나 빠질 수 있다.
OFFSET FETCH 방식
Oracle 12c 이상이라면 OFFSET FETCH 방식도 쓸 수 있다.
int offset = (currentPage - 1) * pageSize;
SELECT board_id, title, writer, created_at
FROM board
ORDER BY board_id DESC
OFFSET #{offset} ROWS FETCH NEXT #{pageSize} ROWS ONLY
MyBatis를 사용한다면 파라미터 이름과 Mapper XML의 이름이 정확히 맞아야 한다. 값이 안 들어가거나 다른 결과가 나오면 페이징 계산보다 조회 매핑 기준을 먼저 확인해야 할 때도 있다.
JSP에서 page link 출력하기
JSTL forEach로 페이지 번호 출력
Controller에서 pagination 객체를 넘겼다면 JSP에서는 계산된 시작과 끝 범위만 반복한다.
<c:forEach var="pageNo" begin="${pagination.startPage}" end="${pagination.endPage}">
<c:choose>
<c:when test="${pageNo == pagination.currentPage}">
<strong>${pageNo}</strong>
</c:when>
<c:otherwise>
<a href="${pageContext.request.contextPath}/board/list.do?page=${pageNo}">
${pageNo}
</a>
</c:otherwise>
</c:choose>
</c:forEach>
현재 페이지는 링크를 걸지 않고 strong으로 표시한다. 이렇게 해야 사용자가 현재 페이지를 다시 클릭하는 불필요한 요청을 줄일 수 있고, CSS로 현재 위치를 강조하기도 쉽다.
이전 다음 블록 버튼
이전/다음 버튼은 hasPrev, hasNext 기준으로 출력한다.
<c:if test="${pagination.hasPrev}">
<a href="${pageContext.request.contextPath}/board/list.do?page=${pagination.startPage - 1}">
이전
</a>
</c:if>
<c:if test="${pagination.hasNext}">
<a href="${pageContext.request.contextPath}/board/list.do?page=${pagination.endPage + 1}">
다음
</a>
</c:if>
여기서 context path를 붙이는 이유는 운영 배포 경로 때문이다. 로컬에서는 /board/list.do가 동작해도 운영에서는 애플리케이션이 별도 경로로 배포될 수 있다. 이 부분은 운영 배포 경로와도 연결된다.
검색 조건을 유지하는 방법
검색어가 사라지는 문제
페이징에서 가장 흔한 문제는 검색 후 2페이지로 이동하면 검색 조건이 사라지는 것이다. 첫 페이지는 ?keyword=java로 조회했는데 페이지 링크가 ?page=2만 들고 있으면 당연히 전체 목록 2페이지로 이동한다.
페이지 링크에는 현재 검색 조건을 같이 붙여야 한다.
<a href="${pageContext.request.contextPath}/board/list.do?page=${pageNo}&keyword=${param.keyword}">
${pageNo}
</a>
다만 사용자 입력값을 URL에 직접 붙일 때는 인코딩과 특수문자 처리를 고려해야 한다. 검색 조건이 많아지면 hidden form과 JavaScript submit 방식 또는 서버에서 queryString을 조립하는 방식을 검토할 수 있다.
검색 타입과 정렬 조건도 같이 유지한다
검색어뿐 아니라 검색 타입, 정렬, 카테고리도 유지해야 한다. 예를 들어 제목 검색에서 2페이지로 이동했는데 전체 검색으로 바뀌면 사용자는 페이징이 틀렸다고 느낀다.
<a href="${pageContext.request.contextPath}/board/list.do
?page=${pageNo}
&searchType=${param.searchType}
&keyword=${param.keyword}
&sort=${param.sort}">
${pageNo}
</a>
실무에서는 위처럼 줄바꿈을 넣지 않고 한 줄로 쓰거나, 태그 파일/커스텀 태그/헬퍼 메서드로 정리한다. JSP 안에서 URL 조립이 길어지면 화면 코드가 빠르게 지저분해진다.
페이지 블록 계산 예시
전체 게시글이 153개, pageSize가 10, blockSize가 5라고 가정해 보자. 전체 페이지는 16페이지가 된다.
| 현재 페이지 | startPage | endPage | 이전 | 다음 |
|---|---|---|---|---|
| 1 | 1 | 5 | 없음 | 6페이지로 이동 |
| 5 | 1 | 5 | 없음 | 6페이지로 이동 |
| 6 | 6 | 10 | 5페이지로 이동 | 11페이지로 이동 |
| 11 | 11 | 15 | 10페이지로 이동 | 16페이지로 이동 |
| 16 | 16 | 16 | 15페이지로 이동 | 없음 |
마지막 블록에서는 endPage가 totalPage를 넘지 않도록 Math.min()으로 보정해야 한다. 이 보정이 없으면 16페이지까지만 있어야 하는데 화면에는 20페이지까지 표시되는 문제가 생긴다.
Controller에서 JSP로 넘기는 예제
Spring MVC Controller 예시
Controller에서는 요청 파라미터를 받고, 전체 건수와 목록을 조회한 뒤 페이징 객체를 모델에 담는다.
@GetMapping("/board/list.do")
public String list(@RequestParam(defaultValue = "1") int page,
@RequestParam(required = false) String keyword,
Model model) {
int pageSize = 10;
int blockSize = 5;
int totalCount = boardService.countBoard(keyword);
Pagination pagination = paginationService.buildPagination(
totalCount, page, pageSize, blockSize
);
List<Board> list = boardService.findBoardList(
keyword,
pagination.getCurrentPage(),
pageSize
);
model.addAttribute("list", list);
model.addAttribute("pagination", pagination);
model.addAttribute("keyword", keyword);
return "board/list";
}
여기서 Controller가 하는 일은 요청값을 받고, 서비스에 넘기고, 모델에 담는 정도로 제한하는 것이 좋다. 페이징 계산식이 여러 Controller에 복사되기 시작하면 수정할 때 누락이 생긴다.
Service에서 조회 범위를 계산하는 방식
목록 조회 범위는 Service 또는 별도 Pagination 객체에서 계산할 수 있다.
public List<Board> findBoardList(String keyword, int currentPage, int pageSize) {
int startRow = (currentPage - 1) * pageSize + 1;
int endRow = currentPage * pageSize;
Map<String, Object> params = new HashMap<>();
params.put("keyword", keyword);
params.put("startRow", startRow);
params.put("endRow", endRow);
return boardMapper.selectBoardList(params);
}
대용량 데이터에서는 단순 페이징도 성능 문제가 될 수 있다. 데이터 양이 커지고 조건이 복잡해지면 Java 쪽 계산보다 SQL 인덱스, 정렬 기준, 조회 전략이 더 중요해진다. 대량 데이터 처리 관점은 대용량 데이터 조회 흐름을 따로 보는 것이 좋다.
자주 깨지는 지점
currentPage를 보정하지 않는 경우
URL 파라미터는 사용자가 직접 바꿀 수 있다. ?page=-3이 들어오면 startRow가 음수가 되고, ?page=9999가 들어오면 빈 목록만 나온다. currentPage는 항상 1 이상, totalPage 이하로 보정한다.
count 쿼리와 list 쿼리 조건이 다른 경우
검색 조건이 있을 때 count 쿼리와 목록 쿼리의 WHERE 조건이 다르면 totalPage가 틀어진다. 예를 들어 목록은 제목 검색인데 count는 전체 건수를 세면 마지막 페이지가 비정상적으로 늘어난다.
페이지 번호와 DB row 번호를 섞는 경우
currentPage는 화면 페이지 번호이고, startRow/endRow는 DB 조회 범위다. 이 둘을 섞으면 2페이지부터 데이터가 밀린다. 변수 이름을 명확하게 쓰는 것이 중요하다.
실무에서 바로 쓰는 팁
- 페이징 계산은 JSP가 아니라 서버에서 끝낸다. JSP는 계산된 startPage, endPage, currentPage만 출력한다.
- currentPage는 반드시 보정한다. 1보다 작거나 totalPage보다 큰 값은 서버에서 막아야 한다.
- 검색 조건은 모든 페이지 링크에 유지한다. keyword, searchType, sort 값을 빠뜨리면 2페이지부터 결과가 달라진다.
- 현재 페이지는 링크를 걸지 않는다. strong이나 span으로 표시하고 CSS로 강조한다.
- count 쿼리와 list 쿼리 조건을 맞춘다. 둘이 다르면 마지막 페이지 계산이 틀어진다.
자주 묻는 질문
- Q. JSP에서 페이징 계산을 해도 되나요? A. 가능은 하지만 권장하지 않습니다. 계산은 Controller나 Service에서 끝내고 JSP는 출력만 담당하는 구조가 유지보수에 좋습니다.
- Q. 이전 버튼은 currentPage - 1로 가야 하나요? A. 한 페이지씩 이동하는 이전이면 currentPage - 1이고, 페이지 번호 블록 이전이면 startPage - 1입니다. 둘을 구분해야 합니다.
- Q. 검색 후 다음 페이지로 가면 검색어가 사라집니다. A. page link에 keyword, searchType 같은 검색 파라미터를 같이 붙이지 않았을 가능성이 큽니다.
- Q. 마지막 페이지가 비어 있습니다. A. count 쿼리와 목록 쿼리의 조건이 다르거나 currentPage 보정이 빠진 경우가 많습니다.
- Q. blockSize는 몇 개가 적당한가요? A. 일반 게시판은 5개 또는 10개를 많이 씁니다. 모바일 화면까지 고려하면 5개가 더 다루기 쉽습니다.
- Q. Oracle ROWNUM과 OFFSET FETCH 중 무엇을 써야 하나요? A. Oracle 버전과 프로젝트 표준에 따릅니다. 12c 이상이면 OFFSET FETCH를 검토할 수 있고, 레거시 환경은 ROWNUM 방식이 흔합니다.
정리
JSP pagination page link는 단순히 페이지 번호를 반복 출력하는 작업이 아니다. 전체 건수, 현재 페이지, 페이지 크기, 블록 크기를 기준으로 서버에서 계산하고, JSP는 계산된 값을 링크로 출력해야 한다. 이전/다음 버튼도 한 페이지 이동인지 블록 이동인지 기준을 분리해야 한다.
실무에서 가장 많이 깨지는 부분은 currentPage 보정, 검색 조건 유지, count 쿼리와 list 쿼리 조건 불일치다. 이 세 가지를 먼저 확인하면 페이지 번호가 밀리거나 마지막 페이지가 비는 문제를 대부분 줄일 수 있다.
페이징은 UI가 아니라 목록 조회 계약이다. 서버 계산, SQL 범위, JSP 링크가 같은 기준을 써야 한다.
'웹개발 > Java' 카테고리의 다른 글
| Java equals hashCode 차이, HashMap과 Set에서 문제가 되는 이유 (0) | 2026.07.07 |
|---|---|
| Java Optional 사용 기준, null 처리를 더 복잡하게 만들지 않는 방법 (0) | 2026.07.07 |
| Java NullPointerException 원인과 해결 방법, 실무에서 먼저 확인할 부분 (0) | 2026.07.06 |
| Java 기초 핵심 개념, 실무에서 자주 헷갈리는 부분 정리 (1) | 2026.07.06 |
| 자바 MapReduce로 대용량 데이터 추출하기: 날짜·도시 필터 + 사용자 합계 실전 예제 (1) | 2025.12.03 |