[Java/Spring] 스프링 MVC - 2편 정리

전용본·2023년 4월 9일

Spring

목록 보기
3/5

타임리프

특징

서버 사이드 렌더링

  • 타임리프는 백엔드 서버에서 HTML을 동적으로 렌더링하는 용도로 사용된다.

네츄럴 템플릿

  • 타임리프는 순수 HTML을 최대한 유지한다. 타임리프로 작성된 파일은 웹 브라우저에서 파일을 직접 열어도 내용 확인이 가능하고 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
  • 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있ㅈ는 타임리프의 특징을 네츄럴 템플릿이라 한다.

스프링 통합 지원

  • 타임리프는 스프링과 자연스럽게 통합되고, 스프링의 다양한 기능을 편리하게 사용할 수 있게 지원한다.

기본 기능

사용 선언

<html xmlns:th="http://www.thymeleaf.org">

기본 표현식

  • 간단한 표현:
    ◦ 변수 표현식: ${...}
    ◦ 선택 변수 표현식: *{...}
    ◦ 메시지 표현식: #{...}
    ◦ 링크 URL 표현식: @{...}
    ◦ 조각 표현식: ~{...}
  • 리터럴
    ◦ 텍스트: 'one text', 'Another one!',...
    ◦ 숫자: 0, 34, 3.0, 12.3,...
    ◦ 불린: true, false
    ◦ 널: null
    ◦ 리터럴 토큰: one, sometext, main,...
  • 문자 연산:
    ◦ 문자합치기:+
    ◦ 리터럴 대체: |The name is ${name}|
  • 산술 연산:
    ◦ Binary operators: +, -, *, /, %
    ◦ Minus sign (unary operator): -
  • 불린 연산:
    ◦ Binary operators: and, or
    ◦ Boolean negation (unary operator): !, not
  • 비교와 동등:
    ◦ 비교:>,<,>=,<=(gt,lt,ge,le)
    ◦ 동등 연산: ==, != (eq, ne)
  • 조건 연산:
    ◦ If-then: (if) ? (then)
    ◦ If-then-else: (if) ? (then) : (else)
    ◦ Default: (value) ?: (defaultvalue)
  • 특별한 토큰:
    ◦ No-Operation: _

텍스트 - text, utext

  • 타임리프는 기본적으로 HTML 태그의 속성에 기능을 정의해서 동작한다.
  • </span th:text="${data}">
  • HTML 테그의 속성이 아니라 HTML 콘텐츠 영역안에서 직접 데이터를 출력하고 싶으면 다음과 같이 [[...]] 를 사용하면 된다. 컨텐츠 안에서 직접 출력하기 = [[${data}]]

th:text

	@GetMapping("/text-basic")
	public String textBasic(Model model) {
		model.addAttribute("data", "Hello Spring!");
		return "basic/text-basic";
	}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>
<h1>컨텐츠에 데이터 출력하기</h1>

<ul>
	<li>th:text 사용 <span th:text="${data}"></span></li>
	<li>컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
</ul>
</body>
</html>

th:utext

	@GetMapping("/text-unescaped")
    public String textUnescaped(Model model) {
        model.addAttribute("data", "Hello <b>Spring!</b>");
        return "basic/text-unescaped";
    }
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<h1>text vs utext</h1>
<ul>
  <li>th:text = <span th:text="${data}"></span></li>
  <li>th:utext = <span th:utext="${data}"></span></li>
</ul>
<h1><span th:inline="none">[[...]] vs [(...)]</span></h1>
<ul>
  <li><span th:inline="none">[[...]] = </span>[[${data}]]</li>
  <li><span th:inline="none">[(...)] = </span>[(${data})]</li>
</ul>
</body>
</html>
  • Hello Spring!

변수 - SpringEL

  • 타임리프에서 변수를 사용할 때는 변수 표현식을 사용한다.
  • 변수 표현식 : ${...}
    @GetMapping("/variable")
    public String variable(Model model) {
        User userA = new User("userA", 10);
        User userB = new User("userB", 20);

        List<User> list = new ArrayList<>();
        list.add(userA);
        list.add(userB);

        Map<String, User> map = new HashMap<>();
        map.put("userA", userA);
        map.put("userB", userB);

        model.addAttribute("user", userA);
        model.addAttribute("users", list);
        model.addAttribute("userMap", map);

        return "basic/variable";
    }
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<h1>SpringEL 표현식</h1> <ul>Object
  <li>${user.username} = <span th:text="${user.username}"></span></li>
  <li>${user['username']} = <span th:text="${user['username']}"></span></li>
  <li>${user.getUsername()} = <span th:text="${user.getUsername()}"></span></li>
</ul>
<ul>List
  <li>${users[0].username}    = <span th:text="${users[0].username}"></span></li>
  <li>${users[0]['username']} = <span th:text="${users[0]['username']}"></span></li>
  <li>${users[0].getUsername()} = <span th:text="${users[0].getUsername()}"></span></li>
</ul>
<ul>Map
  <li>${userMap['userA'].username} =  <span th:text="${userMap['userA'].username}"></span></li>
  <li>${userMap['userA']['username']} = <span th:text="${userMap['userA']['username']}"></span></li>
  <li>${userMap['userA'].getUsername()} = <span th:text="${userMap['userA'].getUsername()}"></span></li>
</ul>
</body>
</html>
  • 직접 접근, 프로퍼티 접근, 인덱스 접근 모두 가능하다.

타임리프 유틸리티 객체들

  • #message : 메시지, 국제화 처리
  • #uris : URI 이스케이프 지원
  • #dates : java.util.Date 서식 지원
  • #calendars : java.util.Calendar 서식 지원
  • #temporals : 자바8 날짜 서식 지원
  • #numbers : 숫자 서식 지원
  • #strings : 문자 관련 편의 기능
  • #objects : 객체 관련 기능 제공
  • #bools : boolean 관련 기능 제공
  • #arrays : 배열 관련 기능 제공
  • #lists , #sets , #maps : 컬렉션 관련 기능 제공
  • #ids : 아이디 처리 관련 기능 제공, 뒤에서 설명

URL 링크

  • 타임리프에서 URL을 생성할 때는 @{...} 문법을 사용하면 된다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>

<h1>URL 링크</h1>
<ul>
	<li><a th:href="@{/hello}">basic url</a></li>
	<li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
	<li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
	<li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
</ul>
</body>
</html>
  • ()에 있는 부분은 쿼리 파라미터로 처리된다.
  • URL경로상에 변수가 있으면 () 부분은 경로 변수로 처리된다.
  • /hello
  • /hello?param1=data1¶m2=data2
  • /hello/data1/data2
  • /hello/data1?param2=data2

리터럴

  • 리터럴은 소스 코드상에 고정된 값을 말하는 용어다.
  • 타임리프에서 문자 리터럴은 항상 '(작은 따옴표)로 감싸야한다.
  • 매번 감싸는 것이 부담이므로 타임리프에선 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 작은 따옴표를 생략할 수 있다. 룰: A-Z, a-z, 0-9, [], ., -, _
  • 공백이 있다면 작은 따옴표가 필요하다. -> <\span th:text="'hello world!'"></\span>
	@GetMapping("/literal")
    public String literal(Model model) {
        model.addAttribute("data", "Spring!");
        return "basic/literal";
    }
<li>'hello' + ' world!' = <span th:text="'hello' + ' world!'"></span></li> <li>'hello world!' = <span th:text="'hello world!'"></span></li>
<li>'hello ' + ${data} = <span th:text="'hello ' + ${data}"></span></li> 
<li>리터럴 대체 |hello ${data}| = <span th:text="|hello ${data}|"></span></li>
  • 마지막의 ||로 감싸는 것은 리터럴 대체 문법이다.

연산

	@GetMapping("/operation")
    public String operation(Model model) {
        model.addAttribute("nullData", null);
        model.addAttribute("data", "Spring!");

        return "basic/operation";
    }
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">     <title>Title</title>
</head>
<body> <ul>
  <li>산술 연산 <ul>
    <li>10 + 2 = <span th:text="10 + 2"></span></li>
    <li>10 % 2 == 0 = <span th:text="10 % 2 == 0"></span></li>
  </ul>
  </li> <li>비교 연산
  <ul>
    <li>1 > 10 = <span th:text="1 &gt; 10"></span></li>
    <li>1 gt 10 = <span th:text="1 gt 10"></span></li>
    <li>1 >= 10 = <span th:text="1 >= 10"></span></li>
    <li>1 ge 10 = <span th:text="1 ge 10"></span></li>
    <li>1 == 10 = <span th:text="1 == 10"></span></li>
    <li>1 != 10 = <span th:text="1 != 10"></span></li>
  </ul> </li>
  <li>조건식 <ul>
    <li>(10 % 2 == 0)? '짝수':'홀수' = <span th:text="(10 % 2 == 0)? '짝수':'홀수'"></span></li>
  </ul> </li>
  <li>Elvis 연산자 <ul>
    <li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가 없습니다.'"></span></li>
    <li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?: '데이터가 없습니다.'"></span></li>
  </ul> </li>
  <li>No-Operation
    <ul>
      <li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</span></li>
      <li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가 없습니다.</span></li>
    </ul> </li>
</ul>
</body>
</html>
  • 비교연산: HTML 엔티티를 사용해야 하는 부분을 주의하자,
  • (gt), < (lt), >= (ge), <= (le), ! (not), == (eq), != (neq, ne)

  • 조건식: 자바의 조건식과 유사하다.
  • Elvis 연산자: 조건식의 편의 버전
  • No-Operation: _ 인 경우 마치 타임리프가 실행되지 않는 것 처럼 동작한다. 이것을 잘 사용하면 HTML 의 내용 그대로 활용할 수 있다. 마지막 예를 보면 데이터가 없습니다. 부분이 그대로 출력된다.

속성 처리

	@GetMapping("/attribute")
    public String attribute() {
        return "basic/attribute";
    }
<h1>속성 설정</h1>
  <input type="text" name="mock" th:name="userA" />
<h1>속성 추가</h1>
- th:attrappend = <input type="text" class="text" th:attrappend="class=' large'" /><br/>
- th:attrprepend = <input type="text" class="text" th:attrprepend="class='large '" /><br/>
- th:classappend = <input type="text" class="text" th:classappend="large" /><br/>

<h1>checked 처리</h1>
- checked o <input type="checkbox" name="active" th:checked="true" /><br/>
- checked x <input type="checkbox" name="active" th:checked="false" /><br/> 
- checked=false <input type="checkbox" name="active" checked="false" /><br/>
  • th: 속성을 지정하면 타임리프는 기존 속성을 th: 로 지정한 속성으로 대체한다. 기존 속성이 없다면 새로 만든다. <\input type="text" name="mock" th:name="userA">
    타임리프 렌더링 후 <\input type="text" name="userA">
  • th:attrappend : 속성 값의 뒤에 값을 추가한다.
  • th:attrprepend : 속성 값의 앞에 값을 추가한다.
  • th:classappend : class 속성에 자연스럽게 추가한다.
  • HTML에서 checked 속성은 checked 속성의 값과 상관없이 checked 라는 속성만 있어도 체크가 된다. 이런 부분이 true , false 값을 주로 사용하는 개발자 입장에서는 불편하다.
  • 타임리프의 th:checked 는 값이 false 인 경우 checked 속성 자체를 제거한다. <\input type="checkbox" name="active" th:checked="false" />
    타임리프 렌더링 후: <\input type="checkbox" name="active" />

