validation 필요
- Controller의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것
- 클라이언트 검증 + 서버 검증
- 클라이언트 검증은 조작이 가능하므로 보안에 취약하다.
- 서버만으로 검증하면 즉각적인 고객 사용성이 부족해진다.
- 둘이 적절히 섞어서 사용하되, 최종적으로 서버 검증은 빌수이다.
- API 방식에 경우 API 스펙에 맞게 검증오류를 겨롸에 담아 잘 넘겨주어야 한다.
- process
- GET /items/add <-> 상품 등록 화면 전달
- POST /items.add <-> 상품 저장 후 redirect /items/{itemId}
- GET / items/{id} <-> 상품 상세
- 상품 저장 당시 validation을 진행한 후 validation에 실패하면 다시 상품 등록 화면을 전달해야 한다.
V1. Spring에서 제공하는 기능을 사용하지 않는 경우
Map<String, String> errors = new HashMap<>();
if(StringUtils.hasText(item.getName()) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
if(item.getPrice() < 100 && item.getPrice() > 100000) {
errors.put("price", "상품 가격은 100원 이상 100000원 미만입니다..");
}
if(!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "/items/add";
}
- 문제점 1. 타입 바인딩.
- price를 Integer로 구현했을 때, 고객 입력 값이 String이면 바인딩 오류가 발생한다.
- 문제점 2. 복잡한 소스.
V2. BindingResult 사용
- Spring이 제공하는 검증 오류 처리 방법
@GetMapping("/items/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirect) {
if(StringUtils.hasText(item.getName()) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
}
if(item.getPrice() < 100 && item.getPrice() > 100000) {
bindingResult.addError(new FieldError("item", "price", "상품 가격은 100원 이상 100000원 미만입니다."));
}
if(bindingResult.hasErrors() {
log.info("errors: {}", bindingResult);
return "items/add";
}
Item savedItem = itemRepository.save(item);
redirect.addAttribute("itemId", savedItem.getId());
return "redirect:/items/{itemId}";
}
- FieldError, ObjectError
- FieldError: 필드에 오류가 있는 경우 객체를 생성하여 BindingResult 객체에 넣어주면 된다.
- ObjectError: 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성하여 BindingResult 객체에 담아두면 된다.
- 생성자 파라미터
- objectName: 오류가 발생한 객체 이름
- field: 오류 필드
- rejectedValue: 사용자가 입력한 값(거절 이유)
- bindingFailure: 타입 오류 같은 비인딩 실패인지, 검증 실패인지 구분 값
- codes: 메시지 코드
- arguments: 메시지에서 사용하는 인자
- defaultMessage: 기본 오류 메시지
- 바인딩 시점에서 오류가 발생하면 모델 객체에서 사용자가 입력한 값을 유지하기 어렵지만 FieldError, ObjectError를 사용하면 저장하는 기능을 제공한다.
- rejectedValue가 오류 발생시 저장하는 필드
- codes, arguments 로 Message 처리가 가능하다.
- 또는 bindingResult.rejectValue()에 errorCode를 넣어준다. errorCode는 required만 설정해도 관련한 메시지 코드를 생성한다.(MessageCodeResolver)
- ex) errorCode: required -> required.item.itemName, required.itemName, required.java.lang.String, required 순으로 생성
- BindingResult가 없으면 400오류가 발생하여 Controller가 호출되지 않고, 오류 페이지로 이동한다.
-
BindingResult가 있으면 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다.
- BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
- 개발자가 직접 넣어준다.
- Validator 사용
- 타입 오류 확인
- 숫자가 입력되어야 할 곳에 문자를 입력해서 타입을 다르게 해서 BindingResult 를 호출하고 bindingResult 의 값을 확인해보자.
- 주의
- BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.
- BindingResult 는 Model에 자동으로 포함된다.
- BindingResult와 Errors
- org.springframework.validation.Errors
- org.springframework.validation.BindingResult
- BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
- BeanPropertyBindingResult(구현체)는 BindingResult, Errors 를 구현하고 있기 때문에 둘 다 사용할 수 있다.
- Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.
- BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다. addError() 도 BindingResult 가 제공하므로 여기서는 BindingResult 를 많이 사용한다.
- 주로 관례상 BindingResult 를 많이 사용한다.
V3. Validator 분리
- Controller에 validation, controller 소스가 복잡하게 섞여있기 때문에 분리해주는 것이 좋다.
- ItemValidator.java
@Component public class ItemValidator extends Validator { @Override public boolean supports(Class<?> clazz) { return Item.class.isAssingalbeFrom(clazz); } @Override public void validate(Objec target, Errors errors) { Item item = (Item) target; if(StringUtils.hasText(item.getName()) { errors.rejectValue("itemName", "상품 이름은 필수 입니다."); } if(item.getPrice() < 100 && item.getPrice() > 100000) { errors.rejectValue("price", "상품 가격은 100원 이상 100000원 미만입니다."); } } }
- supports: 해당 검증기를 지원하는 여부 확인
- validate: 검증 대상 객체와 bindingResult
- ItemController.java
@Slf4j @Controller public class ItemController { @Autowired private ItemValidator itemValidator; @PostMapping("/items/add") public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirect) { itemValidator.validate(item, bindingResult); if(bindingResult.hasErrors() { log.info("errors: {}", bindingResult); return "items/add"; } Item savedItem = itemRepository.save(item); redirect.addAttribute("itemId", savedItem.getId()); return "redirect:/items/{itemId}"; } }
- WebDataBinder 사용
- WebDataBinder를 사용하면 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함된다.
-
WebDataBinder 에 여러개의 validator를 등록할 때 내부에서 Validator를 구분짖기 위해 support를 정의해주어야 한다.
- ItemController에 추가
@Slf4j @Controller public class ItemController { @Autowired private ItemRepository itemRepository; @Autowired private ItemValidator itemValidator; @InitBinder public void init(WebDataBinder binder) { log.info("init binder: {}", binder); dataBinder.addValidators(itemValidator); } @PostMapping("/items/add") public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirect) { if(bindingResult.hasErrors() { log.info("errors: {}", bindingResult); return "items/add"; } Item savedItem = itemRepository.save(item); redirect.addAttribute("itemId", savedItem.getId()); return "redirect:/items/{itemId}"; } }
Bean Validation
- 검증 기능을 매번 코드로 작성하기 번거롭기 때문에 일반적인 로직은 annotation으로 사용할 수 있으며 표준화한 것을 Bean Validation이라고 한다.
- Bean Validation 2.0(JSR-380) 이라는 기술 표준과 Hibernate Validator라는 구현체가 있다(ORM 과는 관련이 없다.)
- Jakarta Bean Validation: Bean Validation 인터페이스로 hibernate-validator 구현체
- dependency
// Gradle implementation 'org.springframework.boot:spring-boot-starter-validation' // Maven <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
- 검증 annotation
public class Item { private Long id; @NotBlank(message = "공백X") private String itemName; @NotNull @Range(min = 1000, max = 100000) private Integer price; @NotNull @Max(9999) private Integer quantity; }
- @NotBlank: 빈값 + 공백만 있는 경우 허용하지 않는다.
- @NotNull: Null값을 허용하지 않는다.
- @Range(min = 1000, max = 10000): 범위 안의 값이 있어야 한다.
- @Max(9999) : 최대 9999까지만 허용한다.
- message 속성을 통해 validation에 위배되는 경우 나타낼 메시지를 설정해 줄 수 있다.
- 에러 코드로 속성에 설정된 메시지를 보여주는 방법 가능
- Bean Validation 적용
- @InitBinder + WebDataBinder에 validator를 넣는 소스 제거 필요
- 오류 검증기가 중복 적용
- Spring 적용 원리
- dependency를 추가하면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
- 자동으로 Validator로 등록한다.
- LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
- 해당 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행
- 검증이 필요할 때 파라미터에 @Valid(javax 제공) 또는 @Validation(spring 제공)을 적용하면 된다.
- 검증 순서
- @ModelAttribute + @Valid or @Validation 각각의 필드에 타입 변환 시도
- 실패할 경우 typeMismatch로 FieldError 추가
- Validator 적용
- @ModelAttribute + @Valid or @Validation 각각의 필드에 타입 변환 시도
- @InitBinder + WebDataBinder에 validator를 넣는 소스 제거 필요
- 에러 코드
- validation 애노테이션 명 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.
- 예시1. @NotBlank
- NotBlank.item.itemName -> NotBlank.itemName -> NotBlank.java.lang.String -> NotBlank
- 예시2. @Range
- Range.item.price -> Range.price -> Range.java.lang.Integer -> Range
- 예시1. @NotBlank
- 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
- 애노테이션의 message 속성 사용 @NotBlank(message = “공백! {0}”)
- 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다
- validation 애노테이션 명 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.
- BeanValidation - groups
- 한계: ID에 @NotNull 적용이 필요하다. 하지만 등록할 때에는 Id가 바로 채번되지 않기 때문에 form에서 넘어올 때 validation 오류가 발생한다.
- 해결 방법 1. BeanValidation의 groups의 기능 사용
- 해결 방법 2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateFrom 같이 폼전송을 위한 별도의 모델 객체를 만들어 사용
- groups 적용
- @Validated에 적용되는 분류 별 interface 를 정의하고, Item에 groups를 정의하면 된다.
- @Valid에는 적용이 불가능하다
- SaveCheck interface
public interface SaveCheck { }
- UpdateCheck interface
public interface UpdateCheck { }
- Item.java에 groups 속성 정의
public class Item { private Long id; @NotBlank(message = "공백X", groups=UpdateCheck.class) private String itemName; @NotNull @Range(min = 1000, max = 100000, groups= {UpdateCheck.class, SvaeCheck.class}) private Integer price; @NotNull @Max(9999 , groups=SaveCheck.class) private Integer quantity; }
- handler 파라미터에 @Validated에 value 설정
// ... public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult result) { // ... } public String editItem(@Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult result) { // ... } // ...
- @Validated에 적용되는 분류 별 interface 를 정의하고, Item에 groups를 정의하면 된다.
- Form 별로 전달하고자 하는 데이터가 다르기 때문에 groups 를 적용하기 보다는 Form 별로 요청 객체를 분리하여 사용한다.
- 한계: ID에 @NotNull 적용이 필요하다. 하지만 등록할 때에는 Id가 바로 채번되지 않기 때문에 form에서 넘어올 때 validation 오류가 발생한다.
- Form 객체 분리
- Item.java 원복
public class Item { private Long id; private String itemName; private Integer price; private Integer quantity; }
- ItemSaveForm.java
public class ItemSvaeForm { @NotBlank(message = "공백X") private String itemName; @NotNull @Range(min = 1000, max = 100000) private Integer price; @NotNull @Max(9999) private Integer quantity; }
- ItemUpdateForm.java
public class ItemUpdteForm { @NotBlank private Long id; @NotBlank(message = "공백X") private String itemName; @NotNull @Range(min = 1000, max = 100000) private Integer price; private Integer quantity; }
- Item.java 원복
출처
- 김영한님의 Spring MVC 2 편 - 백엔드 웹 개발 활용 기술