• 스프링에서 JSP 위주로 View를 개발하는 것처럼 스프링 부트는 Thymeleaf라는 템플릿 엔진 이용

1. Thymeleaf 기초 문법

 1) 인텔리제이 설정

  • Thymeleaf를 이용하기 위해 html 파일의 네임스페이스에 Thymeleaf를 지정하는 것
  • 네임스페이스를 지정하면 'th:'와 같은 Thymeleaf의 모든 기능을 사용할 수 있게됨
<!-- hello.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>
  • '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);
    }
}
  • templates > ex 디렉토리 생성 > ex1.html 추가하여 결과화면 생성
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
  <h4>[[${list}]]</h4>
  <hr/>
  <h4 th:text="${list}"></h4>
</body>
</html>

 

  - th:with를 이용한 변수 선언

  • Thymeleaf를 이용하는 과정에서 임시로 변수를 선언해야 하는 상황에서 'th:with'를 이용해서 간단히 처리 가능
  • 'th:with'로 만드는 변수는 '변수명 = 값' 형태로, ','를 이용해 여러 개 선언 가능
<!-- hello.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    <h1 th:text="${msg}"></h1>

    <!-- 임시 변수 선언 -->
    <div th:with="num1 = ${10}, num2 = ${20}">
        <h4 th:text="${num1 + num2}"></h4>
    </div>
    
</body>
</html>

 

 2) 반복문과 제어문 처리

  • 크게 두 가지 방법
    • 반복이 필요한 태그에 'th:each'를 적용하는 방법
    • <th:block>이라는 별도의 태그를 이용하는 방법
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    <!-- 반복이 필요한 태그에 'th:each'를 적용하는 방법 -->
    <ul>
        <li th:each="str: ${list}" th:text="${str}"></li>
    </ul>
    
    <!-- <th:block>이라는 별도의 태그를 이용하는 방법 -->
    <ul>
        <th:block th:each="str: ${list}">
            <li>[[${str}]]</li>
        </th:block>
    </ul>

</body>
</html>
  • 결과는 동일

 

  - 반복문의 status 변수

  • Thymeleaf는 th:each를 처리할 때 현재 반복문의 내부 상태에 변수를 추가해서 사용할 수 있음
  • 일명 status 변수라고 하며 index / count / size / first / last / odd / even 등을 이용해 자주 사용하는 값 출력 가
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    ...

    <ul>
        <li th:each="str,status: ${list}">
            [[${status.index}]] -- [[${str}]]
        </li>
    </ul>

</body>
</html>

  - th:if / th:unless / th:switch

  • Thymeleaf는 제어문의 형태로 th:if / th:unless / th:switch를 이용할 수 있음
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    ...

    <ul>
        <li th:each="str,status: ${list}">
            <span th:if="${status.odd}">ODD -- [[${str}]]</span>
            <span th:unless="${status.odd}">EVEN -- [[${str}]]</span>
        </li>
    </ul>

</body>
</html>

  • '? ' 사용하여 더 편하게 이항 혹은 삼항 처리 가
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    ...

    <!-- 이항 연산자로 사용 -->
    <ul>
        <li th:each="str,status: ${list}">
            <span th:text="${status.odd}?'ODD ---' + ${str}"></span>
        </li>
    </ul>
    
    <!-- 삼항 연산자로 사용 -->
    <ul>
        <li th:each="str,status: ${list}">
            <span th:text="${status.odd}?'ODD ---' + ${str} : 'EVEN ---' + ${str}"></span>
        </li>
    </ul>
    

</body>
</html>

  • th:switch는 th:case와 같이 사용해서 Switch 문을 처리할 때 사용할 수 있음
<!-- ex1.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    ...
	
    <!-- 3으로 나눈 나머지의 결과에 따라 각각의 결과를 출력 -->
    <ul>
        <li th:each="str,status: ${list}">
            <th:block th:switch="${status.index % 3}">
                <span th:case="0">0</span>
                <span th:case="1">1</span>
                <span th:case="2">2</span>
            </th:block>
        </li>
    </ul>

</body>
</html>

 

 3) Thymeleaf 링크 처리

  • Thymeleaf에서는 @로 링크를 작성하면 링크 처리됨
<a th:href="@{/hello}">Go to /hello</a>

 

  - 링크의 쿼리 스트링 처리

  • 링크를 'key=value'의 형태로 필요한 파라미터를 처리해야 할 때 상당히 편리
  • 쿼리 스트링은 '()'를 이용해서 파라미터의 이름과 값을 지정
<a th:href="@{/hello(name='AAA', age=16)}">Go to /hello</a>

  • 한글이나 공백에 대한 URL 인코딩 처리가 자동으로 이루어짐
<a th:href="@{/hello(name='한글 처리', age=16)}">Go to /hello</a>
  • 링크를 만드는 값이 배열과 같이 여러 개일 때는 자동으로 같은 이름의 파라미터로 처리
<a th:href="@{/hello(types=${{'AA', 'BB', 'CC'}}, age=16)}">Go to /hello</a>

 

 

2. Thymeleaf의 특별한 기능들

 1) 인라인 처리

  • Thymeleaf에서 상황에 따라 동일한 데이터를 다르게 출력해 주는 인라인 기능은 자바스크립트를 사용할 때 편리한 기능
  • 다양한 종류의 데이터를 Model로 담아서 전달하는 메서드를 SampleController에 추가
// 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;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Controller
@Log4j2
public class SampleController {

    ...
    
    class SampleDTO {
        private String p1,p2,p3;
        
        public String getP1() {
            return p1;
        }
        public String getP2() {
            return p2;
        }
        public String getP3() {
            return p3;
        }
    }
    
    @GetMapping(("/ex/ex2"))
    public void ex2(Model model) {
        log.info("ex/ex2................");
        
        List<String>strList = IntStream.range(1,10)
                .mapToObj(i -> "Data"+i)
                .collect(Collectors.toList());
        
        model.addAttribute("list", strList);
        
        Map<String, String> map = new HashMap<>();
        map.put("A","AAAA");
        map.put("B","BBBB");
        
        model.addAttribute("map", map);
        
        SampleDTO sampleDTO = new SampleDTO();
        sampleDTO.p1 = "Value -- p1";
        sampleDTO.p2 = "Value -- p2";
        sampleDTO.p3 = "Value -- p3";
        
        model.addAttribute("dto", sampleDTO);
    }
}
  • ex2 화면 구성
<!-- ex2.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

  <div th:text="${list}"></div>
  <div th:text="${map}"></div>
  <div th:text="${dto}"></div>

  <script th:inline="javascript">
    const list = [[${list}]]
    const map = [[${map}]]
    const dto = [[${dto}]]
    
    console.log(list)
    console.log(map)
    console.log(dto)
  </script>

</body>
</html>
  • html은 기존처럼 출력되고, <script> 부분은 자바스크립트에 맞는 문법으로 만들어지는 것을 확인

 

 2) Thymeleaf의 레이아웃 기능

  • <th:block>을 이용하면 레이아웃을 만들고 특정한 페이지에서 필요한 부분만 작성하는 방식으로 개발 가능
  • 별도의 라이브러리 필요하므로 build.gradle에서 추가
// build.gradle
dependencies {
	
    ...

	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.1.0'
}

 

  • templates > layout 폴더 생성 > 레이아웃을 위한 layout1.html 작성
<!-- layout1.html -->
<!DOCTYPE html>
<!-- thymeleaf의 layout 작성을 위한 네임스페이스 지정 -->
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Layout Page</title>
</head>
<body>

<div>
  <h3>Sample Layout Header</h3>
</div>

<!-- layout:fragment 속성을 이용하면 나중에 다른 파일에서 해당 부분만 개발 가능 -->
<div layout:fragment="content">
  <p>Page content goes here</p>