반복

  • 타임리프에서 반복은 th:each 를 사용한다. 추가로 반복에서 사용할 수 있는 여러 상태 값을 지원한다.
<body>
<h1>기본 테이블</h1>
<table border="1">
  <tr>
    <th>username</th>
    <th>age</th>
  </tr>
  <tr th:each="user : ${users}">
    <td th:text="${user.username}">username</td>
    <td th:text="${user.age}">0</td>
  </tr>
</table>

<h1>반복 상태 유지</h1>
<table border="1">
  <tr>
    <th>count</th>
    <th>username</th>
    <th>age</th>
    <th>etc</th>
  </tr>
  <tr th:each="user, userStat : ${users}">
    <td th:text="${userStat.count}">username</td>
    <td th:text="${user.username}">username</td>
    <td th:text="${user.age}">0</td>
    <td>
      index = <span th:text="${userStat.index}"></span>
      count = <span th:text="${userStat.count}"></span>
      size = <span th:text="${userStat.size}"></span>
      even? = <span th:text="${userStat.even}"></span>
      odd? = <span th:text="${userStat.odd}"></span>
      first? = <span th:text="${userStat.first}"></span>
      last? = <span th:text="${userStat.last}"></span>
      current = <span th:text="${userStat.current}"></span>
    </td>
  </tr>
</table>
</body>
  • <\tr th:each="user : ${users}"> : 반복시 오른쪽 컬렉션( ${users} )의 값을 하나씩 꺼내서 왼쪽 변수( user )에 담아서 태그를 반복 실행합니다.
  • 반복의 두번째 파라미터를 설정해서 반복의 상태를 확인할 수 있다.
    - index : 0부터 시작하는 값
    - count : 1부터 시작하는 값
    - size : 전체 사이즈
    - even , odd : 홀수, 짝수 여부(boolean)
    - first , last :처음, 마지막 여부( boolean )
    - current : 현재 객체

조건문

<body>
<h1>if, unless</h1>
<table border="1">
  <tr>
    <th>count</th>
    <th>username</th>
    <th>age</th>
  </tr>
  <tr th:each="user, userStat : ${users}">
    <td th:text="${userStat.count}">1</td>
    <td th:text="${user.username}">username</td>
    <td>
      <span th:text="${user.age}">0</span>
      <span th:text="'미성년자'" th:if="${user.age lt 20}"></span> <span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
    </td> </tr>
</table>


<h1>switch</h1>
<table border="1">
  <tr>
    <th>count</th>
    <th>username</th>
    <th>age</th>
  </tr>
  <tr th:each="user, userStat : ${users}">
    <td th:text="${userStat.count}">1</td>
    <td th:text="${user.username}">username</td>
    <td th:switch="${user.age}">
      <span th:case="10">10살</span>
      <span th:case="20">20살</span>
      <span th:case="*">기타</span>
    </td> </tr>
</table>
</body>
  • 타임리프는 해당 조건이 맞지 않으면 태그 자체를 렌더링하지 않는다.
    만약 다음 조건이 false 인 경우 <\span>...<\span> 부분 자체가 렌더링 되지 않고 사라진다. <\span th:text="'미성년자'" th:if="${user.age lt 20}"><\span>
    • 은 만족하는 조건이 없을 때 사용하는 디폴트이다.

주석

<body> 
<h1>예시</h1>
	<span th:text="${data}">html data</span>
	<h1>1. 표준 HTML 주석</h1>
	<!--
	<span th:text="${data}">html data</span> 
    -->
    
	<h1>2. 타임리프 파서 주석</h1> 
    <!--/* [[${data}]] */-->
    
	<!--/*-->
	<span th:text="${data}">html data</span>
	<!--*/-->
    
	<h1>3. 타임리프 프로토타입 주석</h1>
	<!--/*/
	<span th:text="${data}">html data</span>
    /*/-->
</body>
  • 표준 HTML 주석 : 자바스크립트의 표준 HTML 주석은 타임리프가 렌더링 하지 않고, 그대로 남겨둔다.
  • 타임리프 파서 주석 : 타임리프 파서 주석은 타임리프의 진짜 주석이다. 렌더링에서 주석 부분을 제거한다.
  • 타임리프 프로토타입 주석 : 타임리프 프로토타입은 약간 특이한데, HTML 주석에 약간의 구문을 더했다.
    HTML 파일을 웹 브라우저에서 그대로 열어보면 HTML 주석이기 때문에 이 부분이 웹 브라우저가 렌더링하지 않는다. 타임리프 렌더링을 거치면 이 부분이 정상 렌더링 된다. 쉽게 이야기해서 HTML 파일을 그대로 열어보면 주석처리가 되지만, 타임리프를 렌더링 한 경우에만 보이는 기능이다.

블록

  • <th:block> 은 HTML 태그가 아닌 타임리프의 유일한 자체 태그다.
<body>
<th:block th:each="user : ${users}">
  <div>
    사용자 이름1 <span th:text="${user.username}"></span>
    사용자 나이1 <span th:text="${user.age}"></span> </div>
  <div>
    요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span>
  </div>
</th:block>
</body>
  • 타임리프의 특성상 HTML 태그안에 속성으로 기능을 정의해서 사용하는데, 위 예처럼 이렇게 사용하기 애매한 경우에 사용하면 된다. <th:block> 은 렌더링시 제거된다.

자바스크립트 인라인

  • 타임리프는 자바스크립트에서 타임리프를 편리하게 사용할 수 있는 자바스크립트 인라인 기능을 제공한다. 자바스크립트 인라인 기능은 다음과 같이 적용하면 된다. <\script th:inline="javascript
<body>
<!-- 자바스크립트 인라인 사용 전 -->
<script>
  var username = [[${user.username}]];
  var age = [[${user.age}]];
  //자바스크립트 내추럴 템플릿
  var username2 = /*[[${user.username}]]*/ "test username";
  //객체
  var user = [[${user}]];
</script>

<!-- 자바스크립트 인라인 사용 후 -->
<script th:inline="javascript">
  var username = [[${user.username}]];
  var age = [[${user.age}]];
  //자바스크립트 내추럴 템플릿
  var username2 = /*[[${user.username}]]*/ "test username";
  //객체
  var user = [[${user}]];
</script>
</body>

사용 결과

<body>
<!-- 자바스크립트 인라인 사용 전 -->
<script>
  var username = UserA;
  var age = 10;
  //자바스크립트 내추럴 템플릿
  var username2 = /*UserA*/ "test username";
  //객체
  var user = BasicController.User(username=UserA, age=10);
</script>

<!-- 자바스크립트 인라인 사용 후 -->
<script>
  var username = "UserA";
  var age = 10;
  //자바스크립트 내추럴 템플릿
  var username2 = "UserA";
  //객체
  var user = {"username":"UserA","age":10};
</script>
</body>
  • 텍스트 렌더링 : var username = [[${user.username}]];
    인라인 사용 전 var username = userA;
    인라인 사용 후 var username = "userA";
    인라인 사용 전 렌더링 결과를 보면 userA 라는 변수 이름이 그대로 남아있다. 타임리프 입장에서는 정확하게 렌더링 한 것이지만 아마 개발자가 기대한 것은 다음과 같은 "userA"라는 문자일 것이다. 결과적으로 userA가 변수명으로 사용되어서 자바스크립트 오류가 발생한다. 다음으로 나오는 숫자 age의 경우에는 " 가 필요 없기 때문에 정상 렌더링 된다.

  • 자바스크립트 내추럴 템플릿
    타임리프는 HTML 파일을 직접 열어도 동작하는 내추럴 템플릿 기능을 제공한다. 자바스크립트 인라인 기능을 사용하면 주석을 활용해서 이 기능을 사용할 수 있다.
    var username2 = /[[${user.username}]]/ "test username"; 인라인 사용 전 var username2 = /userA/ "test username"; 인라인 사용 후 var username2 = "userA";
    인라인 사용 전 결과를 보면 정말 순수하게 그대로 해석을 해버렸다. 따라서 내추럴 템플릿 기능이 동작하지 않고, 심지어 렌더링 내용이 주석처리 되어 버린다.
    인라인 사용 후 결과를 보면 주석 부분이 제거되고, 기대한 "userA"가 정확하게 적용된다.

  • 객체
    타임리프의 자바스크립트 인라인 기능을 사용하면 객체를 JSON으로 자동으로 변환해준다.
    var user = [[${user}]];
    인라인 사용 전 var user = BasicController.User(username=userA, age=10);
    인라인 사용 후 var user = {"username":"userA","age":10};
    인라인 사용 전은 객체의 toString()이 호출된 값이다. 인라인 사용 후는 객체를 JSON으로 변환해준다.

템플릿 조각

  • 웹 페이지를 개발할 때는 공통 영역이 많이 있다. 예를 들어서 상단 영역이나 하단 영역, 좌측 카테고리 등등 여러 페이지에서 함께 사용하는 영역들이 있다. 이런 부분을 코드를 복사해서 사용한다면 변경시 여러 페이지를 다 수정해야 하므로 상당히 비효율 적이다. 타임리프는 이런 문제를 해결하기 위해 템플릿 조각과 레이아웃 기능을 지원한다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<footer th:fragment="copy">
  푸터 자리 입니다.
</footer>

<footer th:fragment="copyParam (param1, param2)">
  <p>파라미터 자리 입니다.</p>
  <p th:text="${param1}"></p>
  <p th:text="${param2}"></p>
</footer>
</body>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>

<body>
<h1>부분 포함</h1>
<h2>부분 포함 insert</h2>
<div th:insert="~{template/fragment/footer :: copy}"></div>

<h2>부분 포함 replace</h2>
<div th:replace="~{template/fragment/footer :: copy}"></div>

<h2>부분 포함 단순 표현식</h2>
<div th:replace="template/fragment/footer :: copy"></div>

<h1>파라미터 사용</h1>
<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터 2')}"></div>
</body>
</html>
  • template/fragment/footer :: copy : template/fragment/footer.html 템플릿에 있는 th:fragment="copy" 라는 부분을 템플릿 조각으로 가져와서 사용한다는 의미이다.
  • th:insert 를 사용하면 현재 태그(div) 내부에 추가한다.
  • th:replace 를 사용하면 현재 태그( div )를 대체한다.
  • 파라미터를 전달해서 동적으로 조각을 렌더링 할 수도 있다.
  • <\div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"><\div>

템플릿 레이아웃

<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="common_header(title,links)">
  <title th:replace="${title}">레이아웃 타이틀</title>
  <!-- 공통 -->
  <link rel="stylesheet" type="text/css" media="all" th:href="@{/css/awesomeapp.css}">
  <link rel="shortcut icon" th:href="@{/images/favicon.ico}">
  <script type="text/javascript" th:src="@{/sh/scripts/codebase.js}"></script>
  <!-- 추가 -->
  <th:block th:replace="${links}" />
</head>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="template/layout/base :: common_header(~{::title},~{::link})">
  <title>메인 타이틀</title>
  <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
  <link rel="stylesheet" th:href="@{/themes/smoothness/jquery-ui.css}">
</head>
<body> 메인 컨텐츠 </body>
</html>

