스프링 레거시(Spring legacy) - 페이징
저번 글에 이어서 board 모듈에 페이징 기능을 넣어보겠습니다. 스프링 부트에서는 JPA의 Pageable 인터페이스를 통해 쉽게 구현할 수 있지만 레거시에서는 객체를 만들어야합니다.
DTO 작성
우선 페이징 기능과 관련된 데이터를 가지는 PageDTO를 작성하도록 하겠습니다.
다형성을 위해 제네릭으로 작성했습니다.
page
는 현재 페이지를, size
는 한 페이지에 출력되는 리스트의 크기를 나타냅니다. 두 데이터는 클라이언트로부터 요청받을 수 있는 데이터입니다.
totalSize
는 한 테이블의 전체 튜플수를 저장하고, contentList
는 한 페이지에 포함된 튜플 리스트를 저장합니다. 두 데이터는 DAO를 통해 DB로부터 전달받는 데이터 입니다.
4개의 데이터는 유저의 요청과 응답을 저장해야하므로 setter를 만들어야 합니다.
pageSize
는 한 페이지에 출력할 페이지 네비게이션의 크기를 나타냅니다. 저는 계산용도로 고정값으로 설정할 예정이므로 static 키워드를 통해 정적변수로 선언하고 final 키워드를 통해 재할당이 불가능하도록 만들었습니다.
totalPage
는 전체 페이지의 수를 나타냅니다. hasPrev
는 이전 페이지의 존재 여부를 나타내고, hasNext
는 다음 페이지의 존재 여부를 나타냅니다. firstPage
와 lastPage
는 한 페이지 내에서 출력되는 페이지 네비게이션의 첫번째 페이지와 마지막 페이지 번호를 나타내고, first
와 last
는 한 페이지에 출력되는 튜플 중 첫번째 튜플과 마지막 튜플의 순서를 나타냅니다. 이 값들은 계산을 통해 출력만 가능하도록 멤버변수의 선언 없이 getter만 만들 예정입니다.
마지막으로 프론트에서 url을 간편하게 출력할 수 있도록 getUrl 메소드를 만들었습니다. 매개변수로 base 주소와 페이지 번호를 주입받습니다.
클래스 다이어그램을 참고하여 아래와 같이 DTO를 작성합시다.
/kro/rubisco/dto/PageDTO.java
package kro.rubisco.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class PageDTO <T> {
private static final long pageSize = 10;
private long page;
private long size = 10;
private long totalSize;
private List<T> contentList = new ArrayList<T>();
public long getPage() {
return page < 1 ? 1 : page;
}
public long getTotalPage() {
return (long)Math.ceil((double)totalSize/size);
}
public boolean getHasPrev() {
return page <= 1 ? false : true;
}
public boolean getHasNext() {
return page >= getTotalPage() ? false : true;
}
public long getFirstPage() {
return ((getPage()-1)/pageSize|0)*pageSize + 1;
}
public long getLastPage() {
long lastPage = ((getPage()-1)/pageSize|0)*pageSize + pageSize;
return lastPage < getTotalPage() ? lastPage : getTotalPage();
}
public long getFirst() {
long first = (getPage()-1) * size + 1;
return first < 1 ? 1: first;
}
public long getLast() {
return getFirst() + size - 1;
}
public String getUrl(String base, long i) {
if(i > 1 && size != 10) base += "?page=" + i + "&size=" + size;
else if(i > 1) base += "?page=" + i;
else if(size != 10) base += "?size=" + size;
return base;
}
}
매퍼 수정
boardDTO의 목록을 불러오는 listAll select 쿼리문을 다음과 같이 수정하겠습니다.
boardMapper.xml
...
<select id="listAll" resultMap="getBoardList">
<![CDATA[
select *
from (
select rownum num, b.*
from (
select * from board
where document_id > 0
order by document_id desc
) b
where rownum <= #{last}
)
where num >= #{first}
]]>
</select>
...
저는 오라클 DB를 사용하기 때문에 limit를 사용하여 페이징 처리를 하지못하여 rownum
을 사용해서 페이징 처리를 했습니다.
rownum을 통해 페이징 처리를 하면 order by를 통해 정렬을 하기 전에 rownum 컬럼을 가진 튜플이 먼저 select되어 원하는 순서대로 페이징 처리가 되지 않으므로, 우선 서브쿼리를 통해 정렬한 테이블을 생성한 후 rownum 컬럼을 가진 튜플을 select하도록 쿼리문을 작성합니다.
select된 튜플은 지난번에 작성한 getBoardList
resultMap을 통해 매핑되도록 합니다.
하나의 쿼리문을 통해 totalSize와 contentList를 한꺼번에 가져오려했지만 실패하여 2개의 쿼리문으로 나누어 아래와 같이 튜플의 전체 수를 출력하는 쿼리문을 작성합니다.
boardMapper.xml
...
<select id="getCount" resultType="long">
select count(*) from board where document_id > 0
</select>
...
DAO 수정
위와 같이 작성된 쿼리문은 매개변수로 first와 last를 받고 있습니다. 즉, 파라미터로 BoardDTO 대신에 PageDTO를 주입해야합니다. BoardDAO를 다음과 같이 수정하겠습니다.
/kro/rubisco/dao/BoardDAO.java
package kro.rubisco.dao;
import java.util.List;
import kro.rubisco.dto.BoardDTO;
import kro.rubisco.dto.PageDTO;
public interface BoardDAO {
public void create(BoardDTO board) throws Exception;
public BoardDTO read(Long documentId) throws Exception;
public void update(BoardDTO board) throws Exception;
public void delete(Long documentId) throws Exception;
public List<BoardDTO> listAll(PageDTO<BoardDTO> boardPage) throws Exception;
public long getCount() throws Exception;
}
listAll 메소드가 BoardDTO 대신에 PageDTO<BoardDTO>
타입의 프로퍼티를 주입받고, BoardDTO의 리스트를 출력하도록 인터페이스를 수정했습니다. 또한 전체 튜플의 수를 출력하는 getCount
메소드를 정의했습니다.
Service 수정
BoardDAO의 listAll 메소드를 사용하는 BoardService의 listAll 메소드를 수정하도록 하겠습니다. 우선 인터페이스부터 다음과 같이 변경합니다.
/kro/rubisco/service/BoardService.java
package kro.rubisco.service;
import kro.rubisco.dto.BoardDTO;
import kro.rubisco.dto.PageDTO;
public interface BoardService {
public void regist(BoardDTO board) throws Exception;
public BoardDTO read(Long documentId) throws Exception;
public void modify(BoardDTO board) throws Exception;
public void remove(Long documentId) throws Exception;
public PageDTO<BoardDTO> listAll(PageDTO<BoardDTO> boardPage) throws Exception;
}
listAll 메소드가 PageDTO<BoardDTO>
타입의 매개변수를 받고 동일한 타입을 반환하도록 작성했습니다.
구현체도 수정합니다.
/kro/rubisco/service/impl/BoardServiceImpl.java
package kro.rubisco.service.impl;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import kro.rubisco.dao.BoardDAO;
import kro.rubisco.dto.BoardDTO;
import kro.rubisco.dto.PageDTO;
import kro.rubisco.service.BoardService;
@Service
@Transactional(readOnly = true)
public class BoardServiceImpl implements BoardService {
private final BoardDAO boardDAO;
@Autowired
public BoardServiceImpl(SqlSession sqlSession) {
this.boardDAO = sqlSession.getMapper(BoardDAO.class);
}
@Override
public void regist(BoardDTO board) throws Exception {
boardDAO.create(board);
}
@Override
public BoardDTO read(Long documentId) throws Exception {
return boardDAO.read(documentId);
}
@Override
public void modify(BoardDTO board) throws Exception {
boardDAO.update(board);
}
@Override
public void remove(Long documentId) throws Exception {
boardDAO.delete(documentId);
}
@Override
public PageDTO<BoardDTO> listAll(PageDTO<BoardDTO> boardPage) throws Exception {
boardPage.setContentList(boardDAO.listAll(boardPage));
boardPage.setTotalSize(boardDAO.getCount());
return boardPage;
}
}
listAll
메소드에서 boardDAO를 통해 select된 contentList
와 totalSize
를 setter로 주입하여 해당 객체를 반환하도록 작성했습니다.
컨트롤러 수정
마지막으로 컨트롤러를 수정하겠습니다. getBoardListView
메소드를 다음과 같이 수정합니다.
/kro/rubisco/controller/BoardController.java
package kro.rubisco.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import kro.rubisco.dto.BoardDTO;
import kro.rubisco.dto.PageDTO;
import kro.rubisco.service.BoardService;
import kro.rubisco.service.CategoryService;
import lombok.RequiredArgsConstructor;
@Controller
@RequiredArgsConstructor
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
private final CategoryService categoryService;
@GetMapping()
public String getBoardListView(PageDTO<BoardDTO> boardPage, Model model) throws Exception {
model.addAttribute("pageInfo", boardService.listAll(boardPage));
model.addAttribute("boardList", boardPage.getContentList());
return "board/getBoardList";
}
@GetMapping("/{documentId}")
public String getBoardView(
@PathVariable("documentId") Long documentId,
Model model
) throws Exception {
model.addAttribute("board", boardService.read(documentId));
return "board/getBoard";
}
@GetMapping(params = "act=write")
public String getInsertBoardView(
@RequestParam(value="documentId", required=false) Long documentId,
Model model
) throws Exception {
model.addAttribute("board", documentId == null ? new BoardDTO() : boardService.read(documentId));
model.addAttribute("categoryList", categoryService.listAll());
return "board/insertBoard";
}
@PostMapping()
public String insertBoard(BoardDTO board) throws Exception {
boardService.regist(board);
return "redirect:/board/" + board.getDocumentId();
}
@PatchMapping(params = "documentId")
public String updateBoard(BoardDTO board) throws Exception {
boardService.modify(board);
return "redirect:/board/" + board.getDocumentId();
}
@DeleteMapping(params = "documentId")
public String deleteBoard(
@RequestParam("documentId") Long documentId
)throws Exception {
boardService.remove(documentId);
return "redirect:/board";
}
}
PageDTO
뷰 템플릿 작성
페이징이 되는지 확인하기 위해 간단하게 게시글 목록 템플릿을 작성하겠습니다. 레거시에서는 기본 템플릿 엔진으로 jsp를 사용합니다. jsp는 Java Server Page
의 약자로, HTML 코드 내에 JAVA 코드를 넣기 위해 만들어졌습니다.
뷰의 작성은 추후에 종류별로 알아보도록 하고, 여기에서는 페이징 기능을 확인하기 위해 간단히 템플릿을 작성하겠습니다.
우선 /src/main/webapp/WEB-INF/views/ 경로 아래에 board 폴더를 만들고 getBoardList.jsp 파일을 생성하여 다음과 같이 작성합니다.
/src/main/webapp/WEB-INF/views/board/getBoardList.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<%@ include file="./include/tailwindcss.jsp" %>
<title>게시글 목록</title>
</head>
<body>
<main>
<h1>게시글 목록</h1>
<article>
<table>
<thead>
<tr>
<th></th>
<th scope="col">카테고리</th>
<th scope="col" class="w-full">제목</th>
<th scope="col">작성자</th>
<th scope="col">등록일</th>
<th scope="col">조회수</th>
</tr>
</thead>
<tbody>
<c:forEach items="${boardList}" var="board" step="1" varStatus="status">
<tr>
<td>${pageInfo.totalSize - pageInfo.first - status.index + 1}</td>
<td>${board.category.category}</td>
<td><a href="/board/${board.documentId}">${board.title}</a></td>
<td>${board.member.nickName}</td>
<td><fmt:formatDate value="${board.createDate}" pattern="yyyy-MM-dd"/></td>
<td>${board.readCount}</td>
</tr>
</c:forEach>
</tbody>
</table>
</article>
<div class="flex mt-2 sm:mt-4 lg:w-2/3 w-full mx-auto">
<button class="write" onClick="window.location='/board?act=write'">쓰기</button>
</div>
<%@ include file="./include/pageNav.jsp" %>
</main>
</body>
</html>
tailwindcss를 통해 뷰를 개발했습니다. 상단에는 java를 통해 실행되는 jsp 파일임을 명시하고, 그 아래에는 JSTL을 사용하기 위한 taglib을 작성했습니다. 기본 기능인 core와 포멧팅을 위한 fmt 태그라이브러리를 설정했습니다. 또한 include를 통해 통해 조각으로 나누어 작성했습니다. 타임리프의 fragment 기능과 동일하다고 보면 됩니다.
나머지 include 조각들도 작성하겠습니다.
/src/main/webapp/WEB-INF/views/board/include/tailwindcss.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<style type="text/tailwindcss">
main {@apply text-gray-600 container px-5 py-16 mx-auto}
main > h1 {@apply flex flex-col text-center w-full sm:text-4xl text-2xl font-medium mb-5 text-gray-900}
article {@apply lg:w-2/3 w-full mx-auto overflow-auto}
table {@apply table-auto w-full text-xs sm:text-sm}
table > thead > tr > th {@apply px-2 py-2 sm:px-4 sm:py-3 tracking-wider font-medium text-gray-900 bg-gray-100 text-center whitespace-nowrap align-middle}
table > thead > tr > th:first-of-type {@apply rounded-tl rounded-bl}
table > thead > tr > th:nth-of-type(3) {@apply text-left}
table > thead > tr > th:last-of-type {@apply rounded-tr rounded-br}
table > tbody > tr {@apply hover:bg-slate-100}
table > tbody > tr > td {@apply px-2 py-2 sm:px-4 sm:py-3 text-center whitespace-nowrap align-middle}
table > tbody > tr > td:nth-of-type(3) {@apply p-0 text-left}
table > tbody > tr > td > a {@apply block}
table > tbody > tr > td:nth-of-type(3) > a {@apply px-2 py-2 sm:px-4 sm:py-3}
button.write {@apply flex ml-auto text-white text-sm sm:text-base bg-indigo-500 border-0 py-1 px-3 sm:py-2 sm:px-6 focus:outline-none hover:bg-indigo-600 rounded}
nav {@apply isolate flex -space-x-px rounded-md mt-2 sm:mt-4 lg:w-2/3 w-full mx-auto justify-center}
nav > a {@apply relative inline-flex items-center border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500 focus:z-20}
nav > a:not(.active) {@apply hover:bg-gray-50}
nav > a.active {@apply pointer-events-none border-indigo-500 bg-indigo-50 text-indigo-600 z-10}
nav > a:first-of-type {@apply px-2 rounded-l-md}
nav > a:last-of-type {@apply px-2 rounded-r-md}
nav > a.disabled {@apply pointer-events-none bg-slate-200 text-slate-400 border-slate-400}
</style>
/src/main/webapp/WEB-INF/views/board/include/pageNav.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<nav aria-label="Pagination">
<c:choose>
<c:when test="${pageInfo.hasPrev}"><c:set var="url" value="${pageInfo.getUrl('/board', pageInfo.page - 1)}" /></c:when>
<c:otherwise><c:set var="url" value="#" /></c:otherwise>
</c:choose>
<a href="${url}"<c:if test="${!pageInfo.hasPrev}"> class="disabled"</c:if>>
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</a>
<c:forEach var="i" begin="${pageInfo.firstPage}" end="${pageInfo.lastPage}" step="1">
<a href="${pageInfo.getUrl('/board', i)}"<c:if test="${pageInfo.page eq i}">aria-current="page" class="active"</c:if>>${i}</a>
</c:forEach>
<c:choose>
<c:when test="${pageInfo.hasNext}"><c:set var="url" value="${pageInfo.getUrl('/board', pageInfo.page + 1)}" /></c:when>
<c:otherwise><c:set var="url" value="#" /></c:otherwise>
</c:choose>
<a href="${url}"<c:if test="${!pageInfo.hasNext}"> class="disabled"</c:if>>
<span class="sr-only">Next</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</a>
</nav>
DBEver를 통해 예제 데이터 1000개를 입력해줍시다.
테스트 데이터 입력
BEGIN
FOR i IN 1..1000 LOOP
INSERT INTO BOARD (MEMBER_ID, CATEGORY_ID, TITLE, CONTENT)
VALUES (1, 1, '테스트'||i, '테스트'||i);
END LOOP;
END;
localhost:8080/board에 접속하면 다음과 같이 페이징 처리가 되어 출력됩니다.