Spring MVC2

Park sang woo·2022년 10월 26일
0

인프런 공부

목록 보기
6/13
post-thumbnail

Thymeleaf

✿ text, utext

<ul>
  <li>th:text = <span th:text="${data}"></span></li>
  <li>th:utext = <span th:utext="${data}"></span></li>
</ul>

model.addAttribute("data", "Hello Spring"); 에서 data를 넘기고 html에서 th:text를 사용하여 data를 받으려면 th:text="${...}" 를 해주면 된다.

컨텐츠 안에서 직접 출력하고 싶다면 th:text 선언없이 바로 [[${data}]] 사용.

HTML 엔티티 : 웹 브라우저는 <를 HTML 태그의 시작으로 인식하는데 <를 태그의 시작이 아닌 문자로 표현하는 방법이 HTML 엔티티이다.

이스케이프 : HTML에서 사용하는 특수 문자를 HTML 엔티티로 변경하는 것.



✫ 언이스케이프

<, >를 문자로 표현하지 않고 즉 이스케이프가 아닌 언이스케이프로 적용하기 위해서는 utext와 [()]를 사용한다.

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

th:text 면 꾸밈이 적용되지 않지만 th:utext면 꾸밈이 적용된다.
[[${..}]] 으로 하면 escape가 적용된 상태로 같은 꾸밈이 적용되지 않지만 [(${..})] 으로 하면 unescape 되어 꾸밈이 적용된다.
th:text 와 [[..]] 가 escape 적용한 것이다.


✫ 항상 기본적으로 escape 처리를 해야 한다. escape 처리가 되어 있지 않으면 html 화면이 깨질 가능성이 크다 -> 사용자가 임의로 작성한 태그들이 깨진다.

꼭 필요한 때만 unescape를 사용.





✿ 변수 SpringEL

타임리프에서 변수를 사용할 때는 변수 표현식을 사용한다.

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

<h1>지역 변수 - (th:with)</h1>
<div th:with="first=${users[0]}">
  <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

$ {user.username} : user의 username을 프로퍼티 접근 -> user.getUsername()
$ {user['username']} : user.getIsername() 과 같음 -> 위와 같음
$ {user.getUsername()} : user의 getUsername()을 직접 호출

$ {users[0].username} : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 -> list.get(0).getUsername()
$ {users[0]['username']} : 위와 같음
$ {users[0].getUsername()} : List에서 첫 번째 회원을 찾고 메서드 직접 호출

$ {userMap['userA'].username} : Map에서 userA를 찾고 username 프로퍼티 접근 -> map.get("userA").getUsername()
$ {userMap['userA']['username']} : 위와 같음
$ {userMap['userA'].getUsername()} : Map에서 userA를 찾고 메서드 직접 호출

th:with를 사용하면 지역 변수를 선언해서 사용할 수 있다. 지역변수는 선언한 태그 안에서만 사용 가능.




✿ 기본 객체들