결과

<!DOCTYPE html>
<html>
<head>
<title>메인 타이틀</title> 
<!-- 공통 -->

  <link rel="stylesheet" type="text/css" media="all" href="/css/awesomeapp.css">
  <link rel="shortcut icon" href="/images/favicon.ico">
  <script type="text/javascript" src="/sh/scripts/codebase.js"></script>
  
<!-- 추가 -->
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/themes/smoothness/jquery-ui.css">

</head> 
<body> 메인 컨텐츠 
</body> 
</html>
  • common_header(~{::title},~{::link}) 이 부분이 핵심이다. ::title 은 현재 페이지의 title 태그들을 전달한다. ::link 는 현재 페이지의 link 태그들을 전달한다.

템플릿 레이아웃 확장

<!DOCTYPE html>
<html th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org">
<head>
  <title th:replace="${title}">레이아웃 타이틀</title>
</head>
<body>
<h1>레이아웃 H1</h1>
<div th:replace="${content}">
  <p>레이아웃 컨텐츠</p>
</div>
<footer>
  레이아웃 푸터
</footer>
</body>
</html>
<!DOCTYPE html>
<html th:replace="~{template/layoutExtend/layoutFile :: layout(~{::title},~{::section})}"
      xmlns:th="http://www.thymeleaf.org">
<head>
  <title>메인 페이지 타이틀</title> </head>
<body>
<section>
  <p>메인 페이지 컨텐츠</p>
  <div>메인 페이지 포함 내용</div>
</section>
</body>
</html>
  • layoutExtendMain.html 는 현재 페이지인데, <\html> 자체를 th:replace 를 사용해서 변경하는 것을 확인할 수 있다. 결국 layoutFile.html 에 필요한 내용을 전달하면서 <\html> 자체를 layoutFile.html 로 변경한다.

타임리프 스프링 통합

  • 스프링 통합으로 추가되는 기능들
  • 스프링의 SpringEL 문법 통합 ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
  • 편리한 폼 관리를 위한 추가 속성
    • th:object (기능 강화, 폼 커맨드 객체 선택) th:field , th:errors , th:errorclass
  • 폼 컴포넌트 기능
    • checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
  • 스프링의 메시지, 국제화 기능의 편리한 통합 스프링의 검증, 오류 처리 통합
  • 스프링의 변환 서비스 통합(ConversionService)

입력 폼 처리

컨트롤러

@GetMapping("/add")
public String addForm(Model model) {
	model.addAttribute("item", new Item());
	return "form/addForm";
}

View

<form action="item.html" th:action th:object="${item}" method="post">
	<div>
		<label for="itemName">상품명</label>
	<input type="text" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
	</div>
	<div>
		<label for="price">가격</label>
		<input type="text" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
	</div>
	<div>
		<label for="quantity">수량</label>
		<input type="text" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
	</div>
 </form>
  • th:object="${item} : <\form>에서 사용할 객체 지정
  • th:field="*{itemName} : th:object에서 객체로 item을 지정했기에 선택 변수 식을 사용할 수 있다. th:field="${item.itemName]과 동일하다.
  • th:field는 id, name, value 속성을 모두 자동으로 만들어준다.
    - id = "itemName"
    • name = "itemName"
  • 렌더링 전 : <\input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
  • 렌더링 후 : <\input type="text" id="itemName" class="form-control" placeholder="이름을 입력하세요" name="itemName" value="">

입력 폼 편의사항 제공

상품 도메인

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    private Boolean open; // 판매 여부
    private List<String> regions; //등록 지역
    private ItemType itemType; // 상품 종류
    private String deliveryCode; // 배송 방식

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

체크 박스 - 단일

	<!-- single checkbox -->
	<div>판매 여부</div>
	<div>
		<div class="form-check">
			<input type="checkbox" id="open" name="open" th:field="${item.open}" class="form-check-input">
			<label for="open" class="form-check-label">판매 오픈</label>
		</div>
	</div>
  • 체크 박스를 체크하면 HTML Form에서 open=on이라는 값이 넘어간다. 스프링은 on이라는 문자를 true 타입으로 변환해준다.
	@PostMapping("/add")
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {

        log.info("item.open={}", item.getOpen());

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);

        return "redirect:/form/items/{itemId}";
    }
  • HTML 체크 박스는 체크가 선택이 안되면 클라이언트에서 서버로 값 자체를 보내지 않는다. 사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장시 아무 값도 넘어가지 않기 때문에, 서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않을 수도 있다. 이를 해결하기 위해 스프링 MVC는 히든 필드를 만들어 _open처럼 기존 체크 박스 이름 앞에 언드스코어(_)를 전송하면 체크를 해제했다고 인식할 수 있게 한다.
	<!-- single checkbox -->
    <div>판매 여부</div>
    <div>
        <div class="form-check">
            <input type="checkbox" id="open" name="open" class="form-check-input"> 
            <input type="hidden" name="_open" value="on"/> <!-- 히든 필드 추가 -->
            <label for="open" class="form-check-label">판매 오픈</label>
        </div>
    </div>
  • 체크 박스 체크
    open=on&_open=on
    체크 박스를 체크하면 스프링 MVC가 open 에 값이 있는 것을 확인하고 사용한다. 이때 _open 은
    무시한다.

  • 체크 박스 미체크
    _open=on
    체크 박스를 체크하지 않으면 스프링 MVC가 _open 만 있는 것을 확인하고, open 의 값이 체크되지
    않았다고 인식한다.
    이 경우 서버에서 Boolean 타입을 찍어보면 결과가 null 이 아니라 false 인 것을 확인할 수 있다.
    log.info("item.open={}", item.getOpen());

  • 매번 히든 필드를 생성하는 것은 번거로우므로 타임리프가 제공하는 폼 기능을 사용하면 자동으로 처리할 수 있다.

	<!-- single checkbox -->
	<div>판매 여부</div>
	<div>
		<div class="form-check">
			<input type="checkbox" id="open" name="open" th:field="${item.open}" class="form-check-input">
			<label for="open" class="form-check-label">판매 오픈</label>
		</div>
	</div>
  • 타임리프에서 렌더링 과정에서 히든 필드를 자동으로 만들어준다.

체크 박스 - 멀티

@ModelAttribute의 특별한 사용법
	@ModelAttribute("regions")
  	public Map<String, String> regions() {
		Map<String, String> regions = new LinkedHashMap<>(); 
        regions.put("SEOUL", "서울");
		regions.put("BUSAN", "부산");
		regions.put("JEJU", "제주");
      	return regions;
  }
  • 여러 폼에서 서울, 부산, 제주라는 체크 박스를 반복해서 보여주어야 하기에 매 컨트롤러에서 model.addAttribute 메서드를 통해 데이터를 반복해서 넣어주어야 하는데 @ModelAttribute는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있다. 이렇게 하면 컨트롤러를 요청할 때 regions()에서 반환한 값이 자동으로 model에 담기게 된다.

등록 폼

	<!-- multi checkbox -->
	<div>
		<div>등록 지역</div>
		<div th:each="region : ${regions}" class="form-check form-check-inline">
			<input type="checkbox" th:field="*{regions}" th:value="${region.key}" 
            		class="form-check-input">
			<label th:for="${#ids.prev('regions')}"
                       th:text="${region.value}" class="form-check-label">서울</label>
		</div>
	</div>
  • th:for="${#ids.prev('regions')}" : 멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있는데 이때 생성된 HTML 태그 속성에서 name은 같아도 되지만 id는 모두 달라야 된다. <\input> 태그는 타임리프가 자동으로 id뒤에 임의로 숫자를 붙여주지만, <\label> 태그 입장에서는 당장에 id가 존재하지 않는데 th:for="${#ids.prev('regions')}" 구문을 통해 타임리프는 each 루프안에서 체크박스를 만들 때 id의 뒤에 임의로 1,2,3,.. 숫자를 뒤에 붙여준다.

상품 상세 폼

<!-- multi checkbox -->
    <div>
        <div>등록 지역</div>
        <div th:each="region : ${regions}" class="form-check form-check-inline">
            <input type="checkbox" th:field="${item.regions}" th:value="${region.key}"
                   class="form-check-input" disabled>
            <label th:for="${#ids.prev('regions')}"
                   th:text="${region.value}" class="form-check-label">서울</label>
        </div>
    </div>
  • 타임리프의 체크 확인
    checked="checked"
    멀티 체크 박스에서 등록 지역을 선택해서 저장하면, 조회시에 checked 속성이 추가된 것을 확인할 수
    있다. 타임리프는 th:field 에 지정한 값과 th:value 의 값을 비교해서 체크를 자동으로 처리해준다.

결과

  • 서울, 부산 선택 -> regions=SEOUL&_regions=on®ions=BUSAN&_regions=on&_regions=on
  • 지역 선택 X -> _regions=on&_regions=on&_regions=on

라디오 버튼

  • 라디오 버튼은 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.
public enum ItemType {
    BOOK("도서"), FOOD("음식"), ETC("기타");

    private final String description;

    ItemType(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}
	<!-- radio button -->
	<div>
	<div>상품 종류</div>
      	<div th:each="type : ${itemTypes}" class="form-check form-check-inline">
          	<input type="radio" th:field="*{itemType}" th:value="${type.name()}"
  class="form-check-input">
          	<label th:for="${#ids.prev('itemType')}" th:text="${type.description}" class="form-check-label">
              BOOK
          	</label>
      	</div>
	</div>

셀렉트 박스

  • 셀렉트 박스는 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.
	@ModelAttribute("deliveryCodes")
    public List<DeliveryCode> deliveryCodes() {
        List<DeliveryCode> deliveryCodes = new ArrayList<>();
        deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
        deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
        deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));

        return deliveryCodes;
    }
	<!-- SELECT -->
	<div>
	<div>배송 방식</div>
	<select th:field="*{deliveryCode}" class="form-select">
    	<option value="">==배송 방식 선택==</option>
        <option th:each="deliveryCode : ${deliveryCodes}" th:value="$   {deliveryCode.code}" th:text="${deliveryCode.displayName}">FAST</option>
	</select>
	</div>

메세지

  • 웹에서 특정 문구를 바꾸고 싶을 때 HTML 파일에 메세지가 하드코딩 되어 있다면 모든 파일에 들어가 수정을 해야 하는 힘든 작업을 해야 한다. 이런 일을 방지하기 위해 다양한 메세지를 한 곳에서 관리하는 기능을 메세지 기능이라고 한다.
  • message.properties라는 메세지 관리용 파일을 만들고
    • item = 상품
    • item.id = 상품 ID
    • item.itemName = 상품명
      각 HTML에선 해당 데이터를 key 값으로 불러서 사용하는 것이다.
      <label for "itemName" th:text="#{item.itemName}"<\label>

국제화

  • 메세지에서 사용한 메세지 파일(messages.properties)를 나라별로 관리하면 서비스를 국제화할 수 있다.
  • message_en.properties
    • item = Item
    • item.id = Item ID
    • item.itemName = Item Name
  • message_ko.properties
    • item = 상품
    • item.id = 상품 ID
    • item.itemName = 상품명
  • 영어를 사용하는 사람이면 message_en.properties를, 한국어를 사용하는 사람이면 messages_ko.properties를 사용할 수 있게 한다.
  • 한국에서 접근한 것인지 영어에서 접근한 것인지는 인식하는 방법은 HTTP accept-language 해더 값을
    사용하거나 사용자가 직접 언어를 선택하도록 하고, 쿠키 등을 사용해서 처리하면 된다.