</div>

<div>
  <h3>Sample Layout Footer</h3>
</div>

<th:block layout:fragment="script">
  
</th:block>

</body>
</html>

 

  • Samplecontroller에 레이아웃 예제를 위한 ex3()을 추가
// 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;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Controller
@Log4j2
public class SampleController {

    ...

    @GetMapping("/ex/ex3")
    public void ex3(Model model) {
        model.addAttribute("arr", new String[]{"AAA", "BBB", "CCC"});
    }
}

 

  • layout1.html에서 layout:fragment 속성으로 처리했던 content와 script부분을 다른 파일인 ex3에서 개발할 수 있음
<!-- ex3 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout1.html}">

<div layout:fragment="content">
    <h1>ex3.html</h1>
</div>

<!-- ex3 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout1.html}">

<div layout:fragment="content">
    <h1>ex3.html</h1>
</div>

<script layout:fragment="script" th:inline="javascript">
	const arr = [[${arr}]]
</script>

1. 기존 개발에서 달라진 점들

  • 스프링에서 스프링 부트로 넘어오는 일은 기존의 코드나 개념이 그대로 유지됨(현실적으로 새로운 개념 필요 x)
  • 설정과 관련해 직접 필요한 라이브러리를 기존 build.gradle 파일에 추가하는 설정이 자동으로 처리됨
  • 톰캣이 내장된 상태로 프로젝트가 생성되기 때문에 WAS의 추가 설정이 필요 x
  • Bean 설정은 XML을 대신에 자바 설정으로 이용하는 것으로 약간 변경됨
  • 스프링 MVC에서는 JSP, 스프링 부트에서는 Thymeleaf라는 템플릿 엔진 활용
  • 최근 스프링 부트는 화면을 구성하지 않고 데이터만을 제공하는 API 서버 형태를 이용하기도 함
  • 스프링 부트에서도 MyBatis를 이용할 수 있지만, [자바 웹 개발 워크북]에서는 JPA 이용
    • JPA 이용 시 객체지향으로 구성된 객체들을 데이터베이스에 반영할 수 있고, 이를 자동으로 처리할 수 있으므로 별도의 SQL 개발 없이도 개발 가능

 

 1) 스프링 부트의 프로젝트 생성 방식

  • Spring Initializr를 이용한 자동 생성 또는
    Maven이나 Gradle을 이용한 직접 생성
  • 스프링 부트는 거의 모든 개발에 Spring Initializr를 이용(프로젝트의 기본 템플릿 구조를 만들어 주기 때문)

 

 

2. Spring Initailizr를 이용한 프로젝트 생성

  • New Project 생성

  • Dependencies에서 다음 항목들 추가
    • Spring Boot DevTools
    • Lombok
    • Spring Web
    • Thymeleaf
    • Spring Data JPA
    • MariaDB Driver

 

 1) 프로젝트의 실행

  • 스프링 부트의 프로젝트는 서버를 내장한 상태에서 만들어지기 때문에 스프링만을 이용할 때와 달리 별도의 WAS 설정 필요 x
  • main()의 실행을 통해 프로젝트 실행
  • 프로젝트 초기화할 때 실행 메뉴에 'B01Application이라는 이름으로 실행 메뉴가 구성됨

  • main() 실행 시, 다음과 같은 로그 출력

  • 실행 결과는 실패

  • 스프링 부트가 자동 설정을 통해 인식한 Spring Data JPA를 실행할 때 DB와 관련된 설정을 찾을 수 없어 발생
  • 에러가 발생하긴 했지만 아무 설정 없이 자동으로 데이터베이스 관련 설정을 이용함
    → 라이브러리만으로 설정을 인식하려는 특성을 자동 설정(auto configuration)이라고 함

 

  • 스프링 부트 설정은 프로젝트 생성 시 만들어진 application.properties 파일을 이용하거나 application.yml 파일 이용
  • 파일 설정을 피하고 싶다면 @Configuration이 있는 클래스 파일을 만들어서 필요한 설정을 추가
  • 대부분의 스프링을 지원하는 IDE에서 application.properties 파일에 들어갈 수 있는 내용을 쉽게 완성해 주는 기능 제공
  • application.properties 파일에 데이터베이스 설정을 다음과 같이 추가
// application.properties

spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/webdb
spring.datasource.username=webuser
spring.datasource.password=yong1998
  • 다시 실행하면 8080포트로 톰캣이 실행됨

 

  • HikariCP 라이브러리를 가져오거나 HikariConfig 객체를 구성하는 등의 모든 과정이 생략됨

 

 2) 편의성을 높이는 설정

  - 자동 리로딩 설정

  • 웹 개발 시 코드 수정하고 다시 deploy 하는 과정을 자동으로 설정 가능
  • Edit Configuration > Modify options > On 'Update' action / On frame deactivation > Update classes and resources

 

  - Lombok을 테스트 환경에서도 사용하기

  • 스프링 부트는 체크박스를 선택하는 것만으로 Lombok 라이브러리 추가가 가능하지만 테스트 환경에서는 설정이 빠져 있음
  • build.gradle 파일 내 dependencies 항목에 test 관련 설정을 조정
// build.gradle
...

dependencies {
	
    ...
	
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

...

 

  - 로그 레벨의 설정

  • 스프링 부트는 기본적으로 Log4j2가 추가되어 있어 라이브러리를 추가하지 않아고 됨
  • application.properties 파일을 이용해서 간단하게 로그 설정을 추가 가능
// application.properties

...

logging.level.org.springframework=info
logging.level.org.zerock=debug

 

  - 인텔리제이의 DataSource 설정

  • DataSource를 설정해두면 나중에 엔티티 클래스의 생성이나 기타 클래스의 생성과 설정 시에 도움이 됨
  • MariaDB를 설정

 

  - 테스트 환경과 의존성 주입 테스트

  • 스프링에는 'spring-test-xxx' 라이브러리를 추가해야 하고, JUnit 등도 직접 추가해야하지만,
    스프링 부트는 프로젝트 생성 시 이미 테스트 관련 설정이 완료되고 테스트 코드도 생성되어 있음

  • 테스트 코드 실행을 위해 DataSourceTests를 작성해서 HikariCP의 테스트와 Lombok 확인
// DataSourceTests
package org.zerock.b01;

import lombok.Cleanup;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

@SpringBootTest
@Log4j2
public class DataSourcetests {

    // DataSource는 application.properties에 설정된 DataSource 관련 설정을 통해 생성된 bean
    // 이에 대한 별도 설정없이 스프링에서 바로 사용 가능    
    @Autowired
    private DataSource dataSource;
    
    @Test
    public void testConnection() throws SQLException {
        
        @Cleanup
        Connection con = dataSource.getConnection();
        
        log.info(con);
        Assertions.assertNotNull(con);
    }
}
  • 테스트 환경에서도 @Log4j2 어노테이션을 통해 테스트 환경에서 Lombok을 사용할 수 있음을 확인

 

  - Spring Data JPA를 위한 설정

  • application.properties에 다음 내용 추가
//application.properties

...

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
  • spring.jpa.hibernate.ddl-auto 속성은 프로젝트 실행 시 DDL 문을 어떻게 처리할 것인지 명시
  • 속성값은 다음과 같이 명시
속성값 의미
none DDL을 하지 않음
create-drop 실행할 때 DDL을 실행하고 종료 시에 만들어진 테이블 등을 모두 삭제
create 실행할 때마다 새롭게 테이블을 생성
update 기존과 다르게 변경된 부분이 있을 때는 새로 생성
validate 변경된 부분만 알려주고 종료
  • update 속성값의 경우, 테이블이 없을 때는 자동으로 생성하고 변경이 필요할 때는 alter table이 실행됨,
    테이블 뿐만 아니라 인덱스나 외래키 등도 자동으로 처리

 

  • spring.jpa.properties.hibernate.format_sql 속성은 실제로 실행되는 SQL을 포맷팅하여 알아보기 쉽게 출력
  • sprinf.jpa.show-sql은 JPA가 실행하는 SQL을 같이 출력

 

 

3. 스프링 부트에서 웹 개발

  • controller나 화면을 개발하는 것은 유사하지만 web.xml이나 servlet-context.xml과 같은 웹 관련 설정 파일들이 없기 때문에 이를 대신하는 클래스를 작성해 준다는 점이 다름

 

 1) 컨트롤러와 Thymeleaf 만들기

  • 프로젝트에 우선 controller라는 패키지 생성 > SampleController 클래스 생성