${#request}

${#response}

${#session}

${#servletContext}

${#locale} -> ko_KR


✿ 편의 객체

$ {param.paramData} : 파라미터의 value ex)paramData = HelloParam
HelloParam이 param.paramData
$ {session.sessionData} : 같음
$ {@helloBean.hello('Spring!')} : Component 이름을 helloBean으로 하여 스프링 빈에 직접 접근했고 HelloBean 클래스의 hello를 호출함. 그리고 'Spring!' 넘김. 그래서 Hello Spring! 이 됨.





✿ 날짜

<ul>
  <li>default = <span th:text="${localDateTime}"></span></li>
  <li>yyyy-MM-dd HH:mm:ss = <span th:text="${#temporals.format(localDateTime,
'yyyy-MM-dd HH:mm:ss')}"></span></li>
</ul>
<h1>LocalDateTime - Utils</h1>

출력

default = 2022-10-27T15:54:52.098395300

yyyy-MM-dd HH:mm:ss = 2022-10-27 15:54:5



<ul>
  <li>${#temporals.day(localDateTime)} = <span th:text="${#temporals.day(localDateTime)}"></span></li>
  <li>${#temporals.month(localDateTime)} = <span th:text="${#temporals.month(localDateTime)}"></span></li>
  <li>${#temporals.monthName(localDateTime)} = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
  <li>${#temporals.monthNameShort(localDateTime)} = <span th:text="${#temporals.monthNameShort(localDateTime)}"></span></li>
  <li>${#temporals.year(localDateTime)} = <span th:text="${#temporals.year(localDateTime)}"></span></li>
  <li>${#temporals.dayOfWeek(localDateTime)} = <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span></li>
  <li>${#temporals.dayOfWeekName(localDateTime)} = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>
  <li>${#temporals.dayOfWeekNameShort(localDateTime)} = <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span></li>
  <li>${#temporals.hour(localDateTime)} = <span th:text="${#temporals.hour(localDateTime)}"></span></li>
  <li>${#temporals.minute(localDateTime)} = <span th:text="${#temporals.minute(localDateTime)}"></span></li>
  <li>${#temporals.second(localDateTime)} = <span th:text="${#temporals.second(localDateTime)}"></span></li>
  <li>${#temporals.nanosecond(localDateTime)} = <span th:text="${#temporals.nanosecond(localDateTime)}"></span></li>
</ul>

$ {#temporals.day(localDateTime)} = 27
$ {#temporals.month(localDateTime)} = 10
$ {#temporals.monthName(localDateTime)} = 10월
$ {#temporals.monthNameShort(localDateTime)} = 10월
$ {#temporals.year(localDateTime)} = 2022
$ {#temporals.dayOfWeek(localDateTime)} = 4
$ {#temporals.dayOfWeekName(localDateTime)} = 목요일
$ {#temporals.dayOfWeekNameShort(localDateTime)} = 목
$ {#temporals.hour(localDateTime)} = 15
$ {#temporals.minute(localDateTime)} = 54
$ {#temporals.second(localDateTime)} = 52
$ {#temporals.nanosecond(localDateTime)} = 98395300





✿ URL

<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>
  1. 8080/hello
  2. @ {/hello(param1=param1,param2={param1}, param2={param2})} : 8080/hello?param1=data1 & param2=data2 이런 식으로 표현하고 싶을 때 사용.
  3. http://localhost:8080/hello/date1/date2
  4. http://localhost:8080/hello/date1?param2=date2
    남는 param2 가 자동으로 뒤에 쿼리 파라미터가 붙음




✿ Literals

리터럴 : 소스 코드 상에 고정된 값
타임리프에서 문자 리터럴은 항상 '' 작은 따옴표로 감싸야 한다.
ex)

<span th:text="'hello'">

항상 '' 로 감싸기 힘들기 때문에 공백없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 작은 따옴표가 생략가능하다.

<ul>
    <!--주의! 다음 주석을 풀면 예외가 발생함-->
    <!-- <li>"hello world!" = <span th:text="hello world!"></span></li>-->
    <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>
</ul>

리터럴 대체 문법으로 || 를 할 수도 있다.
|hello ${data}| 그러면 hello || 안의 내용을 그냥 문자열로 하고 ${} 부분만 치환해준다.





✿ 연산

true | false 를 봔환.

<!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에 랜더링되면 기존의 name을 지우고 th:name이 name으로 바껴서 값이 설정된다.
기존의 HTML을 최대한 건들지 않고 살짝만 바꿔치기 해서 동작하게 된다.

th:attrappend : 뒤에 붙임.
th:attrprepend : 앞에 붙임.
th:classappend : 알아서 적절하게 class 값 뒤에 띄워서 써줌.
ex) text large.

<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/>
</body>

결과 -> 페이스 소스보면
th:attrappend : class="text large"
th:attrprepend : class="largetext"
th:classappend : class="text large" 이렇게 됨
classappend는 위 2개처럼 띄어쓰기 없이 알아서 적절하게 해줌.

✫ 체크박스

th:checked="true" 이면 체크가 되어있음
th:checked="false" 면 체크가 되어 있지 않음
checked="false" 이 경우 false 여도 체크 되어 있음.
checked 속성 때문에 true인지 false인지 구분을 쉽게 하지 못한다. 그래서 th:checked 값이 false인 경우 checked 속성 자체를 제거한다.





✿ 반복

private void addUsers(Model model) {
        ArrayList<User> list = new ArrayList<>();
        list.add(new User("UserA", 10));
        list.add(new User("UserB", 20));
        list.add(new User("UserC", 30));
        model.addAttribute("users", list);
    }

list에 user 3명 추가해서 @GetMapping.


<tr th:each="user : ${users}">
    <td th:text="${user.username}">username</td>
    <td th:text="${user.age}">0</td>
  </tr>

반복기능
th:each="user : ${users}"
username 으로 설정한 UserA, UserB, UserC 가 출력되고 age로 설정한 10, 20, 30이 출력됨.

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

index - 0부터 시작
count - 1부터 시작
size - 전체 사이즈(위와 같이 했다면 3개)
even - 현재 루프가 짝수 -> 즉 count 2,4,6... 면 true
odd - 현재 루프가 홀수 -> 즉 count 1,3,5... 면 true
first,last - 처음, 마지막 여부 count가 1이면 처음이어서 first 는 true last는 false
current - 현재 객체

지정한 변수명 user + Stat이 되어 userStat은 생략이 가능.





✿ 조건부 평가

th:if="user.agelt2010살로설정했으므로10<20이므로출력됨.th:unless="{user.age lt 20} 10살로 설정 했으므로 10 < 20 이므로 출력됨. th:unless="{user.age ge 20}
10 >= 20인데 unless 이므로 출력됨.
조건에 맞지 않으면 태그가 아예 날라감.

<td th:switch="${user.age}">
      <span th:case="10">10살</span>
      <span th:case="20">20살</span>
      <span th:case="*">기타</span>
    </td>

${user.age} 로 10살, 20살 출력. 그런데 *는 user.age가 아니므로 기타를 출력





✿ 주석

<h1>1. 표준 HTML 주석</h1>
<!--
<span th:text="${data}">html data</span>
-->
<h1>2. 타임리프 파서 주석</h1>
<!--/* [[${data}]] */-->
<!--/*-->
<span th:text="${data}">html data</span>
<!--*/-->
  1. 표준 HTML 주석은 페이지 소스를 보면 이 부분이 그대로 주석 상태로 출력이 된다.
    즉 타임리프는 저 주석을 랜더링 하지 않고 남겨둔다.

2. 타임리프 파서 주석(주로 사용) : 타임리프 파서 주석은 아예 주석도 출력이 되지 않는다.
[[${data}]] 와 html data 부분이 다 날라간다.

  1. 타임리프 프로토타입 주석은 HTML파일을 그대로 열면 주석 처리가 되지만 타임리프를 랜더링 한 경우에는 보인다.




✿ 블록

div 태그를 2개 이상으로 맞추서 돌리고 싶을 때 사용.
div 태그에 each로 반복을 돌린다면 div 태그 하나만 반복이 적용이 된다. 그래서 2개 이상의 div를 모두 적용하고 싶을 때 블록을 사용한다.

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

each는 태그 한 부분만을 반복할 수 있고 여러 개는 불가능하다.
그래서 위 div 태그 같이 태그 여러 개를 반복하고 싶을 때는 block을 사용한다.





✿ 자바스크립트 인라인

<!-- 자바스크립트 인라인 사용 전 -->
<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>

✫ 인라인 사용 전

username = [[${user.username}]] 는 페이지 소스에서 자바스크립트가 깨져버린다. username은 문자이기 때문이다. 그래서 ""를 해주어야 한다.
/[[${user.username}]]/ 주석 처리된 이 부분을 사용할 수 있도록 도와준다.
[[${user}]] 의 경우 객체를 toString() 으로 출력하여 BasicController.User(username=UserA, age=10) 이런 식으로 출력이 된다.

✫ 인라인 사용 후

문자를 다 "" 처리해준다.
내추럴 템플릿도 지원해준다. 주석 부분을 값으로 쓸 수 있게 해주고 "test username"은 랜더링 지워준다.
객체는 {"username":"UserA", "age":10} 으로 출력해준다. (json으로 바꾸서 랜더링 해줌.)
추가로 자바스크립트에서 문제가 될 수 있는 문자가 포함되어 있으면 이스케이프 처리도 해준다.
ex) /"

✫ 간혹 인라인 사용 시 each를 사용해야 할 때가 있다.

<script th:inline="javascript">
  [# th:each="user, stat : ${users}"]
  var user[[${stat.count}]] = [[${user}]];
  [/]
</script>

var user1 = {"username":"UserA","age":10};
var user2 = {"username":"UserB","age":20};
var user3 = {"username":"UserC","age":30};
이렇게 출력이 된다.





✿ 템플릿 조각

footer 로 메서드 호출하듯이 다른 곳에서 불러와 사용할 수 있다.

<footer th:fragment="copy">
  푸터 자리 입니다.
</footer>

물결 표시 + {}를 사용하는 것이 원칙이지만 템플릿 조각을 사용하는 코드가 단순하면 생략 가능
ex) "~{template/fragment/footer :: copy}"
"template/fragment/footer :: copy"

  1. insert
    th:insert="~{template/fragment/footer :: copy}"
    html의 위치 template/fragment/footer 를 불러와서 footer.html에서 지정한 th:fragment="copy" 이곳의 copy를 호출한다.

  2. replace
    th:replace="~{template/fragment/footer :: copy}"
    똑같이 footer의 copy를 호출한다.

다른 점은 페이지 소스에서 insert는

<div><footer>푸터 자리 입니다.</footer></div>

이지만 replace는 div 태그를 대체한다.

파라미터 넣기

<footer th:fragment="copyParam (param1, param2)">
  <p>파라미터 자리 입니다.</p>
  <p th:text="${param1}"></p>
  <p th:text="${param2}"></p>
</footer>

th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"
그러면 "${param1/2}" 가 각각 데이터1, 데이터2로 치환됨.

✿ 템플릿 레이아웃1

<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>
</html>

th:replace 이므로 base.html과 바꿔치기 한다.
common_header에서 title 1개와 link 2개를 모두 넘겨서 바꿔치기하는데 그냥 바꿔치기 하는 것이 아니고 기본 레이아웃은 그대로 둔 채로 넘긴 값으로 랜더링 된다.

결과 페이지 소스

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

✿ 템플릿 레이아웃2

head 정도에만 적용하는게 아니라 html전체에 적용할 수도 있다.
layoutExtendMain.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>

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

html 파일인 layoutFile로 대체하는데 title과 section을 넘긴다.
title과 content를 상속받음. (이름을 content로 해서 그렇지 section임)

레이아웃 사용하여 파일만 수정하면 title, content(내용) 부분을 보호하면서 나머지 모양들을 바꿀 수 있다.
페이지가 적어 단순하면 레이아웃쓰지 않고 fragment로 하는 것도 괜찮지만 페이지 많고 관리가 중요하면 레이아웃 사용하는 것이 좋음.

✿ 타임리프 - 입력 폼 처리

controller에서 model.addAttribute("item", new Item()); 해줬으므로
th:object="${item}"는 아이디 객체를 가지고 코드들이 돌아간다.

th:object="${item}" - form 에서 사용할 객체를 지정한다.

id="itemName", name="itemName" 으로 중복되므로 th:field="${item.itemName}" 를 사용해서 id, name를 제거할 수 있다.
object로 ${item} 으로 잡았으므로 item에 소속된 것으로 인식되어 th:field="*{itemName}" 로 사용할 수 있다. 그러면 itemName 이라는 이름으로 자동으로 id, name을 만들어 준다.
th:field 는 html에서 id, name, value 속성을 모두 자동으로 만들어준다.

th:action - form 태그 사용 시, 해당 경로로 요청을 보낼 때 사용.
th:object, th:field 는 Validation 을 할 때 진가를 보여줌.

✿ 요구사항 - checkbox

HTML checkbox는 선택이 안되면 클라이언트에서 서버로 값 자체를 보내지 않는다. 사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장 시 아무 값도 넘어가지 않기 때문에 서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않을 수 있다.
즉 체크를 하면 값이 true로 넘어오지만 체크를 안 할시 값이 넘어오지 않고 null이 된다.
이 문제를 해결하기 위해서 스프링 MVC는 약간의 트릭을 사용한다.
히든 필드를 하나 만들어서 _open
히든 필드 _open은 무조건 데이터가 넘어가서 _open: on 이 된다.

<input type="hidden" name="_open" value="on"/> <!--히든 필드 추가-->

log.info("item.open={}", item.getOpen()); 으로 출력해본 결과
그래서 체크 박스에 체크하면 open:on & _open=on 이 되고 스프링 MVC가 open에 값이 있는 것을 확인하고 사용한다. (이때 _open은 무시)
체크 박스 미체크 시 _open:on 스프링 MVC가 _open만 있는 것을 확인하고 open 값이 체크되지 않았다고 인식한다. 이 경우 서버에서 Boolean 타입을 찍어보면 결과가 null이 아니라 false 이다.

✿ 타임리프 - checkbox

체크박스 있을 때마다 계속 _open을 만들어주어야 하는 불편함이 있는데 이것을 타임리프가 해결해준다.

<input type="checkbox" id="open" th:field="*{open}" class="form-check-input">

타임리프인 th:field="item.open"또는th:field="open"사용하면된다.그러면체크할시item.open=true가출력되고체크하지않으면item.open=false가된다.주의th:object="{item.open}" 또는 th:field="*{open}" 사용하면 된다. 그러면 체크할 시 item.open=true가 출력되고 체크하지 않으면 item.open=false가 된다. **주의 - th:object="{}" 가 없으므로 *{} 는 에러가 발생한다.**

✿ 멀티

Map<String, String> regions = new LinkedHashMap<>();
        regions.put("SEOUL", "서울");
        regions.put("BUSAN", "부산");
        regions.put("JEJU", "제주");

add(등록), edit(수정), item(목록) 폼에서 위 부분의 체크박스를 반복해서 보여주도록 추가해야 하는데 각각의 컨트롤러에서 model.addAttribute() 해서 사용하면 체크박스를 구성하는 데이터를 반복해서 넣어주는 번거로움이 있다.

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

이러면 Controller를 호출할 때는 항상 addAttribute 해서 "~"을 이름으로 모델에 무조건 담긴다.

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

each로 반복을 하게 되면 자동으로 id를 1,2,3 으로 해준다. 그래서 id=""를 해줄 필요가 없다. 그런데 label의 경우 id가 없다.
타임리프에서 이 문제를 해결하기 위해 #ids.prev("regions") 를 사용하여 th:field 에 있는 regions를 보고 자동으로 생성된 id를 인식해서 값을 가져와 넣어주게 된다.
그러면 th:field에서 생성한 id인 regions1을 label for 에서도 똑같이 regions1을 가져다 쓰게된다.

  • id는 달라야 하지만 name은 같아도 됨. 따라서 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1,2,3 숫자를 뒤에 붙여준다.

라디오 버튼

타임리프에서 ENUM 직접 사용

//라디오 버튼 (ENUM 사용)
    @ModelAttribute("itemTypes")
    public ItemType[] itemTypes() {
        ItemType[] values = ItemType.values();
        //ENUM에 있는 3개를 배열로 넘겨줌.
        return values;
        //단축키로 합치기 Ctrl + Alt + N
    }
<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>

th:value="${type.name()}"
ENUM의 name을 쓰면 ENUM 이름을 문자로 반환.

th:text="${type.description}"
ENUM에서 "도서", "음식", "기타" 가 출력됨.
체크 안하면 null이 나오고 체크를 안 해도 된다. (hidden 필드 없음.)
체크 박스의 경우 체크했다가 다시 체크 뺄 수 있는데 라디오는 그렇지 않기 때문에 hidden 필드 없음.

타임리프에서 ENUM 직접 접근

<div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}" class="form-check form-check-inline">

타임리프에서 패키지를 쓰다보면 나중에 패키지 이동할 때 잘 찾지 못 할 가능성이 있으므로 권장은 X

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>

메시지, 국제화

-메시지 기능
여러 화면에 보이는 상품명, 가격, 수량 등 lable에 있는 단어를 변경하려면 다 찾아가면서 모두 변경해야 한다. 화면 수가 적으면 문제가 없지만 화면이 수십개 이상이라면 문제가 된다.
이런 다양한 메시지를 한 곳에서 관리하도록 하는 기능을 메시지 기능이라 한다.
별도의 메시지 관리용 파일을 만들어 이 파일을 불러다 사용한다.
ex)messages.properties

메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource를 스프링 빈으로 등록하면 되는데 MessageSource는 인터페이스이다. 따라서 구현체인 ResourceBundleMessageSource를 스프링 빈으로 등록해야 한다.
스프링 부트가 자동으로 MessageSource를 스프링 빈으로 등록해주기 때문에 해줄 필요 없음.

application.properties에 입력해주면 끝.

spring.messages.basename=messages, config, i18n.messages

-국제화
각 나라별로 관리하면 서비스를 국제화할 수 있다.
ex)messages_en.properties - 영어
messages_ko.properties - 한국어

messages.properties

hello=안녕
hello.name=안녕 {0}

기본은 messages.properties 이다. 즉 덴마크어나 일본어 같은 언어를 부를 때 존재하지 않은 언어이므로 그냥 기본인 messages.properties에서 설정한 언어를 사용한다.

messages.properties

hello=안녕
hello.name=안녕 {0}

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=취소

html 파일

<h2 th:text="#{page.addItem}">상품 등록</h2>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
~~~

그러면 properties에서 page.addItem=상품 등록222 라고 한다면 랜더링 되서 상품 등록222 가 출력된다.
=참고
hello.name=안녕 {0} 으로 파라미터를 사용할 때는

<p th:text="#{hello.name(${item.itemName})}"></p>

Accept-Language

클라이언트가 서버에 기대하는 언어 정보를 담아서 요청하는 HTTP 요청 헤더

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

LocaleResolver

스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver라는 인터페이스를 지원한다.
스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용한다.

검증

타입 검증(가격이나 수량에 문자를 입력하면 검증 오류 처리), 필드 검증(상품명은 필수 그리고 공백X, 수량은 최대 9999), 특정 필드의 범위를 넘어서는 검증(가격 * 수량의 합은 10000 이상)

참고 - 클라이언트 검증, 서버 검증

클라이언트 검증은 조작할 수 있으므로 보안에 취약하다
서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수다
API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.

이런 식으로 조건을 만족하지 않으면 등록을 하지 못하도록 함.

V1

Map<String, String> errors = new HashMap<>();

//검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            // ModelAttribute에서 넘어온 상품명인 ItemName에 글자가 없으면
            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() >= 9999) {
            errors.put("quantity", "수량은 최대 9,999까지만 허용합니다");
        }
        
        //검증에 실패하면 다시 입력 폼으로 보냄
        //즉 입력 시 에러 발생하면 오류 페이지로 가지 않고 addForm.html로 이동.
        if (!errors.isEmpty()) {
            log.info("errors = {}", errors);
            model.addAttribute("errors", errors);
            return "validation/v1/addForm";
        }

BindingResult

V2

addItem에 BindingResult를 넣어줌.
item에 바인딩된 결과를 bindingResult에 넣음.
bindingResult가 에러 역할을 한다.

Field(ObjectName, Filed, Message)를 사용해서 에러 출력

//검증 로직
        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() >= 9999) {
            bindingResult.addError(new FieldError("item", "itemName",
                    "수량은 최대 9,999까지 입니다"));
        }//검증 로직
        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() >= 9999) {
            bindingResult.addError(new FieldError("item", "itemName",
                    "수량은 최대 9,999까지 입니다"));
        }

글로벌 에러라면

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값은  " + resultPrice));

public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) 에서 처럼 반드시 BindingResult는 @ModelAttribute 뒤에 와야 한다.

html에서 타임리프는 th:field, th:errors, th:errorclass를 사용한다.

th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="이름을 입력하세요!!">

if한 부분이 error에 다 담겨있다.

<input type="text" id="itemName" th:field="*{itemName}"
            th:errorclass="field-error" class="field-error" placeholder="이름을 입력하세요!">
            <div class="field-error" th:errors="*{itemName}">
                상품명 오류
            </div>

#fields - BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errors - 해당 필드에 오류가 있는 경웅 ㅔ태그를 출력한다. (th:if 편의 버전)
th:errorclass - th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

BindingResult가 없으면 -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
BindingResult가 있으면 -> 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 방법.

  1. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
  2. 개발자가 직접 넣어준다
  3. Validator 사용 ->

BindingResult에서의 오류

  1. 바인딩 자체가 실패한 오류로 스프링에서 데이터를 바인딩하는 것 자체를 실패
  2. 비즈니스와 관련된 검증 오류

현재 V2의 BindingResult, FieldError, ObjectError를 사용하면

오류가 발생할 때 고객이 입력한 내용이 모두 사라진다.
즉 가격에 0을 입력하면 오류가 발생하고 0이 남아있지 않고 데이터가 사라진다.

FiledError, ObjectError

사용자 입력 오류 메시지가 남아있도록 하자.
데이터를 보존하기 위해 FieldError() 과 ObjectError() 파라미터에 rejectedValue를 넣어주면 된다.
» objectName - 오류가 발생한 객체 이름
» field - 오류 필드
» rejectedValue - 사용자가 입력한 값(거절된 값)
» bindingFailure - 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분하는 값
» codes - 메시지 코드
» arguments - 메세지에서 사용하는 인자
» defaultMessage - 기본 오류 메시지 ""

ex) 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
그리고 이렇게 보관한 사용자 입력값을 검증 오류 발생 시 화면에 다시 출력하면 된다.

𖤐오류 코드와 메시지 처리

if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName",
                    item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
        }

default 메시지를 주지 않고 errors.properties를 줘서 메시지를 줄 수 있음.

𖤐reject(), rejectValue()

reject(), rejectValue() 사용하면 properties의 앞 부분만 써줘도 알아서 메시지를 찾아준다.
(특정 필드가 아닌 복합 룰이면 reject() 사용)

//검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            //ObjectName은 이미 알고있기 때문에 field 이름부터 시작.
            bindingResult.rejectValue("itemName", "required");
            //required.item.itemName을 찾아간다.
        }
        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() >= 9999) {
            bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
        }
        
if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }

⚑ properties에

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.123

이렇게 사용해줬는데 단순하게 reuired=필수 값입니다. range=범위 오류입니다. 이런 식으로 만들어 줄 수 있다.
그러면 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하게 된다.
그래서 배열로 준 것 처럼 new String[]{"required.item.itemName", "required"}; 으로 해줘서 첫 번째는 required.item.itemName를 찾고 없으면 required를 찾는 식으로 해서 개발자는 개발 코드를 건드리지 않고 메시지만 수정하여 전체 메시지를 관리할 수 있다.

𖤐MessageCodesResolver

검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver 인터페이스이고, DefaultMessageCodesResolver 는 기본 구현체이다
주로 ObjectError, FieldError 과 사용
reject도

⁂ 객체 오류의 경우 다음 순서로 2가지 생성
1. required.item
2. required

⁂ 플드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1. code.objectName.fieldName
2. code.field.Name
3. code.fieldType
4. code

ex)
"required.item.itemName",
"required.itemName", "required.java.lang.String",
"required"
으로 우선순위.

※ 참고

if (!StringUtils.hasText(item.getItemName())) {
            //ObjectName은 이미 알고있기 때문에 field 이름부터 시작.
            bindingResult.rejectValue("itemName", "required");
            //required.item.itemName을 찾아간다.
        }
        //위와 아래는 같은 로직.
        //ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

Validator 분리

validator 로직을 만든 것을 다른 클래스로 따로 분리한다.

@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())) {
            //ObjectName은 이미 알고있기 때문에 field 이름부터 시작.
            errors.rejectValue("itemName", "required");
            //required.item.itemName을 찾아간다.
        }
        //위와 아래는 같은 로직.
        //ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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() >= 9999) {
            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);
            }
        }

    }
}

supports() {} : 해당 검증기를 지원하는 여부 확인

->검증 여러 개일 때 사용한다.

validate(Object target, Errors errors)

검증 대상 객체와 BindingResult

public class ItemValidator implements Validator

validator 를 쓰는 이유
itemVlidator.validate(item, bindingResult) 하여 직접 호출하지 않고 스프링이 호출하도록 하여 없애는 방법이 있다.

WebDataBinder -> 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.

컨트롤러가 호출될 때마다 항상 불려져서 DataBinding이라는게 새로 만들어진다. 그래서 호출될 때마다 검증기가 적용된다. (다른 컨트롤러에는 적용 X)

@InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

@Validated 넣어주면 Item에 대해서 자동으로 검증기가 수행된다.

검증 여러개

@InitBinder 사용해서 검증할 때 여러 검증기가 들어간다면 구분을 할 수 있어야 한다.
그 구분을 implements에 Validator의 public boolean supports(Class<?> clazz) {} 가 한다.
현재는 Item 클래스 타입 정보가 넘어오고 return Item.class.isAssignableFrom(clazz) 가 True가 되면 그 때 validate 호출.

Bean Validation

위 처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화 한 것이 Bean Validation이다.
잘 활용하면 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null을 허용하지 않는다.
@Range(min = ~, max = ~) : 범위 안에 있어야 한다.
@Max : 최대
build.gradle 에 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

스프링 부트는 자동으로 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때무넹 @Valid, @Validated만 적용하면 된다. 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

@Validated 반드시 필요.

public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {}

글로벌 에러로 price * quantity 같은 것을 하기 위한 Object 에러 (실무에서 잘 X)

@ScriptAssert(lang = "javascript", script="_this.price * _this.quanitty >= 10000")

그냥 자바 코드로 작성하는 것이 바람직

//price * quantity
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

editForm에도 동일하게 적용

Bean Validation 한계

id에 @NotNull 추가, quantity에 @Max(9999) 주석
수정은 잘 동작하지만 등록에서 문제가 발생한다.
등록 시에는 id에 값도 없고 quantity 수정 제한 최대 값인 9999도 적용되지 않는 문제가 발생.
id: rejected value [null] 로 값이 없어 등록 시에 id에 값이 없어 검증에 실패하고 다시 폼 화면으로 넘어온다. 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.
결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생한다. -> 등록에서 허용을 하는게 수정에서는 허용하지 못하는 충돌이 발생.
그래서 Bean Validation을 구분해서 적용해야 한다.

Bean Validation 충돌 해결 - groups

  1. BeanValidation의 groups로 따로 묶어서 조건을 적용시킨다.

등록 시 수정 시 둘 다 적용시킬 때

@NotNull(groups = {SaveCheck.class, UpdateCheck.class})

하나만 적용시킬 때

@NotNull(groups = UpdateCheck.class)
@Max(value = 9999, groups = {SaveCheck.class})

@Validated 가 먹힐 때 SaveCheck의 조건만 먹는다.
@Validated() 로 괄호 안에 SaveCheck.class 나 UpdateCheck interface 를 넣어줌

public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {}

public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {}

✦groups를 사용할려면 반드시 @Validated()를 사용 {@Valid는 불가능}
그런데 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문에 잘 사용하지는 않음.

  1. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

폼 데이터 전달을 위한 별도의 객체를 사용

❆장점 - 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
❆단점 - 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

ItemSaveForm의 값이 들어옴.

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

@ModelAttribute("item") 넣지 않으면
이름이 자동으로 itemSaveForm으로 들어간다.
그래서 모델에 model.addAttribute("itemSaveForm", form)으로 담긴다.

그리고 성공 로직에서 ItemRepository에서 원하는 것은 item을 받아야 하기 때문에 getter, setter를 받아줌.
☆ 그렇지만 생성자에서 다 하는 것이 더 좋음.

HttpMessageConverter

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

HttpMessageConverter는 @modelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라 전체 객체 단위로 적용이 된다.
따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid, @Validator가 적용된다.

@ModelAttribute vs @Requestbody

@ModelAttribute 의 경우는 필드 하나하나를 따로 세밀하게 적용한다. (필드 단위로 정교하게 바인딩이 적용됨.)
@RequestBody는 HttpMessageConverter가 정상적으로 동작해서 JSON이 객체로 바뀌어야 Validate가 된다.
(안 바뀌면 이후 단계 자체가 진행되지 않고 예외가 발생. 컨트롤러도 호출X, Validator도 적용X)

로그인

패키지 구조
login package
domain
-item
-login
-member
web
-item
-login
-member
도메인이 가장 중요
화면, UI, 기술 인프라 등의 영역은 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역이다.
향후 web을 다른 기술로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다.
이렇게 하려면 web은 domain을 알고있지만, domain은 web을 모르도록 설계해야 한다.
-> web은 domain을 의존하지만 domain은 web을 의존하지 않는다고 표현한다.
즉 web 패키지를 모두 삭제해도 domain에는 전혀 영향이 없도록 의존관계를 설계하는 것이 중요하다.
ex) ItemController가 domain의 item을 써도 되지만 domain의 item이 ItemController를 호출하면 안됌.

Member

1.

도메인에 Member, MemberRepository(save, findById, findAll, findByLoginId, clearStore)

private Long id;

    @NotEmpty
    private String loginId; //로그인 ID
    @NotEmpty
    private String name; //사용자 이름
    @NotEmpty
    private String password;

web에 MemberController 생성

2.

domain에 LoginService
로그인 Id, Password가 맞는지 틀린지에 대한 비즈니스 로직 만듦

return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);

web에 LoginForm, LoginController 생성

@NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
@PostMapping("/login")
    public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }
        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if (loginMember == null) {
            //bindingResult로 글로벌 에러 처리
            bindingResult.reject("loginFail", "아이드 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm"; //사용자가 다시 로그인할 수 있도록.
        }

        //로그인 성공 처리 Todo
        return "redirect:/";
    }