스프링 메세지 소스

  • 메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource 를 스프링 빈으로 등록하면 되는데, MessageSource 는 인터페이스이다. 따라서 구현체인 ResourceBundleMessageSource 를 스프링 빈으로 등록하면 된다.
  @Bean
  public MessageSource messageSource() {
      ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
      messageSource.setBasenames("messages", "errors");
      messageSource.setDefaultEncoding("utf-8");
      return messageSource;
}
  • basenames : 설정 파일의 이름을 지정한다. messages 로 지정하면 messages.properties 파일을 읽어서 사용한다. 추가로 국제화 기능을 적용하려면 messages_en.properties , messages_ko.properties 와 같이 파일명 마지막에 언어 정보를 주면된다. 만약 찾을 수 있는 국제화 파일이 없으면 messages.properties (언어정보가 없는 파일명)를 기본으로 사용한다. 파일의 위치는 /resources/messages.properties 에 두면 된다. 여러 파일을 한번에 지정할 수 있다. 여기서는 messages , errors 둘을 지정했다.
  • defaultEncoding : 인코딩 정보를 지정한다. utf-8 을 사용하면 된다.

스프링 부트 지원

  • 스프링 부트를 사용하면 자동으로 MessageSource를 스프링 빈으로 등록한다.
  • application.properties에서 spring.messages.basename = messages, ... 처럼 메세지 소스를 설정할 수 있다.
  • 기본값은 spring.messages.basename=messages

스프링 메시지 소스

	public interface MessageSource {
    	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
    	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

테스트

  • messages.properties
    • hello=안녕
    • hello.name=안녕 {0}
  • messages_en.properties
    • hello=hello
    • hello.name=hello {0}
	@Autowired
    MessageSource ms;

    @Test
    void helloMessage() {
        String result = ms.getMessage("hello", null, null);
        assertThat(result).isEqualTo("안녕");
    }
  • 가장 단순한 테스트는 메시지 코드로 hello 를 입력하고 나머지 값은 null 을 입력했다.
    locale 정보가 없으면 basename 에서 설정한 기본 이름 메시지 파일을 조회한다. basename 으로 messages 를 지정 했으므로 messages.properties 파일에서 데이터 조회한다.
	@Test
    void notFoundMessageCode() {
        assertThatThrownBy(() -> ms.getMessage("no-code", null, null))
                .isInstanceOf(NoSuchMessageException.class);
    }
    
    @Test
    void notFoundMessageCode2() {
        String result = ms.getMessage("no_code", null, "기본 메세지", null);
        assertThat(result).isEqualTo("기본 메세지");
    }
  • 메시지가 없는 경우에는 NoSuchMessageException 이 발생한다.
  • 메시지가 없어도 기본 메시지( defaultMessage )를 사용하면 기본 메시지가 반환된다.
	@Test
    void argumentMessage() {
        String message = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
        assertThat(message).isEqualTo("안녕 Spring");
    }
  • 메시지의 {0} 부분은 매개변수를 전달해서 치환할 수 있다. hello.name=안녕 {0} -> Spring 단어를 매개변수로 전달 -> 안녕 Spring
	@Test
    void enLang() {
        assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
    }
  • ms.getMessage("hello", null, Locale.ENGLISH) : locale 정보가 Locale.ENGLISH 이므로 messages_en 을 찾아서 사용

웹 애플리케이션에 메시지 적용

  • messages.properties
    • label.item=상품
    • label.item.id=상품 ID
    • label.item.itemName=상품명
    • label.item.price=가격
    • label.item.quantity=수량
    • page.items=상품 목록
    • page.item=상품 상세
    • page.addItem=상품 등록
    • page.updateItem=상품 수정
    • button.save=저장
    • button.cancel=취소

타임리프

  • 타임리프의 메시지 표현식 #{...} 를 사용하면 스프링의 메시지를 편리하게 조회할 수 있다. 예를 들어서 방금 등록한 상품이라는 이름을 조회하려면 #{label.item} 이라고 하면 된다.
    <div th:text="#{label.item}"></h2> -> <div>상품</div>
<div class="container">
    <div class="py-5 text-center">
        <h2 th:text ="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
        <div>
            <label for="itemName" th:text = "#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text = "#{label.item.itemName}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text = "#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text = "#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/message/items}'|"
                        type="button" th:text = "#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div>
  • 파라미터는 다음과 같이 사용할 수 있다. hello.name=안녕 {0}
    <\p th:text="#{hello.name(${item.itemName})}">

웹 애플리케이션에 국제화 적용

  • messages_en.properties

    • label.item=Item
    • label.item.id=Item ID
    • label.item.itemName=Item Name
    • label.item.price=price
    • label.item.quantity=quantity
    • page.items=Item List
    • page.item=Item Detail
    • page.addItem=Item Add
    • page.updateItem=Item Update
    • button.save=Save
    • button.cancel=Cancel
  • 크롬 브라우저 설정 언어를 검색하고, 우선 순위를 변경하면 된다. 웹 브라우저의 언어 설정 값을 변경하면 요청시 Accept-Language 의 값이 변경된다. Accept-Language 는 클라이언트가 서버에 기대하는 언어 정보를 담아서 요청하는 HTTP 요청 헤더이다.

  • 스프링도 Locale 정보를 알아야 언어를 선택할 수 있는데, 스프링은 언어 선택시 기본으로 Accept-
    Language 헤더의 값을 사용한다.

검증

  • 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다. 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 아마도 이런 서비스라면 사용자는 금방 떠나버릴 것이다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
  • 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.

  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.

  • 올바르지 못한 데이터로 검증이 실패하면 사용자에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.

검증 직접 처리 - V1

	@PostMapping("/add")
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

        // 검증 오류 결과 보관
        Map<String, String> errors = new HashMap<>();

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.put("itemName", "상품 이름은 필수입니다.");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (!errors.isEmpty()) {
            model.addAttribute("errors", errors);
            return "validation/v1/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }
  • 검증시 오류가 발생하면 errors(Map)에 담아둔다. 오류가 발생한 필드명을 key로 사용한다. 이후 뷰에서 errors를 사용해 오류 메세지를 출력할 수 있다.
  • 특정 필드를 넘어서는 오류는 필드 이름을 넣을 수 없으므로 globalError라는 key를 사용한다.
	<div>
		<label for="price" th:text="#{label.item.price}">가격</label>
		<input type="text" id="price" th:field="*{price}"
                   th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="가격을 입력하세요">
		<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">상품 가격 오류</div>
	</div>

Safe Navigation Operator

  • 처음 등록 폼에 진입한 시점에는 errors 가 없다.
  • errors가 null이라면 errors.containsKey()를 호출할 때 NullPointerException이 발생한다.
  • errors?.는 errors가 null일 때 NullPointerException이 발생하는 대신, null을 반환하는 문법이다.
  • 뷰 템플릿에서 중복 처리가 많고 타입 오류 처리를 할 수 없는 문제가 있다. 타입을 잘못 입력했을 경우 컨트롤러에 들어오기 전에 예외가 발생하기 때문에 컨트롤러가 호출되지도 않는다. 더 개선해야 한다.

Binding Result 적용 - V2.1

	@PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            bindingResult.addError(new FieldError("item","quantity", "수량은 최대 9,999 까지 허용합니다."));
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }
  • 기존의 errors(Map)을 대체하는 스프링에서 제공하는 BindingResult 객체를 사용한다.
  • 컨트롤러의 파라미터에서 BindingResult의 위치는 @ModelAttribute 다음에 와야 한다.
public FieldError(String objectName, String field, String defaultMessage) {}
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
  • objectName : @ModelAttribute의 이름
  • field : 오류가 발생한 필드 이름
public ObjectError(String objectName, String defaultMessage) {} 
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
  • objectName : @ModelAttribute의 이름
	<div th:if="${#fields.hasGlobalErrors()}">
    	<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div>

	<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
	<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
  • 타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
  • #fields : BindingResult가 제공하는 검증 오류에 접근 가능
  • th:erros : 해당 필드에 오류가 있는 경우 태그 출력한다. th:if의 편의 버전.
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다. 위 코드에선 itemName을 보고 bindingResult 확인

Binding Result 상세

  • 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
  • BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가
    호출된다.
  • @ModelAttribtue에 바인딩 시 타입 오류가 발생할 때
    • BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동한다.
    • BindingResult가 있으면 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.
  • BindingResult는 검증할 대상 바로 다음에 와야하며 BindingResult는 Model에 자동으로 포함된다.

BindingResult를 사용하여 오류 메세지를 쉽게 처리할 수 있었다. 하지만 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 더 개선해야 한다.

사용자 입력 오류 메세지 - V2.2

	@PostMapping("/add")
    public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 검증 오류 결과 보관
        Map<String, String> errors = new HashMap<>();

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null,  "상품 이름은 필수입니다."));
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            bindingResult.addError(new FieldError("item","quantity", item.getQuantity(), false, null, null,"수량은 최대 9,999 까지 허용합니다."));

        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

FieldError(ObjectError)의 두번째 생성자

public FieldError(String objectName, String field,
@Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @NullableObject[] arguments,
@Nullable String defaultMessage) 
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
타임리프의 사용자 입력 값 유지
  • th:field="*{price}" : 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
스프링의 바인딩 오류 처리
  • 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.

오류 메세지 처리 - V2.3

  • FieldError , ObjectError 의 생성자는 codes , arguments 를 제공한다. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

  • spring.messages.basename=messages,errors
    errors.properties

required.item.itemName=상품 이름은 필수입니다. 
range.item.price=가격은 {0} ~ {1} 까지 허용합니다. 
max.item.quantity=수량은 최대 {0} 까지 허용합니다. 
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
	@PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null,  "상품 이름은 필수입니다."));
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            bindingResult.addError(new FieldError("item","quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999},"수량은 최대 9,999 까지 허용합니다."));

        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000,resultPrice},"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }
  • 'codes'를 사용해서 메세지 코드를 지정한다. 메세지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭한다.
  • 'arguments'를 사용해서 코드의 {0}, {1}, .. 로 치환할 값을 전달한다.

오류 코드, reject, rejectValue - V2.4

  • BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError, ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
	@PostMapping("/add")
    public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.rejectValue("itemName","required");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);

            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }
  • BindingResult가 제공하는 rejectValue(), reject()을 사용하면 FieldError, ObjectError를 직접 생성하지 않고 처리할 수 있다.
void rejectValue(@Nullable String field, String errorCode,
        @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • BindingResult는 어떤 객체를 검증하는지 알고 있어서 target 모델 객체에 대한 정보는 없어도 된다.
  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

오류 코드 상세

  • 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 방식이다.
    FieldError 우선순위
rejectValue("itemName", "required")
  1. required.item.itemName
  2. required.itemName
  3. required.java.lang.String
  4. required

ObjectError 우선순위

reject("totalPriceMin")
  1. totalPriceMin.item
  2. totalPriceMin

MessagesCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
public class MessageCodesResolverTest {

    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
    }
}
  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
    FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
FieldError rejectValue("itemName", "required")
  • 다음 4가지 오류 코드를 자동으로 생성
    • required.item.itemName
    • required.itemName
    • required.java.lang.String
    • required
