'스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 수업을 듣고 정리한 내용입니다.
상품 관리 시스템에 새로운 요구사항이 추가되었다.
요구사항: 검증 로직 추가
(1) 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
(2) 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
(3) 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
🗝 지금까지는...
- 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
- 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
- 아마도 이런 서비스라면 사용자는 금방 떠나버릴 것이다. 😱
- 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
- 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
💡 참고
클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
먼저 검증을 직접 구현해보고, 뒤에서 스프링과 타임리프가 제공하는 검증 기능을 활용해보자❗️
✓ 상품 저장 성공
redirect
한다.
✓ 상품 저장 검증 실패
이제 요구사항에 맞추어 검증 로직을 직접 개발해보자❗️
ValidationItemControllerV1 - addItem() 수정
@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<>();
➡️ 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 파악하기 위해 정보를 errors
에 저장해둔다!
✓ 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
➡️ 검증시 오류가 발생하면 errors
에 담아둔다.
➡️ 이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key
로 사용한다.
➡️ 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
✓ 특정 필드의 범위를 넘어서는 검증 로직
//특정 필드의 범위를 넘어서는 검증 로직
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
➡️ 특정 필드를 넘어서는 오류를 처리해야 할 수도 있다.
➡️ 이때는 필드 이름을 넣을 수 없으므로 globalError
라는 key
를 사용한다.
✓ 검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
➡️ 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 model
에 errors
를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.
실행 결과
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="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>
✓ css 추가
.field-error {
border-color: #dc3545;
color: #dc3545;
}
➡️ 이 부분은 오류 메시지를 빨간색으로 강조하기 위해 추가했다.
✓ 글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
➡️ 오류 메시지는 errors
에 내용이 있을 때만 출력하면 된다.
➡️ 타임리프의 th:if
를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있다.
💡 참고
Safe Navigation Operator
- 만약 여기에서
errors
가null
이라면 어떻게 될까?- 생각해보면 등록폼에 진입한 시점에는
errors
가 없다.- 따라서
errors.containsKey()
를 호출하는 순간NullPointerException
이 발생한다.
errors?.
errors
가null
일 때NullPointerException
이 발생하는 대신,null
을 반환하는 문법이다.th:if
에서null
은 실패로 처리되므로 오류 메시지가 출력되지 않는다.- 이것은스프링의 SpringEL이 제공하는 문법이다. 자세한 내용은 사이트를 참고하자!
✓ 가격 1000원, 수량 1개를 선택, HTML 결과 화면
✓ 필드 오류 처리
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
class="form-control">
➡️ classappend
를 사용해서 해당 필드에 오류가 있으면 field-error
라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조한다.
➡️ 만약 값이 없으면 _
(No-Operation)을 사용해서 아무것도 하지 않는다.
✓ 필드 오류 처리 - 입력 폼 색상 적용
<input type="text" class="form-control field-error">
✓ 필드 오류 처리 - 메시지
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
➡️ 글로벌 오류 메시지에서 설명한 내용과 동일하고, 필드 오류를 대상으로 한다.
실행
http://localhost:8080/validation/v1/items/add
📌 정리
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.
- 상품 수정의 검증은 더 효율적인 검증 처리 방법을 학습한 다음에 진행한다.
🔊 남은 문제점
- 뷰 템플릿에서 중복 처리가 많다.
- 타입 오류 처리가 안된다.
Item
의price
,quantity
같은 숫자 필드는 타입이Integer
이므로 문자 타입으로 설정하는 것이 불가능하다.
➡️ 숫자 타입에 문자가 들어오면 오류가 발생한다.- 이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에
➡️ 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.- 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다.
➡️ 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.
이제 스프링이 제공하는 검증 방법을 하나씩 알아보자!
앞서 만든 기능을 유지하기 위해, 컨트롤러와 템플릿 파일을 복사하자!
수업자료 참고
결과
- 지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자.
- 여기서 핵심은
BindingResult
이다.
ValidationItemControllerV2 - addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
// 검증 로직
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 파라미터의 위치
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {}
➡️ 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
에 담아두면 된다.
objectName
: @ModelAttribute
이름field
: 오류가 발생한 필드 이름defaultMessage
: 오류 기본 메시지
✓ 글로벌 오류 - ObjectError
bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
➡️ ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
➡️ 특정 필드를 넘어서는 오류가 있으면 ObjectError
객체를 생성해서 bindingResult
에 담아두면 된다.
objectName
: @ModelAttribute
의 이름defaultMessage
: 오류 기본 메시지
validation/v2/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>
✓ 타임리프 스프링 검증 오류 통합 기능
➡️ 타임리프는 스프링의 BindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields
: #fields
로 BindingResult
가 제공하는 검증 오류에 접근할 수 있다.th:errors
: 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if
의 편의 버전이다. th:errorclass
: th:field
에서 지정한 필드에 오류가 있으면 class
정보를 추가한다.
✓ 글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="$
{err}">전체 오류 메시지</p> </div>
✓ 필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
실행
- 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
BindingResult
가 있으면@ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가
호출된다!
ex) @ModelAttribute
에 바인딩 시 타입 오류가 발생하면?
BindingResult
가 없으면 → 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.BindingResult
가 있으면 → 오류 정보(FieldError
)를 BindingResult
에 담아서 컨트롤러를 정상 호출한다.
✓ BindingResult에 검증 오류를 적용하는 3가지 방법
(1) @ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError
생성해서 BindingResult
에 넣어준다.
(2) 개발자가 직접 넣어준다.
(3) Validator
사용 → 이것은 뒤에서 설명
✓ 타입 오류 확인
숫자가 입력되어야 할 곳에 문자를 입력해서 타입을 다르게 해서 BindingResult
를 호출하고 bindingResult
의 값을 확인해보자.
⚠️ 주의
BindingResult
는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다.- 예를 들어서
@ModelAttribute Item item
, 바로 다음에BindingResult
가 와야 한다.BindingResult
는Model
에 자동으로 포함된다.
org.springframework.validation.Errors
org.springframework.validation.BindingResult
BindingResult
는 인터페이스이고, Errors
인터페이스를 상속받고 있다.BeanPropertyBindingResult
라는 것인데, 두 인터페이스를 모두 구현하고 있으므로 BindingResult
대신에 Errors
를 사용해도 된다.Errors
인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.BindingResult
는 여기에 더해서 추가적인 기능들을 제공한다.addError()
도 BindingResult
가 제공하므로 여기서는 BindingResult
를 사용하자❗️BindingResult
를 많이 사용한다.
📌 정리
BindingResult
,FieldError
,ObjectError
를 사용해서 오류 메시지를 처리하는 방법을 알아보았다.- 그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자❗️
🔔 목표
- 사용자 입력 오류 메시지가 화면에 남도록 하자.
FieldError
,ObjectError
에 대해서 더 자세히 알아보자.
ValidationItemControllerV2 - addItemV2
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 로직
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 FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
❗️파라미터 목록 ❗️
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
(ObjectError
도 유사하게 두 가지 생성자를 제공한다.)
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
➡️ 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute
에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
➡️ 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer
타입이므로 문자를 보관할 수 있는 방법이 없다.
➡️ 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
➡️ 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
FieldError
: 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.rejectedValue
: 오류 발생시 사용자 입력 값을 저장하는 필드이다.bindingFailure
: 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 바인딩이 실패한 것은 아니기 때문에 false
를 사용한다.
✓ 타임리프의 사용자 입력 값 유지
th:field="*{price}"
th:field
동작 방식
FieldError
에서 보관한 값을 사용해서 값을 출력
✓ 스프링의 바인딩 오류 처리
FieldError
를 생성하면서 사용자가 입력한 값을 넣어둔다.BindingResult
에 담아서 컨트롤러를 호출한다.실행 결과
참고