쿠키

영속 쿠키 - 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 - 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
        //loginMember.getId() 는 Long 인데 String 이 들어가야 하므로 에러가 발생한다.
        //그래서 String으로 바꿔준다.

        //생성한 쿠키를 서버에서 Http응답 보낼 때 response에 넣어서 보내야 한다.
        //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료 시 모두 종료)
        response.addCookie(idCookie);

로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다. 쿠키 이름은 memberId이고 값은 회원의 id를 담아둔다. 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내줄 것이다.

로그아웃할 때는 쿠키를 지워야 한다. -> 시간을 0으로 만들어버리면 됨.

cookie.setMaxAge(0)

@PostMapping("/logout")
    public String logout(HttpServletResponse response) {
        //쿠키 지워야 함.
        expireCookie(response, "memberId");
        return "redirect:/"; //home으로
    }

    private static void expireCookie(HttpServletResponse response, String cookieName) {
        Cookie cookie = new Cookie(cookieName, null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

쿠키 보안 문제

  1. 쿠키 값은 임의로 변경 가능하다.
    ✪클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
    ✪실제 웹 브라우저 개발자모드 -> Application -> Cookie 변경으로 확인

  2. 쿠키에 개인정보나 신용카드 정보가 있다면 이 보관된 정보는 훔쳐갈 수 있다.

  3. 해커가 쿠키를 한 번 훔쳐가면 평생 사용할 수 있다.

대안

⁂쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리
⁂토큰은 해커가 임의의 값을 너허옫 찾을 수 없도록 예상 불가능해야 한다.
⁂토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지한다.
또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

로그인 처리 - 세션

중요한 정보를 모두 서버에 저장하고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.
서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 함.

✹클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다.
-서버는 클라이언트에 mySessionId 라는 이름으로 세션ID 만 쿠키에 담아서 전달한다.
-클라이언트는 쿠키 저장소에 mySessiondId 쿠키를 보관한다.

✫중요
회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.

public static final String SESSION_COOKIE_NAME = "mySessionId";
    //String은 sessionId, Object는 객체
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 1. sessionId 생성 (임의의 추정 불가능한 랜덤 값)
     * 2. 세션 저장소에 sessionId와 보관할 값 저장
     * 3. sessionId에 응답 쿠키를 생성해서 클라이언트에 전달.
     */

    //세션 생성
    public void createSession(Object value, HttpServletResponse response) {
        //sessionId 생성하고 값을 세션에 저장.
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);//Ctrl + Alt + C 로 상수로 만들어줌.
        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 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);
    }

    /**
     * 세션 만료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }

직접 만드는 세션도 있지만 서블릿이 공식 지원하는 세션도 있음.

서블릿 Http 세션

//아직 세션 생성X
        HttpSession session = request.getSession(false);
        if (session == null) {
            return "home";
        }
        Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
            return "home";
        }
        //세션이 유지되면 로그인으로 이동.
        model.addAttribute("member", loginMember);
        return "loginHome";

세션의 create 옵션
✧request.getSession(true)
세션이 있으면 기존 세션을 반환.
세션이 없으면 새로운 세션을 생성해서 반환.

✧request.getSession(false)
세션이 있으면 기존 세션을 반환
세션이 없으면 새로운 세션을 생성X, null 반환.

세션에 로그인 회원 정보 보관

session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

✮ request.getSession()

HttpSession session = request.getSession(false);

request.getSession()을 사용하면 기본 값이 create:true 이므로 로그인 하지 않을 사용자도 의미없는 세션이 만들어진다. 따라서 세션을 찾아서 사용하는 시점에 create:false 옵션을 사용해서 세션을 생성하지 않아야 한다.

서블릿 Http 세션2

@GetMapping("/")
    public String homeLoginV3S(
            @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){
        //request를 받아서 세션으로 하는 작업이 복잡 -> 스프링이 제공(@SessionAttribute)

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
            return "home";
        }
        //세션이 유지되면 로그인으로 이동.
        model.addAttribute("member", loginMember);
        return "loginHome";
    }

jsessionid

✠로그인 하면 URL에 8080/:jsessionid=1DFCSDF 이게 생긴다.
웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 것이다.
타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다.
서버 입장에서 쿠키를 웹 브라우저에 보내는데 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로 쿠키 값도 지원하고, URL에 jsessionid도 함께 전달하게 된다.

URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 application.properties에 이것을 넣어주면 된다.
server.servlet.session.tracking-modes=cookie

세션 정보와 타임아웃 설정

해커가 정보 훔쳐갔을 때 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지해야 한다.

HttpSession session = request.getSession(false);
session.getAttributeNames().asIterator()
                .forEachRemaining(name ->
                        log.info("session name={}, value={}", name, session.getAttribute(name)));


log.info("sessionId={}", session.getId());
        log.info("sessionId={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());

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

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()가 호출되는 경우 삭제되는데 대부분의 사용자는 로그아웃 하지 않고 그냥 웹 브라우저를 종료시킨다. HTTP가 비연결성이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인실할 수 없다. 그래서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

30분 마다 로그인 해야 하는 번거로움을 없애기 위해 세션 생성 시점이 아닌 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해준다.

스프링 부트로 글로벌 설정하는 방법으로 application.properties에 server.servlet.session.timeout 으로 설정 (60 -> 1분, 300 -> 5분)

application.properties에 server.servlet.session.timeout=1800
//1800이 기본

그러면 로그인 후 30분이 지나고나서 refresh를 하면 자동으로 로그아웃이 된다.

실무에서 주의할 점은 최소한의 데이터만 보관해야 한다. (보관한 데이터 용량 * 사용자 수)로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다. 그래서 멤버 객체를 다 담는 것이 아닌 member의 id만 담거나 로그인용 멤버 객체를 따로 만들어 자주 사용하는 id,회원명 정도만의 최소한의 정보만으로 세션에 보관해야한다. 추가로 세션의 시간을 너무 깊게 가져가면 메모리 사용이 계속 누적될 수 있으므로 적당한 시간을 선택하는 것이 중요하다. (기본이 30분이라는 것을 기준으로 고민하면 됨.)

필터 (서블릿이 제공)

로그아웃한 사용자는 상품 관리 불가 -> 보안 뚤림.
1. 필터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 (적절하지 않은 요청이라 판단, 서블릿 호출X) //비로그인 사용자
필터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수 있어 로그인 여부를 체크하기에 적절하다.

  1. 필터 체인
    HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
    필터는 체인으로 구서오디는데, 중간에 필터를 자유롭게 추가할 수 있다.
    ex)로그를 남기는 필터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 필터를 만들 수 있다.

필터 인터페이스

코드를 입력하세요

필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리한다.
ღinit() : 필터 초기화 메서드. 서블릿 컨테이너가 생성될 때 호출된다.
ღdoFilter() : 고객의 요청이 올 때마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
ღdestroy() : 필터 종료 메서드. 서블릿 컨테이너가 종료될 때 호출된다.

public class LogFilter implements Filter {

    @Override //필터 초기화 메서드
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
        Filter.super.init(filterConfig);
    }

    @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();// 모든 사용자의 요청 URI

        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");
        Filter.super.destroy();
    }
}

그냥 실행시키면 안되고 등록을 꼭 해주어야 로그가 남는다.
필터 등록

@Configuration
public class WebConfig {
    @Bean //스프링 부트로 사용할 때 필터 등록
    public FilterRegistrationBean logFilter() {
        //스프링 부트가 WAS를 들고 띄우기 때문에 WAS를 띄울 때 필터를 넣어준다.
        FilterRegistrationBean<Filter> filterRegisterationBean = new FilterRegistrationBean<>();
        filterRegisterationBean.setFilter(new LogFilter());
        filterRegisterationBean.setOrder(1); //순서
        filterRegisterationBean.addUrlPatterns("/*"); //모든 URL 패턴 적용.

        return filterRegisterationBean;
    }
}

고객 요청이 엄청 많을 때 로그를 남기면 uuid를 구분하기가 어렵다. 그래서 하나의 HTTP 요청이 들어와서 나의 애플리케이션에 로그를 남길 때는 전부 다 자동으로 같은 uuid를 남기면 좋다. 그러면 로그가 남을 때 "하나의 요청이 이 로그를 남기고 나갔구나." 하고 추적이 쉬워진다. 실무에서 HTTP 요청시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법으로 logback mdc을 사용하면 된다.

서블릿 필터 - 인증 체크

LoginCheckFilter

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);

                    //로그인으로 redirect
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return; //여기가 중요!!  미인증 사용자는 다음으로 진행하지 않고 끝
                }
            }
            chain.doFilter(request, response);
        }catch (Exception e){
            throw e;
        }finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }

    /**
     * 화이트 리스트의 경우 인증 체크X
     */
    private boolean isLoginCheckPath(String requestURI) {
        //whitelist , requestURI 2개가 매칭이 되는지 확인.
        return !PatternMatchUtils.simpleMatch(whitelist , requestURI);
    }