// 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;

@Controller
@Log4j2
public class SampleController {
    
    @GetMapping("/hello")
    public void hello(Model model) {
        log.info("hello.................");
        
        model.addAttribute("msg", "HELLO WORLD");
    }
}
  • 프로젝트 생성 시 만들어져 있는 templates 폴더에 hello.html 작성

<!-- hello.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
	<h1 th:text="${msg}"></h1>
</body>
</html>
  • 실행 결과

 

 2) JSON 데이터 만들기

  • controller 패키지에 SampleJSONComtroller라는 클래스 작성
// SampleJSONController
package org.zerock.b01.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Log4j2
public class sampleJSONController {
    
    @GetMapping("/helloArr")
    public String[] helloArr() {
        log.info("helloArr.................");
        
        return new String[]{"AAA", "BBB", "CCC"};
    }
}
  • 브라우저에 'localhost:8080/helloArr' 경로를 호출하면 배열이 그대로 출력

  • 응답 헤더를 확인해보면 서버에서 해당 데이터는 'application/json'이라는 것을 전송함

7. 검색 / 필터링 조건의 정의

  • 검색 / 필터링 조건
    • 제목(title)과 작성자(writer)는 키워드(keyword)를 이용하는 검색 처리
    • 완료 여부를 필터링 처리
    • 특정 기간을 지정(from, to)한 필터링 처리
  • 검색 / 필터링에 필요한 데이터
    • 제목, 작성자 검색에 사용하는 문자열 - keyword
    • 완료 여부에 사용되는 boolean 타입 - finished
    • 특정 기간 검색을 위한 LocalDate 변수 2개 - from, to

 

 1) 검색 / 필터링 조건의 결정

  • 검색 기능의 경우의 수를 구분하는 작업 필요
  • 검색 종류를 types라고 지정, 제목(t)와 작성자(w)로 구분해서 검색의 실제값은 검색 종류에 따라 키워드(keyword)를 이용
  • PageRequestDTO에 필요한 변수들을 추가해서 구성
// PageRequestDTO
package org.zerock.springex.dto;

import ...

public class PageRequestDTO {

  ...
  
  private String[] types;
  private String keyword;
  private boolean finished;
  private LocalDate from;
  private LocalDate to;

  ...
  
}

 

 2) types에 따른 동적 쿼리

  • Mybatis에는 실행 시에 쿼리를 만들 수 있는 여러 태그들 제공
    • if
    • trim(where, set)
    • choose
    • foreach: 반복 처리를 위해 제공됨, List, Map, Set과 같은 컬렉션 계열이나 배열을 이용할 수 있음
  • TodoMapperTests 클래스에 새로운 테스트 메서드 추가
// TodoMapperTests
@Test
public void testSelectSearch() {
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
        .page(1)
        .size(10)
        .types(new String[]{"t", "w"})
        .keyword("AAAA")
        .build();
    List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
    voList.forEach(vo -> log.info(vo));
}
  • 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>
  • 테스트 코드에서 finished 조건 추가 후 실행시 다음과 같은 쿼리문 출력
package org.zerock.springex.mapper;

import ...

public class TodoMapperTests {
  
  ...

  @Test
  public void testSelectSearch() {
    PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
        .page(1)
        .size(10)
        .types(new String[]{"t", "w"})
        .keyword("스프링")
        .finished(true)
        .build();
    List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
    voList.forEach(vo -> log.info(vo));
  }
}

  • types 조건을 제외하여 finished만이 조건으로 설정된 경우 다음과 같이 'and'가 없는 쿼리문 출력

 

  • 같은 방식으로 만료일 처리
<!-- 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>
        <if test = 'finished'>
            <trim prefix = "and">
                finished = 1
            </trim>
        </if>
        <if test = "from != null and to !== null">
            <trim prefix = "and">
                dueDate between #{from} and #{to}
            </trim>
        </if>
    </where>
    order by tno desc limit #{skip}, #{size}
</select>

 

  - <sql>과 <include>

  • Mybatis의 동적 쿼리 적용은 목록 데이터를 가져오는 부분에도 적용되지만 전체 개수를 가져오는 부분에도 적용되어야 함
  • 전체 개수를 가져오는 TodoMapper의 getCount()에 파라미터로 PageRequestDTO 타입을 지정한 이유는 동적 쿼리를 적용하기 위함
  • Mybatis에 <sql> 태그를 이용해서 동일한 SQL 조각을 재사용 가능
  • 동적 쿼이 부분을 <sql>로 분리, 동적 쿼리가 적용될 부분은 <include>를 이용
<!-- TodoMapper.xml -->
<sql id = "search">
    <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>

        <if test = 'finished'>
            <trim prefix = "and">
                finished = 1
            </trim>
        </if>

        <if test = "from != null and to != null">
            <trim prefix = 'and'>
                dueDate between #{from} and #{to}
            </trim>
        </if>
    </where>
</sql>

<select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
    select * from tbl_todo

    <include refid = "search"></include>

    order by tno desc limit #{skip}, #{size}
</select>

<select id = "getCount" resultType = "int">
    select count(tno) from tbl_todo
    <include refid = "search"></include>
</select>
  • 테스트 코드에서 from, to 조건 추가 후 실행시 다음과 같은 쿼리문 출력
// TodoMapperTests
@Test
public void testSelectSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
    .page(1)
    .size(10)
    .types(new String[]{"t", "w"})
    .keyword("스프링")
    // .finished(true)
    .from(LocalDate.of(2021,12,01))
    .to(LocalDate.of(2022,12,31))
    .build();
List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
voList.forEach(vo -> log.info(vo));
log.info(todoMapper.getCount(pageRequestDTO));
}
  • selectList() 쿼리

  • getCount() 쿼리

 

 

8. 검색 조건을 위한 화면 처리

  • 검색 기능은 /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>
  • 화면 구성 결과

  • 검색 결과

주소창에 모든 검색 조건이 GET 방식의 쿼리 스트링으로 만들어짐, 검색 조건에 해당하는 데이터가 있다면 목록으로 출력될 것

 

 1) 화면에 검색 조건 표시하기

  • 검색이 처리되기는 하지만 PageRequestDTO의 정보를 EL로 처리하지 않아 검색 후 검색부분이 초기화되는 문제
  • 작성된 <div>에 EL을 적용할 때 제목(title)과 작성자(writer)를 배열로 처리하고 있어 문제가 됨
  • PageRequestDTO에 별도 메서드를 구성하여 더 편하게 사용할 수 있음
// PageRequestDTO
package org.zerock.springex.dto;

import ...

public class PageRequestDTO {

  ...
  
  public boolean checkType(String type) {
    if(types == null || types.length == 0) {
      return false;
    }
    return Arrays.stream(types).anyMatch(type::equals);
  }
}
  • 화면에서 EL 적용
<!-- 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";
  }
}
  • 조회에서 수정 / 삭제 이동 시에는 검색 / 필터링 조건 유지
  • 수정 후 조회 시에는 단순 조회

