'th:'로 시작하는 기능을 사용할 수는 있지만 Model에 담긴 데이터를 사용할 때 '해당 변수를 찾을 수 없다'는 에러가 발생할 수 있어 인텔리제이의 Setting > Thymeleaf 검색 > Unresolved references in Thymeleaf expression variables' 체크 해제
- Thymeleaf 출력
Thymeleaf는 Model로 전달된 데이터를 출력하기 위해 HTML 태그 내에 'th:,,'로 시작하는 속성을 이용하거나 인라인 이용
SampleController에서 ex1()을 추가해서 '/ex/ex1'이라는 경로 호출할 때 동작하도록 구성
// SampleController
package org.zerock.b01.controller;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.Arrays;
import java.util.List;
@Controller
@Log4j2
public class SampleController {
...
@GetMapping("/ex/ex1")
public void ex1(Model model) {
List<String> list = Arrays.asList("AAA", "BBB", "CCC", "DDD");
model.addAttribute("list", list);
}
}
TodoMapper의 selectList()는 PageRequestDTO를 파라미터로 받고 있으므로 변경없이 바로 사용 가능하므로 TodoMapper.xml만 수정
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
select * from tbl_todo
<!-- <select id = "selectList">태그에 Mybatis의 <foreach>를 적용 -->
<foreach collection = "types" item = "type">
#type
</foreach>
order by tno desc limit #{skip}, #{size}
</select>
테스트 코드 실행 시, 쿼리문이 정상적이지 않아 에러가 나지만 출력된 쿼리문을 확인하면 다음과 같이 출력됨
select * from tbl_todo ? ? order by tno desc limit ?, ?
if 적용 시 더 현실적인 쿼리를 만들어낼 수 있음
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
select * from tbl_todo
<foreach collection = "types" item = "type">
<!-- 검색 타입이 t(제목)일 때, 제목에 keyword가 포함된 데이터 검색 -->
<if test = "type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<!-- 검색 타입이 w(작성자)일 때, 작성자에 keyword가 포함된 데이터 검색 -->
<if test = "type == 'w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
order by tno desc limit #{skip}, #{size}
</select>
테스트 코드 실행시 다음과 같은 쿼리문 출력
select * from tbl_todo
title like concat('%', ?, '%')
title like concat('%', ?, '%')
order by tno desc limit ?, ?
<foreach>에 open, close, separator 속성을 적용해서 쿼리문에 ()와 OR 처리
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
select * from tbl_todo
<foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
<if test = "type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test = "type == 'w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
order by tno desc limit #{skip}, #{size}
</select>
테스트 코드 실행시 다음과 같은 쿼리문 출력
select * from tbl_todo
(
title like concat('%', ?, '%')
OR
title like concat('%', ?, '%')
)
order by tno desc limit ?, ?
- <where>
types가 null이 아닌 경우에만 where 키워드 추가
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
select * from tbl_todo
<where>
<if test = "types != null and types.length > 0">
<foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
<if test = "type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test = "type == 'w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
</if>
</where>
order by tno desc limit #{skip}, #{size}
</select>
테스트 코드 실행시 다음과 같은 쿼리문 출력
types가 null인 경우 - where 절 출력 안됨 - select * from tbl_todo order by tno desc limit ?, ?
types가 't' 혹은 'w'인 경우('t'인 경우) - select * from tbl_todo WHERE (title like concat('%', ?, '%')) order by tno desc limit ?, ?
- <trim>과 완료 여부 / 만료일 필터링
완료 여부는 PageRequestDTO의 finished 변수 값이 true인 경우에만 'finished = 1'과 같은 문자열이 쿼리문에 추가되도록 구성
앞에 다른 조건이 있는 경우 'and finished = 1'로, 다른 조건이 없는 경우 그냥 'finished = 1'로 추가되어야 함
이런 경우 Mybatis에서 <trim>을 사용
<!-- TodoMapper.xml -->
<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
select * from tbl_todo
<where>
<if test = "types != null and types.length > 0">
<foreach collection = "types" item = "type" open = "(" close = ")" separator = " OR ">
<if test = "type == 't'.toString()">
title like concat('%', #{keyword}, '%')
</if>
<if test = "type == 'w'.toString()">
writer like concat('%', #{keyword}, '%')
</if>
</foreach>
</if>
<!-- trim을 적용하여 prefix를 하게 되면 상황에 따라서 'and'가 추가됨 -->
<if test = 'finished'>
<trim prefix = "and">
finished = 1
</trim>
</if>
</where>
order by tno desc limit #{skip}, #{size}
</select>
검색 기능은 /WEB-INF/views/todo/list.jsp에서 이루어지므로 list.jsp에 검색 관련 화면을 작성하기 위해 <div class = 'card'>를 하나 추가하고 검색에 필요한 내용들을 담을 수 있도록 구성
<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
<div class = "row content">
<div class = "col">
<div class = "card">
<div class = "card-body">
<h5 class = "card-title">Search</h5>
<form action = "/todo/list" method = "get">
<input type = "hidden" name = "size" value = "${pageRequestDTO.size}">
<div class = "mb-3">
<input type = "checkbox" name = "types" value = "finished">완료여부
</div>
<div class = "mb-3">
<input type = "checkbox" name = "types" value = "t">제목
<input type = "checkbox" name = "types" value = "w">작성자
<input type = "text" name = "keyword" class = "form-control">
</div>
<div class = "input-group mb-3 dueDateDiv">
<input type = "date" name = "from" class = "form-control">
<input type = "date" name = "to" class = "form-control">
</div>
<div class = "input-group mb-3">
<div class = "float-end">
<button class = "btn btn-primary" type = "submit">Search</button>
<button class = "btn btn-info" type = "reset">Clear</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
...
</body>
</html>
화면 구성 결과
검색 결과
1) 화면에 검색 조건 표시하기
검색이 처리되기는 하지만 PageRequestDTO의 정보를 EL로 처리하지 않아 검색 후 검색부분이 초기화되는 문제
작성된 <div>에 EL을 적용할 때 제목(title)과 작성자(writer)를 배열로 처리하고 있어 문제가 됨
<!-- list.jsp -->
<form action = "/todo/list" method = "get">
<input type = "hidden" name = "size" value = "${pageRequestDTO.size}">
<div class = "mb-3">
<input type = "checkbox" name = "finished" ${pageRequestDTO.finished?"checked":""}>완료여부
</div>
<div class = "mb-3">
<input type = "checkbox" name = "types" value = "t" ${pageRequestDTO.checkType("t")?"checked":""}>제목
<input type = "checkbox" name = "types" value = "w" ${pageRequestDTO.checkType("w")?"checked":""}>작성자
<input type = "text" name = "keyword" class = "form-control" value = '<c:out value = "${pageRequestDTO.keyword}"/>'>
</div>
<div class = "input-group mb-3 dueDateDiv">
<input type = "date" name = "from" class = "form-control" value = "${pageRequestDTO.from}">
<input type = "date" name = "to" class = "form-control" value = "${pageRequestDTO.to}">
</div>
<div class = "input-group mb-3">
<div class = "float-end">
<button class = "btn btn-primary" type = "submit">Search</button>
<button class = "btn btn-info" type = "reset">Clear</button>
</div>
</div>
</form>
- 검색 조건 초기화 시키기
검색 영역에서 Clear 버튼을 누르면 모든 검색조건 무효화시켜 '/todo/list' 호출하도록 수정
화면에 clearBtn이라는 class 속성 추가
<!-- list.jsp -->
<div class = "input-group mb-3">
<div class = "float-end">
<button class = "btn btn-primary" type = "submit">Search</button>
<button class = "btn btn-info clearBtn" type = "reset">Clear</button>
</div>
</div>
<script>
document.querySelector(".clearBtn").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
self.location = '/todo/list'
})
</script>
2) 조회를 위한 링크 처리
조회나 수정 화면에서 'List' 버튼을 클릭할 때 검색 조건들을 유지하도록 처리
PageRequestDTO의 getLink()를 사용, getLink()를 통해 생성되는 링크에서 검색 조건 등을 반영해 주도록 수정
// PageRequestDTO
package org.zerock.springex.dto;
import ...
public class PageRequestDTO {
...
public String getLin() {
StringBuilder builder = new StringBuilder();
builder.append("page=" + this.page);
builder.append("&size=" + this.size);
if(finished) {
builder.append("&finished=on");
}
if(types != null && types.length > 0){
for (int i = 0 ; i < types.length ; i++) {
builder.append("&types=" + types[i]);
}
}
// keyword 부분은 URLEncoder를 이용해서 링크로 처리할 수 있도록 처리해야 함
if(keyword != null) {
try {
builder.append("&keyword=" + URLEncoder.encode(keyword, "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
if (from != null) {
builder.append("&from=" + from.toString());
}
if (to != null) {
builder.append("&to=" + to.toString());
}
return builder.toString();
}
}
3) 페이지 이동 링크 처리
페이지 이동에서 검색 / 필터링 조건 필요하므로 자바스크립트로 동작하는 부분을 수정
<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
<div class = "row content">
...
</div>
<div class = "row content">
<div class = "col">
<div class="card">
...
<script>
document.querySelector(".pagination").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
const target = e.target
if(target.tagName !== 'A') {
return
}
const num = target.getAttribute("data-num")
const formObj = document.querySelector("form")
// 검색 / 필터링 부분에 name이 page인 부분만 추가
formObj.innerHTML += `<input type = 'hidden' name = 'page' value = '\${num}'>`
// <form> 태그를 submit으로 처리해주면 검색 / 필터링 조건을 유지하면서 페이지 번호만 변경 가능
formObj.submit();
}, false)
</script>
...
</div>
</div>
</div>
...
</body>
</html>
4) 조회 화면에서 검색 / 필터링 유지
조회화면(read.jsp)에서 목록 화면으로 이동하는 작업은 PageRequestDTO의 getLink()를 이용하므로 아무런 처리가 없어도 정상적으로 동작함
수정(Modify) 버튼도 동일하게 동작하므로 추가 개발이 필요하지 않음
5) 수정 화면에서의 링크 처리
수정 화면인 modify.jsp에는 [Remove], [Modify], [List] 버튼이 존재하고 각 버튼에 대한 클릭 이벤트 처리가 되어있음
- List 버튼 처리
List 버튼은 PageRequestDTO의 GetLink()를 이용해 처리
- Remove 버튼 처리
Remove는 삭제된 후에 1페이지로 이동
삭제 후 기존 페이지와 검색 / 필터링 조건을 유지하고 싶다면 PageRequestDTO를 이용
<!-- modify.jsp -->
<script>
document.querySelector(".btn-danger").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
<!-- TodoController의 remove() 메서드가 이미 PageRequestDTO를 파라미터로 받고 있음 -->
<!-- 따라서 리다이렉트 하는 경로에 getLink()의 결과를 반영하도록 수정 -->
formObj.action = "/todo/remove?${pageRequestDTO.link}"
formObj.method = "post"
formObj.submit()
}, false);
</script>
- Modify 버튼 처리
검색 / 필터링 조건에 따라 검색했는데 수정하면서 조건에 맞지 않게 될 수 있음
따라서 안전하게 하려면 검색 / 필터링의 경우 수정한 후에 조회 페이지로 이동하게 하고, 검색 / 필터링 조건은 없애는 것이 안전
<form action = "/todo/modify" method = "post">
<%-- 검색 / 필터링 조건을 유지하지 않는다면 modify.jsp에 선언된 <input type = "hidden"> 태그의 내용은 필요하지 않으므로 삭제 --%>
<%-- <input type = "hidden" name = "page" value = "${pageRequestDTO.page}"> --%>
<%-- <input type = "hidden" name = "size" value = "${pageRequestDTO.size}"> --%>
...
</form>
TodoController에서는 '/todo/list'가 아닌 '/todo/read'로 이동하도록 수정
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
@PostMapping("/modify")
public String modify(PageRequestDTO pageRequestDTO,
@Valid TodoDTO todoDTO,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if(bindingResult.hasErrors()) {
log.info("has errors.......");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
redirectAttributes.addAttribute("tno", todoDTO.getTno());
return "redirect:/todo/modify";
}
log.info(todoDTO);
todoService.modify(todoDTO);
redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
// 리다이렉트 경로를 '/todo/read'로 변경
return "redirect:/todo/read";
}
}
TodoMapper에서 TodoVO의 목록과 전체 데이터 수를 가져온다면 이를 서비스 계층에서 한 번에 담아서 처리하도록 DTO를 구성하는 것이 좋음
PageResponseDTO라는 이름으로 생성하고 다음의 데이터와 기능을 가지도록 구성
TodoDTO 목록
wjscp epdlxj tn
페이지 번호 처리를 위한 데이터(시작 페이지 번호 / 끝 페이지 번호)
// PageResponseDTO
package org.zerock.springex.dto;
import java.util.List;
// 제네릭을 이용해서 설계
public class PageResponseDTO<E> {
private int page;
private int size;
private int total;
// 시작 페이지 번호
private int start;
// 끝 페이지 번호
private int end;
// 이전 페이지 존재 여부
private boolean prev;
// 다음 페이지 존재 여부
private boolean next;
private List<E> dtoList;
}
제네릭을 이용하는 이유는 나중에 다른 종류의 객체를 이용해서 PageResponseDTO를 구성할 수 있도록 하기 위함
PageResponseDTO는 여러 정보를 생성자를 이용해서 받아서 처리하는 것이 안전 예를 들어, PageRequestDTO에 있는 page, size 값이 필요하고, TodoDTO 목록 데이터와 전체 데이터 개수도 필요
// PageResponseDTO
package org.zerock.springex.dto;
import ...
public class PageResponseDTO<E> {
...
// PageResponseDTO의 생성자
@Builder(builderMethodName = "withAll")
public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total) {
this.page = pageRequestDTO.getPage();
this.size = pageRequestDTO.getSize();
this.total = total;
this.dtoList = dtoList;
}
}
1) 페이지 번호의 계산
페이지 번호를 계산하려면 우선 현재 페이지의 번호(page)가 필요
현재 페이지가 1~10 사이인 경우, 시작 페이지는 1, 마지막 페이지는 10
현재 페이지가 11~20 사이인 경우, 시작 페이지는 11, 마지막 페이지는 20
- 마지막 페이지 / 시작 페이지 번호의 계산
마지막 페이지 먼저 구하기
// 현재 페이지(page)를 10으로 나눈 값을 올림 처리한 후 * 10
this.end = (int)(Math.ceil(this.page / 10.0)) * 10;
// 결과
1 / 10 ====> 0.1 =="올림"==> 1 =="*10"==> 10
11 / 10 ====> 1.1 =="올림"==> 2 =="*10"==> 20
10 / 10 ====> 1 =="올림"==> 1 =="*10"==> 10
시작 페이지는 마지막 페이지에서 - 9
this.start = this.end - 9;
마지막 페이지의 경우 전체 개수(total)를 고려 게시물을 10개씩 보여주는 경우, 전체 개수가 75라면 마지막 페이지는 8이 되어야 함
int last = (int)(Math.ceil(total / (double)size));
// 결과
123 / 10.0 ====> 12.3 =="올림"==> 13
100 / 10.0 ====> 10.0 =="올림"==> 10
75 / 10.0 ====> 7.5 =="올림"==> 8
마지막 페이지(end)가 last보다 크면 last가 end값으로 되어야 함
this.end = end > last ? last : end
- 이전(prev) / 다음(next)의 계산
이전 페이지의 존재 여부는 다음 페이지(start)가 1이 아니면 무조건 true
다음 페이지(next)는 마지막 페이지(end)와 페이지당 개수(size)를 곱한 값보다 전체 개수가 더 많은지를 보고 판단
// PageResponseDTO
package org.zerock.springex.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
// 제네릭을 이용해서 설계
public class PageResponseDTO<E> {
private int page;
private int size;
private int total;
// 시작 페이지 번호
private int start;
// 끝 페이지 번호
private int end;
// 이전 페이지 존재 여부
private boolean prev;
// 다음 페이지 존재 여부
private boolean next;
private List<E> dtoList;
// PageResponseDTO의 생성자
@Builder(builderMethodName = "withAll")
public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total) {
this.page = pageRequestDTO.getPage();
this.size = pageRequestDTO.getSize();
this.total = total;
this.dtoList = dtoList;
this.end = (int)(Math.ceil(this.page / 10.0)) * 10;
this.start = this.end - 9;
int last = (int)(Math.ceil(total / (double)size));
this.end = end > last ? last : end;
this.prev = this.start > 1;
this.next = total > this.end * this.size;
}
}
// TodoServiceImpl
package org.zerock.springex.service;
import ...
public class TodoServiceImpl implements TodoService {
...
// @Override
// public List<TodoDTO> getAll() {
// // stream의 map()을 이용해서 TodoVOfmf TodoDTO로 변경
// // collect()를 이용해서 List<TodoDTO>로 묶어줌
// List<TodoDTO> dtoList = todoMapper.selectAll().stream()
// .map(vo -> modelMapper.map(vo, TodoDTO.class))
// .collect(Collectors.toList());
// return dtoList;
// }
@Override
public PageResponseDTO<TodoDTO> getList(PageRequestDTO pageRequestDTO) {
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
List<TodoDTO> dtoList = voList.stream()
.map(vo -> modelMapper.map(vo, TodoDTO.class))
.collect(Collectors.toList());
int total = todoMapper.getCount(pageRequestDTO);
PageResponseDTO<TodoDTO> pageResponseDTO = PageResponseDTO.<TodoDTO>withAll()
.dtoList(dtoList)
.total(total)
.pageRequestDTO(pageRequestDTO)
.build();
return pageResponseDTO;
}
}
- 테스트
TodoServiceTests에서 다음의 코드 작성
TodoController에서 getAll()을 사용하는 부분 삭제 후 테스트 진행
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
@RequestMapping("/list")
public void list(Model model) {
log.info("todo list.......");
// model.addAttribute("dtoList", todoService.getAll());
}
...
}
// TodoServiceTests
package org.zerock.springex.service;
import ...
public class TodoServiceTests {
...
@Test
public void testPaging() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder().page(1).size(10).build();
PageResponseDTO<TodoDTO> responseDTO = todoService.getList(pageRequestDTO);
log.info(responseDTO);
responseDTO.getDtoList().stream().forEach(todoDTO -> log.info(todoDTO));
}
}
1 페이지이므로 마지막 페이지(end)는 10, 이전 페이지(prev)는 없음, 다음 페이지(next)는 있음
3) TodoController와 JSP 처리
TodoController의 list()에서 PageRequestDTO를 파라미터로 처리
Model에 PageResponseDTO의 테이처들을 담을 수 있도록 변경
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
// Valid를 이용해 잘못된 파라미터 값들이 들어오는 경우 page는 1, size는 10으로 고정된 값을 처리하도록 구성
@RequestMapping("/list")
public void list(@Valid PageRequestDTO pageRequestDTO, BindingResult bindingResult, Model model) {
log.info(pageRequestDTO);
if(bindingResult.hasErrors()) {
pageRequestDTO = PageRequestDTO.builder().build();
}
model.addAttribute("responseDTO", todoService.getList(pageRequestDTO));
}
...
}
Model에 responseDTO라는 이름으로 PageResponseDTO를 담아주었기 때문에 list.jsp는 기존의 코드를 많이 수정해야 함
<!-- list.jsp -->
<!-- 목록을 출력하는 부분에서 dtoList가 아니라 responseDTO.dtoList의 형태로 변경 -->
<c:forEach items = "${responseDTO.dtoList}" var = "dto">
<tr>
<th scope = "row"><c:out value = "${dto.tno}"/></th>
<td><a href = "/todo/read?tno=${dto.tno}" class = "text=decoration-none"><c:out value = "${dto.title}"/></a></td>
<td><c:out value = "${dto.writer}"/></td>
<td><c:out value = "${dto.dueDate}"/></td>
<td><c:out value = "${dto.finished}"/></td>
</tr>
</c:forEach>
프로젝트 실행 뒤 '/todo/list' 경로에서 1페이지에 해당하는 데이터들이 출력되는 것을 확인
4) 페이지 이동 확인
화면을 추가로 개발하기 전에 'todo/list?page=xx&size=xx'를 호출해서 결과가 정상적으로 처리되는지 확인
'todo/list?page=12'를 호출하면 그냥 '/todo/list'를 호출했을 때 4588로 시작하는 1페이지가 출력되는 것과 다르게 4478로 시작하는 12페이지가 출력되는 것을 확인
size까지 설정하여 '/todo/list/page=12&size=20'을 호출한 결과 4478로 시작하는 12페이지에 20개의 데이터가 출력되는 것을 확인
- 화면에 페이지 이동을 위한 번호 출력
부트스트랩의 pagination 컴포넌트 적용
list.jsp의 <table> 태그가 끝난 후에 <div> 구성하여 다음과 같이 화면 작성
<!-- list.jsp -->
</table>
<div class = "float-end">
<ul class = "pagination flex-wrap">
<c:forEach begin = "${responseDTO.start}" end = "${responseDTO.end}" var = "num">
<li class = "page-item"><a class = "page-link" href = "#">${num}</a></li>
</c:forEach>
</ul>
</div>
- 화면에서 prev / next / 현재 페이지 표시
<!-- list.jsp -->
<div class = "float-end">
<ul class = "pagination flex-wrap">
<!-- previous 버튼 -->
<c:if test = "${responseDTO.prev}">
<li class = "page-item">
<a class = "page-link">Previous</a>
</li>
</c:if>
<!-- 페이지 버튼 -->
<c:forEach begin = "${responseDTO.start}" end = "${responseDTO.end}" var = "num">
<!-- ${responseDTO.page == num? "active":""} 를 추가하여 현재 페이지 표시 처리 -->
<li class = "page-item ${responseDTO.page == num? "active":""}"><a class = "page-link" href = "#">${num}</a></li>
</c:forEach>
<!-- next 버튼 -->
<c:if test = "${responseDTO.next}">
<li class = "${responseDTO.next}">
<a class = "page-link">Next</a>
</li>
</c:if>
</ul>
</div>
1페이지 ~ 10페이지는 Previous 버튼은 없고 Next 버튼은 출력됨
11페이지부터는 Previous 버튼과 Next 버튼이 모두 출력됨
마지막 페이지에는 Previous 버튼만 출력됨
- 페이지의 이벤트 처리
페이지의 번호를 누르면 이동하는 처리는 자바스크립트 이용
<ul class = "pagination flex-wrap">
<!-- previous 버튼 -->
<c:if test = "${responseDTO.prev}">
<li class = "page-item">
<!-- Previous 버튼에는 data-num - 1의 값이 저장되도록 설정 -->
<a class = "page-link" data-num = "${responseDTO.start - 1}">Previous</a>
</li>
</c:if>
<!-- 페이지 버튼 -->
<c:forEach begin = "${responseDTO.start}" end = "${responseDTO.end}" var = "num">
<!-- "${responseDTO.page == num? "active":""}" 를 추가하여 현재 페이지 표시 처리 -->
<!-- data-num이라는 속성을 추가하여 페이지 번호를 보관하도록 구성 -->
<li class = "page-item ${responseDTO.page == num? "active":""}"><a class = "page-link" data-num = "${num}">${num}</a></li>
</c:forEach>
<!-- next 버튼 -->
<c:if test = "${responseDTO.next}">
<li class = "${responseDTO.next}">
<!-- Next 버튼에는 data-num + 1의 값이 저장되도록 설정 -->
<a class = "page-link" data-num = "${responseDTO.end + 1}">Next</a>
</li>
</c:if>
</ul>
</div>
<!-- 페이지 번호 눌렀을 때 이벤트 처리 -->
<script>
document.querySelector(".pagination").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
const target = e.target
if(target.tagName !== 'A') {
return
}
const num = target.getAttribute("data-num")
self.location = `/todo/list?page=\${num}` // ``를 이용해서 템플릿 처리
}, false)
</script>
브라우저에서 각 페이지 번호의 data-num부분에 각 페이지 번호 값이 저장됨을 확인
Next 버튼에는 그 다음의 페이지 번호가 저장됨을 확인
페이지 번호를 눌러 각 페이지로 이동 가능
- 조회 페이지로의 이동
기존에는 목록에서 제목을 눌러 조회 페이지로 이동
이때 단순히 tno만 전달하여 '/todo/read?tno=1'과 같은 방식으로 이동
페이지 번호가 붙을 때는 page와 size를 같이 전달해주어야 조회 페이지에서 다시 목록으로 이동할 때 기존 페이지를 볼 수 있게 됨
// PageRequestDTO
package org.zerock.springex.dto;
import ...
public class PageRequestDTO {
...
private String link;
public int getSkip() {
return (page-1) * 10;
}
// GET 방식으로 페이지 이동에 필요한 링크 생성
public String getLink() {
if(link == null) {
StringBuilder builder = new StringBuilder();
builder.append("page=" + this.page);
builder.append("&size=" + this.size);
link = builder.toString();
}
return link;
}
}
<!-- list.jsp -->
<c:forEach items = "${responseDTO.dtoList}" var = "dto">
<tr>
<th scope = "row"><c:out value = "${dto.tno}"/></th>
<!-- 링크 주소에 PageRequestDTO에서 생성한 link부분 추가 -->
<td><a href = "/todo/read?tno=${dto.tno}&${pageRequestDTO.link}" class = "text=decoration-none"><c:out value = "${dto.title}"/></a></td>
<td><c:out value = "${dto.writer}"/></td>
<td><c:out value = "${dto.dueDate}"/></td>
<td><c:out value = "${dto.finished}"/></td>
</tr>
</c:forEach>
코드 수정 후 4페이지의 4554번 데이터를 조회하면 주소에 다음과 같이 page=4&size=10이 같이 전달됨
- 조회에서 목록으로
4페이지의 데이터를 조회한 후 다시 목록으로 돌아갈 때, 1페이지 목록이 아닌 4페이지 목록으로 돌아갈 수 있도록 설정
조회 화면에서는 기존과 달리 PageRequestDTO를 추가로 이용하도록 TodoController를 수정해야함
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
// read() 메서드에 PageRequestDTO 파라미터를 추가
@GetMapping({"/read", "/modify"})
public void read(Long tno, PageRequestDTO pageRequestDTO, Model model) {
TodoDTO todoDTO = todoService.getOne(tno);
log.info(todoDTO);
model.addAttribute("dto", todoDTO);
}
...
}
TodoController의 read() 메서드는 GET 방식으로 동작하는 'todo/modify'에 동일하게 처리하게 되므로 JSP에서 PageRequestDTO를 사용할 수 있음
<!-- modify.jsp -->
<script>
...
document.querySelector(".btn-secondary").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
// List 버튼을 누르는 자바스크립트 이벤트 부분을 다음과 같이 변경
self.location = `todo/list${pageRequestDTO.link}`
}, false);
</script>
- 수정 / 삭제 처리 후 페이지 이동
실제 수정 / 삭제 작업은 POST 방식으로 처리되고 삭제 처리된 후에는 다시 목록으로 이동
수정 화면에서 <form> 태그로 데이터를 전송할 때 페이지와 관련된 정보를 같이 추가해서 전달해야함
modify.jsp의 <input type = 'hidden'>을 이용
<!-- modify.jsp -->
...
<form action = "/todo/modify" method = "post">
<input type = "hidden" name = "page" value = "${pageRequestDTO.page}">
<input type = "hidden" name = "size" value = "${pageRequestDTO.size}">
...
TodoController에서 POST 방식으로 이루어지는 삭제처리에도 PageRequestDTO를 이용해서 <form>태그로 전송되는 태그들을 수집
수정 후 목록 페이지로 이동할 때 page는 무조건 1페이지로 이동해서 size 정보를 활용
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
@GetMapping("/read")
public void read(Long tno, Model model) {
TodoDTO todoDTO = todoService.getOne(tno);
log.info(todoDTO);
model.addAttribute("dto", todoDTO);
}
}
webapp > WEB-INF > views > todo에 read.jsp 추가
read.jsp에는 JSTL 관련 설정 추가
<!-- read.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<html lang="en">
<head>
...
</head>
<body>
<div class = "container-fluid">
...
<div class = "row content">
<div class = "col">
<div class="card">
<div class="card-header">
Featured
</div>
<div class="card-body">
<div class = "input-group mb-3">
<span class = "input-group-text">TNO</span>
<input type = "text" name = "tno" class = "form-control" value = "<c:out value = "${dto.tno}"></c:out>" readonly>
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">Title</span>
<input type = "text" name = "title" class = "form-control" value = "<c:out value = "${dto.title}"></c:out>" readonly>
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">DueDate</span>
<input type = "text" name = "dueDate" class = "form-control" value = "<c:out value = "${dto.dueDate}"></c:out>" readonly>
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">Writer</span>
<input type = "text" name = "writer" class = "form-control" value = "<c:out value = "${dto.writer}"></c:out>" readonly>
</div>
<div class = "form-check">
<label class = "form-check-label">
Finished
</label>
<input class = "form-check-input" type = "checkbox" name = "finished" ${dto.finished?"checked":""} disabled>
</div>
<div class = "my-4">
<div class = "float-end">
<button type = "button" class = "btn btn-primary">Modify</button>
<button type = "button" class = "btn btn-secondary">List</button>
</div>
</div>
</div>
</div>
</div>
</div>
...
</div>
</body>
</html>
- 수정 / 삭제를 위한 링크 처리
Modify 버튼을 누르면 GET 방식의 수정 / 삭제 선택이 가능한 화면으로 이동
<!-- read.jsp -->
<div class = "my-4">
<div class = "float-end">
<button type = "button" class = "btn btn-primary">Modify</button>
<button type = "button" class = "btn btn-secondary">List</button>
</div>
</div>
<script>
document.querySelector(".btn-primary").addEventListener("click", function(e){
self.location = "/todo/modify?tno="+${dto.tno}
}, false)
document.querySelector(".btn-secondary").addEventListener("click", function(e){
self.location = "todo/list";
}, false)
</script>
- list.jsp의 링크 처리
list.jsp에서는 각 TodoDTO의 title에 'todo/read?tno=xxx'와 같이 이동 가능하도록 링크 처리
<!-- list.jsp -->
<tr>
<th scope = "row"><c:out value = "${dto.tno}"/></th>
<td><a href = "/todo/read?tno=${dto.tno}" class = "text=decoration-none"><c:out value = "${dto.title}"/></a></td>
<td><c:out value = "${dto.writer}"/></td>
<td><c:out value = "${dto.dueDate}"/></td>
<td><c:out value = "${dto.finished}"/></td>
</tr>
10) Todo의 삭제 기능 개발
수정과 삭제는 GET 방식으로 조회한 후 POST 방식으로 처리
GET 방식의 내용은 조회 화면과 같지만 스프링 MVC에는 여러 경로를 배열과 같은 표기법을 사용해 하나의 @GetMapping으로 처리 가능
read() 기능을 수정해서 수정과 삭제에 같은 메서드 시용
// TodoController
// GetMapping에 "/read"만 적용되어 있던 것을 {}안에 "/modify"와 같이 묶어서 같은 기능을 이용
@GetMapping({"/read", "/modify"})
public void read(Long tno, Model model) {
TodoDTO todoDTO = todoService.getOne(tno);
log.info(todoDTO);
model.addAttribute("dto", todoDTO);
}
WEB-INF > views > todo 폴더에 read.jsp를 복사하여 modify.jsp 구성
<!-- modify.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<html lang="en">
<head>
...
</head>
<body>
<div class = "container-fluid">
<div class = "row">
<h1>Header</h1>
</div>
<div class = "row content">
<div class = "col">
<div class="card">
<div class="card-header">
Featured
</div>
<div class="card-body">
<!-- form 태그 구성, 항목들을 수정 가능하도록 readonly 제거 -->
<form action = "/todo/modify" method = "post">
<div class = "input-group mb-3">
<span class = "input-group-text">TNO</span>
<input type = "text" name = "tno" class = "form-control" value = "<c:out value = "${dto.tno}"></c:out>" readonly>
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">Title</span>
<input type = "text" name = "title" class = "form-control" value = "<c:out value = "${dto.title}"></c:out>">
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">DueDate</span>
<input type = "text" name = "dueDate" class = "form-control" value = "<c:out value = "${dto.dueDate}"></c:out>">
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">Writer</span>
<input type = "text" name = "writer" class = "form-control" value = "<c:out value = "${dto.writer}"></c:out>" readonly>
</div>
<div class = "form-check">
<label class = "form-check-label">
Finished
</label>
<input class = "form-check-input" type = "checkbox" name = "finished" ${dto.finished?"checked":""}>
</div>
<div class = "my-4">
<div class = "float-end">
<button type = "button" class = "btn btn-danger">Remove</button>
<button type = "button" class = "btn btn-primary">Modify</button>
<button type = "button" class = "btn btn-secondary">List</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
...
</div>
</body>
</html>
// TodoServiceImpl
package org.zerock.springex.service;
import ...
public class TodoServiceImpl implements TodoService {
...
@Override
public void modify(TodoDTO todoDTO) {
TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
todoMapper.update(todoVO);
}
}
- checkbox를 위한 Formatter
수정 작업에서는 화면에서 체크박스를 이용해서 완료여부(finished) 처리
체크박스가 클릭된 상태일 때 브라우저는 'on'이라는 값을 전송하며, TodoDTO로 데이터를 수집할 때 'on'을 boolean 타입으로 처리할 수 있어야 하므로 Controller에서 데이터를 수집할 때 타입을 변경해주기 위한 CheckboxFormatter를 formatter 패키지에 추가
<!-- servlet-context.xml -->
<bean id = "conversionService" class = "org.springframework.format.support.FormattingConversionServiceFactoryBean">
<property name = "Formatters">
<set>
<bean class = "org.zerock.springex.controller.formatter.LocalDateFormatter"/>
<bean class = "org.zerock.springex.controller.formatter.CheckboxFormatter"/>
</set>
</property>
</bean>
- TodoController의 modify()
TodoController에서 POST 방식으로 동작하는 modify() 작성
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
// @Valid를 활용해 피룡 내용 검증, 문제가 있는 경우 다시 '/todo/modify'로 redirect
// '/todo/modify'로 이동할 때 tno 파라미터가 필요하므로 RedirectAttributes를 이용해 addAttribute()를 이용하고 errors라는 이름으로 BindingResult의 모든 에러들을 전달
@PostMapping("/modify")
public String modify(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if(bindingResult.hasErrors()) {
log.info("has errors.......");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
redirectAttributes.addAttribute("tno", todoDTO.getTno());
return "redirect:/todo/modify";
}
log.info(todoDTO);
todoService.modify(todoDTO);
return "redirect:/todo/list";
}
}
WEB-INF > views > todo > modify.jsp에는 검증된 정보를 처리하는 코드 추가
<!-- modify.jsp -->
</form>
</div>
<!-- <form> 태그가 끝난 후 <script> 태그를 이용해 @Valid 문제 발생 시, 이를 자바스크립트 객체로 필요할 때 사용할 수 있도록 함 -->
<script>
const serverValidResult = {}
<c:forEach items = "${errors}" var = "error">
serverValidResult['${error.getField()}'] = '${error.defaultMessage}'
</c:forEach>
console.log(serverValidResult)
</script>
<script>
const formObj = document.querySelector("form")
document.querySelector(".btn-danger").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
formObj.action = "/todo/remove"
formObj.method = "post"
formObj.submit()
}, false);
// 실제 Modify 버튼의 이벤트 처리에는 <form> 태그 전송
document.querySelector(".btn-primary").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
formObj.action = "todo/modify"
formObj.method = "post"
formObj.submit()
}, false);
// List 버튼의 클릭 이벤트 처리
document.querySelector(".btn-secondary").addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
self.location = "todo/list";
}, false);
</script>
TodoService 인터페이스 추가 → 이를 구현한 TodoServiceImpl을 Bean으로 처리
// TodoService
package org.zerock.springex.service;
import org.zerock.springex.dto.TodoDTO;
// register는 여러 개의 파라미터 대신 TodoDTO로 묶어서 전달받음
public interface TodoService {
void register(TodoDTO todoDTO);
}
TodoServiceImpl에는 의존성 주입을 이용하여 데이터베이스 처리를 하는 TodoMapper와 DTO, VO의 변환을 처리하는 ModelMapper를 주입
// service > TodoServiceImpl
package org.zerock.springex.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;
import org.zerock.springex.domain.TodoVO;
import org.zerock.springex.dto.TodoDTO;
import org.zerock.springex.mapper.TodoMapper;
@Service
@Log4j2
@RequiredArgsConstructor
// 의존성 주입이 필요한 객체의 타입을 final로 지정
// @RequiredArgsConstructor를 이용해서 생성자를 생성하는 방식
public class TodoServiceImpl implements TodoService {
// 의존성 주입을 이용해 데이터베이스 처리를 하는 TodoMapper
private final TodoMapper todoMapper;
// VO의 변환을 처리하는 ModelMapper
private final ModelMapper modelMapper;
@Override
public void register(TodoDTO todoDTO) {
log.info(modelMapper);
// ModelMapper를 이용해서 TodoDTO를 TodoVO로 변환
TodoVO todoVO = modelMapper.map(todoDTO, TodoVO.class);
log.info(todoVO);
// TodoMapper를 통해 insert 처리
todoMapper.insert(todoVO);
}
}
service 패키지를 webapp > WEB-INF > root-context.xml에 component-scan 패키지로 추가
<mybatis:scan base-package = "org.zerock.springex.mapper"></mybatis:scan>
<!-- ModelMapperConfiguration을 스프링의 Bean으로 인식시키기 위한 추가 -->
<context:component-scan base-package = 'org.zerock.springex.config'/>
<!-- service 패키지 추가 -->
<context:component-scan base-package = 'org.zerock.springex.service'/>
register.jsp에 class 속성이 "card-body"로 지정된 부분의 코드를 다음과 같이 수정
<!-- register.jsp -->
<%@ taglib prefix ="form" uri = "http://www.springframework.org/tags/form" %>
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hello, world</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
crossorigin="anonymous">
</head>
<body>
<div class = "container-fluid">
...
<div class = "row content">
...
<div class="card-body">
<form action = "/todo/register" method = "post">
<div class = "input-group mb-3">
<span class = "input-group-text">Title</span>
<input type = "text" name = "title" class = "form-control" placeholder = "Title">
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">DueDate</span>
<input type = "date" name = "dueDate" class = "form-control" placeholder = "Writer">
</div>
<div class = "input-group mb-3">
<span class = "input-group-text">Writer</span>
<input type = "date" name = "dueDate" class = "form-control" placeholder = "Writer">
</div>
<div class = "my-4">
<div class = "float-end">
<button type = "submit" class = "btn btn-primary">Submit</button>
<button type = "result" class = "btn btn-secondary">Reset</button>
</div>
</div>
</form>
</div>
...
</div>
</body>
</html>
- post 방식의 처리
register.jsp의 <form action = "/todo/register" method = "post"> 태그에 의해 [Submit]버튼을 클릭하면 POST 방식으로 "title, dueDate, writer"을 전송
TodoController에서는 TodoDTO로 바로 전달된 파라미터 값들을 수집
// TodoController
package org.zerock.springex.controller;
import ...
@Controller
@RequestMapping("/todo")
@Log4j2
public class TodoController {
...
//POST 방식으로 처리한 후에 "/todo/list"로 이동해야 하므로 "redirect:/todo/list"로 이동할 수 있도록 문자열을 반환할 수 있게 처리
@PostMapping("/register")
public String registerPOST(TodoDTO todoDTO, RedirectAttributes redirectAttributes) {
log.info("POST todo register...............");
log.info(todoDTO);
return "redirect:/todo/list";
}
}
todo/register에서 정보 입력 후 submit 버튼을 클릭하면 로그에 다음과 같이 정보가 출력됨
페이지는 todo/list로 redirect됨
5) 한글 처리를 위한 필터 설정
위와 같이 한글을 쓰면 로그에 한글이 깨져서 출력되므로 스프링 MVC에서 제공하는 필터로 처리가 필요
<web-app ... >
...
<!-- 한글 설정을 위한 필터(web-app 태그 안에 입력) -->
<filter>
<filter-name>encoding</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encoding</filter-name>
<servlet-name>appServlet</servlet-name>
</filter-mapping>
</web-app>
6) @Valid를 이용한 서버사이드 검증
브라우저를 사용하는 프론트 쪽과 더불어 서버를 사용하는 백에서도 입력되는 값들의 유효성 검증을 하는 것이 일반적
검증 작업은 Controller에서 진행되며 스프링 MVC의 경우 @Valid와 BindingResult를 이용해서 처리
스프링 MVC에서 검증을 처리하기 위해 hibernate-validate 라이브러리가 필요
// build.gradle
// 유효성 검증을 위한 Validate 관련 라이브러리
implementation group: 'org.hibernate', name: 'hibernate-validator', version: '6.2.1.Final'
// TodoController
public class TodoController {
private final TodoService todoService;
...
@PostMapping("/register")
public String registerPOST(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
log.info("POST todo register...............");
// 검증 과정에 문제가 있다면 다시 입력 화면으로 Redirect 되도록 처리
// 잘못된 결과는 RedirectAttributes의 addFlashAttributes()를 이용해서 전달
if(bindingResult.hasErrors()) {
log.info("has errors.......");
redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
return "redirect:/todo/register";
}
log.info(todoDTO);
todoService.register(todoDTO);
return "redirect:/todo/list";
}
8) Todo 목록 기능 개발
- TodoMapper의 개발
TodoMapper 인터페이스에는 가장 최근 등록된 TodoVO가 우선적으로 나올 수 있도록 selectAll() 추가
서비스 계층의 개발은 특별한 파라미터가 없는 경우 TodoMapper를 호출하는 것이 전부
TodoMapper가 반환하는 데이터의 타입이 List<TodoVO>이기 때문에 이를 List<TodoDTO>로 변환하는 작업이 필요
TodoService 인터페이스에 getAll() 기능 추가
// TodoService
package org.zerock.springex.service;
import org.zerock.springex.dto.TodoDTO;
import java.util.List;
// register는 여러 개의 파라미터 대신 TodoDTO로 묶어서 전달받음
public interface TodoService {
void register(TodoDTO todoDTO);
List<TodoDTO> getAll();
}
TodoServiceImpl에서 getAll()을 다음과 같이 개발
// TodoServiceImpl
package org.zerock.springex.service;
import ...
@Service
@Log4j2
@RequiredArgsConstructor
public class TodoServiceImpl implements TodoService {
...
@Override
public List<TodoDTO> getAll() {
// stream의 map()을 이용해서 TodoVOfmf TodoDTO로 변경
// collect()를 이용해서 List<TodoDTO>로 묶어줌
List<TodoDTO> dtoList = todoMapper.selectAll().stream()
.map(vo -> modelMapper.map(vo, TodoDTO.class))
.collect(Collectors.toList());
return dtoList;
}
}
- TodoController의 처리
TodoController의 list() 기능에서 TodoService를 처리하고 Model에 데이터를 담아서 JSP로 전달
// TodoController
package org.zerock.springex.controller;
import ...
public class TodoController {
...
@RequestMapping("/list")
public void list(Model model) {
log.info("todo list.......");
// Model에 dtoList라는 이름으로 목록 데이터를 담았기 때문에 JSP에서 JSTL을 이용해서 목록 출력
model.addAttribute("dtoList", todoService.getAll());
}
...
}
WEB-INF > views > todo > list.jsp 페이지 상단에 JSP 관련 설정과 JSTL 설정 추가
<!-- list.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
<div class = "row content">
<div class = "col">
<div class="card">
<div class="card-header">
Featured
</div>
<div class="card-body">
<h5 class="card-title">Special title treatment</h5>
<table class = "table">
<thead>
<tr>
<th scope = "col">Tno</th>
<th scope = "col">Title</th>
<th scope = "col">Writer</th>
<th scope = "col">DueDate</th>
<th scope = "col">Finished</th>
</tr>
</thead>
<tbody>
<c:forEach items = "${dtoList}" var = "dto">
<tr>
<th scope = "row"><c:out value = "${dto.tno}"/></th>
<td><c:out value = "${dto.title}"/></td>
<td><c:out value = "${dto.writer}"/></td>
<td><c:out value = "${dto.dueDate}"/></td>
<td><c:out value = "${dto.finished}"/></td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</div>
</div>
</div>
...
</body>
</html>
localhost:8080/todo/list 경로로 접속했을 때, 가장 최근 등록된 것부터 출력됨
// DTO 검증을 위한 validation 관련 라이브러리
implementation group: 'org.hibernate', name: 'hibernate-validator', version: '6.2.1.Final'
2) 프로젝트의 폴더 / 패키지 구조
예제 실습을 위해 작성했던 Sample 관련 파일 정리
서버가 제대로 작동하는지 확인
테이블 수정
drop table tbl_todo;
create table tbl_todo(
tno int auto_increment primary key,
title varchar(100) not null,
dueDate date not null,
writer varchar(50) not null,
finished tinyint default 0
)
서비스 패키지 설정: 프로젝트 내에 서비스 영역을 담당하는 service 패키지 생성
3) ModelMapper 설정과 @Configuration
DTO → VO 또는 VO → DTO의 변환이 빈번하므로 ModelMapper를 스프링의 Bean으로 등록해서 처리
config 패키지 추가 > ModelMapperConfig 클래스 추가
// ModelMapperConfiguration
package org.zerock.springex.config;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// @Configuration은 해당 클래스가 스프링 Bean에 대한 설정을 하는 클래스림을 명시
@Configuration
public class ModelMapperConfig {
// getMapper() 메서드가 ModelMapper를 반환
// @Bean 어노테이션은 해당 메서드의 실행 결과로 반환된 객체를 스프링의 Bean으로 등록시키는 역할
@Bean
public ModelMapper getMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldMatchingEnabled(true)
.setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
.setMatchingStrategy(MatchingStrategies.STRICT);
return modelMapper;
}
}
ModelMapperConfiguration을 스프링의 Bean으로 인식할 수 있도록 root-context.xml에 config 패키지를 추가
<!-- root-context.xml -->
<!-- ModelMapperConfiguration을 스프링의 Bean으로 인식시키기 위한 추가 -->
<context:component-scan base-package = 'org.zerock.springex.config'/>
<!doctype html>
...
<body>
<div class = "container-fluid">
<div class = "row">
<h1>Header</h1>
</div>
<div class = "row content">
<h1>Content</h1>
</div>
<div class = "row footer">
<h1>Footer</h1>
</div>
</div>
...
</body>
</html>
2) Card 컴포넌트 적용하기
부트스트랩 사이트의 Component > Card > Header and Footer 부분의 코드 사용
<!doctype html>
...
<body>
...
<!-- body안에 "row content" 클래스 안에 "col" 클래스 생성 후 부트스트랩 코드 복사 -->
<div class = "row content">
<div class = "col">
<div class="card">
<div class="card-header">
Featured
</div>
<div class="card-body">
<h5 class="card-title">Special title treatment</h5>
<p class="card-text">With supporting text below as a natural lead-in to additional content.</p>
<a href="#" class="btn btn-primary">Go somewhere</a>
</div>
</div>
</div>
</div>
...
</body>
</html>
3) Navbar 컴포넌트 적용
부트스트랩 사이트의 Component > Navbar > Nav 부분의 코드 사용
<!doctype html>
...
<body>
<div class = "container-fluid">
<!-- body안에 "row" 클래스 안에 "col" 클래스 생성 후 부트스트랩 코드 복사 -->
<div class = "row">
<div class = "col">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Features</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Pricing</a>
</li>
<li class="nav-item">
<a class="nav-link disabled">Disabled</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
...
</body>
</html>
4) Footer 처리
가장 아래에 "row footer" 클래스에는 간단한 footer 적용
<!doctype html>
...
</head>
<body>
...
<div class = "row footer">
<div class = "row fixed-bottom" style = "z-index: -100">
<footer class = "py-1 my-1">
<p class = "text-center text-muted">Footer</p>
</footer>
</div>
</div>
</div>
...
</body>
</html>
HttpServletRequest / HttpServletResponse 이용하지 않아도 될 만큼 추상화된 방식으로 개발 가능
스프링 MVC의 전체 흐름
1) DispatcherServlet과 Front Controller
스프링 MVC의 모든 요청은 반드시 DispatcherServlet이라는 존재를 통해서 실행됨
Front-Controller 패턴을 이용하면 모든 요청이 반드시 하나의 객체를 지나서 처리되어 모든 공통적인 처리를 Front-Controller에서 처리 가능
스프링 MVC에서 DispatcherServlet이라는 객체가 Front-Controller 역할 수행
Front-Controller가 사전 / 사후에 대한 처리를 하게 되면 중간에 매번 다른 처리를 하는 부분만 별도로 처리하는 구조를 만들게 됨(이 부분이 Controller이고 @Controller를 이용해서 처리)
실습
1) 스프링 MVC 사용하기
- 프로젝트의 webapp 폴더 > resources 폴더 생성: 이미지나 html 파일 같은 정적인 파일을 서비스하기 위한 경로
- webapp 폴더 > WEB-INF > servlet-context.xml 생성
<!-- servlet-context.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns = "http://www.springframework.org/schema/beans"
xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc = "http://www.springframework.org/schema/mvc"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<!-- 스프링 MVC 설정을 어노테이션 기반으로 처리한다는 의미, 스프링 MVC의 여러 객체들을 자동을 ㅗ스프링의 Bean으로 등록하게 하는 기능 -->
<mvc:annotation-driven></mvc:annotation-driven>
<!-- 이미지나 html 파일 같은 정적인 파일 경로 지정 -->
<!-- "/resources" 경로로 들어오는 요청은 정적인 파일을 요구하는 것으로 판단하고 스프링 MVC에서 처리하지 않는다는 의미 -->
<mvc:resources mapping = "/resources/**" location = "/resources/"></mvc:resources>
<!-- InternalResourceViewResolver는 스프링 MVC에서 제공하는 View를 어떻게 결정하는지에 대한 설정 담당 -->
<bean class = "org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name = "prefix" value = "/WEB-INF/views/"></property>
<property name = "suffix" value = ".jsp"></property>
</bean>
</beans>
2) web.xml의 DispatcherServlet 설정
스프링 MVC 실행을 위해 Front-Controller 역할을 하는 DispatcherServlet 설정
<!-- web.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
...
<!-- DispatcherServlet 로딩 시 servlet-context.xml을 이용하도록 설정 -->
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/servlet-context.xml</param-value>
</init-param>
<!-- Tomcat 로딩 시 클래스를 미리 로딩해두기 위한 설정 -->
<load-on-startup>1</load-on-startup>
</servlet>
<!-- DispatcherServlet이 모든 경로의 요청에 대한 처리를 담당하기 때문에 '/'fh wlwjd -->
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
실습
2) 스프링 MVC Controller
- 스프링 MVC Controller의 다른 점
상속이나 인터페이스를 구현하는 방식을 사용하지 않고 어노테이션만으로 처리 가능
오버라이드 없이 필요한 메서드 정의
메서드의 파라미터를 기본 자료형이나 객체 자료형을 마음대로 지정
메서드의 리턴타입도 void, String, 객체 등 다양한 타입 사용 가능
- org.zerock.springex 프로젝트 내에 controller 패키지 추가 > SampleController 클래스 추가
// SampleController
package org.zerock.springex.controller;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
// 해당 클래스가 MVC에서 Controller 역할을 한다는 의미, 스프링의 Bean으로 처리되기 위해 사용
@Controller
@Log4j2
public class SampleController {
// GET 방식으로 들어오는 요청을 처리하기 위해 사용("/hello"라는 경로를 호출할 때 동작)
@GetMapping("/hello")
public void hello() {
log.info("hello........" );
}
}
3) servlet-context.xml의 component-scan
controller 패키지에 존재하는 Controller 클래스들을 스프링으로 인식하기 위해 @Controller 어노테이션이 추가된 클래스들의 객체들이 스프링의 Bean으로 설정되게 만들어야 함
@RequestMapping을 이용하는 것만으로 여러 Controller를 하나의 클래스로 묶을 수 있고, 각 기능마다 메서드 단위로 설계할 수 있게 되어 많은 양의 코드 줄일 수 있음
스프링 4버전 이후에는 @GetMapping / @PostMapping 어노테이션으로 GET/ POST 방식 구분해서 처리 가능
예를 들어, "/todo/register"는 GET 방식으로 화면을 보여주고, POST 방식으로 처리하므로 다음과 같이 설계
// TodoController
package org.zerock.springex.controller;
import ...
@Controller
@RequestMapping("/todo")
@Log4j2
public class TodoController {
// 클래스 선언부에서 RequestMapping의 value가 "/todo"이고 list() 메서드에서 ReuestMapping의 value가 "/list"이므로
// 최종 경로는 "/todo/list"가 됨
@RequestMapping("/list")
public void list() {
log.info("tood list..........");
}
// @RequestMapping(value = "/register", method = RequestMethod.GET)
@GetMapping("/register")
public void registerGET() {
log.info("GET todo register...............");
}
@PostMapping("/register")
public void registerPOST() {
log.info("POST todo register...............");
}
}
2. 파라미터 자동 수집과 변환
파라미터 자동 수집은 DTO, VO 등을 메서드의 파라미터로 설정하면 자동으로 전달되는 HttpServletRequest의 파라미터들을 수집해주는 기능
단순 문자열만이 아니라 숫자, 배열, 리스트, 첨부 파일도 가능
파라미터 수집 동작 기준
기본 자료형의 경우 자동으로 형 변환처리 가능
객체 자료형의 경루 setXXX()를 통해 처리
객체 자료형의 경우 생성자가 없거나 파라미터가 없는 생성자가 필요(Bean)
실습
3) 단순 파라미터의 자동 수집
- SampleController에서의 예시
// SampleController
package org.zerock.springex.controller;
import ...
// 해당 클래스가 MVC에서 Controller 역할을 한다는 의미, 스프링의 Bean으로 처리되기 위해 사용
@Controller
@Log4j2
public class SampleController {
...
@GetMapping("/ex1")
public void ex1(String name, int age) {
log.info("ex1.......");
log.info("name: " + name);
log.info("age: " + age);
}
}
- 주소를 "http://localhost:8080/ex1?name=AAA&age=16"로 설정하면 자동으로 name은 문자열 AAA로, age는 숫자 16으로 파라미터를 수집해와서 로그에 출력
- @RequestParam
- 요청에 전달된 파라미터 이름을 기준으로 동작하지만, 간혹 파라미터가 전달되지 않으면 문제 발생할 수 있음
- 이 때 @RequestParam이라는 어노테이션 고려
- @RequestParam은 defaultValue라는 속성이 있어서 '기본값'을 지정할 수 있음
package org.zerock.springex.controller;
import ...
// 해당 클래스가 MVC에서 Controller 역할을 한다는 의미, 스프링의 Bean으로 처리되기 위해 사용
@Controller
@Log4j2
public class SampleController {
...
@GetMapping("/ex2")
public void ex2(@RequestParam(name = "name", defaultValue = "AAA") String name,
@RequestParam(name = "age", defaultValue = "20") int age) {
log.info("ex2.......");
log.info("name: " + name);
log.info("age: " + age);
}
}
- 주소에 "http://localhost:8080/ex2"만 입력하고 파라미터를 주지 않아도 기본값으로 파라미터를 받아서 로그로 출력함
- Formatter를 이용한 파라미터의 커스텀 처리
- 기본적으로 HTTP는 문자열로 데이터를 전달하기 때문에 Controller는 문자열을 기준으로 특정 클래스의 객체로 처리하는 작업이 진행
ex5 호출하면 name과 result라는 이름을 가진 값들이 생성되어 ex6으로 redirect됨
ex6의 화면이 나오고 쿼리 스트링으로 준 name의 값 "ABC"가 주소창에 전달되어 있고, 화면에 ${result}로 출력한 result의 값인 "success"가 출력되어 있음
addFlashAttribute는 일회용으로 전달하고 사라지므로, 해당 페이지를 새로고침하면, "success"가 사라짐을 확인
4) 다양한 리턴 타입
스프링 MVC에서 Controller내에 선언하는 메서드의 리턴 타입을 다양하게 사용 가능
void: 화면이 따로 있는 경우, @RequestMapping값 과 @GetMapping 등 메서드에서 선언된 값을 그대로 View의 이름으로 사용, 주로 상황에 관계없이 동일한 화면을 보여줄 때 사용
문자열: 화면이 따로 있는 경우, 상황에 따라 다른 화면 보여줄 때 사용, 다음과 같은 특별한 접두어 사용가능
redirect: 리다이렉션을 이용하는 경우, 주로 forward 대신 redirect 이용
forward: 브라우저의 URL은 고정하고 내부적으로 다른 URL로 처리하는 경우
객체나 배열, 기본 자료형: JSON 타입 활용 시
ResponseEntity: JSON 타입 활용 시
5) 스프링 MVC에서 주로 사용하는 어노테이션
Controller 선언부에 사용하는 어노테이션
@Controller: 스프링 Bean의 처리됨을 명시
@RestController: REST 방식의 처리를 위한 Controller임을 명시
@RequestMapping: 특정한 URL 패턴에 맞는 Controller인지를 명시
메서드 선언부에 사용하는 어노테이션
@GetMapping / @PostMapping / @DeleteMapping / @PutMapping ...: HTTP 전송방식에 따라 해당 메서드를 지정하는 경우 사용, 일반적으로 @GetMapping과 @PostMapping을 주로 사용
@RequestMapping: GET / POST 방식 모두 지원하는 경우 사용
@ResponseBody: REST 방식에서 사용
메서드의 파라미터에 사용하는 어노테이션
@RequestParam: Request에 있는 특정한 이름의 데이터를 파라미터로 받아서 처리하는 경우 사용
@PathVariable: URL 경로의 일부를 변수로 삼아서 처리하기 위해 사용
@ModelAttribute: 해당 파라미터는 반드시 Model에 포함되어 다시 View로 전달됨을 명시(주로 기본 자료형이나 Wrapper 클래스, 문자열에 사용)
기타: @SessionAttribute, @Valid, @RequestBody 등
3. 스프링 MVC의 예외 처리
스프링 MVC에서 예외를 처리하는 가장 일반적인 방법은 @ControllerAdvice를 이용하는 것
@ControllerAdvice는 Controller에서 발생하는 예외에 맞게 처리할 수 있는 기능 제공
@ControllerAdvice가 선언된 클래스 역시 스프링의 Bean으로 처리됨
실습을 위해 controller 패키지 > exception 패키지 > CommonExceptionAdvice 클래스 작성
// CommonExceptionAdvice
package org.zerock.springex.controller.exception;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.ControllerAdvice;
@ControllerAdvice
@Log4j2
public class CommonExceptionAdvice {
}
1) @ExceptionHandler
@ControllerAdvice의 메서드들에는 특별하게 @ExceptionHandler라는 어노테이션 사용 가능
이를 이용해서 전달되는 Exception 객체들을 지정하고 메서드의 파라미터에서 이를 이용 가능
고의로예외를 발생시키는 코드 작성하여 실험
// SampleController
package org.zerock.springex.controller;
import ...
@Controller
@Log4j2
public class SampleController {
...
// p1에는 문자열이, p2에는 숫자가 전달되어야 함
@GetMapping("/ex7")
public void ex7(String p1, int p2) {
log.info("p1........" + p1);
log.info("p2........" + p2);
}
}
해당 코드를 작성하고, p2에 숫자가 아닌 문자열을 쿼리 스트링으로 전달해주면 예외가 발생
"localhost:8080/ex7?p1=AAA&p2=BBB"를 주면 BBB는 int형이 아니므로 에러 코드 400이 발생
해결을 위해 CommonExceptionAdvice에 NumberFormatException을 처리하도록 지정
// CommonExceptionAdvice
package org.zerock.springex.controller.exception;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice
@Log4j2
public class CommonExceptionAdvice {
// 문자열이나 JSON 데이터를 그대로 전송할 때 사용되는 어노테이션
@ResponseBody
// @ExceptionHandler를 가진 모든 메서드는 해당 타입의 예외를 파라미터로 전달받을 수 있음
@ExceptionHandler(NumberFormatException.class)
// exceptNumber()는 @ResponseBody를 이용해서 만들어진 문자열을 그대로 브라우저에 전송하는 방식 이용
public String exceptNumber(NumberFormatException numberFormatException) {
log.error("--------------------------------");
log.error(numberFormatException.getMessage());
return "NUMBER FORMAT EXCEPTION";
}
}
2) 범용적인 예외처리
예외 처리의 상위 타입인 Excpetion 타입을 처리하도록 구성
// CommonExceptionAdvice
package org.zerock.springex.controller.exception;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Arrays;
@ControllerAdvice
@Log4j2
public class CommonExceptionAdvice {
// 문자열이나 JSON 데이터를 그대로 전송할 때 사용되는 어노테이션
@ResponseBody
// @ExceptionHandler를 가진 모든 메서드는 해당 타입의 예외를 파라미터로 전달받을 수 있음
@ExceptionHandler(Exception.class)
// exceptCommon은 Exception 타입을 처리하여 사실상 거의 모든 예외를 처리하는 용도로 사용 가능
public String exceptCommon(Exception exception) {
log.error("--------------------------------");
log.error(exception.getMessage());
// <ul>로 시작하는 buffer 문자열 작성
StringBuffer buffer = new StringBuffer("<ul>");
// 예외 처리 메세지가 발생할 때마다 <li>와 함께 <ul></ul> 안에 리스트 형태로 해당 메세지를 추가
buffer.append("<li>" + exception.getMessage() + "</li>");
// 에러가 났을 때, 현재의 함수나 메서드 명도 같이 출력하여 더 자세히 디버깅할 수 있도록 함
Arrays.stream(exception.getStackTrace()).forEach(stackTrackElement -> {
buffer.append("<li>" + stackTrackElement + "</li>");
});
// 마지막에는 리스트 형식을 끝내도록 </ul> 추가
buffer.append("</ul>");
return buffer.toString();
}
}
3) 404 에러 페이지와 @ResponseStatus
서버 내부가 아닌 시작부터 잘못된 URL을 호출할 때 404 예외 발생
@ControllerAdvice에 작성하는 메서드에 @ResponseStatus를 이용하면 404상태에 맞는 화면을 별도로 작성 가능
// CommonExceptionAdvice
package org.zerock.springex.controller.exception;
import ...
@ControllerAdvice
@Log4j2
public class CommonExceptionAdvice {
...
// 404 에러 대비
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public String notFound() {
return "custom404";
}
}
custom404의 페이지를 jsp 파일로 작성
<!-- custom404 -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>Oops! 페이지를 찾을 수 없습니다!</h1>
</body>
</html>
web.xml에서는 DispatcherServlet의 설정을 조정해야함
<servlet> 태그 내에 <init-param>을 추가하고 throwExceptionIfNoHandlerFound라는 파라미터 설정 추가