⁂whitelist - 화이트 리스트의 경우 인증 체크X
* 인증 체크를 하지 않는다는 것은 로그인 하지 않아도 갈 수 있는 URL이라는 것.

⁂PatternMatchUtils.simpleMatch() - 간단한 패턴 매치를 판별하기 위함. 해당 유틸에서는 표현식들 중에 만 검사를 해준다. 그러므로 을 사용해 패턴 매치를 하는 경우에 사용.
simpleMatch() 는 어떤 문자열이 특정 패턴에 매칭되는지를 검사함.

인터셉터 (스프링이 제공)

스프링 인터셉터는 서블릿 필터보다 편리하고, 더 정교하고 다양한 기능을 지원한다.
스프링 인터셉터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
☪ 스프링 인터셉터는 디스패쳐 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다.
☪ 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패쳐 서블릿 이후에 등장하게 된다. 스프링 MVC의 시작점이 디스패쳐 서블릿이라고 생각하면 됨.
☪ 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL패턴과는 다르고 정밀하게 설정 가능하다.

스프링 인터셉터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러(적절하지 않은 요청이라 판단, 컨트롤러 호출X) // 비로그인 사용자

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

스프링 인터셉터 인터페이스
인터셉터는 컨트롤러 호출 전 preHandle, 호출 후 postHandle, 요청 완료 이후 afterCompletion으로 단계적으로 세분화되어 있다.
request, response 뿐만 아니라 어떤 컨트롤러(handler)가 호출되는지 호출 정보와 어떤 modelAndView가 반환되는지 응답 정보도 받을 수 있다.

