Bean Validation
Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
검증 애노테이션과 여러 인터페이스의 모음이다.
마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. (ORM과 관련X)
검증 애노테이션 모음
Bean Validation 애노테이션 관련해서 뭐가 있는지 궁금하면 해당 사이트를 들어가보자.
2023.12.18 - [Spring] - Spring 검증 - BindingResult (FieldError, ObjectError, rejectValue, reject)
2023.12.19 - [Spring] - Spring 검증 - Validator
해당 글은 위의 두 글에 이어서 작성하는 것임을 참고해주길 바람
Bean Validation 설정
Bean Validation을 사용하려면 build.gradle에 다음 의존관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
의존 관계를 추가해주면 Jakarta Bean Validation 라이브러리가 추가 된다.
jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator 구현체
그래서 어떻게 사용하는 것일까? 애노테이션을 사용한다는게 무엇인지 예시 코드를 통해 확인해보자.
예시 코드
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
검증 애노테이션
- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null 을 허용하지 않는다.
- @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
- @Max(9999) : 최대 9999까지만 허용한다.
위의 애노테이션들과 같이 간단하게 애노테이션을 붙힘으로써 간단한 검증 기능을 사용할 수 있다.
스프링에 Bean Validator 적용
스프링 부트는 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합
스프링 부트는 자동으로 글로벌 Validator로 등록한다. LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
(@Valid, @Validated 둘 다 어느 것을 사용해도 무방하지만, @Validated 는 뒤에 설명할 groups 기능을 추가로 더 제공함)
이건 당연한 얘기인데
바인딩에 성공한 필드만 Bean Validation가 적용된다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
@ModelAttribute → 각각의 필드 타입 변환시도 → 변환에 성공한 필드만 BeanValidation 적용
에러 코드 - 메세지
위에서 말한 Bean Validation을 사용하면, 어떤 기본 메세지가 출력될까?
@NotBlank = 공백일 수 없습니다.
@NotNull = 널일 수 없습니다.
@Max(9999) = 9999 이하여야 합니다.
@Range(min = 1000, max = 1000000) =1000에서 1000000 사이여야 합니다.
위와 같이 출력된다.
이 기본 메세지는 어떻게 바꿀 수 있을까?
우리가 앞선 글에서 말했던 errors.properties를 기억할 것이다.
오류 코드를 통해 메세지를 수정해보자!
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면 오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch 와 유사하다.
Ex) @NotBlank
- 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} 은 필드명이다.
메세지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기(errors.properties)
- 애노테이션의 message 속성 사용 → @NotBlank(message = "공백! {0}")
- 라이브러리가 제공하는 기본 값 사용 → 공백일 수 없습니다.
애노테이션 message 사용 예시
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
Bean Validation - 오브젝트 오류
필드 오류는 위에서 본 듯이, 애노테이션을 통해 해결할 수 있다. 그러면 오브젝트 오류는 어떻게 해결할까?
두가지 방법이 있다.
- @ScriptAssert() 사용
- 자바 코드로 작성
Ex)
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item {
//...
}
애노테이션을 통해 간단하게 작성할 수 있다.
하지만, 이는 이렇게 서비스의 요구 사항이 간단할 때만 해당되고,
실무와 같이 복잡한 비즈니스 요구 사항을 맞추려고 하면 @ScriptAssert() 로는 부족하다.
때문에, 실무에선 자바 코드로 컨트롤러에 작성하는 걸 더 선호한다!!
Ex)
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
Bean Validation - 한계
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
(사실, 지금의 예시들은 간단한 프로젝트이기 때문에 등록, 수정의 구조가 비슷하지만, 실무에선 완전 다를 수 있음)
애노테이션의 설정을 변경해서는 이 문제를 해결할 수가 없다.
그러면 어떻게 해결 할 수 있을까?
두가지 해결 방안이 있다.
- BeanValidation의 groups 기능을 사용
- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용
Bean Validation - groups
저장용 groups 생성
package hello.itemservice.domain.item;
public interface SaveCheck {
}
수정용 groups 생성
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
Item.class 에 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 Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
애노테이션 설정에 groups를 추가해주고 앞서 만들어놓은 groups 인터페이스를 추가해주면 된다.
컨트롤러 - 저장 로직에 groups 기능 적용
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
@Validated(SaveCheck.class) 만 추가해주면 된다.
컨트롤러 - 수정 로직에 groups 기능 적용
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
//...
}
※ @Valid 는 groups 기능이 없기 때문에, @Validated 를 사용해야 함.
groups 기능을 적용함으로써, 등록과 수정시에 각각 다르게 검증을 할 수 있다.
사실 groups 기능은 실제 잘 사용되지 않는다.
그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다
Form 전송 객체 분리
실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다. 그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form → Item → Controller → Item → Repository
간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form → ItemSaveForm → Controller → Item 생성 → Repository
폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가
말이 이해가 안가면 코드를 보면서 이해해보자.
Item 클래스는 원복해놓고 아래와 같이 새로운 폼 클래스를 만들어보자.
저장용 Form
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
수정용 Form
@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 @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
view 템플릿에서 이미 나는 item으로 다 사용하고 있기 때문에 @ModelAttribute("item") 으로 넘겨준다.
이렇게 작성하지 않으면 ItemSaveForm 의 이름을 따서 "itemSaveForm" 으로 넘어가게 된다.
저장용 로직이므로, ItemSaveForm을 사용하고, Item 객체를 새로 만들어서 레포지토리에 저장해준다.
(레포지토리에 Item 객체로 저장하도록 작성해놨기 때문)
수정용 로직도 위와 같은 흐름으로 변경해주면 된다.
Bean Validation - HTTP 메시지 컨버터
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
- @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
- @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
즉, API JSON 요청 시에 Bean Validation 을 적용할 수 있다는 말이다.
API의 경우 3가지 경우를 나누어 생각해야 한다.
- 성공 요청: 성공
- 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
- 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
실패 요청과 검증 오류 요청에 대해서만 알아보면 된다.
실패 요청은 예를 들어, price 부분에 "A" 와 같은 문자열이 들어갔을 때, JSON 데이터에서 객체로 바꾸는 과정에서 오류가 발생할 때이다.
실패 요청 결과
{
"timestamp": "2021-04-20T00:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/validation/api/items/add"
}
HttpMessageConverter 에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
물론 Validator도 실행되지 않는다.
검증 오류 요청은 HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생하는 경우이다.
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
위의 데이터를 POST 방식으로 전송해보자. 우리는 quantity에 max로 9999를 두었기 때문에 검증 오류에 걸릴 것이다.
검증 오류 결과
[
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "9999 이하여야 합니다",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 10000,
"bindingFailure": false,
"code": "Max"
}
]
return bindingResult.getAllErrors(); 를 통해 모든 오류를 반환한다.
지금은 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다.
실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있다.
그러나,
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.
- @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다.
- 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
- @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다.
- 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
참고
HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 게시글을 통해 따로 설명할 예정이다.
정리
Bean Validation을 적용하면 검증 기능을 간단하게 사용할 수 있다.
폼 전송 객체는 등록과 수정 등을 위해 따로 분리하는 것이 좋다.
검증 기능은 오류가 발생하지 않고 바인딩이 되어야, 적용이 되며, 검증의 의미가 있다.
API 전송 등에서도 검증 기능을 적용할 수 있다.
'Spring' 카테고리의 다른 글
Spring - API 예외 처리 (HandlerExceptionResolver, ExceptionResolver ) 이론 (2) | 2024.01.01 |
---|---|
Spring(스프링) - 서블릿 예외 처리 및 오류 페이지(필터, 인터셉터) (1) | 2023.12.29 |
Spring 검증 - Validator (0) | 2023.12.19 |
Spring 검증 - BindingResult (FieldError, ObjectError, rejectValue, reject) (1) | 2023.12.18 |
스프링 메세지 소스 및 국제화(MessageSource, Locale) (0) | 2023.12.14 |