검증은 어떻게 이루어질까. (@Valid, @Validated)
스프링은 직접 Validatior를 만들지 않아도 간단히 검증을 할 수 있는 라이브러리를 제공한다.
* 만약 직접 등록할 시에는 Bean Validator를 글로벌 Validator로 등록하지 않아 애노테이션 기반의 빈 검증기가 동작하지 않는다.
🔩 bulid.gradle 라이브러리 추가
자동으로 글로벌 Validator로 등록한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
🔩 FieldError, ObjectError
정확한 설명아니지만 내가 이해한 바로는
FieldError는 하나의 필드에 대한
ObjectError는 필드 하나를 넘어서 필드 값을 곱한 값에 대한 검증을 필요한다거나 할 때 필요한 생성자이다.
public FieldError(String objectName, String field, String defaultMessage) {}
public ObjectError(String objectName, String defaultMessage) {}
* bindingResult.addError(new FieldError()); 이런 식으로 사용
if (!StringUtils.hasText(item.getName())) {
bindingResult.addError(new FieldError("item", "name",
item.getName(), false, null, null, "이름은 필수입니다."));
}
이런 식으로 필드별로 하나하나 다 작성해주어야 하는데 그렇게 되면 Controller의 코드도 너무 길어지고 복잡해진다. 또 이런 검증 기능은 기본적으로 사용하기 때문에 기본적으로 자바나 스프링에서 제공하는 검증 라이브러리를 사용한다.
🔩 @Valid, @Validated 라이브러리
1. @Valid - Java 표준
2. @Validated - 스프링 전용
🔩 애노테이션으로 검증 (@NotNull, @Range 등)
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
Hibernate Validator 6.2.5.Final - Jakarta Bean Validation Reference Implementation: Reference Guide
Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th
docs.jboss.org
* 대상 : @ModelAttribute에서 바인딩 성공한 필드들만!
예시) 예시) age 필드에 "Merry" 라는 String 값을 넣으면
해당 필드만 typeMismatchFieldError가 추가되면서 Bean Validation은 적용이 되지 않는다. (각각 필드 단위로 적용)
- Domain
@Data
public class Item {
@NotNull
private Long id;
@NotBlank
private String name
@NotNull
@Range(min = 1, max = 150)
private Integer age;
@NotNull
@Max(value = 9999)
private Integer etc;
public Item() {
}
public Item(String name, Integer age, Integer etc) {
this.name = name;
this.age = age;
this.etc = etc;
}
}
* 애노테이션에서 message 속성 사용 가능 - @NotBlank(message = "공백 안돼!")
* FieldError가 아닌 ObjectError는 @ScriptAssert()을 사용할 수 있지만 권장하지 않음 (자바 코드로 직접 작성 '복합 룰 검증 ')
- Controller
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "test/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("name", savedItem.getName());
redirectAttributes.addAttribute("status", true);
return "redirect:/test/{id}";
}
* 복합 룰 검증 : a 값과 b값을 곱한 값에 대한 검증을 부여하는 등 복합 룰 검증이 필요하다면 자바 코드로 작성하며 method로 분리하는 것을 권장한다.
- errors.properties
#Bean Validation 추가
NotBlank={0} 공백 안됨
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
실무를 할 때엔 회원가입 시, 회원수정 시 넘겨아 하는 데이터가 다르다.
하나의 모델 객체에서 검증 룰이 다르다면 어떻게 해야할지 알아보자.
결론은 사뭇 비슷하다고 하나의 객체를 사용할 수도 있지만 깔끔하게 다르게 넘기는 것을 추천한다.(2번)
1. BeanValidation groups (복잡도 상승 및 실무에서 사용 거의 안함)
생성용, 수정용 Interface를 각각 만들어서 (실습은 domain 폴더에 생성함)
스프링에서 제공하는 groups 기능을 사용한다.
Interface는 내용없이 파일만 생성했고
Domain 모든 필드에 적용할 Interface를 작성하고
Controller 메소드에 @Validated(UpdateCheck.class)에 작성한다.
* Javax 라이브러리 @Valid로는 해당 기능을 사용하지 못하므로 참고바람.
- Domain
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
}
- Controller
@PostMapping("/{id}/edit")
public String editV2(@PathVariable Long id, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
//...
}
2. 폼 전송을 위한 별도 모델 객체 생성
각 생성, 수정 시 주고 받는 데이터들이 다음과 같다고 치자.
Field | 생성 | 수정 |
id | X | O |
name | O | O |
age | O | O |
Controller가 있는 경로에 form 패키지를 추가하여 생성, 수정 폼 모델 객체를 새로 만들었다.
- Domain
@Data
public class ItemSaveForm {
@NotBlank
private String name;
@NotNull
@Range(min = 1, max = 150)
private Integer age;
}
@Data
public class ItemUpdateForm {
@NotNull
private long id;
@NotBlank
private String name;
@NotNull
private Integer age; // 수정 시에는 범위 검증X
}
- Controller
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult,
RedirectAttributes redirectAttributes, Model model) {
log.info("bindingResult.getObjectName() => {}",bindingResult.getObjectName()); // item
log.info("bindingResult.getTarget() => {}", bindingResult.getTarget()); // toString() override
// domain에서 @ScriptAssert으로 검증할 수 있으나 자바 코드 권장
// object 에러는 자바 코드로, field error는 Bean Validation으로 진행 권장
// 특정 필드가 아닌 복합 룰 검증 (method로 뽑아 쓰는거 권장)
// 여기가 복합 룰 검증 코드
// 검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()){
log.info("bindingResult = {}", bindingResult);
// model에 자동으로 담아줌
return "test/addForm";
}
// 성공 로직
Item item = new Item();
item.setName(form.getName()); // 원래는 GETTER, SETTER 없이 생성자로 하는 것이 좋다.
item.setAge(form.setAge());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/test/{id}";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증 (method로 뽑아 쓰는거 권장)
// 여기가 복합 룰 검증 코드
// 검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()){
log.info("bindingResult = {}", bindingResult);
// model에 자동으로 담아줌
return "test/editForm";
}
// 성공 로직
Item itemParam = new Item();
item.setName(form.getName()); // 원래는 GETTER, SETTER 없이 생성자로 하는 것이 좋다.
item.setAge(form.setAge());
itemRepository.update(itemId, itemParam);
return "redirect:/test/{id}";
}
* 복합 룰 검증 코드는 설계에 따라 다르므로 주석 처리함.
>> 생성 시 코드만 따로 보자
1) @ModelAttribute("item") ItemSaveForm form
Item 객체가 아닌 ItemSaveForm 객체에 담아야한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult,
RedirectAttributes redirectAttributes, Model model) {
//...
}
이럴 경우 Item 객체로 변환하는 과정이 필요하다. (이런 과정이 복잡하게 느껴서 폼을 하나로 하면 그러다 더 망할 확률이 높다.)
2) 폼 객체를 Item 객체로 변환
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult,
RedirectAttributes redirectAttributes, Model model) {
// 검증에 실패하면 다시 입력 폼으로
if(bindingResult.hasErrors()){
log.info("bindingResult = {}", bindingResult);
return "test/addForm";
}
// 성공 로직
Item item = new Item();
item.setName(form.getName()); // 원래는 GETTER, SETTER 없이 생성자로 하는 것이 좋다.
item.setAge(form.setAge());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/test/{id}";
}
Item 객체 생성해서
Form객체로 부터 생성자 OR getter, setter로 넣어준 Item 객체 값을 Repositroy에 보내야한다!
🔩 HTTP 메시지 컨버터
@Valid, @Validated 적용 가능
- Controller (Domain은 동일)
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult){
// ItemSaveForm 객체가 만들어지지 않으면 controller 호출 x
log.info("api controller 호출");
if(bindingResult.hasErrors()){
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors(); // 실무에서는 필요한 것만 뽑아서 객체 만들어서 반환 필요
}
log.info("성공 로직 실행");
return form;
}
1. 타입 오류 : 400 에러
예시) age 필드에 "Merry" 라는 String 값을 넣으면
ItemSaveForm객체 자체가 만들어지지 않아 Bean Validation은 적용이 되지 않는다. Json 객체가 만들어지지 않았으니 당연히 Controller 호출도 되지 않는다. (전체 객체 단위로 적용)
2. 검증 오류
모든 값을 다 뽑아내서 Body에 출력한 화면이다.
rejectedValue 등 필요한 데이터만 뽑아서 API 개발 필요하다.
+) 타임리프 사용 시 화면에 오류 표출 부분
<div>
<label for="name" th:text="#{label.item.name}">이름</label>
<input type="text" id="name" th:field="*{name}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{name}">
이름 오류
</div>
</div>
처음 공부를 하고 게시판을 만들었을 때 입력 폼과, 수정 폼 비슷한데 한번에 쓰면 되지 않나? 라는 생각을 했던 적이 있었다. 그런 단계에서 이 강의를 봤으면 말끔하게 생각 정리를 할 수 있었을 것 같다.
Validaion 챕터는 세부 단계로 나눠서 따라가다 보니 긴가민가했었는데 Bean Validation에서는 확실히 이해가 쉬웠다. ModelAttribute는 각각 필드 단위로 이루어지고 RequestBody에서는 Json 객체가 만들어지지 않으면 Controller를 태우지 않고 검증 단계로 이어가지 않는다는 정리까지도 좋았다. 생각보다 많은 어노테이션으로 검증을 할 수 있었다. 여기 실무 프로젝트에서는 직접 Validator를 작성해서 검증하는 것으로 보이는데 한번 조사를 해봐야겠다!
>> 내가 담당하고 있는 프로젝트에는 없어서 동료 프로젝트 validator를 간략하게 확인해봤는데
로직에 RequestValidator.validateSample(SampleRQmsg); 메소드를 날려서
RequestValidator class에서는 if문으로 다 분기처리해서 각 검증 룰에 맞게 contant로 관리되는 에러코드를 넣어주고 rs하고 있었다.