☄ preHandle - 컨트롤러 호출 전에 호출. (핸들러 어댑터 호출 전에 호출됨.)
⇝preHandle의 응답값이 true이면 다음으로 진행하고 false이면 더 진행되지 않는다.
⇝false라면 나머지 인터셉터도, 핸들러 어댑터도 호출되지 않는다.
☄ postHandle - 컨트롤러 호출 후에 호출 (핸들러 어댑터 호출 후에 호출됨.)
☄ afterCompletion - 뷰가 렌더링 된 이후에 호출됨.

✎ 예외 발생시

preHandle : 컨트로럴 호출 전에 호출된다.
postHandle : 컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않는다.
afterCompletion : afterCompletion은 항상 호출된다. 이 경우 예외를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다.

✎ afterCompletion은 예외가 발생해도 호출된다.

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

LoginInterceptor

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    private static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        //모든 사용자들의 요청 URI를 남김.
        String uuid = UUID.randomUUID().toString();
        //사용자들의 요청 온 것 구분

        request.setAttribute(LOG_ID, uuid);
        //요청에 데이터 저장 (key, value);

        //HandlerMethod
        //@RequestMapping과 그 하위 어노테이션(@GetMapping, @PostMapping 등)이 붙은 메소드의 정보를 추상화한 객체
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
        }
        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true; //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 uuid = (String) request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        //예외가 터진 경우
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    }
}

✔ String uuid = UUID.randomUUID().toString()
요청 로그를 구분하기 위한 uuid를 생성

✔ request.setAttribute(LOG_ID, uuid)
서블릿 필터의 경우 지역변수로 해결이 가능하지만, 스프링 인터셉터는 호출 시점이 완전히 분리되어 있어 preHandle에서 지정한 값을 postHandle, afterCompletion에서 함께 사용하려면 어딘가에 담아두어야 한다.
LogInterceptor도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 위험하다.
그래서 request에 담아두고 이 값은 afterCompletion에서 request.getAttribute(LOG_ID) 로 찾아서 사용한다.

✔ HandlerInterceptor
preHandle, postHandle, afterCompletion 총 세 개의 추상 메서드를 포함.

✔ HandlerMethod - 핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라지는데 스프링을 사용하면 일반적으로 @Controller, @RequestMapping 을 활용한 핸들러 매칭을 사용한다. 이 경우 핸들러 정보로 HandlerMethod가 넘어온다.

WebConfig (인터셉터 등록)

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

    @Override //애플리케이션 내에 인터셉터를 등록해주는 기능
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**") //하위는 전부 다.
                .excludePathPatterns("/css/**", "/*.ico", "/error");
        //전체 경로는 다 되지만 이 경로는 interceptor 하지 않기.

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

WebMvcConfigurer

인터페이스로 addInterceptors()를 사용하기 위해서 implement 한다.

InterceptorRegistry registry

InterceptorRegistry의 addInterceptor() 메서드를 이용하여 인터셉터 클래스를 등록한다.

인터셉터 인증 체크

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
    //private static String

    @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("미인증 사용자 요청");
            //로그인으로 redirect
            response.sendRedirect("/login?redirectURL = " + requestURI);
            return false; //더 이상 진행 안 함.
        }

        return true;
    }
}

인증 체크 인터셉터 등록은 registy.addInterceptor(new LoginCheckInterceptor()) 로 했음.

ArgumentResolver 활용

HomeController

@GetMapping("/")
    public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model){
        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
            return "home";
        }
        //세션이 유지되면 로그인으로 이동.
        model.addAttribute("member", loginMember);
        return "loginHome";
    }

@Login 어노테이션에 Member가 있으면 자동으로 로그인된 사용자로 인식하여 세션에서 찾아서 넣어주는 과정을 편리하게 할 수 있다.

