인프런 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 강의를 요약정리한 내용입니다
: 타임리프는 백엔드 서버에서 HTML을 동적으로 렌더링 하는 용도로 사용된다.
: 웹 브라우저에서 파일을 직접 열어도 내용을 확인할 수 있고, 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인 할 수 있다.
: 이러한 특징을 일컬어 Natural templates 라고 한다.
// controller
model.addAttribute("data", "Hello World"); //으로 데이터를 보낸다
//html 내에서
<html xmlns:th="http://www.thymeleaf.org"> // 이 문구를 추가해줘야 한다.
// 아래와 같은 두 가지 방법이 있다.
// 1번
<li> th : text 사용하는 방법 <span th:text = "${data}"></span></span></li>
// 2번
<li> 컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
: 뷰 템플릿으로 출력할 때에는 '<','>'와 같은 문자의 출력을 주의해야 한다.
: < 를 태그의 시작이 아닌 문자로 표현하고자 하기 때문에 발생했는데, 이를 HTML 엔티티라고 한다. 변경하는 것을 escape라고 한다.
: 이를 사용하지 않기 위해서는 다음과 같이 변경하면 된다.
// 1번
<li>th:utext = <span th:utext="${data}"></span></li>
// 2번
<li><span th:inline="none">[(...)] = </span>[(${data})]</li>
아래의 세 가지 설명은 PPT를 참고했습니다.
user.username : user의 username을 프로퍼티 접근 user.getUsername()
user['username'] : 위와 같음 user.getUsername()
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를 찾고 메서드 직접 호출
<h1>식 기본 객체 (Expression Basic Objects)</h1>
<ul>
<li>request = <span th:text="${#request}"></span></li>
<li>response = <span th:text="${#response}"></span></li>
<li>session = <span th:text="${#session}"></span></li>
<li>servletContext = <span th:text="${#servletContext}"></span></li>
<li>locale = <span th:text="${#locale}"></span></li></ul>
<h1>편의 객체</h1>
<ul>
// 1. HTTP 요청 파라미터 접근
<li>Request Parameter = <span th:text="${param.paramData}"></span></li>
// 2. HTTP 세션 접근
<li>session = <span th:text="${session.sessionData}"></span></li>
// 3. 스프링 빈 접근
<li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></li>
</ul>
: 문자, 숫자, 날짜, URI등을 편리하게 다루는 다양한 유틸리티 객체들을 제공한다.
https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#expression-utility-objects
: 날짜 관련은 아래와 같다.
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>
<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>
<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>
: 리터럴은 소스코드 상에 고정된 값을 말하는 용어이다.
// ex) int a = 5 일 때 의 5
<body>
<h1>리터럴</h1>
<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>
// 1. 리터럴을 '' 작은 따옴표로 감싼다.
<li>리터럴 대체 |hello ${data}| = <span th:text="|hello ${data}|"></span></li>
// 2. 리터럴 대체 문법 더해서 간편하게
</ul>
</body>
<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 > 10"></span></li>
<li>1 gt 10 = <span th:text="1 gt 10"></span></li> // >, lt 는 <
<li>1 >= 10 = <span th:text="1 >= 10"></span></li>
<li>1 ge 10 = <span th:text="1 ge 10"></span></li> // >=, le 는 <=
<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 // 타임리프 오퍼레이션을 수행하지 않음 HTML처럼 그대로 사용
<ul>
<li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</span></li>
<li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가 없습니다.</span></li>
</ul>
</li>
<body>
<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/> // 알아서 적정하게 붙여주고, 띄어쓰기 X
<h1>checked 처리</h1>
// 값이 false인 경우 checked 속성 자체를 제거한다.
- 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>
<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>
<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>
<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> // 사용하기 애매한 겨웅에 사용한다.
<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>
: 자바스크립트를 편리하게 사용하는 기능
<script th:inline="javascript">
[# th:each="user, stat : ${users}"]
: 템플릿을 조각화 내놓고 렌더링 하는 것이다.
<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>
: 코드 조각을 레이아웃에 넘겨서 사용하는 방법이다. (페이지 만들 때 중복시 유용)
: 1은 템플릿 조각과 유사했고
: 2는 HTML전체에 레이아웃을 적용하는 크기이다.
ex) 페이지는 똑같고 타이틀만 다른 경우
// 페이지의 원형
<!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>
// 덮어쓸 파일 1
<!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>
: th:field를 사용하면 id name value 등을 자동으로 처리해준다.
: 이의 예시는 아래와 같다.
<form action="item.html" th:action th:object="${item}" method="post">
<div>
<label for="id">상품 ID</label>
<!-- <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}" readonly>-->
<input type="text" id="id" class="form-control" th:field="*{id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<!-- <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}">-->
<input type="text" id="itemName" th:field="*{itemName}" class="form-control">
</div>
<div>
<label for="price">가격</label>
<!-- <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">-->
<input type="text" id="price" th:field="*{price}" class="form-control">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control">
<!-- <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">-->
</div>
: 체크박스는 체크시 HTML에서 open = on이라는 값이 넘어가고 스프링 타입 컨버터가 on은 true 타입으로 변환해준다.
: 다만 체크박스 선택하지 않을 시 open field 자체가 서버로 전송되지 않는다. 수정의 경우에는 이게 문제가 될 수 있다.
: 이를 해결 하기 위해 MVC는 히든 필드를 하나 만드는데, 체크 박스 이름 앞에 _를 붙여서 전송하면 체크를 헤제했다고 인식할 수 있다. 체크를 해제한 경우 '_open'만 전송돼서 이를 통해 체크 해제를 판단한다.
<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on" > // 히든필드
: 타임필드가 자동으로 위의 코드를 생성해줌
<input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
: 위의 체크박스를 확장해서 다중 선택이 가능케 한다.
: 여기서 ModelAttribute를 기존과는 조금 다르게 활용한다.
: 체크 박스를 반복해서 보여주어야 하는데, 이를 위해선 각각의 컨트롤러에서 model.addAttribute(...) 을 사용해서 체크 박스를 구성하는 데이터를 반복해서 넣어주어야 한다. 이를 위해 컨트롤러에 아래와 같이 ModelAttribute를 이용하면,
@ModelAttribute("regions")// 일반적인 ModelAttribute와 다른 기능
public Map<String, String> regions(){
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("BERLIN", "베를린");
regions.put("LIVERPOOL", "리버풀");
return regions;
}
// 컨트롤러 호출시 항상 ModelAttribute를 통해 regions의 반환 값이 Model에 자동으로 담긴다. 밑의 코드들에 주석 처리된 부분 지우기 가능
// 그러면 아래처럼 매번 써야하는 수고를 덜 수 있다.
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
// Map<String, String> regions = new LinkedHashMap<>();
// regions.put("SEOUL", "서울");
// regions.put("BERLIN", "베를린");
// regions.put("LIVERPOOL", "리버풀");
// model.addAttribute("regions", regions);
return "form/item";
}
: 여러 선택지 중에 하나만 고르는 것이 라디오 버튼이다. ENUM을 통해서 개발해본다.
: 먼저 앞서 설명한 ModelAttribute의 기능을 활용한다.
@ModelAttribute("itemTypes")
public ItemType[] itemTypes(){
ItemType[] values = ItemType.values();
return values; // ENUM의 정보를 배열로 반환
}
<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>
: 한번 선택하면 다시 NULL로 그 값을 바꿀수 없다는 특징이 있다. 그래서 별도의 히든 필드 사용의 필요성이 떨어진다.
: 여러 선택지 중에 하나를 선택할 때에 사용한다.
: 먼저 자바 객체를 만들고 반환해서 ModelAttribute를 활용한다.
<!-- 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>
: th:object를 사용하는지 등의 여부를 주목한다.
<select th:field="${item.deliveryCode}" class="form-select" disabled>
: disabled를 사용하면 셀렉트 박스를 선택되지 않게 할 수 있다.
: HTML 파일에 메시지가 하드코딩 되어있어서 바꾸기 곤란한 경우에, 다양한 메시지를 한 곳에서 관리하여 바꾸기 쉽게 할 수 있는 기능이 메시지 기능이다.
: 메시지에서 설명한 메시지 파일( messages.properteis )을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다(접속 지역에 따라 언어를 다르게 설정)
: 직접 등록하는 방식도 있다.
: 스프링 부트를 사용 하면 부트에서 자동으로 MessageSouce를 스프링 빈으로 등록한다.
: application properties에 다음과 같은 코드를 넣어야 한다.
spring.messages.basename=messages
: 이와 같이 하면 스프링 빈에 메시지 소스가 등록이 된다.
: main이 아닌 Test창에서 메시지 소스가 제대로 적용되는지 확인해 본다.
@Test
void helloMessage() {
String result = ms.getMessage("hello", null, null);
// 지역 설정이 default로 되어있어서 "안녕"이 실행이 됨
assertThat(result).isEqualTo("안녕");
}
@Test
void argumentMessage() {
String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
// 값을 넘겨서 치환하는데 Object[] 배열을 사용
assertThat(result).isEqualTo("안녕 Spring");
}
: 이 처럼 메시지 소스가 잘 적용됨을 확인 할 수 있다.
: 진행중에 한글이 제대로 인식되지 않는 에러가 발생해서 시간이 걸렸다.
: 해결하기 위해서는 Files > settings> Editor > File Encoding 메뉴에서 사진에 하이라이트 된 부분을 UTF-8로 바꾸고, 프로그램을 종료하고 다시 켠 뒤에 깨진 문자들을 지우면 된다.