5. 페이징 처리를 위한 TodoMapper

  • Todo 데이터 수가 많아지면 목록 페이지를 가져올 때 많은 시간과 자원이 소모됨
  • 많은 데이터를 보여주는 작업은 페이징 처리를 해서 최소한의 데이터들을 보여주는 방식 선호
  • 페이징 처리에서 중요한 것은 데이터베이스에서 필요한 데이터만 가져오도록 하는 것
  • MySQL, MariaDB에서는 limit라는 기능을 이용해서 비교적 쉽게 페이징 처리 구현 가능

 

 1) 페이징을 위한 SQL 연습

  - 더미 데이터 추가: 기존에 데이터베이스에 있던 데이터를 여러번 복사하여 생성

insert into tbl_todo (title, dueDate, writer) (select title, dueDate, writer from tbl_todo);

 

  - limit 실습

  • MariaDB / MySQL에서 페이징 처리를 위해 select의 마지막 부분에 limit 처리를 이용
  • limit뒤에는 하나 또는 두 개의 값 전달
    • 첫번째 값: 건너뛰는 데이터 수(skip)
    • 두번째 값: 가져오는 데이터 수(fetch)
    • 하나의 값만 전달할 때: 가져오는 데이터 수만 전달
  • 먼저 가장 마지막에 등록된 데이터부터 순차적으로 보여지도록 tno를 기준으로 내림차순 정렬
select * from tbl_todo order by tno desc limit 10;

  • 처음 10개를 건너뛰고 나온 다음 10개를 출력하면 처음 10개의 마지막 숫자 4579 다음의 숫자인 4578에서 시작
select * from tbl_todo order by tno desc limit 10, 10;

  • 위와 같은 규칙으로
    • 1페이지: limit 10;
    • 2페이지: limit 10, 10;
    • 3페이지: limit 20, 10;
    • 4페이지: limit 30, 10;
    • ....

 

  - limit의 단점

  • limit 뒤에 식 사용 불가, 오직 값만 주어야 함(limit (2-1)*10, 10; 등의 식은 실행되지 않음)

 

  - count의 필요성

  • 페이징 처리를 하기 위해 전체 데이터의 개수도 필요
  • 전체 데이터의 개수는 페이지 번호를 구성할 때 필요(30개의 데이터가 있으면 3페이지까지 출력해야하는 등)

 

 2) 페이지 퍼리를 위한 DTO

  • 페이지 퍼리는 현재 페이지 번호, 한 페이지당 보여주는 데이터 개수가 기본적으로 필요
  • 2개의 숫자를 매번 전달할 수도 있지만 확장을 고려하여 별도의 DTO로 만들어 두는 것이 좋음
  • dto 패키지에 PageRequestDTO 클래스 정의
// PageRequestDTO
package org.zerock.springex.dto;

import lombok.*;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Positive;

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {

  // 페이지 번호
  @Builder.Default
  @Min(value = 1)
  @Positive
  private int page = 1;

  // 한 페이지당 개수
  @Builder.Default
  @Min(value = 10)
  @Max(value = 100)
  @Positive
  private int size = 10;

  // limit에서 사용하는 건너뛰기의 수
  public int getSkip() {
    return (page-1) * 10;
  }
}

 

 3) TodoMapper의 목록 처리

  • TodoMapper 인터페이스는 PageRequestDTO를 파라미터로 처리하는 selectList()를 추가
// TodoMapper
package org.zerock.springex.mapper;

import ...

public interface TodoMapper {
  
  ...

  List<TodoVO> selectList(PageRequestDTO pageRequestDTO);
}
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    
    ...

    <select id = "selectList" resultType = "org.zerock.springex.domain.TodoVO">
        <!-- MyBatis는 기본적으로 getXXX, setXXX를 통해 작동하므로 #{skip}은 getSkip()을 호출하게됨 -->
        select * from tbl_todo order by tno desc limit #{skip}, #{size}
    </select>

</mapper>

 

  • TodoMapperTests에 테스트 코드를 작성하여 selectList() 작동 확인
// TodoMapperTests
@Test
public void testSelectList() {
	PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
    	.page(1)
    	.size(10)
    	.build();
	List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
	voList.forEach(vo -> log.info(vo));
}

 

 4) TodoMapper의 count 처리

  • 화면에 페이지 번호들을 구성하기 위해 전체 데이터수 확인 필요
  • TodoMapper에 getCount() 추가하고, 검색을 대비해 PageRequestDTO를 파라미터로 받도록 설계
// TodoMapper
package org.zerock.springex.mapper;

import ...

public interface TodoMapper {
  
  ...
  
  int getCount(PageRequestDTO pageRequestDTO);
}
  • TodoMappe.xml은 우선 전체 개수를 반환하도록 구성
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    
    ...

    <select id = "getCount" resultType = "int">
        select count(tno) from tbl_todo
    </select>
</mapper>

 

 

6. 목록 데이터를 위한 DTO와 서비스 계층

  • 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)를 곱한 값보다 전체 개수가 더 많은지를 보고 판단
this.prev = this.start > 1;
this.next = total > this.end * this.size;

 

  - 최종 PageResponseDTO 코드

// 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;
  }
}

 

 

 2) TodoService / TodoServiceImpl

  • TodoService와 TodoServiceImpl에서 PageResponseDTO를 반환 타입으로 지정해서 getList() 구성(기존의 getAll()을 대체)
// TodoService
package org.zerock.springex.service;

import org.zerock.springex.dto.TodoDTO;
import org.zerock.springex.dto.PageRequestDTO;
import org.zerock.springex.dto.PageResponseDTO;
// import java.util.List;

// register는 여러 개의 파라미터 대신 TodoDTO로 묶어서 전달받음
public interface TodoService {
  void register(TodoDTO todoDTO);

  // List<TodoDTO> getAll();
  PageResponseDTO<TodoDTO> getList(PageRequestDTO pageRequestDTO);

  TodoDTO getOne(Long tno);

  void remove(Long tno);

  void modify(TodoDTO todoDTO);
}
// 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);
  }

  ...
  
}

 

  • read.jsp에서 List 버튼의 링크도 다시 처리
<!-- list.jsp -->
...
<script>
    
    ...
    
    document.querySelector(".btn-secondary").addEventListener("click", function(e){
      self.location = "/todo/list?${pageRequestDTO.link}";
    }, false)
</script>
...

 

 

  - 조회에서 수정으로

  • 조회화면에서 수정화면으로 이동할 때도 현재 페이지 정보를 유지해야해서 링크 처리 부분 수정
<!-- list.jsp -->
...
<script>
    
    document.querySelector(".btn-primary").addEventListener("click", function(e){
      self.location = `/todo/modify?tno=${dto.tno}&${pageRequestDTO.link}`
    }, false)
    
    ...
    
</script>
...

 

  • Modify 버튼 클릭 시 page 번호와 size 까지 같이 전달된 주소로 이동

 

  - 수정 화면에서의 링크 처리

  • 수정 화면에서 다시 목록으로 돌아가는 링크 처리
  • 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 {

  ...

  @PostMapping("/remove")
  public String remove(Long tno, PageRequestDTO pageRequestDTO, RedirectAttributes redirectAttributes) {
    log.info("----------remove----------");
    log.info("tno: "+tno);

    todoService.remove(tno);

    redirectAttributes.addAttribute("page", 1);
    redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
    return "redirect:/todo/list";
  }

  ...

}

 

  • 정상적으로 삭제되고 삭제 후에는 목록 페이지로 이동됨을 확인

 

  - 수정 처리 후 이동

  • 수정 후에 목록으로 이동할 때는 페이지 정보를 이용해야 하므로 TodoController의 modify()에서는 PageRequestDTO를 받아서 처리하도록 변경