Login 애노테이션 생성

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

@Target(ElementType.PARAMETER)

@Retention(RetentionPolicy.RUNTIME)

@Login 어노테이션이 있으면 modelAttribute가 동작하는 것이 아닌 우리가 만든 ArgumentResolver가 동작하도록 만들어야 한다. 그래서 따로 LoginMemberArgumentResolver를 생성.

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        
        //Login 애노테이션이 파라미터에 있는지 확인.
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);

        //getParameterType() 하면 Member 클래스가 들어옴.
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
        //Login 애노테이션이 있고 MemberType 을 만족하면 true
        //false면 resolveArgument 실행 안 함.
    }

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

        HttpSession session = request.getSession(false);
//세션을 생성하는데 true하면 의미없는 세션이 생성되기 때문에 false
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
//public static final String LOGIN_MEMBER = "loginMember";
    }
}

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

☆ HandlerMethodArgumentResolver
컨트롤러 메소드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩 해주는 인터페이스이다.
스프링 사용 시, Controller에 들어오는 Parameter를 수정 또는 공통적으로 추가해주어야 하는 경우가 많은데 HandlerMethodArgumentResolver는 사용자 요청이 Controller에 도달하기 전에 Parameter들을 수정할 수 있도록 해준다.

예를 들어, 로그인한 사용자의 정보를 가져와야 하는데 이 정보들은 보통 세션(Session)에 담아놓고 사용한다.
이를 사용하기 위해서는 세션(Session)에서 값을 꺼내와서 파라미터로 추가해야한다.
이러한 작업을 여러번 반복해야하는 것은 비효율적이다. 때문에 HandlerMethodArgumentResolver를 사용하여 처리하는 것이 좋다.

이제 등록을 해주어야 한다.
WebConfig에

@Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

ArgumentResolver를 사용하면 결과는 동일하지만, 더 편리하게 로그인 회원 정보를 조회할 수 있다. ArgumentResolver를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다.

서블릿 예외 처리 - 시작

2가지 방식으로 예외 처리 지원
☉ 예외 터졌을 때
☉ response.sendError(HTTP 상태 코드, 오류 메시지)

자바는 메인 메서드를 직접 실행할 경우 main이라는 이름의 쓰레드가 실행되는데 실행 도주엥 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 발생하면 예외 정보를 남기고 해당 쓰레드는 종료된다.

웹 애플리케이션의 경우
사용자 요청별로 별도의 쓰레드가 할당되고 서블릿 컨테이너 안에서 실행된다.
try, catch로 예외 잡아서 처리하면 문제가 없지만 만약에 애플리케이션에서 예외를 잡지 못하고 서블릿 밖으로까지 예외가 전달된다면 동작 방식이 달라진다.
WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 였지만 컨트롤러에서 예외가 발생하면

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 이렇게 된다.

이렇게 에러를 직접 나타낼 수 있고

@GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("예외 발생!!");
    }

response.sendError(HTTP 상태 코드, 오류 메시지) 이렇게 나타낼 수 있다.

@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, "500 오류!!");
    }

이 경우는 WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(reponse.sendError())
WAS에서 response의 sendError를 까보고 response의 내부에 오류가 발생했다는 상태를 저장한다. 그리고 서블릿 컨테이너는 고객에게 응답 전에 response에 sendError() 가 호출되었는지 확인한다. 그리고 호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.

오류 화면 제공

스프링 부트가 제공하는 기능을 사용해서 서블릿 오류 페이지를 등록

public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/400");
        //404 예외가 발생하면 400 에러 페이지로 이동.
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        //500 예외가 발생하면 500 에러 페이지로 이동.
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
        //RuntimeException과 그 자식 타입 예외들 발생 시 500 에러 페이지로 이동
        
        //등록
        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
    }
}

오류가 났을 때 오류 화면 보여주기 위한 컨트롤러
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";
    }

오류 페이지 작동 원리

WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러
컨트롤러에서 예외가 발생해서 WAS까지 전파가 되면 WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이 때 오류 페이지 경로로 새로 호출하기 때문에 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

request.attribute에 서버가 담아준 정보

public static final String ERROR_EXCEPTION = "javax.servlet.error.exception"; //예외
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_t //예외 타입
public static final String ERROR_MESSAGE = "javax.servlet.error.message"; //오류 메시지
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri"; //클라이언트 요청 URI
public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name" //오류가 발생한 서블릿 이름
public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code"; //HTTP 상태 코드

request.getAttribute();

예외 처리 - 필터

오류가 발생하면 오류 페이지를 출력하기 위해 WAS내부에서 다시 한 번 호출했는데 로그인 인증 체크 같은 경우는 이미 한번 필터나, 인터셉터에서 로그인 체크를 완료했다. 그래서 서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉터가 한번 더 호출되는 것은 비효율적이다.

클라이언트로부터 발생한 요청인지, 오류 페이지를 출력하기 위한 WAS 내부 요청인지 구분을 해야 한다.
서블릿은 이 문제 해결을 위해 DispatcherType이라는 추가 정보를 제공한다.

DispatcherType

오류 페이지에서 request.getDispatcherType() 하면 ERROR로 출력되는데 고객이 처음 요청하면 REQUEST로 출력이 된다. 그래서 라이언트로부터 발생한 요청인지, 오류 페이지를 출력하기 위한 WAS 내부 요청인지 구분할 수 있다.

DispatcherType

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

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        //없으면 필터가 적용되지 않아 LogFilter가 생성되지 않는다.

        return filterRegistrationBean;
    }
}

filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
2가지를 모두 넣으면 클라이언트 요청, 오류 페이지 요청에서도 필터가 호출된다. 필터는 오류 페이지 호출해도 DispatcherType.REQUEST가 기본이므로 클라이언트의 요청이 있는 경우에만 필터가 적용된다.
특별히 오류 페이지 경로도 필터를 적용할 것이 아니면, 기본 값을 그대로 사용하면 된다.

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

인터셉터는 필터처럼 setDispatcherType 할 수 없다.
대신에 excludePathPatterns로 error-page와 하부 페이지들을 모두 제거한다.
"/error-page/**" 없으면 error-page/500 같은 내부 호출의 경우에도 인터셉터가 호출된다.
(인터셉터가 2번 호출되지 않음)

☛ 전체 흐름
1. WAS(/error-ex, dispatcherType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. 예외 발생하면 WAS까지 다시 전파
3. WAS에서 필요한 정보 담으면서 오류 페이지 확인
4. WAS(/error-page/500, dispatcherType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러
인터셉터(x) 는 "/error-page/**"로 경로 제외해서 호출X

근데 WebServerCustomizer 에서 처럼 ErrorPage errorPage404 = new ErrorPage() 이런식으로 오류 페이지 계속 설정하고 등록하는 것이 번거로움.

스프링 부트로 오류 페이지

WebServerCustomizer 만들고 예외 종류에 따라서 ErrorPage 추가하고 ErrorPageController도 만듦. 스프링 부트는 이런 과정을 모두 기본으로 제공함.

ErrorPage를 자동으로 등록하고 BasicErrorController라는 스프링 컨트롤러를 자동으로 등록한다.
-> ErrorPage에서 등록한 /error 를 매핑해서 처리하는 컨트롤러이다.

스프링 부트가 제공하는 기본 오류 메커니즘을 사용하도록 WebServerCustomizer에 있는 @Component를 주석 처리해야 한다.

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

뷰 템플릿이면 resources/templates/error/500.html
정적 리소스(static)면 resources/static/error/400.html
적용 대상이 없을 때 뷰 이름(error) resources/templates/error.html
위 순서대로 뷰 템플릿이 정적 리소스보다 우선순위가 높고 404, 500 처럼 4xx 보다 구체적인 것들이 우선순위가 높다. 5xx, 4xx 라고 하면 500대, 400대 오류를 처리해준다.

<ul>
        <li>오류 정보</li>
        <ul>
            <li th:text="|timestamp: ${timestamp}|"></li>
            <li th:text="|path: ${path}|"></li>
            <li th:text="|status: ${status}|"></li>
            <li th:text="|message: ${message}|"></li>
            <li th:text="|error: ${error}|"></li>
            <li th:text="|exception: ${exception}|"></li>
            <li th:text="|errors: ${errors}|"></li>
            <li th:text="|trace: ${trace}|"></li>
        </ul>
        </li>
    </ul>

오류 관련 내부 정보들을 출력할 수 있는데 고객에게 노출시키는 것은 좋지 않다. 고객이 해당 정보를 읽어도 혼란만 생기고 보안상 문제가 될 수 있다. 그래서 BasicErrorController 오류 컨트롤러에서 다음 오류 정보를 model에 포함할지 여부를 선택할 수 있다.

application.properties

#server.error.include-exception=true
#server.error.include-message=on_param
#server.error.include-stacktrace=on_param
#server.error.include-binding-errors=on_param

on_param - 파라미터가 있을 때 사용, always - 항상 사용
ex) localhost:8080/error-ex?message=&errors=&trace=
이렇게 있는데 실무에서는 사용자에게는 이쁜 오류 화면과 고객이 이해할 수 있는 간단한 오류 메시지르 보여주고 오류는 서버에 로그로 남겨서 로그로 확인해야 한다.

그냥 주석처리

server.error.whitelabel.enabled=true
오류 처리 파일을 찾지 못한다면 스프링 whitelabel 오류 페이지를 적용

API 예외 처리

HTML 페이지로 4xx, 5xx 페이지를 처리할 수 있지만 API의 경우 각 오류 상황에 맞는 오류 응 답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
API를 요청했을 때 클라이언트는 정상 요청이든 오류 요청이든 JSON이 반환되기를 원하기 때문에 HTML을 받아서는 할 수 있는 것이 별로 없다.

클라이언트에서 받아들일 수 있는 것이 application/json 타입이면 produces = MediaType.APPLICATION_JSON_VALUE가 우선순위가 된다. (postman)

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


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

html로 나왔던 결과가 json으로 나오게 된다.

{
	"message" : "잘못된 사용자",
	"status" : 500
}

API 예외 처리 - HandlerExceptionResolver

예외가 발생해서 서블릿을 넘어 예외가 전달되면 HTTP 상태코드가 500을 ㅗ처리되는데 발생하는 예외에 따라서 400, 404 등등 다른 상태코드도 처리하고 싶다. 즉 오류 메시지, 형식 등을 API마다 다르게 처리하고 싶음.
(컨트롤러 내부에서 예외가 발생하면 컨트롤러 밖으로 나온다.)

ex)IllegalArgumentException을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리한다. (500은 서버가 잘못, 400은 클라이언트가 잘못)

