<Spring MVC> 검증1 - Validation
Intro
웹 애플리케이션에서 폼 입력시 숫자를 문자로 작성하는 등의 검증 오류가 발생해서 오류 화면으로 바로 이동하게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 이 경우 사용자 입장에선 매우 불편하다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려줘야 한다.
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
📌 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨줘야 한다.
검증 과정
- 사용자가 상품 등록 페이지에 접근(HTTP GET /add)
- 사용자가 상품 정보를 입력 후 서버로 전송(HTTP POST /add)
- 상품이 성공적으로 등록된 후 Location 정보로 상품 정보 상세 경로를 Redirect로 응답
- 클라이언트에선 응답 받은 정보에 있는 Location 정보로 Redirect하여 신규 상세 페이지로 이동
- 사용자가 상품 등록 페이지에 접근(HTTP GET /add)
- 사용자가 상품 정보를 입력 후 서버로 전송(HTTP POST /add)
- 상품의 유효성 검증이 실패하며 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동
검증에 실패하는 경우?
Null
TypeMissMatch
- 비즈니스 요구사항에 맞지 않음
다양한 검증 방식
검증을 하는 방식은 다양하다. 단순히 Map
에 에러 내용을 담아 모델에 넣어 반환하는 방식도 있고, BindingResult
를 사용하여 담아 보낼수도 있고, Validator
라는 인터페이스를 구현하여 사용하는 방식도 있다. 더 나아가 애노테이션 기반으로 편리하게 검증하는 방식도 있다.
Map
서버에서 전달받은 데이터를 직접 검증하여 Map
에 담아 RedirectAttributes
에 담아 보내는 방법
@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() > 100000) {
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()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
// 성공 로직
Item saveItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", saveItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
검증시 오류가 발생하면 errors
에 담아둔다. 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key
로 사용한다. 이후 뷰에서 이 데이터를 사용해 사용자에게 오류 메시지를 출력할 수 있다.
하지만, 이 방식은 타입 오류 처리가 안된다. Item
의 price
, quantity
같은 숫자 필드는 타입이 Integer
이므로 문자 타입으로 설정하는 것이 불가능하다. 이런 오류는 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 Bad Request
에러가 발생하면서 오류 페이지를 띄워준다.
타입 에러가 발생하더라도 오류 페이지로 넘기지 않고 사용자가 입력한 문자를 화면에 남겨야 한다.
BindingResult
BindingResult
는 스프링이 제공하는 검증 오류 처리 방법이다.
이를 사용하면 컨트롤러의 매핑 메서드에서 타입 불일치에 대한 대응도 가능해진다.
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
// 기타 검증 로직
if (item.getPrice() == null || item.getPrice < 1000 || item.getPrice() > 100000) {
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 saveItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", saveItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
필드에 오류가 있으면 FieldError
객체를 생성해서 bindingResult
에 담아둔다.
objectName
:@ModelAttribute
이름field
: 오류가 발생한 필드 이름defaultMessage
: 오류 기본 메시지
public FieldError(String objectName, String field, String defaultMessage) {}
특정 필드를 넘어서는 글로벌 오류인 경우 ObjectError
객체를 이용해 담는다.
public ObjectError(String objectName, String defaultMessage) {}
thymeleaf 에선 다음과 같이 사용한다.
<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>
</form>
thymeleaf는 스프링의 BindingResult
를 활용해서 편리하게 검증 오류를 포현하는 기능을 제공한다.
#fields
:BindingResult
가 제공하는 검증 오류에 접근이 가능하다.th:errors
: 해당 필드에 오류가 있는 경우 태그를 출력한다.th:if
의 편의 버전이다.th:errorclass
:th:field
에서 지정한 필드에 오류가 있으면class
정보를 추가한다.
BindingResult
는 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다. BindingResult
가 있으면 @ModelAttribute
에 바인딩 시 타입 오류가 발생해도 컨트롤러가 호출된다.
게다가 BindingResult
는 Model
에 자동으로 포함된다.
하지만, 오류가 발생하는 경우 사용자가 입력한 내용이 모두 사라진다.
FieldError, ObjectError
FieldError
는 두 가지 생성자를 제공한다. (ObjectError
도 유사함)
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
: 기본 오류 메시지
다음과 같은 방식으로 사용하면, 오류 발생 시 사용자 입력 값이 유지된다.
new FieldError("item", "price", item.getPrice(), false, null, null,
"가격은 1,000 ~ 1,000,000 까지 허용합니다.");
오류가 발생한 경우 사용자의 입력 값을 보관하는 별도의 방법이 필요하고, 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력해야 한다. rejectedValue
가 바로 오류 발생 시 사용자 입력 값을 저장하는 필드이다. bindingFailure
는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 바인딩이 실패한 것이 아니면 false
를 사용한다.
오류 코드와 메시지 처리
FieldError
, ObjectError
Spring Boot가 오류 메시지를 구분하기 쉽게 파일로 만드는 방법이다.
해당 메시지 파일을 인식할 수 있도록 다음 설정을 추가한다.
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}
이 후 다음과 같이 코드를 변경하면 해당 에러 메시지를 사용할 수 있다.
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false,
new String[] {"required.item.itemName"}, null, null));
생성자의 인자를 살펴본다면,
codes
:required.item.itemName
을 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.arguments
:Object[] {1000, 100000}
을 사용해서 코드의{0}, {1}
로 치환할 값을 전달한다.
이런 방식은 매우 번거롭고, 담아야 할 속성도 너무 많다. messages
의 이름을 매번 다 적는 것도 불편하다.
BindingResult
의 rejectValue()
, reject()
를 사용해서 FieldError
, ObjectError
를 직접 생성하지 않고 편리하게 처리하는 방식을 사용해보자.
rejectValue()
, reject()
이전 방식보다 더 깔끔하게 처리가 가능하다.
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
errors.properties
에 있는 코드를 직접 입력하지 않았는데 어떤 방식으로 가져오는 것일까?
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명errorCode
: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니라MessageCodesResolver
를 위한 오류 코드이다.)errorArgs
: 오류 메시지에서{0}
을 치환하기 위한 값defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
MessageCodesResolver
스프링에서 제공하는 마커 인터페이스인 MessageCodesResolver
는 다음과 같은 메서드가 정의되어 있다.
public interface MessageCodesResolver {
String[] resolveMessageCodes(String errorCode, String objectName);
String[] resolveMessageCodes(String errorCode, String objectName,
String field, @Nullable Class<?> fieldType);
}
이 인터페이스의 기본 구현체로 DefaultMessageCodesResolver
를 제공하기 때문에 이를 이용해서 각종 메시지에 대한 대처가 쉽게 가능하다.
MessageCodesResolver
는 메시지의 단계에 따라 범용성이 낮은 순서에서 높은 순서대로 차례대로 찾으면서 처음 매칭되는 결과를 가져온다.
#level 1
required.item.itemName: 상품 이름은 필수입니다.
#level 2
required: 필수 값 입니다.
errors.properties
가 위처럼 작성되어 있다면 리졸버는 디테일한 순서부터 차례대로 찾는다.
- 객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
ex) 오류 코드: required, object name: item
1.: required.item
2.: required
- 필드 오류
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name
2.: code + "." + field
3.: code + "." + field type
4.: code
ex) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
스프링이 직접 만든 오류 메시지 처리
우리가 정의한 오류 코드는 rejectValue()
를 직접 호출해서 담아준다.
타입 정보 불일치와 같이 스프링이 직접 검증 오류에 추가한 경우도 있는데 이는 사용자에게 노출해선 안되는 메시지까지 노출하게 된다.
BindingResult
를 확인하면 FieldError에 다음과 같은 메시지 코드가 생성되어 추가되었다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
기본 메시지를 사용자에게 노출하지 않기 위해 다음과 같이 errors.properties
에 메시지를 선언해준다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
Validator 분리
스프링에선 검증에 필요한 Validator
라는 인터페이스를 정의해두었다. 이를 활용해서 검증 로직을 분리하여 모듈화 하자.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
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() < 100 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
Item.class.isAssignableFrom(clazz)
: 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미한다.Errors errors
: 매개 변수 타입인 Errors는 BindingResult 클래스의 부모 타입이기 때문에 공변성이 성립한다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
//...
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
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}";
}
}
컨트롤러에 있던 많은 검증 로직이 ItemValidator
로 분리되었기 때문에 컨트롤러에서는 validate
메서드 호출로 검증이 가능해졌다.
애노테이션 기반 분리
스프링에선 Validator
인터페이스를 구현해서 검증로직을 만들면 추가적으로 애노테이션을 사용하여 검증을 수행할수도 있다. WebDataBinder
를 이용하는 방법인데, 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스이다. 이 객체에 직접 만든 검증기를 추가하면 자동으로 검증기 적용이 가능해진다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
databinder.addValidators(itemValidator);
}
}
이런식으로 WebDataBinder
에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
// @InitBinder
: 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증에 실패하면 다시 입력 폼으로
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
애노테이션을 사용해서 Item의 검증 로직을 수행해준다.- 이 애노테이션이 붙으면
WebDataBinder
에 등록한 검증기를 찾아서 실행한다. - 여러 검증기를 등록한다면 그 중 어떤 검증기가 실행되어야 할지 구분이 필요한데, 이때
supports()
가 사용된다.
@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) {...}
}
📌 @Validated, @Valid
javax.validation.@Valid
를 사용하려면 build.gradle
에 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
을 추가하자.
@Validated
는 스프링 전용 검증 애노테이션이고, @Valid
는 자바 표준 검증 애노테이션이다.
References
Author And Source
이 문제에 관하여(<Spring MVC> 검증1 - Validation), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다
https://velog.io/@songs4805/Spring-MVC-검증1-Validation
저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념
(Collection and Share based on the CC Protocol.)
Author And Source
이 문제에 관하여(<Spring MVC> 검증1 - Validation), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@songs4805/Spring-MVC-검증1-Validation저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)