/ TodoController
package org.zerock.springex.controller;

import ...

public class TodoController {

  ...
  @PostMapping("/modify")
  // PageRequestDTO를 파라미터로 추가
  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);
    
    // page와 size를 받아 리다이렉트 되도록 처리
    redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
    redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
    return "redirect:/todo/list";
  }
}

 

  • 수정 후, 정상적으로 원래 목록 페이지로 이동하는지 확인

  9) Todo 조회 기능 개발

  • 목록 화면에서 Title을 누르면 '/todo/read/?tno=xx'와 같이 TodoController를 호출하도록 개발

 

  - TodoMapper 조회 기능 개발

  • TodoMapper에 selectOne()이라는 메서드 추가
// TodoMapper
package org.zerock.springex.mapper;

import org.zerock.springex.domain.TodoVO;
import java.util.List;
public interface TodoMapper {

  String getTime();
  void insert(TodoVO todoVO);
  List<TodoVO> selectAll();

  // 파라미터는 Long 타입으로 tno를 받도록 설계, TodoVO 객체 반환하도록 구성
  TodoVO selectOne(Long tno);
}
  • TodoMapper.xml에 selectOne을 추가
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">

    ...
    
    <select id = "selectOne" resultType = "org.zerock.springex.domain.TodoVO">
        select * from tbl_todo where tno = #{tno}
    </select>
    
</mapper>
  • 테스트 코드를 통해 현재 데이터베이스에 존재하는 번호로 결과를 확인
// TodoMapperTests
@Test
public void testSelectOne() {
TodoVO todoVO = todoMapper.selectOne(3L);
log.info(todoVO);
}

tno가 3인 목록 조회

 

  - TodoService / TodoServiceImpl의 개발

  • TodoService에 getOne() 메서드 추가
// TodoService
package org.zerock.springex.service;

import ...

public interface TodoService {

  void register(TodoDTO todoDTO);
  List<TodoDTO> getAll();
  
  TodoDTO getOne(Long tno);
}
// TodoServiceImpl
package org.zerock.springex.service;

import ...
public class TodoServiceImpl implements TodoService {

  ...

  @Override
  public TodoDTO getOne(Long tno) {
    TodoVO todoVO = todoMapper.selectOne(tno);
    TodoDTO todoDTO = modelMapper.map(todoVO, TodoDTO.class);
    return todoDTO;
  }
}

 

  - TodoController의 개발

  • GET 방식으로 동작하는 read() 기능 개발
// 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 &nbsp;
                        </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>

링크가 추가된 title 목록

 

 

 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 &nbsp;
                        </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>
  • Remove 버튼이 생겼으며 수정이 가능해짐

 

  - Remove 버튼의 처리

  • 자바스크립트를 이용해서 <form> 태그의 action을 조정하는 방식으로 동작하게 구성
<!-- modify.jsp -->
	</form>
</div>
<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);
</script>
  • TodoController에는 POST 방식으로 동작하는 remove() 메서드를 설계
// TodoController
@PostMapping("/remove")
public String remove(Long tno, RedirectAttributes redirectAttributes) {
    log.info("----------remove----------");
    log.info("tno: "+tno);
    return "redirect:/todo/list";
}

Remove 버튼을 눌렀을 때 출력되는 로그

 

  - TodoMapper와 TodoService의 처리

  • TodoMapper에 delete() 메서드 추가, TodoMapper.xml에는 SQL을 추가
// TodoMapper
package org.zerock.springex.mapper;

import org.zerock.springex.domain.TodoVO;
import java.util.List;
public interface TodoMapper {

  String getTime();
  void insert(TodoVO todoVO);
  List<TodoVO> selectAll();
  TodoVO selectOne(Long tno);
  
  void delete(Long tno);
}
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    
    ...
    
    <delete id = "delete">
        delete from tbl_todo where tno = #{tno}
    </delete>
</mapper>

 

  • TodoService / TodoServiceImpl에는 remove() 메서드 추가
// TodoService
package org.zerock.springex.service;

import ...

public interface TodoService {

  void register(TodoDTO todoDTO);
  List<TodoDTO> getAll();
  TodoDTO getOne(Long tno);

  void remove(Long tno);
}
// TodoServiceImpl
package org.zerock.springex.service;

import ...

public class TodoServiceImpl implements TodoService {

  ...
  
  @Override
  public void remove(Long tno) {
    todoMapper.delete(tno);
  }
}

 

  • TodoController에서 TodoService의 remove()를 호출하는 코드 추가
// TodoController
@PostMapping("/remove")
    public String remove(Long tno, RedirectAttributes redirectAttributes) {
    log.info("----------remove----------");
    log.info("tno: "+tno);

    todoService.remove(tno);

    return "redirect:/todo/list";
}

 

  • 결과

 

 11) Todo의 수정 기능 개발

  • TodoMapper에 update() 메서드 추가 및 TodoMapper.xml에 update 기능 추가
// TodoMapper
package org.zerock.springex.mapper;

import ...

public interface TodoMapper {

  String getTime();
  void insert(TodoVO todoVO);
  List<TodoVO> selectAll();
  TodoVO selectOne(Long tno);
  void delete(Long tno);

  void update(TodoVO todoVO);
}
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    
    ...
    
    <update id = "update">
        update tbl_todo set title = #{title}, dueDate = #{dueDate}, finished = #{finished} where tno = #{tno}
    </update>

</mapper>

 

  • TodoService와 TodoServiceImpl에서는 TodoDTO를 TodoVO로 변환해서 처리
  • TodoService 인터페이스에 modify() 기능 추가
// TodoService
package org.zerock.springex.service;

import ...

public interface TodoService {

  void register(TodoDTO todoDTO);
  List<TodoDTO> getAll();
  TodoDTO getOne(Long tno);
  void remove(Long tno);
  
  void modify(TodoDTO todoDTO);
}
// 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 패키지에 추가
// org.zerock.springex > controller > formatter > CheckbocFormatter
package org.zerock.springex.controller.formatter;
import org.springframework.format.Formatter;

import java.text.ParseException;
import java.util.Locale;

public class CheckboxFormatter implements Formatter<Boolean> {
  @Override
  public Boolean parse(String text, Locale locale) throws ParseException {
    if(text ==null) {
      return false;
    }
    return text.equals("on");
  }
  
  @Override
  public String print(Boolean object, Locale locale) {
    return object.toString();
  }
}
  • 추가한 CheckbocFormatter는 servlet-context.xml에 등록
<!-- 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>

 

4. Todo 기능 개발

  • TodoMapper → TodoService → TodoController → JSP 순서로 처리

 

 1) TodoMapper 개발 및 테스트

  • TodoVO를 파라미터로 입력받는 insert() 추가
// TodoMapper
package org.zerock.springex.mapper;

import org.zerock.springex.domain.TodoVO;
public interface TodoMapper {
  String getTime();