if (id.equals("bad")) { //id가 ex이랑 같으면
            throw new IllegalArgumentException("잘못된 입력값");
        }

id를 bad라고 해서 호출하면 IllegalArgumentException 이 발생한다. 실행해보면 상태 코드가 500인 것을 확인할 수 있다.

HandlerExceptionResolver 적용 후

스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공하는데 이 때 이것을 사용한다. (줄여서 ExceptionResolver 라고도 함.)
핸들러에서 예외가 전달이 되면 ExceptionResolver가 호출이 되어 예외를 해결하도록 시도를 한다. 해결이 된다면 render 호출하고 afterCompletion 호출하고 정상 응답한다.

어떤 예외가 발생하면 정상적인 ModelAndView로 반환한다.

@Slf4j
public class MyHandlerExceptionResolver implements **HandlerExceptionResolver** {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                // IllegalArgumentException 발생하면 400으로 보낸다.
                log.info("IllegalArgumentException resolver to 400");

                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage()); //400
//exception을 sendError로 바꿔치기 함.
                return new ModelAndView(); // 정상적 흐름으로 WAS까지 리턴됨.
            }
        }catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

등록해야 함

//ExceptionResolver 한거 등록
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }

ExceptionResolver가 ModelAndView를 반환하는 이유는 Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다. 여기서는 IllegalArgumentException이 발생하면 response.sendError(400)을 호출해서 HTTP 상태 코드를 400으로 지정하고 빈 ModelAndView를 반환한다.

ExceptionResolver 활용

예외 발생하면 WAS까지 예외 전달되고 WAS에서 오류 페이지 정보 찾아서 다시 /error 호출하는 과정은 복잡했다. ExceptionResolver를 활용하여 복잡한 과정을 없앨 수 있다.

@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");
                // HTTP header 가 json인 경우, html로 요청한 경우를 나눠서
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST); //400

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

                    //errorResult 객체를 json문자로.
                    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

ExceptionResolver를 구현하려니 복잡해서 스프링이 제공하는 ExceptionResolver를 사용.
€ 스프링 부트가 기본으로 제공하는 ExceptionResolver
1. ExceptionHandlerEcxeptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver (우선순위 가장 낮음.)

@ResponseStatus

예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류!!!!")
public class BadRequestException extends RuntimeException {
}
//reason은 메시지.

이 애노테이션을 적용하면 HTTP 상태 코드를 변경해준다.

☼ 추가 메시지 기능
@ResponseStatus()에서 reason = "error.bad" 로 하고 messages.properties에 error.bad=잘못된 요청 오류입니다. 메시지 사용 이런식으로 넣어줘서 출력되도록 할 수 있다.

DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고 결과적으로 500 에러가 발생한다.
그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP에서는 이런 경우 400을 사용하도록 되어 있다.
(DefaultHandlerExceptionResolver 는 500 -> 400 으로 변경)

@ExceptionHandler

API 예외처리의 어려운 점

  1. HandlerExceptionResolver 에서 ModelAndView를 반환해야 했는데 API에는 필요가 없다.
  2. API 응답을 위해서 HttpServletResponse에 직접 응답 데이터를 넣어주었다.
  3. 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. -> 회원 처리 컨트롤러와 상품관리 컨트롤러에서 RuntimeException이 발생한다면 이 예외를 서로 다른 방식으로 처리하기가 어렵다.

스프링이 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공한다. 그게 ExceptionHandlerExceptionResolver이다 (우선순위 가장 높음.)

@ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

서블릿 컨테이너로 안 올라가고 정상적으로 리턴하고 json만들어서 반환하고 Http 상태 코드 BAD_REQUEST 되어 정상 흐름으로 끝이 나게 된다.

정리
@ExceptionHandler 애노테이션 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 즉 컨트롤러 안에서만 해결이 되고 다른 컨트롤러까지 영향을 주지는 않는다. 그리고 예외의 하위 자식 클래스까지 잡아서 처리해 줄 수 있다.

실행 흐름.

RestController

@Controller + @ResponseBody
@ResponseBody 어노테이션을 붙이지 않아도 문자열과 JSON 등을 전송할 수 있다. 즉 컨트롤러 클래스의 각 메서드마다 @ResponseBody를 추가할 필요가 없어짐.

ControllerAdvice

정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여있어 @ControllerAdvice 또는 @RestControllerAdvice를 사용하여 둘을 분리할 수 있다.
@ExceptionHandler와 @ControllerAdvice를 조합하면 예외를 깔끔하게 해결할 수 있다.
대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다. 그리고 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)

@ControllerAdvice에 대상을 지정하는 방법 3가지

@ControllerAdvice(annotations = RestController.class)
-> @RestController가 있는 모든 컨트롤러를 대상으로 지정.
@ControllerAdvice("org.example.controller")
-> 이 패키지 포함 하위에 있는 모든 컨트롤러를 대상으로 지정.
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
-> 대상을 직접 지정. (부모 클래스를 지정해주거나 특정 컨트롤러를 지정.)

EXControllerAdvice 에다가 할 것.

스프링 타입 컨버터

request.getParameter("data") 를 하는 것처럼 HTTP 요청 파라미터는 모두 문자로 처리된다.(10을 넣어도 마찬가지.) 따라서 다른 타입으로 변환해서 사용을 해야 한다. ex) Integer.valueOf()
@RequestParam Integer data
@RequestParam으로 문자 10을 Integer 타입의 숫자 10으로 변환해줄 수 있다.
@ModelAttributem @PathVariable 도 마찬가지.

컨버터 인터페이스
모든 타입에 적용할 수 있다. 필요하면 X타입 -> Y타입 변환하는 컨버터 인터페이스를 만들고 Y -> X타입으로 변환하는 컨버터 인터페이스를 하나 더 만들어서 등록하면 된다.

public interface Converter<X, Y>{
	Y convert(X source);
}

String -> Integer

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

Integer -> String 으로 하는 것도 새로운 클래스를 만들어서 똑같이 하면 됨.

String -> IpPort

@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.parseInt(split[1]);
        // "127.0.0.1:" 를 문자로 8080은 숫자로.
        //IpPort 객체 -> "127.0.0.1:8080"

        return new IpPort(ip, port);
    }
}

컨버전 서비스

타입 컨버터를 하나하나 직접 찾아서 타입 변환을 하는 것은 번거로우므로 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공한다.

타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입을 반환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.

@Test
    void conversionService() {
        //작성한 converter 4개 등록
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        //사용
        //문자 10이 들어왔는데 숫자가 반환되도록 함.

        //System.out.println("result = " + result);

//        Integer result = conversionService.convert("10", Integer.class);
//        assertThat(result).isEqualTo(10);
        assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);

//        String result = conversionService.convert(10, String.class);
//        assertThat(result).isEqualTo("10");
        assertThat(conversionService.convert(10, String.class)).isEqualTo("10");

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

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

DefaultConversionService 는 다음 두 인터페이스를 구현
인터페이스가 컨버터 사용과 등록으로 잘 분리가 되어 있다.
ConversionService : 컨버터 사용에 초점
ConverterRegistry : 컨버터 등록에 초점
인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.

스프링에 Converter 적용

스프링은 내부에서 ConversionService를 제공한다. WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 추가하고 싶은 컨버터를 등록하면 된다. 그러면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가해준다.

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

@Configuration
설정파일을 만들기 위한 애노테이션 or Bean을 등록하기 위한 애노테이션

  • Bean을 등록할때 싱글톤(singleton)이 되도록 보장해준다.
  • 스프링컨테이너에서 Bean을 관리할수있게 됨.

뷰 템플릿에 Converter 적용

문자를 객체로 변환하는 것이 아닌 객체를 문자로 변환한다.

Model에 숫자 10000와 IpPort 객체를 담아서 뷰 템플릿에 전달한다.

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

√ ${{number}} : 뷰 템플릿은 데이터를 문자로 출력하므로 컨버터를 적용하게 되면 Integer 타입인 10000을 String 타입으로 변환하는 컨버터인 IntegerToStringConverter를 실행하게 된다. 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환하기 때문에 컨버터를 적용할 때와 하지 않을 때가 같다.
√ ${{ipPort}} : 뷰 템플릿은 데이터를 문자로 출력하므로 컨버터를 적용하게 되면 IpPort 타입을 String 타입으로 변환해야 하므로 IpPortStringConverter가 적용된다.
결과 -> 127.0.0.1:8080 이 출력

${{number}} , ${{ipPort}} 이렇게 {{}} 괄호가 2개 있어야 컨버터가 적용된 것이다.

포맷터 - Formatter (컨버터의 특별한 버전)

