본 포스팅은 인프런 - 스프링 MVC 2편을 강의를 바탕으로 공부하고 정리한 글입니다.
검증 기능을 매번 코드로 작성하는 것은 번거로운 일이다.
더욱 편리하게 검증을 하기 위해 검증 로직을 모든 프로젝트에 적용할 수 있도록 공통화 하고, 표준화 한 것이 Bean Validation이다.
Bean Validation을 사용하면 검증에 대한 제약조건을 어노테이션에 넣어 사용할 수 있다.
Bean Validation
- Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
- 쉽게 말해 검증 어노테이션과 여러 인터페이스의 모음이다.
- Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
❗참고(하이버 네이트 Validator 관련 링크)
- 공식 사이트
- 공식 메뉴얼
- 검증 어노테이션 모음
의존관계 추가 (gradle)
📁 build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
검증 어노테이션
@NotNull // 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotBlank // null을 허용하지 않는다.
@Range(min=__ , max=__) // 범위 안의 값이어야 한다.
@Max(__) // 최대 __까지만 허용한다.
검증 테스트
👉🏻 전체 코드
public class BeanValidationTest {
@Test
void beanValidation() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" "); //공백
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation.getMessage() = " + violation.getMessage());
}
}
}
# 테스트 결과
violation = ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = 1000에서 1000000 사이여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
violation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = 9999 이하여야 합니다
- 검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
- 검증 실행
Set<ConstraintViolation<Item>> violations = validator.validate(item);
검증 대상을 검증기에 넣고 그 결과를 받는다.
Set에는 ConstraintViolation이라는 검증 오류가 담기며, Set이 비어있다면 검증 오류가 없는 것이다.
지금까지는 빈 검증기를 직접 사용하는 방법을 알아봤다.
그렇다면 실제 스프링 MVC 검증 방법에서 빈 검증기를 어떻게 적용할 수 있을까?
스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.
따라서 우리는 가져다 쓰기만 하면 된다 😊
웹 어플리케이션에 적용
- 스프링 부트는 'spring-boot-starter-validation' 라이브러리를 넣으면, 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
- 스프링 부트는 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록하며, 이 Validator는 @NotNull과 같은 어노테이션을 보고 검증을 수행한다.
- 이렇게 글로벌 Validator가 자동으로 적용되기 때문에, 우리는 @Valid, @Validated만 등록해주면 된다.
- @Valid, @Validated가 있으면 등록되어 있는 글로벌 검증기를 사용해 검증을 진행한다.
- 검증 오류가 발생하면 FieldError, ObjectError를 생성해 BindingResult에 담아준다.
@Validated vs @Valid
@Validated는 스프링 전용 검증 어노테이션이고, @Valid는 자바 표준 검증 어노테이션이다.
검증 시 둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated는 내부에 groups 등 더욱 많은 기능을 포함하고 있다. (뒤에서 설명)
직접 글로벌 Validator를 등록해준 경우, 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않아 어노테이션 기반의 빈 검증기가 동작하지 않는다.
따라서 Bean Validator를 사용하고자 한다면, 직접 등록해준 글로벌 Validator는 제거해줘야 한다.
검증 순서
- @ModelAttribute가 있으면 requestparam을 모델 객체에 바인딩하여 넣어준다.
- 성공하면 다음으로
- 실패하면 typeMismatch로 FieldError 추가
- Validator를 적용한다.
이때 바인딩에 성공한 필드만 Bean Validation을 적용한다.
일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증에 의미가 있기 때문이다.
예를 들어, 다음과 같이 Item 객체가 있다고 생각해보자.
@Data
public class Item {
@NotBlank
private String itemName;
@NotNull
@Range(min=1000, max=1000000)
private int price;
}
- itemName에 문자 "A"를 입력 → 타입 변환 성공 → name 필드에 Bean Validation 적용
- prive에 문자 "A"를 입력 → 타입 변환 실패 → typeMismatch FieldError 추가 → Bean Validation 적용 X
오류 메시지 변경
Bean Validation이 기본으로 제공하는 오류 메시지를 변경하려면 어떻게 해야 할까?
우선 Bean Validation을 적용하면 어노테이션 이름으로 오류 코드가 등록된다.
예를 들어 @NotBlank라는 검증 오류가 발생했을 경우, 다음과 같이 NotBlank라는 오류 코드를 기반으로 MassageCodesResolver를 통해 다양한 메시지 코드가 생성된다.
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
따라서 이것들을 오류 메시지로 등록하여 메시지를 바꿔줄 수 있다.
오류 메시지로 등록하기
📂 errors.properties
# Bean Validation 추가
NotBlank = {0} 공백X
Range = {0}, {2} ~ {1} 허용
Max = {0}, 최대 {1}
- {0} : 필드명
- {1}, {2} ... : 각 어노테이션 마다 다른 값
메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource에서 찾아서 사용
- 어노테이션의 message 속성을 찾아 사용 ➡ 예) @NotBlank(message = "공백!")
- 라이브러리가 제공하는 기본 값 사용 ➡ "공백일 수 없습니다."
ObjectError 처리
@ScriptAssert
Bean Validation에서 특정 필드(FieldError)가 아닌 오브젝트 관련 오류(ObjectError)를 처리할 경우에는 @ScriptAssert()를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000") // _this : 본인 것
public class Item {
...
}
메시지 코드는 다음과 같이 생성된다.
- ScriptAssert.item
- ScriptAssert
실행해보면 정상 수행은 되지만, 이러한 방법은 제약이 많고 복잡하다.
또한 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그럴 경우 대응이 어렵다.
따라서 ObjectError의 경우 @ScriptAssert를 사용하는 것 보다는 ObjectError 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
직접 자바 코드 작성
📂 ValidationItemControllerV3
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
...
}
한계점
수정 시와 등록 시 검증 요구사항이 다음과 같이 다를 수 있다.
- 등록시
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- FieldError
- 상품명 : 필수, 공백X
- 가격 : 1000 이상, 1000000 이하
- 수량 : 최대 9999
- ObjectError
- 타입 검증
- 수정시
- 등록시에는 수량을 최대 9999까지 등록할 수 있지만, 수정 시에는 수량을 무제한으로 변경할 수 있다.
- 등록시에는 id에 값이 없어도 되지만, 수정 시에는 id값이 필수이다.
수정 시 검증 요구사항을 적용하기 위해 다음을 변경해준다.
- id : @NotNull 추가
- quantity : @Max(9999) 제거
실행하면 수정은 잘 동작하지만, 등록에서 다음과 같은 문제가 발생하게 된다.
- 상품을 등록할 때는 id값이 없기 때문에(디비에 등록되는 시점에 생성) @NotNull 검증 오류가 발생해 등록 불가능
- 수량 제한 최대 값인 9999가 적용되지 않음
결과적으로 item 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다는 한계가 있다.
이러한 한계점을 해결할 수 있는 두가지 방법을 소개하도록 하겠다.
해결1 - groups
Bean Validation의 속성 값으로 groups를 사용해 동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증해줄 수 있다.
👉🏻 모델 수정
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정 요구사항 추가
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private int price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = {SaveCheck.class}) // 수정 요구사항 추가
private Integer quantity;
}
👉🏻 컨트롤러 수정
@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item, BindingResult bindingResult) {
//...
}
- 등록 컨트롤러에 SaveCheck.class 적용
- 수정 컨트롤러에 UpdateCheck.class 적용
이렇게 모델 객체에 사용되는 Bean Validation에 groups를 지정해 주고, 컨트롤러에서 적용시키면 등록시와 수정시 각각 개별적으로 검증을 선택하여 진행할 수 있다.
결과적으로 id는 수정시에만 NotNull이라는 검증을 진행하며, quantity는 등록시에만 max=9999 검증을 진행하게 된다!
@Valid에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야 한다.
하지만, 이러한 groups 기능은 전반적으로 코드가 복잡해져 실무에서는 잘 사용되지 않는다 .
실무에서는 주로 다음에 소개 할 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하는 방법을 많이 사용한다.
해결2 - 폼 객체 분리(⭐)
실무에서는 등록시 폼에서 전달하는 데이터가 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 회원과 관련없는 수 많은 부가 데이터가 넘어온다.
따라서 보통 모델 객체(여기서는 Item)를 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어 전달한다.
예를 들어 ItemSaveForm이라는 html로부터 데이터를 전달 받는 전용 객체를 만들어 @ModelAttribute로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해 Item을 생성하는 것이다.
폼 데이터 전달 방법은 다음과 같이 두가지로 나뉠 수 있다.
- 폼 데이터 전달에 Item 도메인 객체 사용
- HTML Form ➡ Item ➡ Controller ➡ Repository
- 장점 : Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달하므로 중간에 Item을 만드는 과정이 없어 간단하다.
- 단점 : 간단한 경우에만 적용할 수 있으며, 수정 시 검증이 중복될 수 있고, groups를 사용해야 한다.
- HTML Form ➡ Item ➡ Controller ➡ Repository
- 폼 데이터 전달을 위한 별도의 객체 사용
- HTML Form ➡ ItemSaveForm ➡ Controller ➡ Item 생성 ➡ Repository
- 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해 데이터를 전달 받을 수 있다. 보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
- 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
- HTML Form ➡ ItemSaveForm ➡ Controller ➡ Item 생성 ➡ Repository
실제 회원 가입과 회원 수정을 생각해보면, 들어가는 내용이 완전 다르다는 것을 알 수 있다.
따라서 첫번째 방법보다 폼 데이터 전달을 위한 별도의 객체를 사용하는 두번째 방법을 사용하는 것이 좋다.
그럼 등록, 수정 용 폼 객체를 분리해 코드를 작성해보도록 하자.
👉🏻 등록용 폼 객체 생성
📂 web/calidation/form/itemSaveForm
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
👉🏻 수정용 폼 객체 생성
📂 web/calidation/form/itemUpdateForm
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000
private Integer price;
// 수정 시 수량은 자유롭게 변경 가능
private Integer quantity;
}
👉🏻 컨트롤러 수정
@PostMapping("/add")
public String addItem(@Validated @ModelAttribut("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
...
// 전달받은 등록 폼 데이터를 가지고 Item 생성
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
...
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("Item") ItemUpdateForm form, BindingResult bindingResult) {
...
// 전달받은 수정 폼 데이터를 가지고 Item 생성
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
...
}
- @ModelAttribute("item") ItemSaveForm form
- ModelAttribute 이름 생략시 ItemSaveForm의 경우 규칙에 의해 itemSaveForm이라는 이름으로 모델에 담기게 된다. 이렇게 되면 뷰 템플릿에서 모델에 접근하기 위한 th:object 이름도 함께 변경해줘야 한다.
- 여기서는 뷰 템플릿 변경 없이 item이라는 이름을 그대로 사용하기 위해 ModelAttribute에 item으로 이름을 지정해주었다.
- Item 대신에 폼 데이터를 전달 받는 별도의 객체(ItemSaveForm, ItemUpdateForm)를 전달받아, @Validated로 검증을 수행하고, BindingResult로 검증 결과를 받는다.
- 성공 로직에 폼 객체의 데이터를 기반으로Item 객체 변환하는 과정이 추가된다.
이렇게 Form 전송 객체를 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증 또한 명확하게 분리할 수 있다. !
HTTP 메시지 컨버터에 적용 (@RequestBody)
@Valid, @Validated는 HttpMessageConverter가 동작하는 @RequestBody에도 적용할 수 있다.
• @ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용
• @RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용, 주로 API JSON 요청을 다룰 때 사용
@RequestBody에 Bean Validation을 사용하는 방법을 코드로 알아보자.
👉🏻 컨트롤러
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMappin("/add")
public Object addItem(@Validated @RequestBody ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
// 검증 실패 로직
if (bindingResult.hasError())
return bindingResult.getAllErrors();
// 검증 성공 로직
log.info("성공 로직 실행")
return form;
}
}
- 포스트맨을 사용해 테스트를 진행
- 성공 요청 : {"itemName":"hello", "price":1000, "quantity":10}
- 실패 요청 : {"itemName":"hello", "price":"A", "quantity":10}
- price에 숫자가 아닌 문자를 전달해 검증에 실패한다.
- 이때는 컨트롤러 자체가 호출되지 않고 예외가 발생한다. (Json 데이터를 파싱해서 ItemSaveForm을 생성해야 하는데 하지 못함)
- 실패 요청 : {"itemName":"hello", "price":"1000", "quantity":10000}
- 수량은 최대 9999개까지 등록 가능하기 때문에 검증오류가 발생한다.
- Json 데이터를 전달받아 ItemSaveForm을 생성하는 데 성공하고, 검증을 수행한 뒤 bindingResult에 검증 결과를 담아 반환한다.
이렇게 API의 경우 3가지 경우를 나누어 생각해야 한다.
- 성공 요청 : 성공
- 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패, 다음 단계로 진행 불가
- 검증 오류 요청 : JSON을 객체로 생성하는 것은 성공, 검증에서 실패
@ModelAttribute vs @RequestBody
- ModelAttribute
- HTTP 요청 파라미터를 처리
- 각각의 필드 단위로 세밀하게 바인딩 된다.
- 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되어 검증을 수행할 수 있다.
- RequestBody
- 주로 JSON 데이터를 처리
- 필드 단위가 아닌, 전체 JSON 객체 단위로 바인딩 된다.
- 메시지 컨버터의 작동이 성공해 Item 객체를 만들어야 @Valid, @Validated를 수행할 수 있다.
- 메시지 컨버터 작동 단계에서 JSON 데이터를 객체로 변환하지 못하면 다음 단계로 진행이 불가능 해 예외가 발생한다. ➡ 컨트롤러 호출되지 않음, 검증 수행 불가
HttpMessageConverter 단계에서 실패하면 예외가 발생하는데, 예외 발생시 원하는 모양으로 예외를 처리할 수 있다.
'🌱 Spring > Web MVC' 카테고리의 다른 글
API 예외처리 (0) | 2022.05.19 |
---|---|
예외처리, 오류 페이지 (0) | 2022.05.16 |
로그인 (필터, 인터셉터) (0) | 2022.05.10 |
로그인 (쿠키, 세션) (0) | 2022.05.02 |
Validation (0) | 2022.04.26 |
타임리프(Thymeleaf) (1) | 2022.03.23 |
스프링 MVC 웹 페이지 만들기 (0) | 2022.03.22 |
로깅 (0) | 2022.03.18 |