  void insert(TodoVO todoVO);
}

 

  • resources > mappers 폴더에 만들어둔 TodoMapper.xml에 insert를 구현
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    <select id = "getTime" resultType = "string">
        select now()
    </select>
    
    <!-- insert 추가 -->
    <insert id = "insert">
    	<!-- Mybatis 이용시, #{title}, #{dueDate}, #{writer}를 파라미터로 처리
       	      이 파라미터 부분은 PreparedStatement로 다시 변경되면서 '?'로 처리됨
              주어진 객체의 getTitle()을 호출한 결과를 적용하게 됨 -->
        insert into tbl_todo(title, dueDate, writer) values (#{title}, #{dueDate}, #{writer})
    </insert>
    
</mapper>

 

  • 테스트 코드를 통해 TodoVO의 입력을 확인
package org.zerock.springex.mapper;

import ...

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TodoMapperTests {
  @Autowired(required = false)
  private TodoMapper todoMapper;

  ...

  @Test
  public void testInsert() {
    TodoVO todoVO = TodoVO.builder()
        .title("스프링 테스트")
        .dueDate(LocalDate.of(2022,10,10))
        .writer("user00")
        .build();
    todoMapper.insert(todoVO);
  }
}

위의 테스트 코드로 tbl_todo 테이블에 정상적으로 추가된 정보

 

 2) TodoService와 TodoServiceImpl 클래스

  • TodoMapper와 Todocontroller 사이에는 서비스 계층을 설계해서 적용
  • 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'/>

 

 3) TodoService 테스트

  • 서비스 계층에서 DTO를 VO로 변환하는 작업을 테스트
// TodoServiceTests
package org.zerock.springex.service;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.zerock.springex.dto.TodoDTO;

import java.time.LocalDate;

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TodoServiceTests {
  
  @Autowired
  private TodoService todoService;
  
  @Test
  public void testRegister()  {
     TodoDTO todoDTO = TodoDTO.builder()
         .title("Test.....")
         .dueDate(LocalDate.now())
         .writer("user1")
         .build();
     todoService.register(todoDTO);
  }
}

정상적으로 테스트 내용이 추가된 테이블
로그에서 TodoServiceImpl이 동작하는 것을 확인

 

 4) TodoController의 GET/POST 처리

  • 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>

localhost:8080/todo/register의 화면

 

  - 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'
  • hibernate-validator를 이용해서 사용하는 대표적인 어노테이션
@NotNull Null 불가
@Null Null만 입력 가능
@NotEmpty Null, 빈 문자열 불가
@NotBlank Null, 빈 문자열, 스페이스만 있는 문자열 불가
@Size(min=,max=) 문자열, 배열 등의 크기가 만족하는가?
@Pattern(regex=) 정규식을 만족하는가?
@Max(num) 지정 값 이하인가?
@Min(num) 지정 값 이상인가?
@Future 현재보다 미래인가?
@Past 현재보다 과거인가?
@Positive 양수만 가능
@PositiveOrZero 양수와 0만 가능
@Negative 음수만 가능
@NegativeOrZero 음수와 0만 가능

 

  - TodoDTO 검증하기

  • TodoDTO에 어노테이션 적용하여 수정
// TodoDTO
package org.zerock.springex.dto;

import lombok.*;

import javax.validation.constraints.Future;
import javax.validation.constraints.NotEmpty;
import java.time.LocalDate;

@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TodoDTO {

  private Long tno;

  @NotEmpty
  private String title;

  @Future
  private LocalDate dueDate;
  private boolean finished;

  @NotEmpty
  private String writer;

}
  • TodoController에서 POST 방식으로 처리할 때 이를 반영하도록 BindingResult와 @Valid 어노테이션을 적용
// TodoController
package org.zerock.springex.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.servlet.mvc.support.RedirectAttributes;
import org.zerock.springex.dto.TodoDTO;
import org.zerock.springex.service.TodoService;

import javax.validation.Valid;

@Controller
@RequestMapping("/todo")
@Log4j2
@RequiredArgsConstructor
public class TodoController {

  // 클래스 선언부에서 RequestMapping의 value가 "/todo"이고 list() 메서드에서 ReuestMapping의 value가 "/list"이므로
  // 최종 경로는 "/todo/list"가 됨
  @RequestMapping("/list")
  public void list() {
    log.info("todo list..........");
  }

  // @RequestMapping(value = "/register", method = RequestMethod.GET)
  @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...............");

    // 검증 과정에 문제가 있다면 다시 입력 화면으로 Redirect 되도록 처리
    // 잘못된 결과는 RedirectAttributes의 addFlashAttributes()를 이용해서 전달
    if(bindingResult.hasErrors()) {
      log.info("has errors.......");
      redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
      return "redirect:/todo/register";
    }
    log.info(todoDTO);
    return "redirect:/todo/list";
  }
}
  • writer에 @NotNull가 적용되어 있으므로 다음과 같이 Writer항목이 없다면 todo/register의 처음 화면으로 redirect 됨

 

  - JSP에서 검증 에러 메세지 확인하기

  • JSP 상단에 태그 라이브러리 추가
<%@ taglib prefix ="c" uri = "http://java.sun.com/jsp/jstl/core" %>
  • <form> 태그가 끝난 후 <script> 태그 추가
<!-- register.jsp -->

...

</form>
    <script>
        const serverValidResult = {}
        <c:forEach items = "${errors}" var = "error">
        serverValidResult['${error.getField()}'] = '${error.defaultMessage}'
        </c:forEach>
        console.log(serverValidResult)
    </script>

  • 빈 화면으로 Submit 클릭 시, 다음과 같은 내용이 출력됨

  • 과거 날짜 입력 시, 다음과 같은 내용이 출력되기도 함

 

 7) Todo 등록 기능 완성

  • TodoService 주입하고 연동하도록 구성
  • TodoController의 클래스 선언부에서 TodoService 주입
  • registerPost()에서는 TodoService의 기능을 호출하도록 구성
// 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";
    }

정상적으로 처리되어 todo/list로 redirect 되었고, todo/list의 개발을 아직 하지 않아 에러 화면 출력
데이터베이스에는 정상적으로 입력됨

 

 8) Todo 목록 기능 개발

  - TodoMapper의 개발

  • TodoMapper 인터페이스에는 가장 최근 등록된 TodoVO가 우선적으로 나올 수 있도록 selectAll() 추가
// TodoMapper
package org.zerock.springex.mapper;

import org.zerock.springex.domain.TodoVO;
import java.util.List;
public interface TodoMapper {
  String getTime();

  void insert(TodoVO todoVO);

  List<TodoVO> selectAll();
}
  • resource > mappers > TodoMapper.xml에 selectAll()의 실제 쿼리문 작성
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    <select id = "getTime" resultType = "string">
        select now()
    </select>

    <insert id = "insert">
        insert into tbl_todo(title, dueDate, writer) values (#{title}, #{dueDate}, #{writer})
    </insert>

    <!-- resultType은JDBC의 한 행을 어떤 타입의 객체로 만들것 인지 지정 -->
    <select id = "selectAll" resultType = "org.zerock.springex.domain.TodoVO">
        select * from tbl_todo order by tno desc
    </select>
</mapper>
  • test폴더에 TodoMapperTests에 테스트 코드 작성
// TodoMapperTests

...

  @Test
    public void testSelectAll() {
      List<TodoVO> voList = todoMapper.selectAll();
      voList.forEach(vo -> log.info(vo));
    }
}
  • 테스트 코드 수행 시 다음과 같이 나중에 추가된 데이터를 우선 출력

 

  - TodoService / TodoServiceImpl의 개발

  • 서비스 계층의 개발은 특별한 파라미터가 없는 경우 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 경로로 접속했을 때, 가장 최근 등록된 것부터 출력됨

1. 프로젝트의 구현 목표와 준비

  • 프로젝트의 전체 구조
    1. 검색과 필터링을 적용할 수 있는 화면 구성, MyBatis 동적 쿼리를 이용해서 상황에 맞는 Todo 검색
    2. 새로운 Todo 등록 시, 문자열, boolean, LocalDate 자동 처리
    3. 목록에서 조회 화면으로 이동할 때 모든 검색, 필터링, 페이징 조건 유지하도록 구성
    4. 조회 화면에서 모든 조건을 유지한 채 수정 / 삭제 화면으로 이동
    5. 삭제 시 다시 목록 화면으로
    6. 수정시 다시 조회 화면으로, 검색, 필터링, 페이징 조건은 초기화
  • 프로젝트의 3티어 구성

 

 1) 프로젝트 준비

  • Spring 관련 라이브러리