ObjectError reject("totalPriceMin")
  • 다음 2가지 오류 코드를 자동으로 생성
    • totalPriceMin.item
    • totalPriceMin

오류 메세지 출력

  • 타임리프 화면을 렌더링할 때 th:errors가 실행되고 이때 오류가 있다면 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾는다.

rejectValue() 호출 -> MessageCodesResolver를 사용해서 메세지 코드들을 생성 -> new FieldError()을 생성하면서 메세지 코드들을 보관 -> th:errors에서 메세지 코드들로 메세지를 순서대로 메세지에서 찾고, 노출

검증 분리 및 검증기 직접 호출 - V2.5

  • 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이
    좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.
@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName","required");
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
    }
}
  • 스프링은 검증을 체계적으로 제공하기 위해 인터페이스를 제공한다.
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports() : 해당 검증기를 지원하는지 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
	private final ItemValidator itemValidator;

	@PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        itemValidator.validate(item, bindingResult);

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }
  • @RequiredArgsConstructor 어노테이션을 통해 final이 붙은 선언에 대해 생성자가 생성되었고 단일 생성자일 경우 @Autowired 어노테이션을 생략할 수 있다.
  • ItemValidator를 스프링 빈으로 주입받아서 직접 호출했다.

검증 분리 및 검증기 스프링 호출 - V2.6

	@InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }
  • WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. @InitBinder -> 해당 컨트롤러에만 영향을 준다.
	@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }
  • validator를 직접 호출하는 부분이 사라지고, 대신 검증 대상 앞에 @Validated가 붙었다.

동작 방식

  • @Validated는 검증기를 실행하라는 어노테이션.
  • 어노테이션이 붙으면 WebDataBinder에서 검증기를 찾아 실행한다. 이때 supports()가 사용된다.
  • supports(Item.class) -> ItemValidator.validate()

Bean Validation

  • Bean Validation은 특정 구현체가 아닌 기술 표준이다.
  • Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
@Data
public class Item {
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

Bean Validation

  • 검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은
    대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
  • 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.
  • Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.
  • 스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

Bean Validation 적용 - V3.1

@Data
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 스프링 부트는 자동으로 글로벌 Validator로 등록한다.
  • LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 어노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에 @Valid, @Validated만 적용하면 된다.

검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    • 실패시 typeMismatch로 FieldError 추가
  2. Validator 적용
    • BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
    • price 에 문자 "A" 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 -> price 필드는 BeanValidation 적용 X

오류 코드

  • 오류 코드는 어노테이션 이름으로 등록된다.
@NotBlank
  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank
@Range**
  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range
errors.properties

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

  • {0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.

  • 오브젝트 관련 오류(ObjectError)은 @ScriptAssert()을 사용한다.

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
	...
}
  • 실제 사용하기 제약이 많고 복잡하므로 오브젝트 관련 오류는 자바 코드로 직접 작성하는 것이 권장된다.
	@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v3/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

Bean Validation 한계

  • 데이터를 등록, 수정할 때 요구사항이 다를 수 있다. 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다.
  • ex) 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다. 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
@Data
public class Item {

    @NotNull
    private Long id;

    @NotBlank(message = "공백은 입력할 수 없습니다.")
    private String itemName;

    @Range(min = 1000, max = 1000000)
    private Integer price;

    //@Max(value = 9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • id : @NotNull 추가
  • quantity : @Max(9999) 제거
  • 이렇게 적용하면 수정은 문제없지만 등록 시에 문제가 발생한다. 등록할 때에는 id에 값이 없기 때문이다.
  • 결과적으로 등록과 수정은 같은 BeanValidation을 적용할 수 없다.

groups 적용 - V3.2

  • Bean Validation은 groups라는 기능을 제공한다.
    예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
	public interface SaveCheck {
	}
	public interface UpdateCheck {
  	}
@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = {SaveCheck.class})
    private Integer quantity;
}
	@PostMapping("/add")
    public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v3/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }
    
    @PostMapping("/{itemId}/edit")
    public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v3/editForm";
        }

        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
  • groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있다. 하지만 전반적으로 복잡도가 높다.
  • groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

Form 전송 객체 분리

  • 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.
  • Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

Form 전송 객체 분리 적용 - V4

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    // 수정에서는 수량은 자유롭게 변경
    private Integer quantity;
}

	@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        // 특정 필드가 아닌 복합 룰 검증
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            return "validation/v4/addForm";
        }

        // 성공 로직
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setPrice(form.getPrice());
        item.setQuantity(form.getQuantity());

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v4/items/{itemId}";
    }
    
    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

        // 특정 필드가 아닌 복합 룰 검증
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v4/editForm";
        }

        Item itemParam = new Item();
        itemParam.setItemName(form.getItemName());
        itemParam.setPrice(form.getPrice());
        itemParam.setQuantity(form.getQuantity());

        itemRepository.update(itemId, itemParam);
        return "redirect:/validation/v4/items/{itemId}";
    }
  • Item 대신에 ItemSaveform을 전달받는다. 폼 객체의 데이터를 기반으로 Item 객체를 생성한다.
  • 폼 객체 처럼 중간에 다른 객체가 추가되면 변환하는 과정이 추가된다.

Bean Validation - HTTP 메세지 컨버터

  • @Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
  • @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
  • @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트로러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}
  • HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.
  • @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

로그인 처리

로그인 방식

LoginForm

@Data
public class LoginForm {

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
}

LoginController

	@PostMapping("/login")
	public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");

            return "login/loginForm";
        }

        // 로그인 성공 처리

        return "redirect:/";
    }

LoginService

	public Member login(String loginId, String password) {
        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
  • 로그인 컨트롤러는 필드 오류가 존재하면 다시 로그인 작성 화면으로 돌려 보내고 필드 오류가 존재하지 않는다면 로그인 서비스를 호출해서 member 저장소에 해당 아이디와 패스워드를 가진 member가 있는지 확인한다.
  • 해당 member가 없으면 bindingResult.reject 메서드를 통해 ObjectError를 생성한다.

로그인 - 쿠키

  • 서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.
	@PostMapping("/login")
  	public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
		if (bindingResult.hasErrors()) {
			return "login/loginForm";
		}
      	Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
      	log.info("login? {}", loginMember);
      
      	if (loginMember == null) {
        	bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
          	return "login/loginForm";
      	}
       
       	//로그인 성공 처리

		//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
      	Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
		response.addCookie(idCookie);
      	return "redirect:/";
	}

	@GetMapping("/")
    public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
        if (memberId == null) {
            return "home";
        }

        Member loginMember = memberRepository.findById(memberId);
        if (loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";
    }
  • @CookieValue를 사용하면 편리하게 쿠키를 조회할 수 있다.
  • 로그인하지 않은 사용자도 홈에 접근할 수 있기 때문에 required=false 사용한다.
  • 로그인 쿠키가 있고 회원인 사용자는 로그인 사용자 전용 홈 화면으로 보낸다.

로그아웃

  • 세션 쿠키이므로 웹 브라우저 종료시 서버에서 해당 쿠키의 종료 날짜를 0으로 지정해서 로그아웃 실행
	@PostMapping("/logout")
    public String logoutV1(HttpServletResponse response) {
        expireCookie(response);

        return "redirect:/";
    }
    
    private static void expireCookie(HttpServletResponse response) {
        Cookie cookie = new Cookie("memberId", null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

쿠키 보안 문제

  • 쿠키 값은 임의로 변경 가능
  • 쿠키에 보관된 정보는 훔쳐갈 수 있음
  • 한번 훔쳐가면 평생 사용 가능

대안

  • 사용자 별로 예측 불가능한 임의의 토큰 노출하고 서버에서 토큰과 사용자 id를 매핑해서 사용
  • 토큰을 훔쳐가도 사용성이 없도록 토큰의 만료시간을 짧게 유지한다.

로그인 - 세션

  • 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다. 이런 방식으로 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다
  • 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
  • 세션 ID를 생성하는데, 추정 불가능해야 한다. UUID는 추정이 불가능하다.
  • 생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.
  • 서버는 클라이언트에 mySessionId 라는 이름으로 세션ID 만 쿠키에 담아서 전달한다.
  • 클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

  • 클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
  • 서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.

SessionManager

@Component
public class SessionManager {

    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    
    public static final String SESSION_COOKIE_NAME = "MySessionId";
    
    public void createSession(Object value, HttpServletResponse response) {

        // 세션 id 생성
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        // 쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);

    }

    public Object getSession(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());
    }

    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }

    public Cookie findCookie(HttpServletRequest request, String cookieName) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return null;
        }
        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}

LoginController

	private final SessionManager sessionManager;

	@PostMapping("/login")
    public String loginV2(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // 로그인 성공 처리
		sessionManager.createSession(loginMember, response);

        return "redirect:/";
    }
  • 스프링 빈으로 등록한 SessionManager를 주입받고 로그인 성공시에 sessionManager.createSessio 메서드를 호출함으로써 세션을 등록(세션 저장소에 저장, response객체에 cookie 추가)한다.

로그인 유지

	@GetMapping("/")
    public String homeLoginV2(HttpServletRequest request, Model model) {

        Member member = (Member) sessionManager.getSession(request);

        if (member == null) {
            return "home";
        }

        model.addAttribute("member", member);
        return "loginHome";
    }

로그아웃

	@PostMapping("/logout")
    public String logoutV2(HttpServletRequest request) {
        sessionManager.expire(request);

        return "redirect:/";
    }

로그인 처리 - 서블릿 HTTP 세션 이용

  • 서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다. 위에서 직접 만든 SessionManager와 같은 방식으로 동작한다.

LoginController

	@PostMapping("/login")
    public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
                          @RequestParam(defaultValue = "/") String redirectURL,
                          HttpServletRequest request) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // 로그인 성공 처리
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

        return "redirect:" + redirectURL;
    }
  • 세션을 생성하기 위해 request.getSession(true)를 사용한다.
  • session.setAttribute 메서드를 통해 세션에 데이터를 보관한다.

로그인 유지

	@GetMapping("/")
    public String homeLoginV3(HttpServletRequest request, Model model) {

        HttpSession session = request.getSession(false);
        if (session == null) {
            return "home";
        }

        Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);

        if (loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";
    }

로그인 유지 - @SessionAttribute

	@GetMapping("/")
    public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
        if (loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";
    }
  • 스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다.

로그아웃

	@PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/";
    }
  • request.getSession(false)에서 boolean create의 value가 false인 이유는 true는 session이 존재하지 않을 경우 새로 생성해서 반환해주기 때문이다.

세션 상세

  • sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로
    sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부

타임아웃

  • 세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다. 문제는 HTTP가 비 연결성(ConnectionLess)이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
  • 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 방식으로 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘린다. 따라서 매 타임아웃마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

현재 문제 사항

  • 로그인한 사용자만 상품 관리, 수정 등을 할 수 있어야 하나 현재 URL 직접 접근을 통해 로그인을 하지 않은 사용자도 접근이 가능한 상태이다.
  • 상품 등록, 수정, 삭제 등 컨트롤러에서 로그인 여부를 확인하는 인증 시스템을 갖추어도 해결할 수 있으나 로그인과 관련된 로직이 수정되면 모두 수정해야 하는 문제가 있다.
  • 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사라 하고 웹과 관련된 공통 관심사는 서블릿 필터, 스프링 인터셉터를 사용하여 처리하는 것이 좋다.

