요구사항: 검증 로직 추가
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.
참고: 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
검증 직접 처리 - 소개
상품 저장 검증 실패
목표
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.
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()) {
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 errors = new HashMap<>();
만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다
검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
검증시 오류가 발생하면 errors 에 담아둔다.
이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다.
이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해
model 에 errors 를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.
남은 문제점
- 타입 오류 처리가 안된다.
이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에
컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다. - 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
만약 컨트롤러가 호출된다고 가정해도 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고
고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다 - 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.
지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자. 여기서 핵심은 BindingResult이다.
ValidationItemControllerV2 - addItemV1
@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() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
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
// Field Error 생성자
// public FieldError(String objectName, String field, String defaultMessage) {}
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.
- objectName : @ModelAttribute 이름
- field : 오류가 발생한 필드 이름
- defaultMessage : 오류 기본 메시지
글로벌 오류 - ObjectError
//ObjectError 생성자
//public ObjectError(String objectName, String defaultMessage) {}
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.
- objectName : @ModelAttribute 의 이름
- defaultMessage : 오류 기본 메시지
BindingResult
- 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
- BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다
만약 @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
- BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동한다.
- BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.
남은 문제점
BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다.
그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.
@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() > 10000) {
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 생성자
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 : 기본 오류 메시지
오류 발생시 사용자 입력 값 유지
new FieldError("item", "price", item.getPrice(), false,
null, null, "가격은 1,000 ~1,000,000 까지 허용합니다.")
여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.
그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.
따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
오류 코드와 메시지 처리1
오류 메시지를 체계적으로 다루어보자.
ValidationItemControllerV2 - addItemV3() 추가
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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() > 10000) {
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}";
}
errors.properties 추가
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
FieldError 예시
//FieldError의 2번째 생성자
public FieldError(
String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable Object[] arguments,
@Nullable String defaultMessage
)
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false,
new String[] {"range.item.price"}, new Object[]{1000, 1000000}
)
- codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다.
메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데
순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. - arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
rejectValue() , reject()
BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면
FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다
ValidationItemControllerV2 - addItemV4() 추가
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
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}";
}
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
파라미터
- field : 오류 필드명
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다.
뒤에서 설명할 messageResolver를 위한 오류 코드이다.) - errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
예시)
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.
따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용한다.
reject()
void reject(String errorCode, @Nullable Object[] errorArgs,
@Nullable String defaultMessage);
rejectValue() 와 파라미터 설명은 같다.
MessageCodesResolver
- 검증 오류 코드로 메시지 코드들을 생성한다.
- MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
- 주로 다음과 함께 사용 : ObjectError , FieldError
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 처럼 구체적인 것을 먼저 만들어주고,
required 처럼 덜 구체적인 것을 가장 나중에 만든다
왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고,
정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
ValidationUtils
스프링이 제공하는 ValidationUtils 클래스를 사용해보자.
ValidationUtils 사용 전
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
동작 방식
- rejectValue() 호출
- MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
- new FieldError() 를 생성하면서 메시지 코드들을 보관
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;
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
if (item.getPrice() == null ||
item.getPrice() < 1000 ||
item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
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 {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
- supports() {} : 해당 검증기를 지원하는 여부 확인
- validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
ItemValidator 직접 호출하기
ValidationItemControllerV2 - addItemV5()
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 를 스프링 빈으로 주입 받아서 직접 호출했다.
@Validated 적용
ValidationItemControllerV2 - addItemV6()
@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 는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다.
그런데 여러 검증기를 등록하면 그 중 어떤 검증기가 실행되어야 할지 구분이 필요하다.
이때 supports() 가 사용되고 여기서는 supports(Item.class) 호출되고,
결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.
'백엔드 > 강의' 카테고리의 다른 글
[MVC 2편] 섹션 5. 검증2 - Bean Validation (0) | 2023.08.15 |
---|---|
[MVC 2편] 섹션 3. 메시지, 국제화 (0) | 2023.08.15 |
[MVC 1편] 섹션 7. 스프링 MVC - 웹 페이지 만들기 (0) | 2023.08.07 |
[MVC 1편] 섹션 6. 스프링 MVC - 기본 기능 (0) | 2023.08.01 |
[MVC 1편] 섹션 5. 스프링 MVC - 구조 이해 (0) | 2023.08.01 |