// spring 관련 라이브러리
implementation group: 'org.springframework', name: 'spring-core', version: '5.3.20'
implementation group: 'org.springframework', name: 'spring-context', version: '5.3.20'
implementation group: 'org.springframework', name: 'spring-test', version: '5.3.20'
implementation group: 'org.springframework', name: 'spring-webmvc', version: '5.3.20'
implementation group: 'org.springframework', name: 'spring-jdbc', version: '5.3.19'
implementation group: 'org.springframework', name: 'spring-tx', version: '5.3.19'
  • MyBatis / MariaDB / HikariCP 관련 라이브러리
// MyBatis 라이브러리
implementation group: 'org.mybatis', name: 'mybatis', version: '3.5.6'
implementation group: 'org.mybatis', name: 'mybatis-spring', version: '2.0.6'

// mariadb 라이브러리
implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.4'

// HikariCP 관련 라이브러리
implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.1'
  • JSTL 관련 라이브러리
// JSTL 라이브러리
implementation group: 'jstl', name: 'jstl', version: '1.2'
  • DTO와 VO 변환을 위한 ModelMapper
// ModelMapper
implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.0.0'
  • DTO 검증을 위한 validation 관련 라이브러리
// 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'/>

 

 

2. 화면 디자인 - 부트스트랩 적용

 

Get started with Bootstrap

Bootstrap is a powerful, feature-packed frontend toolkit. Build anything—from prototype to production—in minutes.

getbootstrap.com

<!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>
<h1>Hello, world!</h1>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
        crossorigin="anonymous">
</script>

</body>
</html>

 

 1) 부트스트랩의 container, row 적용

<!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>

가장 아래에 Footer가 생성됨

 

 

3. MyBatis와 스프링을 이용한 영속 처리

  • MyBatis와 스프링을 연동하여 기존 JDBC보다 적은 양의 코드로 개발 가능
  • MyBatis를 이용한 개발 단계
    1. VO 선언
    2. Mapper 인터페이스의 개발
    3. XML의 개발
    4. 테스트 코드의 개발
  • 프로젝트에 domain 패키지 선언 > TodoVO 클래스 추가
// TodoVO
package org.zerock.springex.domain;

import lombok.*;
import java.time.LocalDate;

@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TodoVO {
  private Long tno;
  private String title;
  private LocalDate dueDate;
  private String writer;
  private boolean finished;
}

 

 1) TodoMapper 인터페이스와 XML

  • TodoVO는 Mapper 인터페이스의 파라미터나 리턴타입이 될 수 있기 때문에 먼저 정의하고 이를 이용해 TodoMapper 인터페이스 정의
// TodoMapper
package org.zerock.springex.mapper;

public interface TodoMapper {
  String getTime();
}

 

  • resources > mappers 폴더에 TodoMapper.xml을 선언하고 getTime()에 해당하는 내용 작성
  • XML 작성 시 (namespace 값 = 인터페이스의 이름), (메서드 이름 = <select> 태그의 id)로 설정
<!-- TodoMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTO Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- namespace는 TodoMapper의 경로를 정확히 일치시켜야 함 -->
<mapper namespace = "org.zerock.springex.mapper.TodoMapper">
    <!-- id도 TodoMapper 내의 메서드 getTime()과 일치해야 함 -->
    <select id = "getTime" resultType = "string">
        select now()
    </select>
</mapper>

 

  • 테스트 코드로 동작 여부 확인
  • Test > java > org.zerock.springex.mapper > TodoMapperTests 클래스 생성
// TodoMapperTests
package org.zerock.springex.mapper;

import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/root-context.xml")
public class TodoMapperTests {
  @Autowired(required = false)
  private TodoMapper todoMapper;
  
  @Test
  public void testGetTime() {
    log.info(todoMapper.getTime());
  }
}

정상적으로 실행되었을 때 로그에 현재 시간이 출력됨을 확인

 

  • SQL 실행로그를 더 자세히 보기 위해 org.zerock.springex.mapper 패키지 로그는 TRACE 레벨로 기록하도록 log4j2.xml에 코드 추가
<!-- log4j2.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<Configuration status = "INFO">
    <Appenders>

		...

    </Appenders>
    <Loggers>
        
        ...
        
        <logger name = "org.zerock.springex.mapper" level = "TRACE" additivity = "false">
            <appender-ref ref = "console" />
        </logger>
        
        ...
        
    </Loggers>
</Configuration>

동일한 테스트 실행 시 실해오디는 SQL이 출력됨을 확인

1. 스프링 Web MVC의 특징

  • 스프링 Web MVC는 웹 MVC 패턴으로 구현된 구조
  • 기본적인 흐름과 Controller, View, Model 등의 용어도 그대로 사용
  • 스프링 MVC가 기존 구조와 다른 부분
    • Front-Controller패턴을 이용해서 모든 흐름의 사전 / 사후 처리 가능
    • 어노테이션을 적극 활용, 최소한의 코드로 많은 처리 가능
    • 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으로 설정되게 만들어야 함
  • servelt-context.xml의 component-scan을 다음과 같이 적용
<!-- 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"
       xmlns:context = "http://www.springframework.org/schema/context"
       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
                http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

   ...
   
   <context:component-scan base-package = "org.zerock.springex.controller" />

</beans>
  • "/hello" 경로의 화면을 간단하게 작성(webapp > WEB-INF > views 폴더 생성 > hello.jsp 파일 생성)
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h1>Hello JSP</h1>
</body>
</html>

 

 4) @RequestMapping과 파생 어노테이션들

  • @RequestMapping은 특정 경로의 요청을 지정하기 위해 사용
  • 클래스 선언부에도 사용할 수 있고, Controller의 메서드에도 사용할 수 있음
  • Servlet 중심의 MVC에서는 Servlet을 상속받아서 doGet() / doPost()와 같은 제한적인 메서드를 오버라이드해서 사용했지만, 스프링 MVC의 경우 하나의 Controller를 이용해서 여러 경로의 호출 처리 가능
  • org.zerock.springex > controller 패키지 > TodoController 작성
// TodoController
package org.zerock.springex.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("/todo")
@Log4j2
public class TodoController {

  @RequestMapping("/list")
  public void list() {
    log.info("tood list..........");
  }

  @RequestMapping(value = "/register", method = RequestMethod.GET)
  public void register() {
    log.info("todo register");
  }
  
}

/todo/list 경로의 화면을 만들지 않아서 화면을 띄우는 것은 오류가 나지만, 로그에 list() 메서드가 실행되어 메세지가 뜬 것을 확인

  • @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는 문자열을 기준으로 특정 클래스의 객체로 처리하는 작업이 진행

  - 이때 날짜 등 특정 타입을 처리하는 Formatter 이용 가능

  - Formatter는 문자열을 포맷을 이용해서 특정 객체로 변환하는 경우 사용

  - controller 패키지 > formatter 패키지 작성 > LocalDateFormatter 클래스 작성

  - Formatter에는 parse()와 print() 메서드 존재

// LocalDateFormatter
package org.zerock.springex.controller.formatter;

import org.springframework.format.Formatter;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class LocalDateFormatter implements Formatter<LocalDate> {

  @Override
  public LocalDate parse(String text, Locale locale) {
    return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
  }

  @Override
  public String print(LocalDate object, Locale locale) {
    return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(object);
  }
}

  - Formatter를 servlet-context.xml에 적용하기 위해 FormattingConversionServiceFactoryBean 객체를 스프링의 Bean으로 등록하고 이 안에 작성한 LocalDateFormatter를 추가해야 함