서블릿 필터

필터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

필터 제한

HTTP 요청 -> WAS -> 필터(적절하지 않은 요청 판단)

필터 체인

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

필터 인터페이스

public interface Filter {
      public default void init(FilterConfig filterConfig) throws ServletException {}
      public void doFilter(ServletRequest request, ServletResponse response,
              FilterChain chain) throws IOException, ServletException;
      public default void destroy() {}
   }
  • init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter() : 고객의 요청이 올 때 마다 메서드 호출된다
  • destory() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.

필터 테스트 - 로그 필터

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("log filter doFilter");

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST [{}] [{}]", uuid, requestURI);
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("RESPONSE [{}] [{}]", uuid, requestURI);
        }
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");

    }
}
  • 필터를 사용하려면 필터 인터페이스를 구현해야 한다.
  • doFilter의 파라미터가 ServletRequest, ServletResponse인 것은 HTTP 요청이 아닌 경우까지 고려해서 만든 케이스이다. HTTP를 사용하려면 다운 캐스팅해서 사용한다.

필터 등록 - WebConfig

  • 스프링 부트를 사용한다면 FilterRegistrationBean 을 사용해서 등록하면 된다.
	@Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LogFilter());
        filterFilterRegistrationBean.setOrder(1);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }
  • setFilter : 등록할 필터를 지정
  • setOrder : 필터는 체인으로 동작하기에 순서를 지정
  • addUrlPatterns : 필터를 적용할 URL 패턴을 지정

로그인 체크 필터

@Slf4j
public class LoginCheckFilter implements Filter {

    private static final String[] whitelist = {"/", "members/add", "/login", "/logout", "/css/*"};

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

        String requestURI = httpRequest.getRequestURI();

        HttpServletResponse httpResponse = (HttpServletResponse) response;

