김영한님의 스프링 mvc2편 - 벡엔트 웹 개발 활용 기술 을 듣고 정리한 내용입니다.
지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류
화면으로 바로 이동한다. 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
검증을 직접 처리하는 경우
ValidationItemControllerV1
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() >= 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", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
log.info("errors = {} ", errors);
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}";
}
검증오류 보관
Map<String, String> errors = new HashMap<>();
검증 로직
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
특정 필드의 범위를 넘어서는 검증 로직
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
특정 필드의 범위를 넘어서는 오류를 처리할 수 도 있다. 이떄 globalError 라는 키를 사용한다.
실패시 입력폼으로
//검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
log.info("errors = {} ", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
addForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류
메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}"th:text="${errors['itemName']}">
상품명 오류
</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}"
th:text="${errors['price']}">
가격 오류
</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}"
th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('quantity')}"
th:text="${errors['quantity']}">
수량 오류
</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit"
th:text="#{button.save}">저장</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v1/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
필드 오류 처리
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
</div>
문제점
- 뷰 템플릿에서 중복 처리가 많다.
- 타입 오류 처리가 안된다. Item의 price 나 quantity 같은 숫자 필드는 타입이 Integer이므로 문자타입 설정이 불가능하다.
- Item 의 price 에 문자를 입력하는 것 처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야
한다.
ValidationItemControllerV2 - addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 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) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
필드오류 - FieldError
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
}
FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아둔다.
- obejctName: @ModelAttribute 이름
- field : 오류 발생 필드이름
- defaultMessage : 오류 기본 메세지
글로벌 오류 - ObjectError
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담는다.
- objectName : @ModelAttribute 의 이름
- defaultMessage : 오류 기본 메시지
addForm.html 수정
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:errors="*{price}">
가격 오류
</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}"
th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}">
수량 오류
</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v2/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
타임리프 스프링 검증 오류 통합 기능
타임리프는 스프링의 BindingReult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
- #filelds : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
- th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의버전
- th:errorclass : th:fild에서 지정한 필드에 오류가 있으면class정보를 추가한다.
글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
<div>
필드 오류 처리
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
</div>
BindingResult에 검증오류를 적용하는 3가지 방법
1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우
스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
2. 개발자가 직접 넣어준다
3. Validator 사용
BindingResult와 Errors
- org.springframework.validation.Errors
- org.springframework.validation.BindingResult
BindingResult는 인터페이스고, Errors 인터페이스를 상속받는다.
실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데,
둘다 구현하고 있으므로 BindingResult대신 Error를 사용해도 되지만
BindingResult는 여기에 더해 추가적인 기능을 제공한다.
addError()도 BindingResult가 제공하므로 BindingResult를 관례상 많이 사용한다.
하지만 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지므로 해당 문제의 해결이 필요하다.
FieldError, ObjectError
사용자 입력 오류 메세지가 화면에 남도록 하자.,
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null ,null, "수량은 최대 9,999 까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",null ,null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
FieldError 클래스는 두가지 생성자를제공한다.
public class FieldError extends ObjectError {
private final String field;
@Nullable
private final Object rejectedValue;
private final boolean bindingFailure;
/**
* Create a new FieldError instance.
* @param objectName the name of the affected object
* @param field the affected field of the object
* @param defaultMessage the default message to be used to resolve this message
*/
public FieldError(String objectName, String field, String defaultMessage) {
this(objectName, field, null, false, null, null, defaultMessage);
}
/**
* Create a new FieldError instance.
* @param objectName the name of the affected object
* @param field the affected field of the object
* @param rejectedValue the rejected field value
* @param bindingFailure whether this error represents a binding failure
* (like a type mismatch); else, it is a validation failure
* @param codes the codes to be used to resolve this message
* @param arguments the array of arguments to be used to resolve this message
* @param defaultMessage the default message to be used to resolve this message
*/
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {
super(objectName, codes, arguments, defaultMessage);
Assert.notNull(field, "Field must not be null");
this.field = field;
this.rejectedValue = rejectedValue;
this.bindingFailure = bindingFailure;
}
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
오류 발생 시 사용자 입력 값 유지
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
사용자의 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩 되는 시점에 오류가 발생하게 된다면
모델 객체에 사용자 입력 값을 유지하기 어렵다.
그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
이렇게 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력ㅎ나다.
FieldError는 오류 발생 시 사용자 입력 값을 제공하는 기능을 제공한다.
여기서 rejectiveValue가 오류 발생 시 사용자 입력 값을 저장하는 필드이다.
bindingFailure은 타입 오류 같은 바인딩이 왜 실패했는지 여부를 적어주면 된다.
타임리프의 사용자 입력 값 유지
th:field="*{price}"
타임리프의 th:field는 매우 똑똑하게 동작하는데, 정상 상황에서는 모델 객체의 값을 사용하지만
오류가 발생 시 FieldError에서 보관한 값을 사용해 출력한다.
스프링에서 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은 FieldError을 생성하면서 사용자가 입력한 값을 넣는다.
- 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
- 따라서 타입 오류같은 바인딩 실패에도 사용자의 오류메세지를 정상 출력할 수 있다.
오류코드와 메시지 처리
public class FieldError extends ObjectError {
private final String field;
@Nullable
private final Object rejectedValue;
private final boolean bindingFailure;
/**
* Create a new FieldError instance.
* @param objectName the name of the affected object
* @param field the affected field of the object
* @param defaultMessage the default message to be used to resolve this message
*/
public FieldError(String objectName, String field, String defaultMessage) {
this(objectName, field, null, false, null, null, defaultMessage);
}
/**
* Create a new FieldError instance.
* @param objectName the name of the affected object
* @param field the affected field of the object
* @param rejectedValue the rejected field value
* @param bindingFailure whether this error represents a binding failure
* (like a type mismatch); else, it is a validation failure
* @param codes the codes to be used to resolve this message
* @param arguments the array of arguments to be used to resolve this message
* @param defaultMessage the default message to be used to resolve this message
*/
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {
super(objectName, codes, arguments, defaultMessage);
Assert.notNull(field, "Field must not be null");
this.field = field;
this.rejectedValue = rejectedValue;
this.bindingFailure = bindingFailure;
}
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
FieldError와 Object Error 생성자에서
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
두가지를 제공한다.
이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.
error 메시지 파일 생성
message.properties를 사용해도 되지만, errors.properties를 사용해서
오류메시지만 따로 관리할 수 있다.
스프링 부트 메세지 설정만 추가해 주면된다.
application.properties
spring.messages.basename=messages,errors
src/main/resources/errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
controller
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"} ,new Object[]{9999}, null));
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",new String[]{"totalPriceMin"} ,new Object[]{10000, resultPrice}, null));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
FieldError의 codes와 arguments의 사용
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}
arguments: Object[] {1000,10000} 을 사용하여 코드의 {0}, {1} 로 치환할 값을 전달한다.
오류코드의 자동화
BindingReesult는 검증해야 되는 객체인 target 바로 다음에 온다.
BindingResult는 이미 자신이 검증해야되는 객체인 target을 알고있다.
해당 코드를 컨트롤러에서 실행해보면
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
결과
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)
BindingResult가 제공하는 rejectValue(), reject()를 사용하면
FildError, ObjectError를 직접 생성하지 않고 검증 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
Errors 클래스
void rejectValue(@Nullable String field, String errorCode);
/**
* Register a field error for the specified field of the current object
* (respecting the current nested path, if any), using the given error
* description.
* <p>The field name may be {@code null} or empty String to indicate
* the current object itself rather than a field of it. This may result
* in a corresponding field error within the nested object graph or a
* global error if the current object is the top object.
* @param field the field name (may be {@code null} or empty String)
* @param errorCode error code, interpretable as a message key
* @param defaultMessage fallback default message
* @see #getNestedPath()
*/
void rejectValue(@Nullable String field, String errorCode, String defaultMessage);
- field : 오류 필드명
- errorCode : 오류 코드(messageResolver를 위한 오류 코드이다.)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
앞에서 BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다.
따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용했다
스프링에서는 MessageCodesResolver로 세부적인 오류 코드를 지원할 수 있다.
예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자.
다음과 같이 required 라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.
required: 필수 값 입니다.
그런데 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지
코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
객체 오류의 경우
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드오류
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
동작 방식
- rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지코드들을 생성한다.
- FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
- MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
오류코드 관리 전략
구체적인것에서 덜 구체적인것으로
MessageCodesResolver는 required.item.itemName 처럼 구체적인것을 먼저 만들어준뒤
requird 처럼 덜 구체적인 것을 나중에 만든다.
모든 오류코드에 대해서 메세지를 각각 정의하면 개발자 입장에서 관리가 어렵다
중요하지 않은 메세지는 범용성 있는 required로 끝내고
정말 중요한 메세지는 필요 시 구체적으로 적어 사용하는게 효과적이다.
오류 코드 전략을 도입해보자
errors.properties
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
크게 객체 오류와 필드 오류를 나누었다. 범용성에 따라 레벨을 나눈다.
ValidationUtils
사용전
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
사용후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
스프링이 직접 만든 오류 메세지 처리
검증오류 코드는 2가지
1. 개발자가 직접 설정한 오류코드 - rejectValue()를 직접 호출
2. 스프링이 직접 검증 오류에 추가한 경우
Validator 분리
ItemValidator
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 10000000}, 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);
}
}
}
}
스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.
public interface Validator {
/**
* Can this {@link Validator} {@link #validate(Object, Errors) validate}
* instances of the supplied {@code clazz}?
* <p>This method is <i>typically</i> implemented like so:
* <pre class="code">return Foo.class.isAssignableFrom(clazz);</pre>
* (Where {@code Foo} is the class (or superclass) of the actual
* object instance that is to be {@link #validate(Object, Errors) validated}.)
* @param clazz the {@link Class} that this {@link Validator} is
* being asked if it can {@link #validate(Object, Errors) validate}
* @return {@code true} if this {@link Validator} can indeed
* {@link #validate(Object, Errors) validate} instances of the
* supplied {@code clazz}
*/
boolean supports(Class<?> clazz);
/**
* Validate the supplied {@code target} object, which must be
* of a {@link Class} for which the {@link #supports(Class)} method
* typically has (or would) return {@code true}.
* <p>The supplied {@link Errors errors} instance can be used to report
* any resulting validation errors.
* @param target the object that is to be validated
* @param errors contextual state about the validation process
* @see ValidationUtils
*/
void validate(Object target, Errors errors);
}
supprots() : 해당 검증기를 지원하는 여부 확인
validate(Object object, Errors errors) : 검증 대상 객체와 BindingResult
ItemValidator 직접 호출하기
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
itemValidator.validate(item, bindingResult);
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
기존 검증과 관련된 부분이 깔끔하게 분리되었다.
WebDataBinder를 통해서 사용하기
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
이렇게 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동 적용 가능하다.
@Validated 적용
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
@Validated 는 검증기를 실행하라는 어노테이션이다.
해당 어노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다.
여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.
이 때 supports()가 사용된다.
Bean Validation
검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증은 대부분 빈값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 일반적인 로직이다.
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
//...
}
이런 검증 로직을 모든 프로젝트에 적용할 수 있게
공통화하고, 표준화 한 것이 바로 Bean Validation이다.
Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
테스트코드
public class BeanValidationTest {
@Test
void beanValidation() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" "); //공백
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation = " + violation.getMessage());
}
}
}
검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
검증 실행
Set<ConstraintViolation<Item>> violations = validator.validate(item);
검증 대상 (item)을 직접 검증기에 넣고 결과를 받는다.
Bean Validation - 에러코드
NotBlank라는 오류코드 기반으로 MessageCodesResolver을 통해 다양한 메세지코드가 순차 생성된다.
@NotBlank
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
@Range
- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
errors.properties
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
BeanValidation 메세지 찾는 순서
- 생성된 메세지 코드 순서대로 messageSource에서 메세지 찾기
- 어노테이션의 message 속성 사용 - @NotBlank(message = "공백 ! {0}")
- 라이브러리가 제공하는 기본 값 사용
Bean Validation - 오브젝트 오류
BeanValidation에서 특정 필드(FieldError) 가 아닌
오브젝트 관련 오류 ( ObjectError)는 @ScriptAssert()를 사용한다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item {
//...
}
하지만 사용 시 제약이 많고 복잡하다.
실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우도 종종 있다.
그런 경우 대응이 어려우므로
@ScriptAssert를 사용하는 것보다
오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
@PostMapping("/add")
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()) {
log.info("errors={} ", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
Bean Validation - 한계
등록 시 기존 요구사항
타입검증
- 가격 수량에 문자 들어갈 시 오류
필드검증
- 상품명 : 필수 , 공백
- 가격 : 1000원 이상, 백만원 이하
- 수량 최대 : 9999
특정 필드의 범위 넘어서는 검증
- 가격 * 수량의 합은 10000원 이상
수정 요구사항
등록 시 quentity 수량을 9999 개까지 등록 가능하지만
수정시에는 수량을 무제한으로 변경할 수 있다.
등록시에는 id에 값이 없어도 되지만 수정시에는 id 값이 필수적이다.
@Data
public class Item {
@NotNull //수정 요구사항 추가
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
//@Max(9999) //수정 요구사항 추가
private Integer quantity;
//...
}
수정은 잘 동작하지만 등록시 문제 발생.
등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.
'id': rejected value [null];
왜냐하면 등록시에는 id 에 값이 없다. 따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시
폼 화면으로 넘어온다. 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.
Bean Validation - groups
동일한 모델 객체를 수정할 떄와 등록할때 각각 다르게 검증하는 방법
1. BeanValidation의 groups 기능을 사용
2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어 사용
BeanValidation groups 기능 사용
등록과 수정과 같은 로직에서 차이가 있을경우 로직별 검증 방식을 다르게 하기 위해
groups 라는 기능을 사용한다.
저장용 groups 생성
package hello.itemservice.domain.item;
public interface SaveCheck {
}
수정용 groups 생성
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
Item - groups 적용
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정 요구사항 추가
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = {SaveCheck.class})
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
저장 로직에 SaveCheck Groups 적용
@PostMapping("/add")
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()) {
log.info("errors={} ", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
Form 전송 객체 분리
실무에서는 groups를 잘 활용하지 않는데
등록 시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
그래서 보통 Item을 직접 전달받는게 아니라 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어 전달한다.
Form 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
장점 : Item 도메인 객체를 컨트롤러, 리포지토리까지 직접전달
단점 : 간단한 경우에만 적용 가능
Form 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용하여 데이터 전달 받기 가능,
검증 중복 안된다.
단점 : 폼 데이터를 기반으로 하는 컨트롤러에서 Item 객체를 생성하는 변환 과정 추가
수정의 경우 등록과 수정은 완전 다른 데이터가 들어온다.
그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 받는게 좋다.
item 클래스 원복
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
ItemSaveForm
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
ItemUpdateForm
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
폼 객체 바인딩
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드가 아닌 복합 룰 검증
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
Item 대신에 ItemSaveForm을 전달받는다.
@Validated로 검증도 수행하고, BindingResult로 검증 결과도 받는다.
@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면
ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다.
폼객체를 Item으로 변환
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
Bean Validation - HTTP 메세지 컨버터
@ModelAttribute는 HTTP의 요청 파라미터(URL 쿼리스트링, POST Form)을 다룰 떄 사용한다.
@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다.
주로 API JSON 요청을 다룰때 사용
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
성공 요청
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10}
실패요청
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}
실패요청 결과
{
"timestamp": "2023-08-07T00:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/validation/api/items/add"
}
실패 요청 로그
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "A":
not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value at [Source: (PushbackInputStream); line: 1, column: 30] (through reference chain: hello.itemservice.domain.item.Item["price"])]
HttpMessageConverter에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그전에 예외가 발생
검증 오류 요청
HttpMessageConverter는 성공하지만 Validator 검증에서 오류가 발생하는 경우 요청하기
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
검증오류 결과
[
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "9999 이하여야 합니다",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 10000,
"bindingFailure": false,
"code": "Max"
}
]
return bindingResult.getAllErrors(); 는 ObjectError 와 FieldError 를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
이 객체들을 그대로 사용하지 말고 필요한 데이터만 뽑아 별도의 API 스펙을 정의하고
그에 맞는 객체를 만들어 반환해야 한다.
@ModelAttribute vs @RequestBody
@ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다.
특정 필드가 바인딩 되지 않아도 정상 바인딩 되고, Validator을 사용한 검증도 적용이 가능하다.
@RequestBody는 httpMessageConverter 단계에서 JsON데이터를 객체로 변경하지 못하면
단계 자체가 진행되지 않고 예외가 발생
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
'스터디 > 2023_스프링부트' 카테고리의 다른 글
[study] 스프링MVC2 - 6. 로그인 처리 - 필터 , 인터셉터 (0) | 2023.08.13 |
---|---|
[study] 스프링MVC2 - 5. 쿠키, 세션 (0) | 2023.08.13 |
[study] 스프링MVC2 - 3. 메세지 국제화 (0) | 2023.08.08 |
[study] 스프링MVC2 - 2. 타임리프 스프링 통합과 폼 (0) | 2023.08.08 |
[study] 스프링MVC2 - 1. 타임리프 (0) | 2023.08.08 |