- 영속처리: 데이터를 영구적으로 저장하고 유지
1) VO 선언
2) Mapper
3) xml
4) 테스트 코드
- 등록작업 TodoMapper -> TodoService -> TodoController ->jsp 순서로 진행
TodoMapper
- TodoMapper.java
- TodoMapper.xml
- TodoMapperTests.java
TodoService
- TodoService.java
- TodoServiceImpl.java
- TodoServiceTests.java
TodoController
- TodoController.java
package org.zerock.springex.controller; import org.zerock.springex.dto.TodoDTO; import jakarta.validation.Valid; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import lombok.extern.log4j.Log4j2; import org.springframework.web.servlet.mvc.support.RedirectAttributes; @Controller @RequestMapping("/todo") @Log4j2 public class TodoController { @RequestMapping("/list") public void list() { log.info("todo list..............."); } @GetMapping("/register") public void registerGET() { log.info("GET todo register~~~~~"); } @PostMapping("/register") public String registerPost(@Valid TodoDTO todoDTO, BindingResult bindingResult, RedirectAttributes redirectAttributes ) { log.info("POST todo register~~~~~"); if(bindingResult.hasErrors()) { log.info("has errors!!!!!!!"); redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors()); return "redirect:/todo/register"; } log.info(todoDTO); return "redirect:/todo/list"; } }jsp
- register.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> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- Bootstrap CSS --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> <title>Hello, world!</title> </head> <body> <div class="container-fluid"> <div class="row"> <!-- 기존의 <h1>Header</h1> --> <div class="row"> <div class="col"> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <div class="container-fluid"> <a class="navbar-brand" href="#">Navbar</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarNavAltMarkup"> <div class="navbar-nav"> <a class="nav-link active" aria-current="page" href="#">Home</a> <a class="nav-link" href="#">Features</a> <a class="nav-link" href="#">Pricing</a> <a class="nav-link disabled">Disabled</a> </div> </div> </div> </nav> </div> </div> <!-- header end --> <!-- 기존의 <h1>Header</h1>끝 --> <div class="row content"> <div class="col"> <div class="card"> <div class="card-header"> Featured </div> <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="text" name="writer" 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> <script> const serverValidResult = {} <c:forEach items="${errors}" var="error"> serverValidResult['${error.getField()}'] = '${error.defaultMessage}' </c:forEach> console.log(serverValidResult) </script> </div> </div> </div> </div> </div> <div class="row content"> <h1>Content</h1> </div> <div class="row footer"> <!--<h1>Footer</h1>--> <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> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> </body> </html>
- web.xml
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd" version="5.0"> <context-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/root-context.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <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> <init-param> <param-name>throwExceptionIfNoHandlerFound</param-name> <param-value>true</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>appServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <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>
hibernate-validator
@NotNull: 필드나 메서드 매개변수가 null이 아니어야 함을 나타냅니다.
@Null: Null만 입력 가능
@NotEmpty: 문자열, 배열, 컬렉션 등이 비어 있지 않아야 함을 나타냅니다.
@NotBlank: 문자열 필드가 비어 있지 않아야 함을 나타냅니다.
@Pattern(regexp): 정규식 패턴을 사용하여 문자열의 형식을 검사합니다.
@Size(min, max): 문자열, 배열, 컬렉션의 길이 또는 크기에 대한 제약을 설정합니다.
@Min(value): 숫자 형식의 필드가 지정된 최소 값 이상이어야 함을 나타냅니다.
@Max(value): 숫자 형식의 필드가 지정된 최대 값 이하여야 함을 나타냅니다.
@Past: 날짜와 시간 필드가 현재 날짜 이전인지 확인합니다.
@Future: 날짜와 시간 필드가 이후인지 확인합니다.
@Positive: 양수만 가능
@PositiveOrZero: 양수와 0만 가능
@Negative: 음수만 가능
@NegativeOrZero: 음수와 0만 가능
@Email: 이메일 주소의 유효성을 검사합니다.
@AssertTrue, @AssertFalse: 논리 값 (true 또는 false)를 검사합니다.
@CreditCardNumber: 신용 카드 번호의 유효성을 검사합니다.
@Length(min, max): 문자열의 길이 제한을 설정합니다.
@Range(min, max): 숫자 필드가 지정된 범위 내에 있어야 함을 나타냅니다.
- 유효성 검사를 자바스크립트를 했었지만 모바일과 같은 다양한 환경에서 서버를 이용하는 현재에는 브라우저를 사용하는 프론트쪽에서의 검증과 더불어 백서버에서도 입력되는 값들을 검증하는 것이 일반적이다.
- 이러한 검증 작업은 컨트롤러에서 진행하는데 스프링 MVC의 경우 @Valid와 BindingResult라는 것을 이용해서 간단하게 처리할 수 있다.
- @Valid의 애너테이션을 이용해서 검증하고, BindingResult는 스프링 프레임워크에서 유효성 검사 후에 발생하는 오류 및 검증 결과를 저장
- @Valid annotion을 사용한 모델 객체 검증
- BindingResult에 검증에 대한 결과를 저장
- RedirectAttribute 리다이렉트 한 후 데이터 전달
순서를 지켜 사용하면 스프링 mvc는 유효성 검사를 수행하고 유효성 검사 결과를BindingResult 에 저장한 후 리다이렉트 한 후에 데이터를 RedirectAttributes를 통해 안전하게 전달할 수 있다
- 화면에서 체크박스를 이용해서 완료여부(finished)를 처리.
문제는 브라우저가 체크박스가 클릭된 상태 전송되는값이 'on'이라는 값을 전달한다.
TodoDTO로 데이터를 수집할때 문자열 'on'을 boolean타입으로 처리할 수 있어야 한다.- 컨트롤러에서 데이터를 수집할 때 타입을 변경해줘야 한다.
- 가져오는수
select * from tbl_todo order by tno desc limit 10;- 첫번째 10 건너뛰는 데이터수(skip), 두번째 10가져오는 데이터수 (fetch)
2페이지 == select from tbl_todo order by tno desc limit 10, 10;
5페이지 == select from tbl_todo order by tno desc limit 40, 10;- 전체 데이터 개수
select count(tno) from tbl_todo;
- 현재 페이지의 번호(page)
- 한페이지당 보여주는 데이터의 수(size)
package com.springex.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Builder @Data @AllArgsConstructor @NoArgsConstructor public class PageRequestDTO { @Builder.Default // 필드에 기본값 설정 @Min(value = 1) // 최솟값 @Positive // 양수여야 한다. private int page = 1;// 현재 페이지 번호 @Builder.Default // 필드에 기본값을 설정 @Min(value = 10) // 최솟값 10 @Max(value = 100) // 최댓값 100 @Positive private int size = 10; //한페이지당 보여주는 데이터의 수 public int getSkip() { // 일반적으로 데이터베이스나 컬렉션에서 데이터를 가져올때 // 0부터 시작하는 인덱스를 사용하는 경우가 많아서 // 페이지를 가져오려면 인덱스 0번부터 시작해야 한다. // ex)페이지번호 1이면 (1-1)*10 은 0 -> 첫번째 페이지의 데이터를 가져올때 사용 // 페이지번호가 2이면 (2-1)*10 -> 10부터는 두번째 페이지의 데이터를 가져올때 사용 return (page -1) * 10; } }
- 화면에 페이지 번호들을 구성하기 위해서는 전체 데이터의 수를 알아야만 가능하다. 예를 들어 마지막 페이지가 7에서 끝나야 하는 상황이 생긴다면 화면상에서도 페이지 번호를 조절해야 하기 때문이다.
- TodoDTO의 목록
- 전체 데이터 수
- 페이지 번호의 처리를 위한 데이터들(시작 페이지 번호/ 끝 페이지 번호)
- 화면상에서 페이지 번호들을 출력하려면 현재 페이지번호(page)와 페이지당 데이터 수(size)를 이용해서 계산해야 한다.
- PageResponseDTO는 생성자를 통해서 필요한 page나 size등을 전달받도록 구성한다
- 페이지 번호를 계산하려면 현재 페이지의 번호(page)가 필요
- 화면에 10개의 페이지 번호를 출력한다고 했을 때 경우의 수들이 발생한다
1) page가 1인 경우: 시작페이지(start)는 1, 마지막페이지(end)는 10
1) page가 10인 경우: 시작페이지(start)는 1, 마지막페이지(end)는 10
1) page가 11인 경우: 시작페이지(start)는 11, 마지막페이지(end)는 20- 마지막 페이지/시작페이지 번호의 계산
흔히들 처음에 구해야 하는 것이 start라고 생각하지만, 마지막 패아자(end)를 구하는 계산이 더 편할 수 있다.- end는 현재 페이지 번호를 기준으로 계산한다.
- 마지막 페이지를 먼저 계산한 이유가 시작페이지(start)의 계산을 쉽게 하기 위함. 시작페이지(start)의 경우 계산한 마지막 페이지에서 9를 빼면 된다.
this.start = this.end -9
10-9=1
20-9=11- 시작페이지의 구성은 끝났지만 마지막 페이지의 경우 다시 전체 개수(total)를 고려해야 한다. 만일 10개씩(size) 보여주는 경우 전체 개수(total)가 75라면 마지막 페이지는 10이 아닌 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가 되어야한다
end 7 last9 7 > 8 // last가 최종이어야한다 int last = (int)(Math.ceil((total/(double)size))); this.end = end > last ? last: end;
- 이전(prev)/다음(next) 계산
이전페이지의 존재 여부는 시작 페이지(start)가 1이 아니라면 무조건 true가 되어야 한다
(1페이지부터 시작이고 아니라면 2,3,4,, 이기 때문에 페이지가 존재하고 있다)- 다음(next)은 마지막 페이지(end)와 페이지당 개수(size)를 곱한 값보다 전체 개수(total)가 더 많으면 다음(next)가 더 있다고 판단해야 한다.
this.prev = this.start >1; this.next = total > this.end * this.size;
- 목록 페이지는 특정 Todo의 제목(title)을 누르면 조회 페이지로 이동됨
- 기존에는 단순히 tno만을 전달해서 '/todo/read?tno=22'과 같은 방식으로 이동했지만 페이지 번호가 붙을 때는 page와 size 같이 전달이 되어야만 조회 페이지에서 다시 목록 이동할대 기존 페이지를 볼 수 있게 된다
- 수정/삭제 작업은 POST방식으로 처리되고 삭제 처리가 된 후에는 다시 목록으로 이동해야 한다.
그렇기 때문에 수정 화면에서[form] 태그로 데이터를 전송할때 관련 정보를 같이 추가해서 전달해야만 한다.<input type="hidden" name="page" value="${pageRequestDTO.page}"> <input type="hidden" name="size" value="${pageRequestDTO.size}">post 방식으로 이루어지는 삭체 처리도 PageRequestDTO를 이용해서 [form] 태그로 전송되는 태그들을 수집하고 수정 후에 목록페이지로 이동할 때 page는 무조건 1페이지로 이동하도록 만들 수 있다
- 수정 처리 후 이동
Todo를 수정한 후에 목록으로 이동할때 페이지 정보를 이용해야
TodoController의 Modify()에서 PageRequestDTO를 받도록 구현한다