객체를 문자로 변경, 문자를 객체로 변경 2가지 기능을 모두 수행한다.
웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 경우가 많다.
ex) 10000 -> "10,000" (문자) 로 쉼표를 넣어서 출력 또는 반대로 "1,0000" -> 10000
"2022-11-29 21:32:59 와 같이 출력
추가로 날짜 숫자의 표현 방법은 Locale 현지화 정보가 사용될 수도 있다.

parse()를 사용하여 문자를 숫자로 변환, print()를 사용해서 객체를 문자로 변환.

@Slf4j //Integer, Long, Float, Double 같은 타입이 Number라는 부모에 모두 있다.
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
        return NumberFormat.getInstance(locale).parse(text);
    }

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

        return NumberFormat.getInstance(locale).format(object);
        //String으로 반환.
    }
}

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

포멧터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포멧터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter가 Converter처럼 동작하도록 지원한다.
DefaultFormattingConversionService는 FormattingConversionService에 기본적인 통화, 숫자, 관련 몇가지 기본 포멧터를 추가해서 제공한다.
FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포멧터도 모두 등록할 수 있다. 그리고 사용할 때는 ConversionService가 제공하는 convert()를 사용하면 된다.

@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");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    }

포멧터 적용

WebConfig 에서

registry.addFormatter(new MyNumberFormatter());

10000 이 10,000 으로 적용되는 것을 확인할 수 있다.

스프링이 제공하는 기본 포멧터

내가 원하는 포멧으로 지정할 수 있다.

@NumberFormat(pattern = "###,###")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")

@ModelAttribute 하면 자동으로 model에 담김. 굳이 model.addAttribute() 해주지 않아도 됨.

주의

메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 메시지 컨버터의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해 포멧을 지정해야 한다. 즉 컨버전 서비스와는 전혀 관계가 없다.

서블릿과 파일 업로드

상품명: <span th:text="${item.itemName}">상품명</span><br/>
    첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|" th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
    <img th:each="imageFile : ${item.imageFiles}" th:src="|/images/${imageFile.getStoreFileName()}|" width="300" height="300"/>
 @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("request = {}", request);

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

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

        return "upload-form";

        //이미지 선택하여 제출하면 바이너티 타입이 넘어가기 때문에 글자가 막 깨져서 나온다.
    }

파일 업로드 하면 parts가 2개 들어온다.

그런데 spring.serlvet.multipart.enabled=false하면 request에 RequestFacade가 들어오고 itemName, parts=[] 로 멀티parts와 관련된 처리를 하지 않는다.

@Value("${file.dir}") //application properties에 있는 속성을 그대로 가져올 수 있다
    private String fileDir;

멀티파트 형식은 전송 데이터를 하나하나 각각 부분으로 나누어 전송한다. parts에는 이렇게 나누어진 데이터가 각각 담긴다. 서블릿이 제공하는 Part는 멀티파트 형식을 편리하게 읽을 수 있는 메서드를 제공한다.

Part 주요 메서드

part.getSubmittedFileName() : 클라이언트가 전달한 파일명
part.getInputStream() : Part의 전송 데이터를 읽을 수 있다.
part.write() : Part를 통해 전송된 데이터를 저장할 수 있다.

결과 로그
~~

  • 참고
    큰 용량의 파일을 업로드를 테스트할 때는 로그가 너무 많이 남아서 옵션을 끄는 것이 좋다.
    logging.level.org.apache.coyote.http11=debug
    log.info("body={}", body); -> 파일의 바이너리 데이터를 모두 출력하므로 이것도 끄는 것이 좋음.

스프링으로 파일 업로드

서블릿으로 하는 것보다 코드를 간단하게 @ReusetParam으로 multipart 파일을 받아서 쓸 수 있다.

@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
    @Value("${file.dir}")
    private String fileDir;

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

        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath = {}", fullPath);
            file.transferTo(new File(fullPath));
        }

        return "upload-form";
    }
}

getOriginalFilename() - 업로드 되는 파일 이름.
getSize() - 업로드 되는 파일 크기.
getInputStream() - 파일데이터와 연결된 InputStream을 반환.
transferTo() - 파일 저장.

파일 업로드, 다운로드

Item, ItemRepository, UploadFile을 만들어 준다.

⁂ 데이터 베이스에 이미지 바이너리를 다 올리지 않는다.
실제 파일이나 이미지를 업로드, 다운로드 할 때는 고려해야 할 점이 있다.
상품을 관리할 때 상품 이름, 첨부파일 하나, 이미지 파일 여러 개를 업로드 할 수 있어야 하고 첨부파일을 업로드 했으면 다운로드가 가능해야 한다. 업로드한 이미지를 웹 브라우저에서 확인 할 수 있어야 한다.

업로드 파일 정보를 보관한다.

@Data
public class UploadFile {
    private String uploadFileName;
    private String storeFileName;

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
    //uploadFileName : 고객이 업로드한 파일명
    //storeFileName : 서버 내부에서 관리하는 파일명
}

☼ 고객이 업로드한 파일명으로 서버 내부에 파일을 저장하면 절대 안된다. 서로 다른 고객이 같은 파일 이름을 업로드한다면 기존 파일 이름과 충돌이 날 수 있다. 그래서 서버에서는 저장할 파일며잉 겹치지 않도록 내부에서 관리하는 별도의 파일명이 필요하다.

파일 저장과 관련된 업무를 처리

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

    public String getFullPath(String filename) {
        //filename을 받아서 fullPath를 반환.
        return fileDir + filename;
    }

    //이미지의 경우 여러 개가 날라오므로 List로 반환한다.
    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();

        for (MultipartFile multipartFile : multipartFiles) {
            if(!multipartFile.isEmpty()){
                //루프를 돌면서  저장한 multipartFile을 storeFileResult에 넣는다.
                storeFileResult.add(storeFile(multipartFile));
            }
        }
        return storeFileResult;
    }

    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        //스프링이 제공하는 multipartFile 을 받아서 UploadFile로 바꿔준다.
        if (multipartFile.isEmpty()) {
            return null;
        }
        //1.jpg
        String originalFilename = multipartFile.getOriginalFilename();


        //1.jpg 일 때 서버에 저장하는 파일명
        String storeFileName = createStoreFileName(originalFilename);
        // 그러면 qwe-qwe-123-qwe-qer.jpg 이런 식으로 jpg가 붙어서 저장이 된다.

        //저장
        multipartFile.transferTo(new File(getFullPath(storeFileName)));

        //UploadFile 클래스에서 만들었던 두 필드를 반환하면 됨.
        return new UploadFile(originalFilename, storeFileName);
    }

    private String createStoreFileName(String originalFilename) {
        String uuid = UUID.randomUUID().toString();
        //qwe-qwe-123-qwe-qer; uuid는 이런 형태임.
        //서버에서 어떤 파일인지 확장자를 남겨주기 위해 originalFilename을 가져온다.

        //1.jpg 이면 . 뒤에 부분을 가져옴.
        String ext = extracted(originalFilename);

        return uuid + "." + ext;
    }

    private String extracted(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        return originalFilename.substring(pos + 1);
    }

createStoreFileName() 메서드 : 서버 내부에서 관리하는 파일명은 유일한 이름을 가지도록 UUID를 사용해서 충돌하지 않도록 한다.
extracted() : 확장자를 별도로 추출해서 서버 내부에서 관리하는 파일명에도 붙여준다.

Item은 업로드 파일이지만 ItemForm은 MultipartFile이다.
MultiPartFile은 스프링에서 제공하는 인터페이스를 이용한 파일 업로드.
이미지를 다중 업로드 하기 위해 List 사용, MultipartFile attachFile은 @ModelAttribute에서 사용할 수 있다.

private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;

    private MultipartFile attachFile;

상품 이름, 첨부파일 하나(다운로드도), 이미지 파일 여러 개를 업로드 할 수 있는 Controller

private final ItemRepository itemRepository;
    private final FileStore fileStore;

    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
//        MultipartFile attachFile = form.getAttachFile();
//        UploadFile uploadFile = fileStore.storeFile(attachFile);

        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
//        List<MultipartFile> imageFiles = form.getImageFiles();
//        List<UploadFile> uploadFiles = fileStore.storeFiles(imageFiles);

        //파일은 데이터 베이스에 저장하지 않고 store에 저장

        //데이터베이스에 저장
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setAttachFile(attachFile);
        item.setImageFiles(storeImageFiles);
        itemRepository.save(item);

        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }

    //저장했으니 고객에게 보여주고 파일을 다운로드하는 기능을 구현
    @GetMapping("/items/{id}") //고객에게 보여주기
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);

        return "item-view";
    }

    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        //"file: " + fileStore.getFullPath(filename) 이것은
        //"file:C:/Temp/6832809ff-dd8c-39ad-e033-c49873197bv3.png" 이런 형식이 된다.
        //"6832809ff-dd8c-39ad-e033-c49873197bv3.png" 이게 filename
        return new UrlResource("file: " + fileStore.getFullPath(filename));
        //이 경로에 있는 파일에 접근해서 그 파일을 스트림으로 반환한다.
    }

    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        // item을 접근할 수 있는 사용자만 다운로드 받을 수 있도록 한다.
        // itemId를 받아서 접근 권한이 있는지 확인을 하고 로직을 통해 접근 권한이 있으면 다운받을 수 있다.
        Item item = itemRepository.findById(itemId);
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();

        UrlResource resource = new UrlResource(fileStore.getFullPath(storeFileName));

        log.info("uploadFileName = {}", uploadFileName);

        //한글 깨지지 않도록
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);

        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
        return ResponseEntity.ok()
                // header 없으면 다운로드 하지 않고 그냥 파일을 열어서 보게 된다.
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
        //브라우저에서 다운로드 했을 때 contentDisposition을 보고 다운로드를 결정한다.
    }
profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글