<!-- servlet-context.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<beans ...>

    <!-- conversionService라는 Bean을 등록한 후에 스프링 MVC를 처리할 때 <mvc:annotation-driven에 이를 이용한다는 것을 명시해야 함 -->
    <mvc:annotation-driven conversion-service = "conversionService"></mvc:annotation-driven>
    
    ...
    
    <bean id = "conversionService" class = "org.springframework.format.support.FormattingConversionServiceFactoryBean">
        <property name = "Formatters">
            <set>
                <bean class = "org.zerock.springex.controller.formatter.LocalDateFormatter"/>
            </set>
        </property>
    </bean>

</beans>

  - ex3 경로에 날짜를 파라미터로 받는 메서드 추가후 날짜 형변환 정상 작동 여부 확인

// SampleController
package org.zerock.springex.controller;

import ...

@Controller
@Log4j2
public class SampleController {

 ...

  @GetMapping("/ex3")
  public void ex3(LocalDate dueDate) {
    log.info("ex3.......");
    log.info("dueDate: " + dueDate);
  }

}

  - 주소로 "http://localhost:8080/ex3?dueDate=2020-10-10"를 작성하면 날짜형태로 잘 받아서 로그에 출력함을 확인


 

 1) 객체 자료형의 파라미터 수집

  • 기본 자료형과 달리 객체 자료형을 파라미터로 처리하기 위해서는 객체가 생성되고 setXXX()을 이용해서 처리
  • Lombok을 활용하면 @Setter나 @Data를 이용하는 것이 간단
  • 프로젝트에 dto 패키지 추가 > TodoDTO 클래스 추가
// TodoDTO
package org.zerock.springex.dto;

import lombok.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDate;

@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TodoDTO {

  private Long tno;
  private String title;
  private LocalDate dueDate;
  private boolean finished;
  private String writer;

}

 

※ javax.validation을 사용하려면 build.gradle에 다음을 의존성으로 추가해야함

implementation group: 'javax.validation', name: 'validation-api', version: '2.0.1.Final'

 

  • TodoController의 '/todo/register'를 POST 방식으로 처리하는 메서드에 TodoDTO를 파라미터로 적용
package org.zerock.springex.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.zerock.springex.dto.TodoDTO;

@Controller
@RequestMapping("/todo")
@Log4j2
public class TodoController {

  @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...............");
  }

  // TodoDTO를 파라미터로 적용
  // 자동으로 형변환되기 때문에 다양한 타입의 멤버 변수들의 처리가 자동으로 이루어짐
  @PostMapping("/register")
  public void registerPOST(TodoDTO todoDTO) {
    log.info("POST todo register...............");
    log.info(todoDTO);
  }

}

 

  • WEB-INF/views에 todo 폴더 생성 > register.jsp 파일 추가
<!-- register.jsp -->
<%@ taglib prefix ="form" uri = "http://www.springframework.org/tags/form" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <form action = "/todo/register" method = "post">
        <div>
            Title: <input type = "text" name = "title">
        </div>
        <div>
            DueDate: <input type = "date" name = "dueDate" value = "2023-01-17">
        </div>
        <div>
            Writer: <input type = "text" name = "writer">
        </div>
        <div>
            Finished: <input type = "checkbox" name = "finished">
        </div>
        <div>
            <button type = "submit">Register</button>
        </div>
    </form>
</body>
</html>

  • 'localhost:8080/todo/register'에 들어가면 위와 같은 입력 화면이 나오고, 각 입력창에 데이터를 입력한 뒤, Register 버튼을 누르면 로그에 해당 데이터가 출력됨

 

 2) Model이라는 특별한 파라미터

  • 웹 MVC와 스프링 MVC 모두 Model이라고 부르는 데이터를 JSP에 전달해야함
  • Servlet 방식에서는 request.setAttribute()를 이용해서 데이터를 담아 JSP에 전달
  • 스프링 MVC 방식에서는 Model이라는 객체를 이용해서 처리 가능
  • Model에서 addAttribute() 메서드를 이용해서 View에 전달할 이름과 값을 지정
// SampleController
package org.zerock.springex.controller;

import ...

@Controller
@Log4j2
public class SampleController {

  ...

  @GetMapping("/ex4")
  public void ex4(Model model) {
    log.info("----------------");
    model.addAttribute("message", "Hello World");
  }

}

 

  • 데이터를 받을 ex4.jsp 파일도 WEB-INF/views 폴더에 생성
<!-- ex4.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri ="http://java.sun.com/jsp/jstl/core" prefix = "c" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h1>${message}</h1>
    <h1><c:out value = "${message}"></c:out></h1>
</body>
</html>

 

  • Model에 담긴 데이터는 HttpServletRequest의 setAttribute()와 동일한 동작을 수행하여 JSP에서 EL을 이용해 별다른 처리없이 사용 가능

 

  • 스프링 MVC의 Controller는 파라미터로 getter / setter를 이용하는 Java Beans의 형식의 사용자 정의 클래스가 파라미터인 경우 자동으로 화면까지 객체 전달
  • 예를 들어, 파라미터로 TodoDTO를 받는 경우, 다음과 같이 작성하고, JSP에서 ${todoDTO}를 통해 이용 가능
@GetMapping("/ex4_1")
public void ex4Extra(TodoDTO todoDTO, Model model) {
	log.info(todoDTO);
}
  • 이때 자동으로 생성된 todoDTO라는 이름 외에 다른 이름을 사용하고 싶으면 @ModelAttribute()를 사용하여 지정가능
@GetMapping("/ex4_1")
public void ex4Extra(@ModelAttribute("dto") TodoDTO todoDTO, Model model) {
	log.info(todoDTO);
}
  • 이렇게 하면 ${dto}로 사용가능

 

 3) RedirectAttributes와 리다이렉션

  • POST 방식으로 어떤 처리를 하고 Redirect 해서 GET 방식으로 특정 페이지 이동하는 PRG 패턴 처리를 위해 스프링 MVC에서는 RedirectAttributes라는 타입 사용
  • RedirectAttributes의 중요 메서드
    • addAttribute(키, 값): Redirect할 때 쿼리 스트링 값 적용
    • addFlashAttribute(키, 값): 일회용으로 데이터만 전달하고 삭제되는 값 지정
  • SampleController에서 ex5()는 RedirectAttributes를 파라미터로 추가하고 addAttribute와 addFlashAttribute를 사용해서 name과 result라는 이름을 가진 값들을 전달
// SampleController
package org.zerock.springex.controller;

import ...

@Controller
@Log4j2
public class SampleController {

  ...
  
  @GetMapping("/ex5")
  public String ex5(RedirectAttributes redirectAttributes) {
    redirectAttributes.addAttribute("name", "ABC");
    redirectAttributes.addFlashAttribute("result", "success");
    
    // redirect를 위해 "redirect:"라는 접두어를 붙여 문자열로 반환
    return "redirect:/ex6";
  }
  
  @GetMapping("/ex6")
  public void ex6() {
    
  }

}
  • ex5에서 넘겨준 값을 받은 ex6의 jsp 화면 작성
<!-- ex6.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h1>ADD FLASH ATTRIBUTE: ${result}</h1>
</body>
</html>

 

  • 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라는 파라미터 설정 추가
<!-- web.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<web-app ...>

    ...
    
    <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>

        <!-- 404에러 처리용으로 추가 -->
        <init-param>
            <param-name>throwExceptionIfNoHandlerFound</param-name>
            <param-value>true</param-value>
        </init-param>
		<!-- 여기까지 -->
        
        <load-on-startup>1</load-on-startup>
    </servlet>

    ...
    
    
</web-app>

설정하지 않은 주소를 쳤을 때, 404에러 대신 직접 만든 페이지가 나오는 것을 확인

+ Recent posts