: 메시지를 활용하여 $대신 #을 쓰면 된다.
<div th:text="#{label.item}"></h2>
: 이처럼 관련 파일들을 수정하면 효과적으로 관리하고, 내용이 적용이 되는 것을 눈치챌 수 있다.
: 영어버전 메시지 파일을 수정한 후 크롬의 언어 설정에서 영어를 제일 위로 적용하면 적용되는 것을 확인 할 수 있다.
: 에러가 발생 하였을때, 어떤 오류가 발생했는지 친절하게 가르쳐주어야 한다.
: 실제로 저장되는 @Post annotation이 있는 곳에 적용한다.
: StringUtils는 springframework의 것을 사용한다.
: 다음과 같이 조건문을 통하여 예외사항을 검증하고,
@PostMapping("/add") //실제 저장
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직 - itemName에 글자가 없을 경우
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName", "상품 이름은 필수입니다.");
}
//검증 로직 - itemPrice가 범위를 넘어설 경우
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
//검증 로직 - itemQuantity의 수량 검즘
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
errors.put("globalError", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + 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}";
}
: HTML 파일에 th:if를 사용하여 조건을 만족할 경우에 에러가 출력되도록 만들 수 있다.
: BindingResult라는 도구를 사용한다.
: 먼저, BindingResult를 우선적으로 적용하는 방식에 대해 알아보겠다.
: 여기선 매개변수에서의 BindingResult의 위치, 그리고 간단한 활용법에 대해 알아본다.
: 필드에 오류는 FieldError 글로벌 오류는 ObjectError로 처리했다.
@PostMapping("/add") //실제 저장
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직 - itemName에 글자가 없을 경우
if(!StringUtils.hasText(item.getItemName())){
bindingResult.addError(new FieldError("item", "itemName", "상뭄 이름은 필수입니다."));
}
//검증 로직 - itemPrice가 범위를 넘어설 경우
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
//검증 로직 - itemQuantity의 수량 검즘
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
// 글로벌에러는 ObjectError를 사용한다.
bindingResult.addError(new ObjectError("item", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));
}
}
// 검증을 모두 실행한 이후에는, 다시 입력폼으로 돌아가야함
if(bindingResult.hasErrors()){ // 만약 에러가 있었다면의 표현 방식이 바뀜
// 자동으로 뷰에 넣기 때문에 Model에 담을 필요가 없음
return "validation/v2/addForm"; //입력폼 템플릿으로 보내버리기
}
// 예외사항 안타면 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
: 이와 같이 타임리프도 BindingResults를 활용하여 다음과 같이 변경해 주어야 한다.
// #fields를 통하여 검증 오류에 접근
<div th:if="${#fields.hasGlobalErrors()}">
// 해당 필드에 오류가 있는 경우에 태그를 출력한다 th:if와 같다.
<div class="field-error" th:errors="*{quantity}">
// th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
th:errorclass="field-error" class="form-control"
: BindingResults가 있을 경우에는, 상품 가격에 문자열을 입력했을 때와 같은 경우, 어떤 것이 문제인지 컨트롤러를 정상 호출한다.
: 입력한 값을 화면에 남겨보자
: Field와 ObjectError는 크게 두 가지의 생성자를 가진다.
bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false,null,null, "상뭄 이름은 필수입니다."));
bindingResult.addError(new ObjectError("item",null,null, "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));
: 타임리프 또한 타입 오류로 바인딩에 실패하면 담아서 컨트롤러를 호출하기 때문에 정상 출력이 가능하다.
: 먼저 errors.properties를 만든다.
: 스프링 부트가 파일을 인식할 수 있게 다음 문장을 추가한다.
spring.messages.basename=messages,errors
: errors에 등록된 메시지를 사용해본다.
이전 파일을 돌아보고 싶을땐 ctrl + E를 활용한다. alt tab과 유사
// 코드는 String 배열로 넘긴다.
bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,new String[]{"range.item.price"},new Object[]{1000,1000000}, null));
: 메시지는 다음과 같이 배열을 사용한다.
: 위와 같은 과정이 조금 번거로워서, 보다 자동화를 거친다.
: rejectValue() , reject()를 사용하면 FieldError,ObjectError를 사용하지 않고 깔끔하게 검증 오류를 다룰 수 있다.
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
: 어떤식으로 오류코드를 설계할 것인가 에 대한 내용. * 중요
: 범용성과 세밀성을 염두에 두면서 만들어야 한다.
: 세밀한 메시지를 높은 우선순위로 사용하는 것이다.
: 스프링에서는 MessageCodesResolver를 활용하여 오류 메시지를 관리한다.
: MessageCodesResolver를 직접 활용해보았다.
: MessageCodesResolver는 구체적인 것을 먼저 만들고 덜 구체적인 것을 가장 나중에 만든다.
: required로 크게 중요하지 않은 메시지를 처리하고, 특정 경우만 구체적으로 잘라서 사용하는게 효과적이다.
: 먼저 errors.properties에 메시지를 추가한다.
: 이렇게 분리하면 애플리케이션 코드를 수정하지 않고, properties파일만 수정하면 관리가 가능하다.
: 검증 오류 코드는 직접 설정, 스프링 설정 두 가지가 있다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
: 소스코드를 하나도 건드리지 않고 원하는 메시지를 단계별로 설정할 수 있다.
: 이처럼 에러에 대해 따로 처리하면, 해당 에러에 대해 메시지적용이 잘 되었음을 알 수 있다.
: validator로직을 분리하는 방법에 대하여 알아보겠다.
: additemV4 컨트롤러가 너무 많은 일을 담당하고 있기 때문에 검증 로직은 다른 클래스에 맡긴다.
: 위쪽의 검증 부분은 itemvalidatior에 맡긴다.
: Validator인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움도 받을 수 있다.
: 아래와 같이 선언한다.
private final ItemValidator itemValidator;
@InitBinder // 컨트롤러가 호출 될때마다 validator에 항상 넣어지게 된다. 항상 검증 적용 가능 컨트롤러에서만 적용 가능
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
: itemvalidator를 직접 언급하지 않을 수 있다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// @Validated라는 것을 넣어줘야 아이템에 대해서 자동으로 검증해줌
// 검증이 여러개 올 경우 서포트로 관리한다.
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}";
}
: 애노테이션 하나로 검증 로직을 쉽게 구현할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
// 를 gradle에 추가해주어야 한다.
: 아래와 같이 제한 조건들을 간단하게 나타낼 수 있다.
import lombok.Data;
// hibernate에서만 동작함.
import org.hibernate.validator.constraints.Range;
// Bean validation이 표준적으로 제공 어느 구현체에서나 동작
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 100, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
: @Validated 덕분에 적용된다. 다만 글로벌 등록은 하지 말아야한다.
: 타입 변환에 성공해서 바인딩해 성공해야만 적용된다.
: 앞서 배운 메세지(properties)에 넣어서 적용할 수 있다.
: 상품수정에 적용하는 과정이다. 앞에서 수정한 내용과 맞게 적절하게 수정하면 된다.
: 등록할 때와 수정할 때의 요구사항이 다른 경우 문제가 발생함. 등록에서는 ID를 입력받는 칸이 없지만, 수정할 때에는 ID가 필수인 경우가 그 예시이다.
: 위의 한계를 해결하기 위한 방법이다.
: 각각 등록과 수정을 위해서 따로 인터페이스를 만든다.
: 이후에 Validated에 groups를 적용하면, 되는 모습을 알 수 있다.
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
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 edit2(@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()){
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
: 등록시 폼에서 전달하는 데이터가 도메인 객체와 맞지 않아서 잘 쓰이지 않는다.
: 위의 문제 때문에 폼 데이터 전달에 별도의 객체를 사용한다.
// 아래와 같이 폼을 두개 만들어서 따로 적용한다. ModelAttriute에 이름도 제대로 적용하여야 한다. (폼 객체를 item 객체로 변환하는 과정이다.)
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//...
}
: Item 대신에 ItemSaveform을 전달받고 Validated로 검증 수행 후 BindgingResult로 결과도 받는다.
: item으로 넣지 않을 경우 MVC model에 itemSaveForm으로 담기게 된다.
: @ Validated는 HTTP 메시지 컨버터에도 적용이 가능하다.
@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때
사용한다.
API의 경우에는 3가지로 나누어 생각해야한다.
1. 성공 요청
2. 실패 요청
: 컨트롤러 자체도 호출되지 않고 그 전에 에러가 발생한다.
3. 검증 오류 요청
: HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 8과에서 다룬다.
: 도메인은 화면 UI기술인프라 등등의 영역을 제외한 시스템이 구현해야하는 핵심 비즈니스 업무 영역을 말한다. Web을 다른 기술로 바꿔도 도메인은 유지해야한다.
: member, memberRepository, memberController를 추가한후, 실험 데이터도 추가하였다.
: LoginService에서 아래의 코드는 주의깊게 봐야할 필요가 있다.
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
// 회원을 조회하고 파라미터로 넘어온 암호가 같으면 회원을, 아니면 null을 반환하는 식이다.
: 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()); log.info("login? {}", loginMember);
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
// 글로벌 오류로 objectError를 사용하였다.
return "login/loginForm";
}
쿠키에 대한 자세한 설명을 아래의 링크에서 쿠키에 관한 부분을 참조하자
: 로그인의 상태를 유지하기 위하여 쿠키를 사용한다.
: 서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면, 브라우저는 해당 쿠키를 지속해서 보내준다.
영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 브라우저 종료까지만 유지된다.
: 쿠키 생성 로직은 LoginController내에서 다음과 같은 코드로 적용한다.
//로그인 성공 처리
// 쿠키에 시간 정보를 주지 않으면 세션 쿠키가 된다.
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
// long을 스트링으로 바꾸기 위해 String,valueOf를 사용한다.
response.addCookie(idCookie);
// 생성한 쿠키를 HTTPServletResponse에 담는다.
// 쿠키 이름은 memberId이고 값은 id를 담아둔다.
: 실행 하였을 때 아래 사진과 같이 로그인이 잘 되어있음을 알 수 있다.
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId"); // 응답 넣고 쿠키명 넣으면 expire 해주는 것
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
: 실행해보면 쿠키가 잘 제거되는 것을 볼 수 있다.
: 다만 이렇게 개발하면 보안상의 큰 문제가 있다.
- 쿠키 값은 임의로 변경할 수 있다.
- 쿠키에 보관된 정보는 훔쳐갈 수 있다.
- 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
: 쿠키에 중요한 값을 노출하지 않는다.
: 서버에서 해당 토큰의 만료시간을 짧게 유지한다.
: 토큰에 임의의 값을 넣어도 찾을 수 없게 예상 불가능 해야한다.
: 위의 문제 해결을 위해 중요한 정보는 모두 서버에 저장해야하고, 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 함을 알 수 있다.
: 서버에서 회원 아이디 비밀번호가 맞을 경우, 세션 저장소에 세션 ID를 생성하는데, UUID를 통하여 추정이 불가능하게 만든다.
: 이 세션 아이디와 세션에 보관할 값을 서버의 세션 저장소에 보관한다.
: 서버는 클라이언트에 세션 ID만 쿠키에 담아서 저장하고, 클라이언트는 쿠키 저장소에 쿠키를 보관한다.
: 이 세션 아이디를 이용해서 서버에서 중요한 정보를 관리할 수 있다.
- 세션 생성
- 세션 조회
- 세션 만료
: 크게 이 세가지 기능이 있어야 한다.
상수로 만들기 단축키 : ctrl + alt + c
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME= "mySessionId";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
// 1. 세션 생성
public void createSession(Object value, HttpServletResponse response){
//세션 아이디 생성하고 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
// 쿠키를 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
// 2. 세션 조회
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
private Cookie findCookie(HttpServletRequest request, String cookieName) {
if (request.getCookies() == null) {
return null;
}
return Arrays.stream(request.getCookies())// 배열의 값을 하나씩 넘기며 돌리는게 stream
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
// 위의 코드는 아래의 코드를 리팩토링 한 것이다.
// public Object getSession(HttpServletRequest request){
// Cookie[] cookies = request.getCookies();// 배열로 반환됨
// if (cookies == null){
// return null;
// }
// for(Cookie cookie : cookies){
// if(cookie.getName().equals(SESSION_COOKIE_NAME)){
// return sessionStore.get(cookie.getValue());
// }
// }
// return null;
// }
// 3. 세션 만료
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
}
: 로그인 함수 내에서 아래의 코드를 추가한다.
// 세션 관리자를 통해 세션을 생성하고, 회원데이터 보관
sessionManager.createSession(loginMember, response);
: 로그아웃은 HttpServletRequest를 사용한다.
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
: 아래와 같이 Home에도 적용한다.
@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("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
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";
}
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember); return "redirect:/";
}
: 로그아웃
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if(session != null){
session.invalidate();
}
return "redirect:/";
}
: 홈컨트롤러에선 다음과 같이 사용한다
//@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);
// 세션에 회원 데이터가 없으면 home
if(loginMember == null){
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
: 홈 컨트롤러에서 위 어노테이션을 사용하면 세션을 찾고, 세션의 데이터를 찾는 긴 과정을 편리하게 처리해주는 것을 확인할 수 있다.
@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";
}
: 웹브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법
: 사용하지 않으려면 다음 코드를 apllication properties에 추가하면 된다
server.servlet.session.tracking-modes=cookie
: 세션 정보를 확인하기 위해 아래와 같은 코드를 만들었다.
public class SessionInfoController {
@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request){
HttpSession session = request.getSession(false);
if(session == null){
return "세션이 존재하지 않습니다";
}
session.getAttributeNames().asIterator()
.forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
log.info("sessionId={}", session.getId());
log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
: 로그인 한후 session-info로 들어가 내용을 확인해본다.
: maxInactiveInterval은 세션의 유효 시간을 의미한다.
: 세션은 로그아웃을 눌렀을때 삭제가 되는데, 대부분의 사용자는 창을 그냥 꺼버리고, 웹 브라우저는 비 연결성이기 때문에 사용자가 웹을 종료했는지 아닌지를 인식할 수 없다.
: 사용자가 서버에 최근에 요청한 시간을 기준으로, 30분 정도를 유지하면 좋다
: 예제에서는 60초를 사용하였다.
: application.properties에 다음 코드를 추가하면 된다.
server.servlet.session.timeout=60
: 필터는 서블릿, 인터셉터는 스프링에서 제공하는 기능이다.
: 로그인하지 않은 사용자도 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다.
: 웹과 관련된 공톰 관심사(애플리케이션 여러 로직에서 공통적으로 관심이 있는 것)에는 필터 또는 인터셉터를 사용하는 것이 좋다.
: 필터는 서블릿이 지원하는 수문장이다.
HTTP요청 -> WAS(서버) -> 필터 -> 서블릿 -> 컨트롤러
: 필터 호출한 다음에 서블릿이 호출된다. 특정 URL 패턴에 적용할 수 있다.
: 제한 하면 필터에서 자체적으로 서블릿을 호출하지 않는다.
: 필터 인터페이스는 싱글톤이다.
: 모든 요청을 로그로 남기는 필터를 개발한다.
: 필터는 아래와 같다.
@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();
// 모든 사용자의 요청 URI 남기기
String uuid = UUID.randomUUID().toString();
// 요청 온것을 구분하기 위해 UUID 사용
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");
}
}
: 필터를 쓸 수 있게 등록을 해야한다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
// 필터의 순서 정해주기
filterRegistrationBean.addUrlPatterns("/*");
// 어떤 URL패턴에 적용하는가 (모든 URL에 적용)
return filterRegistrationBean;
}
}
: 스프링 부트를 사용한다면 FilterRegistrationBean 을 사용해서 등록하면 된다.
: 인증 받지 않으면 해당 페이지에 들어가지 못하게 한다.
: 아래와 같이 필터를 만든다
@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);
//로그인으로 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) {
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
: 앞서 필터를 쓰기위하여 등록했듯이, WebConfig에 등록해준다.
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
: 위의 코드를 적용 후에 실행하면 리다이렉트가 제대로 되는 것을 확인할 수 있다.
: 밑의 코드는 로그인 성공 시에 처음 요청한 URL로 이동하는 기능이다.
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) {
// @RequestParam(defaultValue = "/") String redirectURL,를 수정함
// 없으면 /로 갈꺼고 아니면 redirectURL로 가게 설정한다.
.....
return "redirect:" + redirectURL;
}
: 위의 필터와는 순서와 범위 사용방법이 다르다.
: 인터셉터의 흐름은 아래와 같다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
: 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.
: 모든 요청을 로그로 남기는 인터셉터를 개발한다.
: LogInterceptor의 코드는 다음과 같다
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
// 싱글톤이라 여기서 prehandle코드 작성 불가
@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);
// @Controller가 아니라 정적 리소스가 호출되는 경우에는 : ResourceHttpRequestHandler
if (handler instanceof HandlerMethod){ // @RequestMapping의 경우 사용 되는 handler가 handlerMethod이다.
HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메소드의 모든 정보가 포함되어 있다
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
// true 다음 컨트롤러 호출
// false 여기서 끝남
}
@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, requestURI);
if (ex != null) {
// 예외가 NUll이 아니면(예외처리를 여기서 하는 이유는 PostHandle이 호출되지 않는다)
log.error("afterCompletion error!!", ex); // 에러를 찍어볼 수 있음
}
}
}
: 등록하기 위해 만든 WebConfig의 코드는 아래와같았다.
@Configuration
public class WebConfig implements WebMvcConfigurer { // implement함
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**") // 리소스 폴더 포함 하위의 모든 패턴
.excludePathPatterns("/css/**", "/*.ico", "/error");// 이 경로는 인터셉터 먹이지마
}
}
: 인증이란 것은 컨트롤러 호출 전에만 호출하면 되기 때문에, preHandle만 구현하면 된다.
@Slf4j
public class LoginCheckInterceptor 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(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청");
//로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
} return true;
}
}
: 다음과 같이 인터셉터를 등록한다.
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error"
);
// 인터셉터의 장점: 패턴을 세밀하게 가져갈 수 있음
: ArgumentResolver에 대한 내용은 다음과 같다.
: ArgumentResolver 를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다
: 먼저 HomeController에서 세션 대신에 @Login을 추가한다.
: ArgumentResolver 가 동작해서 자동으로 세션의 로그인 회원을 찾아주고, 세션에 없다면 NULL을 반환하게 개발한다.
public String homeLoginV3Argumentresolver(@Login Member loginMember, Model model){
// 어노테이션 하나로 간단하게 해결한다.
: 아래와 같이 남긴다.
@Target(ElementType.PARAMETER)
// 파라미터에만 사용
@Retention(RetentionPolicy.RUNTIME)
// 런타임까지 어노테이션 정보를 남김
public @interface Login {
}
: ArgumentResolver를 개발한다.
: supportsParameter() : @Login 애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver
가 사용된다.
: resolveArgument() : 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다. 여기서는
세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준다. 이후 스프링MVC는 컨트롤러의
메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달해준다
@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);
}
}
: 마지막으로, Config에 등록한다.
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
: 자바는 메인 메소드 실행시 main의 쓰레드가 실행된다. 예외를 잡지 못하고 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다.
: 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
: Excpetion이 터지면 서블릿 컨테이너는 500으로 처리한다.
: 직접 오류메시지 등을 담아서 처리하고 싶은 경우 response.sendError
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx(){
throw new RuntimeException("Exception occured");
}
@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException{
response.sendError(404, "404 Error");
}
@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
response.sendError(500);
}
}
: 다만 사용자가 보기에 불편하다.
: 먼저 스프링 부트가 제공하는 기능을 사용해 서블릿 오류 페이지를 등록한다.
@Component // 스프링에 등록해주는 어노테이션
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
// 서블릿 컨테이너가 이렇게 사용하도록 지정함
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
// 낫파운드 에러가 뜨면 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";
}
}
- 예외가 발생해서 서버까지 전파된다.
- 서버는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 서블릿 인터셉터 컨트롤러가 모두 다시 호출된다.
public class ErrorPageController {
public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "javax.servlet.error.message";
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_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";
@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";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
//ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
log.info("dispatchType={}", request.getDispatcherType());
}
: 오류 정보를 위와 같이 사용할 수 있다.
: 이렇게 두번씩 호출되는게 비효율적이기 때문에 클라이언트로부터 발생된 정상 요청인지 오류 페이지 출력을 위한 내부 요청인지 구분해야하고, 이를 위해 DispatcherType을 사용한다.
고객이 한 요청 : dispatcherType=REQUEST
오류 요청인 경우 : dispatchType=ERROR
MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때 : dispatchType=FORWARD
서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때 : dispatchType= INCLUDE
서블릿 비동기 호출 : dispatchType=ASYNC
: 필터는 기존코드와 똑같지만, 로그 출력부에 request.getDispatcherType() 을추가한다.
: 등록하는 WebConfig내에서 다음과 같이 구성한다.
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
// 클라이언트 요청, 오류 페이지 요청에서도 필터가 호출된다.
// 오류 페이지 경로도 필터 적용할 거 아니면 기본 값을 그대로 적용하면 된다.
: 위와 마찬가지로, 기존 코드는 같지만 request.getDispatcherType()를 추가한다.
: WebConfig또한 다음과 같이 구성한다.
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**");
}
: 필터는 DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST )
: 인터셉터는 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/**") )
: 예외처리 페이지를 보다 간편하게 만들기 위해 스프링 부트에서 지원하는 기능에 대해 알아본다.
: 개발자는 오류 페이지 화면만 BasicErrorController 가 제공하는 룰과 우선순위에 따라서 등록하면
된다.
: 정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿
경로에 오류 페이지 파일을 만들어서 넣어두기만 하면 된다.
resources/templates/error/4xx.html
: 다음의 경로에 다음과 같이 파일을 넣으면 400대의 html이 자동으로 나오게 된다.
: BasicErrorController가 model에 담아서 뷰에 전달하는 정보들이 있다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.
: 보안상 문제가 될 수 있으니, 오류 컨트롤러에서 정보를 모델에 담을지 말지의 여부를 선택할 수 있다.
: HTML과는 달리 API는 각 오류 상황에 맞는 스펙을 정하고 JSON으로 데이터를 내려주어야 한다.
: 먼저 API 예외 컨트롤러를 만들어보자
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto{
private String memberId;
private String name;
}
}
: 여기서 에러를 반환하게 할 경우, 기존에 만들었던 HTML페이지가 반환된다.
: 오류페이지 컨트롤러도 JSON응답을 하게 아래와 같이 코드를 추가한다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
// MediaType은 스프링 프레임워크
// produces = MediaType.APPLICATION_JSON_VALUE 의 뜻은 클라이언트가 요청하는 HTTP Header의
// Accept 의 값이 application/json 일 때 해당 메서드가 호출된다는 것이다. 결국 클라어인트가 받고
// 싶은 미디어타입이 json이면 이 컨트롤러의 메서드가 호출된다.
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));
}
: 스프링 부트는 BasicErrorController 가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다.
: 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다.
: 컨트롤러에서 예외를 받으면, afterCompletion을 호출하지 않고, ExceptionResolver를 호출해 예외를 해결하려고 한다.
: ExceptionResolver에서 정상적으로 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일 경우
log.info("IllegalArgumentException resolver to 400");
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
// BAD_REQUEST가 400이다, 400으로 변경 Excpetion을 sendError로 바꿔치기 하는 것
return new ModelAndView();
}
}catch(IOException e){
log.error("resolver ex", e);
}
return null;
}
}
: 예외를 해결해도 PostHandle은 호출되지 않는다.
: 포스트맨에서 http://localhost:8080/api/members/bad, http://localhost:8080/api/members/ex를 입력하면, 각각 맞게 에러가 터지는걸 확인할 수 있다.
@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;
}
}
: 예외가 발생해도 서블 까지 전송되지 않고, MVC에서 예외처리가 끝이 난다.
: 다만 직접 구현하기가 힘들어, 스프링이 제공하는 ExceptionResolver를 사용한다.
: 스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.
HandlerExceptionResolverComposite에 다음 순서로 등록
1. ExceptionHandlerExceptionResolver
: @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다. 제일 중요
2. ResponseStatusExceptionResolver
: HTTP 상태 코드를 지정해준다.
3. DefaultHandlerExceptionResolver
: 스프링 내부 기본 예외를 처리한다.
: 우선 순위가 가장 낮다9.2.1. ExceptionHandlerExceptionResolver
: 오류가 발생했을 때 응답의 모양이 다를 수 있다.
: 이렇게 API예외처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용해 편리한 예외 처리 기능을 제공하는데 이게 ExceptionHandlerExceptionResolver이다.9.2.2.1 ResponseStatusExceptionResolver
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") public class BadRequestException extends RuntimeException { } // reason을 메세지 소스에서 찾는 기능도 제공한다. @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad"): 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.
: 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.
: 이를 극복하기 위해 ResponseStatusException 사용
: @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식
클래스는 모두 잡을 수 있다.
: 스프링의 우선순위는 항상 더 자세한 것이 우선권을 가진다.9.2.2.2 ResponseStatusException
@GetMapping("/api/response-status-ex2") public String responseStatusEx2() { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException()); }: response.sendError(statusCode, resolvedReason)를 호출한다.
: 스프링 내부에서 발생하는 스프링 에외를 해결해준다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면, 내부에서 TypeMismatchException이 발생하여서 500오류가 발생한다. 근데 파라미터 바인딩은 대부분 클라이언트가 HTTP요청을 잘못 호출해서 생긴 것이라 HTTP서는 이 오류에 HTTP상태 코드 400을 사용하게 한다. Default를 사용하면 HTTP상태코드 400으로 변경해준다.
: 따라서 아래 코드를 호출 했을때,
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
: HTTP상태코드 400로 변경된걸 확인 할 수 있다.
: 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice를 사용하면 이를 분리할 수 있다.
: @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을
부여해주는 역할을 한다.
: @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
: @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
: @Controller , @RestController 의 차이와 같다
결론적으로, @ExceptionHandler 와 @ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다
: 예전에 자바에서는 변환하는 과정을 항상 거쳐야 했다.
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
// Http 요청 파라미터는 모두 문자로 처리된다.
// 다른 타입으로 변환하고 싶으면 숫자 타입으로 변환하는 과정을 거쳐야 한다.
String data = request.getParameter("data"); //문자 타입 조회
Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경
System.out.println("intValue = " + intValue);
return "OK";
}
: 스프링에서는 다음과 같이 @RequestParam을 통해서 중간에서 형변환을 해준다.
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data){
System.out.println("data = " + data);
return "OK";
}
: 이러한 것과 같은 에시는 @ModelAttribute @PathVariable에서도 볼 수 있다.
: 스프링에 추가적인 타입 변환이 필요할 경우 컨버터 인터페이스를 사용하여 활용한다.
import org.springframework.core.convert.converter.Converter;
// 컨버터는 종류가 많음으로 주의
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
: 위와 같이 컨버터를 만들고, 테스트하면 정상 동작함을 알 수 있다.
: 여기서 나아가 IP, Port를 입력하면 IpPort객체로 변환하는 컨버터를 만들어보자
: 하나하나 직접 찾아서 쓰는 것이 불편하다.
: 개별 컨버터를 모아두고 묶어서 편리하게 사용할 수 있는 기능을 제공한다.
: 컨버팅 할수 있는가와 컨버팅을 해주는 두 가지 기능을 제공한다.
: 아래와 같이 만든 컨버터를 등록하고 사용만 하면 된다.
//등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//사용
assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
assertThat(ipPortString).isEqualTo("127.0.0.1:8080");
: 이렇게 등록과 사용이 잘 분리된 것을 인터페이스 분리 원칙(ISP)를 잘 지켰다고 한다.
: 스프링은 내부에서 ConversionService를 제공한다.
: WebMvcConfigurer 가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.
: 코드는 아래와 같다.
@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());
}
}
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort PORT = " + ipPort.getPort());
return "ok";
}
: 타임리프에서는 {}가 두 개 있면 자동으로 적용한다.
<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>
GET /converter/edit
th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.
POST /converter/edit
@ModelAttribute 를 사용해서 String IpPort 로 변환된다
: 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이
바로 포맷터( Formatter )이며 컨버터의 특화된 기능이다.
: 객체를 문자로 변경하고 문자를 객체로 변경하는 두 가지 기능을 모두 수행한다
String print(T object, Locale locale) : 객체를 문자로 변경한다.
T parse(String text, Locale locale) : 문자를 객체로 변경한다.
: 포맷터를 지원하는 컨버전 서비스이다.
@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);
}
: 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다 이래서 아래의 두 가지를 제공한다.
@NumberFormat : 숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory
@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory
@Controller
public class FormatterController {
@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;
}
}
: 위와 같이 컨트롤러를 만들고 폼을 만들어 호출하면, 변환이 잘 되는 것을 확인할수 있다.
: 다만, HttpMessageConverter에는 컨버젼 서비스가 적용되지 않는다.
: HTML 폼을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 두 가지 방식의 차이를 이해해야한다.
- application/x-www-form-urlencoded
- multipart/form-data
: 가장 기본적인 방법이다.
: 폼에 전송할 항목을 HTTP Body에 문자로 &로 구분해서 전송한다.
: 다만 이 방식을 사용하면 문자와 바이너리 두 개를 동시에 저장해야 하기 때문에 아래의 방식을 사용한다.
: 다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있다.
: 각각의 항목을 구분해서 한번에 전송하는 것이다.
: A와 구분했을때 더욱 복잡하다.
: 다음과 같이 서블릿을 통해 파일 업로드를 한다.
: doDispatch 로직이 중요
@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@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();
// multipart/form-data방식에서 각각 나누어진 부분을 받아 확인할 수 있다.
log.info("parts={}", parts);
return "upload-form";
}
}
logging.level.org.apache.coyote.http11=debug
: 옵션을 통해 HTTP 요청 메시지를 확인할 수 있다.
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
: 옵션을 통해 파일 하나의 최대 사이즈, 파일들의 총합 사이즈를 정의할 수 있다.
spring.servlet.multipart.enabled=false
: 이 옵션을 통해 스프링 부트는 서블릿 컨테이너에게 멀티파트 데이터를 처리하라고 설정한다.
: 복잡한 멀티파트 요청을 처리해서 사용할 수 있게 제공한다.
: 파일을 업로드를 하려면 실제 파일이 저장되는 경로가 필요하다.
iter + enter 하면 가장 가까이 있는거 loop 돌릴 수 있다.
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
//spring의 value 사용해야 함
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@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);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
// Parts의 헤더와 바디 구분
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info(headerName, part.getHeader(headerName));
}
// 편의 메서드
// content-dispositon, file-name
log.info(part.getSubmittedFileName());
log.info("size={}", part.getSize());
// part body size
// 데이터 읽기
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
// 바디 읽은걸 String으로
// 바이너리와 문자간의 변경에는 char 제공해줘야함
log.info(body);
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath);
// 편리하게 저장 가능
}
}
return "upload-form";
}
}
: 경로는 아래와 같이 '/'으로 시작하고 끝내야 한다.
file.dir=/C:/Users/wrjan/Desktop/Programming/inflearn backend study/9. SpringMvc 2/saving/
: MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.
: 주요 메소드는 다음과 같다.
file.getOriginalFilename() : 업로드 파일 명
file.transferTo(...) : 파일 저장
@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("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";
}
}
: 업로드하는 HTML Form의 name에 맞추어 @RequestParam 을 적용하면 된다.
: 서블릿보다 훨씬더 간편해진 것을 알 수 있다.
: Item 도메인 객체, 리포지토리, 업로드 파일정보 보관을 만든다.
: 이 때, 파일명이 겹치지 않도록 관리가 필요하다.
: 파일 저장과 관련된 업무 처리를 위해 FileStore파일을 만든다.
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
// 이미지 같은 경우는 여러개의 파일을 업로드 할 수 있어야 함
}
: 이름을 구분해서 파일을 업로드하였다.
@Data
public class UploadFile {
private String uploadFileName;
private String storeFileName;
// 내부에서의 이미지는 안겹치게 만들어야 함
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
: 저장과 관련된 코드는 아래와 같다.
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String fileName){
return fileDir + fileName;
}
// 여러개를 업로드
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
// storeFile을 loop를 돌며서 시행한다.
}
}
return storeFileResult;
}
// 하나를 업로드
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
// 멀티파트 파일을 받아서 uploadfile로 변환해줌
if(multipartFile.isEmpty()){
return null;
}
String originalFileName = multipartFile.getOriginalFilename();
String storeFileName = createStoreFileName(originalFileName);
// image.png가 들어오면 서버에 저장하는 파일명을 UUID로 만들어준다. 다만 확장자는 가져오고 싶다
// 서버에 저장하는 파일명
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFileName, storeFileName);
}
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
private String extractExt(String originalFileName) {
// 확장자 추출을 위한 메소드
int pos = originalFileName.lastIndexOf(".");
return originalFileName.substring(pos + 1);
}
}
: 컨트롤러의 코드는 아래와 같다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
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());
List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
//데이터베이스에 저장
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}")
// <img> 태그로 이미지를 조회할 때 사용된다. UrlResurce로 읽고, @ResponseBody로 이미지 바이너리를 반환한다.
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
// 파일에 직접 접근해서 리소스 가져옴
}
@GetMapping("/attach/{itemId}")
// 파일 다운로드시 권한체크를 한다. 고객이 업로드한 파일 이름으로 다운로드 한다.
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
Item item = itemRepository.findById(itemId);
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
// 다운로드 받게 하기 위함
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
}
: 하나의 첨부파일을 업로드 다운로드 하고 여러개의 이미지를 업로드 할 수 있다.
: 이미지 업로드와 다운로드시 파일명을 다르게 해서 관리하는 점, 이미지 보여주는 경로도 별도로 관리해야한다는 점, 이미지 파일명을 관리해야하는 점 등을 주의해야한다.