        try {
            log.info("인증 체크 필터 시작 {}", requestURI);

            if (isLoginCheckPath(requestURI)) {
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요쳥 {}", requestURI);

                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;
                }
            }
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("인증 체크 필터 종료 {]", requestURI);
        }
    }

    /**
     * 화이트 리스트
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}
  • 인증 필터를 적용해도 홈, 회원가입, 로그인 화면 등은 접근할 수 있어야 하므로 필터를 적용하지 않을 화이트 리스트를 적용한다.
  • 로그인 후에 일괄적으로 홈 화면을 보게 하는 것보다 로그인 후 접근하려던 서비스 화면을 출력하는 것이 사용자 입장에서 좋을 것이기에 쿼리 파라미터로 redirectURL을 함께 보낸다.

필터 등록 - WebConfig

	@Bean
    public FilterRegistrationBean loginCheckFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
        filterFilterRegistrationBean.setOrder(2);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }

로그인 유지

	@PostMapping("/login")
    public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
                          @RequestParam(defaultValue = "/") String redirectURL,
                          HttpServletRequest request) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // 로그인 성공 처리
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

        return "redirect:" + redirectURL;
    }

스프링 인터셉터

-서블릿 필터가 서블릿이 제공하는 기술이고, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 서로 적용되는 순서와 범위, 사용방법이 다르다.

스프링 인터셉터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

  • 스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 호출된다.
  • 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기에 디스패처 서블릿 이후에 등장한다. 스프링 MVC의 시작점이 디스패처 서블릿이기 때문이다.

스프링 인터셉터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청 판단)

스프링 인터셉터 체인

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

스프링 인터셉터 인터페이스

	public interface HandlerInterceptor {
    	default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {}
        
    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {}

 	default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {}
  • 서블릿 필터의 경우 단순하게 doFilter() 하나만 제공된다. 인터셉터는 컨트롤러 호출 전, 호출 후, 요청 완료 이후와 같이 단계적으로 세분화되어 있다.
  • 서블릿 필터의 경우 단순히 request, response만 제공했지만, 인터셉터는 컨트롤러 호출 정보, modelAndView 응답 정보도 받을 수 있다.

  • preHandle : 컨트롤러 호출 전에 호출되고 응답값에 따라 진행 여부가 결정된다.
  • postHandle : 컨트롤러 호출 후에 호출된다.
  • afterCompletion : 뷰 렌더링 후에 호출된다.

  • afterCompletion은 예외가 발생해도 호출된다.
  • 예외가 발생하면 postHandle() 는 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면 afterCompletion() 을 사용해야 한다.
  • 예외가 발생하면 afterCompletion() 에 예외 정보( ex )를 포함해서 호출된다.

인터셉터 테스트 - 로그 인터셉터

@Slf4j
public class LogIntercepter implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute(LOG_ID, uuid);

        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 담겨 있다.
        }

        log.info("REQUEST [{}] [{}] [{}]", uuid, requestURI, handler);

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = (String) request.getAttribute("logId");
        log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
        if (ex != null) {
            log.error("afterCompletion error", ex);
        }
    }
}
  • 핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다. 스프링을 사용하면 일반적으로 @Controller , @RequestMapping 을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod 가 넘어온다.
  • @Controller 가 아니라 /resources/static 와 같은 정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler 가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요하다.

인터셉터 등록 - WebConfig

public class WebConfig implements WebMvcConfigurer {
	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogIntercepter())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }
}
  • WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록한다.
  • order : 인터셉터 호출 순서 지정
  • addPathPatterns : 인터셉터 적용할 URL 패턴 지정
  • excludePathPatterns : 인터셉터에서 제외할 URL 패턴 지정

로그인 체크 인터셉터

@Slf4j
public class LoginCheckIntercepter implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        log.info("인증 체크 인터셉터 실행 {}",requestURI);

        HttpSession session = request.getSession();

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");

            response.sendRedirect("/login?redirectURL" + requestURI);
            return false;
        }
        return true;
    }
}

인터셉터 등록

	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogIntercepter())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");

        registry.addInterceptor(new LoginCheckIntercepter())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/error");

    }

ArgumentResolver 활용

	@GetMapping("/")
    public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
        if (loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";
    }

@Login 애노테이션 생성

	@Target(ElementType.PARAMETER)
  	@Retention(RetentionPolicy.RUNTIME)
  	public @interface Login {
  	}

LoginMemberArgumentResolver 생성

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • supportsParameter() : @Login 애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver 가 사용된다.
  • resolveArgument() : 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다. 여기서는 세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준다. 이후 스프링MVC는 컨트롤러의 메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달해준다.

등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(new LoginMemberArgumentResolver());
	}
	//...
}

서블릿 예외 처리

  • 2가지 방식으로 예외 처리를 지원한다. Exception (예외), response.sendError(HTTP 상태 코드, 오류 메시지)

Exception

웹 어플리케이션 예외 흐름

  • 애플리케이션에서 예외를 잡지 못하고, 서블릿 밖으로 까지 예외가 전달되면 톰캣 같은 WAS 까지 예외가 전달된다.
  • WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)

예제

	@Slf4j
  	@Controller
  	public class ServletExController {
    	@GetMapping("/error-ex")
      	public void errorEx() {
        	throw new RuntimeException("예외 발생!"); 
        }
	}
  • HTTP status 500 - Internal Server Error 가 발생한다. Exception의 경우 서버 내부에서 처리할 수 없는 오류로 생각해 HTTP 상태 코드 500을 반환한다.
http://localhost:8080/no-page
  • HTTP status 404 - Not Found가 발생한다. 톰캣이 기본으로 제공하는 404 오류이다.

response.sendError()

  • 오류가 발생했을 때 HttpServletResponse가 제공하는 sendError라는 메서드를 사용할 수 있다. 호출하면 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
	@GetMapping("/error-404")
  	public void error404(HttpServletResponse response) throws IOException {
		response.sendError(404, "404 오류!"); 
   	}
  	@GetMapping("/error-500")
  	public void error500(HttpServletResponse response) throws IOException {
      	response.sendError(500);
  	}

sendError 흐름

  • WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(response.sendError())

오류 페이지 제공

스프링 부트 서블릿 오류 페이지 등록

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {

        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);

    }
}

@Slf4j
@Controller
public class ErrorPageController{
	
    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 404");
        return "/error-page/404";
    }

    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 500");
        return "/error-page/500";
    }
}

오류 페이지 작동 원리

  • 서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나 response.sendError()가 호출되었을 때 설정된 오류 페이지를 찾는다.
  • WAS는 예외를 처리하는 오류 페이지 정보를 확인한다.
new ErrorPage(RuntimeException.class, "/error-page/500")
  • RuntimeException의 오류 페이지로 /error-page/500이 지정되어 있으므로 WAS는 오류 페이지를 출력하기 위해 /error-page/500을 다시 요청한다.
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View
  • 웹 클라이언트는 서버 내부에서 이런 흐름에 대해 전혀 알지 못한다.

서블릿 예외 처리 - 필터

  • 오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생하고 이때 필터, 서블릿, 인터셉터도 모두 다시 호출된다. 이 때 필터나 인터셉터가 한번 더 호출되는 것은 매우 비효율적인 경우가 있다.

  • 클라이언트로부터의 정상 요청인지, 오류 페이지를 출력하기 위한 내부 요청인지 구분하기 위해 서블릿은 DispatcherType을 제공한다.

  • DispatcherType

    • REQUEST : 클라이언트 요청
    • ERROR : 오류 요청
    • FORWARD : 서블릿에서 다른 서블릿이나 JSP 호출
    • INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
    • ASYNC : 서블릿 비동기 호출

LogFilter

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST  [{}][{}][{}]", uuid,
                    request.getDispatcherType(), requestURI);
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("RESPONSE [{}][{}][{}]", uuid,
                    request.getDispatcherType(), requestURI);
        }
    }
}

WebConfig

	@Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);

        return filterRegistrationBean;
    }
  • setDispatcherTypes에서 REQUEST, ERROR 모두 넣으면 클라이언트 요청, 오류 페이지 요청 모두 필터가 호출된다. 아무것도 넣지 않으면 기본값이 REQUEST이므로 클라이언트 요청에만 필터가 적용된다.

서블릿 예외 처리 - 인터셉터

LogIntercepter

@Slf4j
public class LogInterceptor implements HandlerInterceptor {
    public static final String LOG_ID = "logId";
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse
            response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();
        request.setAttribute(LOG_ID, uuid);
        log.info("REQUEST  [{}][{}][{}][{}]", uuid,
                request.getDispatcherType(), requestURI, handler);
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse
            response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse
            response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = (String)request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]", logId, request.getDispatcherType(),
                requestURI);
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    } 
}

WebConfig

	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyhandlerExceptionResolver());
        resolvers.add(new UserHandlerExceptionResolver());
    }
  • 필터의 경우에는 필터를 등록할 때 DispatcherType을 지정해 필터를 선택 적용할 수 있었는데 인터셉터는 서블릿이 아닌 스프링이 제공하는 기능이므로 무관하게 항상 호출된다.
  • 인터셉터는 excludePathPatterns를 통해 오류 페이지 경로를 선택할 수 있다.

전체 흐름

  • WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
  • WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
  • WAS 오류 페이지 확인
  • WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View

스프링 부트 - 오류 페이지

  • WebServerCustomizer, ErrorPage, ErrorPageController 만드는 과정을 스프링 부트는 기본으로 제공한다.
  • ErrorPage 를 자동으로 등록한다. 이때 /error 라는 경로로 기본 오류 페이지를 설정한다. new ErrorPage("/error") , 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용된다. 서블릿 밖으로 예외가 발생하거나, response.sendError(...) 가 호출되면 모든 오류는 /error 를 호출하게 된다.
  • BasicErrorController라는 스프링 컨트롤러가 자동으로 등록한다.

BasicErrorController

  • 오류가 발생했을 때 오류 페이지로 /error 를 기본 요청한다. 스프링 부트가 자동 등록한 BasicErrorController 는 이 경로를 기본으로 받는다.
  • 이 컨트롤러는 기본적인 로직이 이미 개발되어 있기에 개발자는 룰과 우선순위에 따라 등록하면 된다.

뷰 선택 우선순위**

  1. 뷰 템플릿
    • resources/templates/error/500.html
    • resources/templates/error/5xx.html
  2. 정적 리소스
    • resources/static/error/400.html
    • resources/static/error/404.html
    • resources/static/error/4xx.html
  3. 적용 대상이 없을 때 뷰 이름
    • resources/templates/error.html
  • 해당 경로 위치에 HTTP 상태 코드 이름의 뷰 파일을 넣어두면 된다.

제공 정보

  • BasicErrorController 컨트롤러는 다음 정보를 model에 담아서 뷰에 전달한다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.

    • timestamp: Fri Feb 05 00:00:00 KST 2021
    • status: 400
    • error: Bad Request
    • exception: org.springframework.validation.BindException
    • trace: 예외 trace
    • message: Validation failed for object='data'. Error count: 1 * errors: Errors(BindingResult)
    • path: 클라이언트 요청 경로 (/hello)
  • 오류 관련 내부 정보를 클라이언트에 노출하는 것이 좋지 않을 수도 있으니 applicatioin.properties에서 오류 정보들을 model에 포함할지 여부도 선택할 수 있다.

server.error.include-exception=false
server.error.include-message=never
server.error.include-stacktrace=never
...

API 예외 처리

  • HTML 페이지의 경우 앞서 설명한 오류 페이지로 대부분의 문제를 해결할 수 있었다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주면 되지만, API는 각 상황에 맞게 응답 스펙을 정하고 JSON으로 데이터를 내줘야 한다.
  • 웹 브라우저가 아닌 이상 HTML을 오류 결과로 직접 받아서 할 수 있는 것은 별로 없다. 오류 페이지 컨트롤러도 JSON 응답을 할 수 있게 만들어야 한다.

ErrorPageController

	@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {

        log.info("API errorPage 500");

        Map<String, Object> result = new HashMap<>();
        Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
        result.put("status", request.getAttribute(ERROR_STATUS_CODE));
        result.put("message", ex.getMessage());

        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);

        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }
  • produces = MediaType.APPLICATION_JSON_VALUE 는 클라이언트가 요청하는 HTTP Header의 Accept 값이 application/json 일 때 해당 메서드가 호출된다는 뜻이다.

스프링 부트 기본 오류 처리

	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
  	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
    
  	@RequestMapping
  	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {} 
  • errorHtml() : produces = MediaType.TEXT_HTML_VALUE, 클라이언트 요청의 Accept 헤더 값이 text/html인 경우에는 errorHtml()을 호출해서 view를 제공한다.
  • error() : 그외 경우 호출되며 ResponseEntity로 Http Body에 JSON 데이터를 반환한다. 스프링 부트는 BasicErrorController 가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다
	{
		"timestamp": "2021-04-28T00:00:00.000+00:00", "status": 500,
		"error": "Internal Server Error",
		"exception": "java.lang.RuntimeException",
		"trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController
  .java:19...,
		"message": "잘못된 사용자",
      	"path": "/api/members/ex"
	}

BasicErrorController는 HTML 페이지를 제공하는 경우에 매우 편리하다. 하지만 API 오류 처리는 API마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있고 매우 복잡하므로 HTML 화면을 처리할 때 주로 사용하고 API 오류 처리는 앞으로 설명할 @ExceptionHandler를 사용하자

HandlerExceptionResolver

  • 예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다. 발생하는 예외에 따라 다른 상태코드로 처리하려 한다.
	@GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        } else if (id.equals("bad")) {
            throw new IllegalArgumentException("예외 발생");
        }
        return new MemberDto(id, "hello " + id);
    }
  • api/members/bad 를 호출하면 IllegalArgumentException이 발생하고 상태코드가 500인 것을 확인할 수 있다.
  • 스프링 MVC는 컨트롤러밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. HandlerExceptionResolver줄여서 ExceptionResolver가 이를 해결한다.


HandlerExceptionResolver 인터페이스

	public interface HandlerExceptionResolver {
    	ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
	}

MyHandlerExceptionResolver 구현

@Slf4j
public class MyhandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());

                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}
  • ModelAndView를 반환하는 이유는 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이기 때문이다.
  • IllegalArgumentException이 발생하면 response.sendError(400)을 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.

반환 값에 따른 동작 방식

  • 빈 ModelAndView : 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정 : 뷰를 렌더링한다.
  • null : 다음 ExceptionResolver를 찾아서 실행한다.

ExceptionResolver 활용

  • 예외 상태 코드 변환 : 예외를 response.sendError(xxx)를 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
  • 뷰 템플릿 처리 : ModelAndView에 값을 채워서 새로운 오류 화면 뷰 렌더링
  • API 응답 처리 : response.getWriter().println()처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON으로 응답하면 API 응답 처리를 할 수 있다.

ExceptionResolver 등록

WebConfig
	@Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyhandlerExceptionResolver());
    }

API 예외 처리

  • 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 생각해보면 너무 복잡하다. ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
예외 발생
public class UserException extends RuntimeException{
    public UserException() {
        super();
    }

    public UserException(String message) {
        super(message);
    }

    public UserException(String message, Throwable cause) {
        super(message, cause);
    }

    public UserException(Throwable cause) {
        super(cause);
    }

    protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}


@GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        } else if (id.equals("bad")) {
            throw new IllegalArgumentException("예외 발생");
        } else if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

        return new MemberDto(id, "hello " + id);
    }
예외 처리
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());

                    String result = objectMapper.writeValueAsString(errorResult);

                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);

                    return new ModelAndView();
                }else{
                	// TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}
  • ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리해버린다. 서블릿 컨테이너까지 예외가 전달되지 않고 스프링 MVC에서 예외 처리가 끝이 난다. 결과적으로 WAS 입장에서는 정상 처리가 된 것이다.

ExceptionResolver - 스프링 부트 제공

  1. ExceptionHandlerExceptionResolver
    • @ExceptionHandler 처리, 대부분의 API 예외 처리
  2. ResponseStatusExceptionResolver
    • HTTP 상태 코드 지정
  3. DefaultHandlerExceptionResolver
    • 스프링 내부 기본 예외 처리

ResponseStatusExceptionResolver

  • @ResponseStatus가 달려있는 예외 처리
  • ResponseStatusException 예외 처리
	@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
    public class BadRequestException extends RuntimeException {}
  • BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver가 어노테이션을 확인하고 오류 코드를 HttpStatus.BAD_REQUEST로 변경하고 메세지를 담는다.

  • sendError를 호출했기에 WAS에서 오류 페이지 /error를 내부 요청한다.

  • @ResponseStatus는 개발자가 직접 변경할 수 있는 예외에만 사용할 수 있다는 단점이 있다. 해결하기 위해 ResponseStatusException 을 사용한다.

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());

DefaultHandlerExceptionResolver

  • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 예를 들어 파라미터 바인딩 시점에 타입이 맞지 않아 TypeMismatchException이 발생하고 그냥 두면 서블릿 컨테이너까지 오류가 올라가고 500 오류가 발생한다. 하지만 이는 클라이언트가 잘못 호출해서 발생하는 문제이므로 이런 경우 400 오류여야 한다.
  • DefaultHandlerExceptionResolver는 이것을 500 오류에서 400 오류로 변경한다.
  • response.sendError(HttpServletResponse.SC_BAD_REQUEST) (400), response.sendError() 를 통해서 문제를 해결한다. sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지(/error)를 내부 요청한다.
	@GetMapping("/api/default-handler-ex")
  	public String defaultException(@RequestParam Integer data) {
    	return "ok";
  	}
  • 타입 오류를 발생시키면 결과가 400 오류임을 알 수 있다.

ExceptionHandlerExceptionResolver

  • @ExceptionHandler
    스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 이다. 스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.
	@Data
  	@AllArgsConstructor
  	public class ErrorResult {
		private String code;
      	private String message;
  	}
  • 예외가 발생했을 때 API 응답으로 사용하는 객체
	@Slf4j
	@RestController
	public class ApiExceptionV2Controller {
    	
        @ResponseStatus(HttpStatus.BAD_REQUEST)
    	@ExceptionHandler(IllegalArgumentException.class)
    	public ErrorResult illegalExHandle(IllegalArgumentException e) {
        	log.error("[exceptionHandle] ex", e);
        	return new ErrorResult("BAD", e.getMessage());
    	}	
    
    	@ExceptionHandler
    	public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        	log.error("[exceptionHandle] ex", e);
        	ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        	return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
		}
    
    	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    	@ExceptionHandler
    	public ErrorResult exHandle(Exception e) {
        	log.error("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류");
    	}
    
    	@GetMapping("/api2/members/{id}")
    	public MemberDto getMember(@PathVariable("id") String id) {
        	if (id.equals("ex")) {
            	throw new RuntimeException("잘못된 사용자"); 
            }
            if (id.equals("bad")) {
            	throw new IllegalArgumentException("잘못된 입력 값");
        	}
        	if (id.equals("user-ex")) {
				throw new UserException("사용자 오류"); 
            }
          	
            return new MemberDto(id, "hello " + id);
      	}
      
      	@Data
      	@AllArgsConstructor
      	static class MemberDto {
       		private String memberId;
          	private String name;
        }
	}
  • @ExceptionHandler 어노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정하면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.
  • @ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
실행 흐름
  • 컨트롤러 호출 결과 예외가 컨트롤러 밖으로 던져진다.
  • 예외가 발생했으므로 ExceptionResolver가 작동한다. 가장 우선순외가 높은 ExceptionHandlerExceptionResolver가 실행된다.
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
  • @ExceptionHandler가 붙어있는 메서드를 실행한다.

@ControllerAdvice

  • 현재 한 컨트롤러 안에 예외 처리, 정상 코드가 모두 섞여 있다. @ControllerAdvice를 통해 분리할 수 있다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @GetMapping("/api2/members/{id}")
    public ApiExceptionController.MemberDto getMember(@PathVariable("id") String id) {

        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        } else if (id.equals("bad")) {
            throw new IllegalArgumentException("예외 발생");
        } else if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

        return new ApiExceptionController.MemberDto(id, "hello " + id);
    }


    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[excepptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandler(Exception e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}
  • @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다. @Controller , @RestController 의 차이와 같다.
대상 컨트롤러 지정 방법
	// Target all Controllers annotated with @RestController
  	@ControllerAdvice(annotations = RestController.class)
  	public class ExampleAdvice1 {}
  
  	// Target all Controllers within specific packages
  	@ControllerAdvice("org.example.controllers")
  	public class ExampleAdvice2 {}
  
  	// Target all Controllers assignable to specific classes
  	@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
  	public class ExampleAdvice3 {}

스프링 타입 컨버터

	@GetMapping("/hello-v1")
    public String helloV1(HttpServletRequest request) {
    	String data = request.getParameter("data"); //문자 타입 조회 Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경 
        System.out.println("intValue = " + intValue);
        return "ok";
   }
  • HTTP 요청 파라미터는 모두 문자로 처리된다. 요청 파라미터를 다른 타입으로 사용하고 싶으면 변환하는 과정을 거쳐야 한다.
	@GetMapping("/hello-v2")
  	public String helloV2(@RequestParam Integer data) {
    	System.out.println("data = " + data);
      	return "ok";
  	}
  • @RequestParam, @ModelAttribute, @PathVariable 등을 사용할 수 있는 것은 스프링이 중간에서 타입을 변환해주었기 때문이다.

스프링의 타입 변환 적용 예

  • 스프링 MVC 요청 파라미터
    • @RequestParam , @ModelAttribute , @PathVariable
  • @Value 등으로 YML 정보 읽기
  • XML에 넣은 스프링 빈 정보를 변환
  • 뷰를 렌더링 할 때

타입 컨버터 구현

  • 스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다. 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.
  • 타입 컨버터를 사용하려면 컨버터 인터페이스를 구현하면 된다.
	public interface Converter<S, T> {
    	T convert(S source);
	}

문자를 숫자로 변환하는 타입 컨버터

@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
    @Override
    public Integer convert(String source) {
        log.info("convert source = {}", source);
        return Integer.valueOf(source);
    }
}

사용자 정의 객체를 변환하는 타입 컨버터

@Getter
@EqualsAndHashCode
public class IpPort {

    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}

@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
    @Override
    public IpPort convert(String source) {
        log.info("convert source = {}", source);

        //"127.0.0.1:8080"
        String[] split = source.split(":");
        String ip = split[0];
        int port = Integer.valueOf(split[1]);
        return new IpPort(ip, port);
    }
}
  • 127.0.0.1:8080 같은 문자를 입력하면 IpPort 객체를 만들어 반환한다.

컨버전 서비스

  • 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공한다.
	public interface ConversionService {
    	boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);
    	boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
    	<T> T convert(@Nullable Object source, Class<T> targetType);
    	Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}
  • 컨버전 서비스 인터페이스는 컨버팅이 가능한지 확인하는 기능과 컨버팅 기능을 제공한다.

Test

	@Test
    void conversionService() {

        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        Integer result = conversionService.convert("10", Integer.class);
        System.out.println("result = " + result);

        assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class))
                .isEqualTo(new IpPort("127.0.0.1", 8080));
    }

등록과 사용 분리

  • 컨버터를 등록할 때는 타입 컨버터 등을 명확하게 알아야 하지만 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 컨버전 서비스 내부에 숨어서 제공된다.

스프링에 Converter 적용

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToIpPortConverter());
        registry.addConverter(new IntegerToStringConverter());
        registry.addConverter(new IpPortToStringConverter());
        registry.addConverter(new StringToIpPortConverter());

    }
}
  • 스프링은 내부에서 ConversionService를 제공한다. WebMvcConfigurer가 제공하는 addFormatters를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.
	@GetMapping("/ip-port")
    public String ipPort(@RequestParam IpPort ipPort) {
        System.out.println("ipPort.getIp() = " + ipPort.getIp());
        System.out.println("ipPort.getPort() = " + ipPort.getPort());
        return "ok";
    }
  • @RequestParam은 ArgumentResolver에서 ConversionService를 사용해서 타입을 변환한다.

뷰 템플릿 적용에 Converter 적용

	@GetMapping("/converter-view")
    public String converterView(Model model) {
        model.addAttribute("number", 10000);
        model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
        return "converter-view";
    }
	<!DOCTYPE html>
	<html xmlns:th="http://www.thymeleaf.org">
	<head>
  		<meta charset="UTF-8">
  		<title>Title</title>
	</head>
	<body> <ul>
    	<li>${number}: <span th:text="${number}" ></span></li>
  		<li>${{number}}: <span th:text="${{number}}" ></span></li>
  		<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
      	<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
	</ul>
	</body>
	</html>
  • 타임리프는 ${{..}}를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
  • 변수 표현식 : ${...}
  • 컨버전 서비스 적용 : ${{...}}

예시
- ${number}: 10000
- ${{number}}: 10000
- ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
- ${{ipPort}}: 127.0.0.1:8080

Form 적용

	@GetMapping("/converter-edit")
    public String converterForm(Model model) {
        IpPort ipPort = new IpPort("127.0.0.1", 8080);
        Form form = new Form(ipPort);
        model.addAttribute("form", form);
        return "converter-form";
    }
    
    @Data
    static class Form {
		private IpPort ipPort;
		public Form(IpPort ipPort) {
			this.ipPort = ipPort;
		} 
	}
	<!DOCTYPE html>
	<html xmlns:th="http://www.thymeleaf.org">
	<head>
  	<meta charset="UTF-8">
  	<title>Title</title>
	</head>
	<body>
	<form th:object="${form}" th:method="post">
  		th:field <input type="text" th:field="*{ipPort}"><br/>
  		th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/> <input type="submit"/>
	</form>
	</body>
	</html>
  • GET /converter/edit : th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.
  • POST /converter/edit : @ModelAttribute 를 사용해서 String IpPort 로 변환된다.

Formatter

  • 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 포맷터이다. 포맷터는 컨버터의 특별한 버전으로 이해한다.

Converter vs Formatter

  • Converter는 범용(객체 -> 객체)
  • Formatter는 문자에 특화(객체 -> 문자, 문자 -> 객체) + 현지화(Locale)

Formatter 인터페이스

	public interface Printer<T> {
    	String print(T object, Locale locale);
	}
  	public interface Parser<T> {
    	T parse(String text, Locale locale) throws ParseException;
	}
  	public interface Formatter<T> extends Printer<T>, Parser<T> {
  	}
  • String print(T object, Locale locale) : 객체를 문자로 변경한다.
  • T parse(String text, Locale locale) : 문자를 객체로 변경한다.

Formatter 구현

	@Slf4j
	public class MyNumberFormatter implements Formatter<Number> {
    	@Override
    	public Number parse(String text, Locale locale) throws ParseException {
        	log.info("text= {}, locale = {}", text, locale);
        	// 1,000 -> 1000
       		NumberFormat format = NumberFormat.getInstance(locale);
        	return format.parse(text);
    	}

    	@Override
    	public String print(Number object, Locale locale) {
        	log.info("object= {}, locale = {}", object, locale);
        	return NumberFormat.getInstance(locale).format(object);
    	}
	}

포맷터를 지원하는 컨버전 서비스

  • FormattingConversionService 는 포맷터를 지원하는 컨버전 서비스이다.
  • DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
	@Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
		// 컨버터 등록
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());
		// 포맷터 등록
        conversionService.addFormatter(new MyNumberFormatter());

        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));

        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
    }
}
  • 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter가 Converter처럼 동작하도록 지원한다.

포맷터 적용

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new IpPortToStringConverter());
        registry.addConverter(new StringToIpPortConverter());

        registry.addFormatter(new MyNumberFormatter());
    }
}

객체 -> 문자

  • ${number}: 10000
  • ${{number}}: 10,000

문자 -> 객체

  • MyNumberFormatter : text=10,000, locale=ko_KR
  • data = 10000

스프링 제공 기본 포맷터

  • 스프링은 어노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 유용한 포맷터 2가지를 기본으로 제공한다.

@NumberFormat : 숫자 관련 형식 지정 포맷터 사용
@DataTimeFormat : 날짜 관련 형식 지정 포맷터 사용

	@GetMapping("/formatter/edit")
    public String formatterForm(Model model) {
        Form form = new Form();
        form.setNumber(10000);
        form.setLocalDateTime(LocalDateTime.now());
        model.addAttribute("form", form);
        return "formatter-form";
    }

    @PostMapping("/formatter/edit")
    public String formatterEdit(@ModelAttribute Form form) {
        return "formatter-view";
    }
    @Data
    static class Form {
        @NumberFormat(pattern = "###,###")
        private Integer number;

        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }

결과

  • ${form.number}: 10000
  • ${{form.number}}: 10,000
  • ${form.localDateTime}: 2021-01-01T00:00:00
  • ${{form.localDateTime}}: 2021-01-01 00:00:00

파일 업로드

HTML Form 데이터 전송 방식

applciation/x-www-form-urlencoded

  • HTML 폼 데이터를 서버에 전송하는 가장 기본적인 방법이다.
  • 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20 와 같이 & 로 구분해서 전송한다.
  • 파일을 업로드하려면 파일은 문자가 아니라 바이너리 데이터를 전송해야 하고 폼을 전송할 때 파일만 전송하는 것이 아니기에 사용하기 어렵다.

multipart/form-data

  • Form 태그에 별도의 enctype="multipart/form-data" 를 지정해야 한다.
  • multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있다.
  • 첨부파일을 포함해 이름, 나이 등 추가적인 정보들까지 함께 전송할 수 있는 방식이다.

서블릿 파일 업로드

파일 업로드 경로 설정

  • file.dir=/Users/xavier/Desktop/file/
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {

    @Value("${file.dir}")
    private String fileDir;

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("reqeust = {}", request);

        String itemName = request.getParameter("itemName");
        log.info("itemName = {}", itemName);

        Collection<Part> parts = request.getParts();
        log.info("parts = {}", parts);

        for (Part part : parts) {
            log.info("==== PART ====");
            log.info("name={}", part.getName());
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info("header {} {}", headerName, part.getHeader(headerName));
            }
            log.info("submittedFilename={}", part.getSubmittedFileName());
            log.info("size={}", part.getSize());

            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            log.info("body = {}", body);

            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);
                part.write(fullPath);
            }
        }

        return "upload-form";
    }
}

업로드 사이즈 제한

  • spring.servlet.multipart.max-file-size=1MB
  • spring.servlet.multipart.max-request-size=10MB

서버에 저장할 파일 경로(application.properties)

  • file.dir=파일 업로드 경로 설정(예): /Users/kimyounghan/study/file/
  • 멀티파트 형식은 전송 데이터를 하나하나 각각 part으로 나누어 전송한다. parts에는 이렇게 나누어진 데이터가 각각 담긴다. 서블릿이 제공하는 Part는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공한다.

  • part.getSubmittedFileName() : 클라이언트가 전달한 파일명

  • part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.

  • part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다.

스프링 파일 업로드

  • 스프링은 MultipartFile이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
    @Value("${file.dir}")
    private String fileDir;

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFile(@RequestParam String itemName,
                           @RequestParam MultipartFile file, HttpServletRequest request) throws IOException {
        log.info("reqeust = {}", request);
        log.info("itemName = {}", itemName);
        log.info("file = {}", file);

        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 path = {}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }
}
  • @RequestParam MultipartFile file : 업로드하는 HTML Form의 name에 맞추어 @RequestParam 을 적용하면 된다.
  • file.getOriginalFilename() : 업로드 파일 명
  • file.transferTo(...) : 파일 저장

예제 - 추가 필요

profile
서강대학교 컴퓨터공학과

0개의 댓글