먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름이 하이버네이트가 붙어서 그렇지 ORM 과는 관련이 없다.
공식 API docs를 보면 다양한 검증 애노테이션이 제공되니 참고하면 된다.
Bean Validation
기능은 라이브러리를 추가해서 사용해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.5.2</version>
</dependency>
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
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을 허용하지 않는다.@Max(최댓값)
: 최댓값 초과를 허용하지 않는다.@Range(min, max)
: 범위 안의 값이어야 한다. 여기서 @Range
은 org.hibernate.validator
에 있는 검증기능인데, 실무에서는 대부분 하이버네이트 validator
를 사용하기에 사용을 자유롭게 해도 된다.
테스트 코드를 작성해서 Bean Validator
가 동작하는지 확인해 보자.
@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>> validate = validator.validate(item);
for (ConstraintViolation<Item> violation : validate) {
System.out.println("violation = " + violation);
System.out.println("violation.getMessage() = " + violation.getMessage());
}
}
물론 실무에서 위와 같이 Validator를 꺼내서 검증을 수행하진 않고 테스트 목적으로 임의로 validator를 꺼내서 검증을 해보았다.
violation={interpolatedMessage='공백일 수 없습니다',
propertyPath=itemName,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
violation={interpolatedMessage='9999 이하여야 합니다',
propertyPath=quantity,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다
violation={interpolatedMessage='1000에서 1000000 사이여야 합니다',
propertyPath=price,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다
스프링 부트에서는 검증 실패 시 위와 같은 violation 인스턴스
를 이용해 결과를 반환한다.
스프링 부트가 spring-boot-starter-validation
라이브러리를 넣으면 자동으로 Bean Validator
를 인지하고 스프링에 통합한다.
LocalValidatorFactoryBean
이 글로벌 Validator
로 등록되며 위에서 사용해 봤던 @NotNull
과 같은 애노테이션 검증을 수행한다. 또한 검증 오류 발생 시 FieldError
, ObjectError
를 생성해 BindingResult
에 담아준다.
상품 엔티티의 각각의 필드에 대해서 다음과 같은 요구사항이 있다고 하자.
위 요구사항을 상품 엔티티(Item)에 Bean Validation
을 적용하면 다음과 같다.
@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;
}
}
검증 애노테이션을 실제로 동작해서 검증하려면 컨트롤러에서 받고자 하는 Request
객체에 검증 애노테이션을 붙여주면 된다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
...
}
컨트롤러에 @Validated
검증 애노테이션을 붙이고, 검증결과를 담기 위해 BindingResult
클래스를 바로 다음 위치에 매개변수로 받아주고 있다. 이처럼 컨트롤러를 작성하면 스프링에서 자동으로 엔티티에 적용된 검증 애노테이션을 수행한다.
@ModelAttribute
각각의 필드에 타입 변환 시도a. 성공하면 다음 필드 진행
b. 실패하면 typeMismatch
로 FieldError
추가
Validator
적용즉, 각각의 필드에 바인딩이 된 필드만 Bean Validation
이 적용된다.
예를 들어 Item
의 price
필드는 Integer
타입이다. 그런데 웹 페이지에서 QQQ
라는 문자열을 전송해 타입 변환을 시도할 경우 typeMismatch
가 발생하여 FieldError
가 추가된다.
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까❓
Bean Validation을 적용하고 bindingResult
에 등록된 검증 오류 코드를 보자. 오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch
와 유사하다.
NotBlank
라는 오류 코드를 기반으로 MessageCodesResolver
를 통해 다양한 메시지 코드가 순서대로 생성된다.
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
Range.item.price
Range.price
Range.java.lang.Integer
Range
메시지 코드에 메시지를 직접 등록해 주면 적용한 메시지가 적용될 것이다.
NotBlank={0} 공백은 유효하지 않습니다.
Range={0}, {2}~{1}만 허용됩니다.
Max={0}, 최대{1}까지만 허용됩니다.
{0}
은 필드명이고 {1}
, {2}
,... 은 각 애노테이션마다 다르다.
물론 properties에 설정하는 게 아니라 직접 애노테이션에 message 속성으로 지정할 수도 있다.
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
messageSource
에서 메시지 찾기message
속성 사용 👉 @NotBlank(message = "공백! {0}")
각각의 필드에 대해서 검증을 했다면 이번에는 객체를 검증해 보자.
객체에 대한 검증은 다음과 같은 예를 말한다.
가격과 수량의 합은 10000원 이상이어야 한다.
하나의 필드에 붙일 수 없는 이런 로직 상의 검증은 두 가지 방법으로 해결할 수 있다.
클래스 레벨에 @ScriptAssert
애노테이션을 활용하여 이런 객체 로직도 검증할 수 있다.
사용하는 방법은 다음과 같다.
@Data
@ScriptAssert(lang = "javascript",script = "_this.price * _this.quantity >= 10000")
public class Item {
...
}
실제로 수행해 보면 제대로 나오는 것을 확인할 수 있으며 다음과 같은 순서로 메시지 코드도 찾는다.
ScriptAssert.item
ScriptAssert
하지만, 이 방식은 다음과 같은 이유로 실무에서 잘 사용되지 않는다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert
을 억지로 사용하는 것보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장된다.
Bean Validation
을 이용하여 검증 애노테이션을 활용하자.우리가 지금까지 해 본 코드는 상품 등록(POST)에 대한 부분이었고, 검증까지 무사히 완료했다.
그렇다면 상품 수정(Fetch or Put)은 어떨까? 실제로 실무에서는 상품에 대한 제약사항이 등록일 경우와 수정일 경우에 달라질 수 있다.
예를 들어, 상품 등록 시에는 아직 등록이 되지 않았기에 아이디(id)가 존재하지 않지만, 수정 시에는 이미 등록된 상품을 수정하는 것이기에 id가 null 이어서는 안된다(NotNull) 또한, 상품 등록 시에는 수량을 1~9999개까지만 허용했지만, 등록 후에는 그 외의 값으로 수정을 해도 제약이 없도록 할 수도 있다.
하지만 이런 변경된 제약조건은 지금 앞서 작성한 상품 엔티티에서는 적용이 불가능하다.
그렇다고 수정에 맞춰서 아이디 필드에 @NotNull
검증 애노테이션을 붙이고 수량 필드에 @Max
애노테이션을 지우면 수정은 의도한 대로 동작할지 몰라도 상품 등록 시 아직 존재하지 않는 게 당연한 아이디가 null
이기에 검증 오류가 날 것이고 수량도 9999개를 넘는 숫자를 넣어도 문제가 발생하지 않을 것이다.
이처럼 상황에 따라 달라지는 검증 조건은 어떻게 적용해야 할까❓
스프링에서는 다음과 같이 두 가지 방법으로 이를 해결할 수 있다.
groups
기능 사용ItemSaveForm
, ItemUpdateForm
)결론부터 얘기하자면 groups는 한계가 명확하다.
Bean Validation
은 위와 같은 검증 모델이 상황에 따라 달라지는 것에 맞춰 적용될 수 있게 groups
라는 기능을 제공한다.
우선 상품 등록과 상품 수정을 구분할 것이기에 ItemSave
, ItemUpdate
인터페이스를 작성하자.
ItemSave
, ItemUpdate
Intefacepublic interface SaveCheck {}
public interface UpdateCheck {}
@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 = 1_000_000)
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;
}
}
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
...
}
addItem
에서는 상품 저장이기에 @Validated
애노테이션에 속성으로 SaveCheck.class
를 사용했다. editV2
에서는 상품 갱신이기에 @Validated
애노테이션 속성으로 UpdateCheck.class
를 사용했다.Item
객체에서는 각각 @Validated
애노테이션에 작성된 인터페이스가 선언된 검증만 수행한다. 하지만, 이 방식은 사실 잘 사용되지 않는다.
그 이유는 해당 애노테이션 자체가 문제가 있는 것은 아니고 등록, 수정 시 전달되는 내용이 상품 도메인 객체(Item)과 딱 일치하지 않기 때문이다.
예를 들어, 회원 가입을 한다고 할 땐 회원 정보에 더해 약관 정보 같은 추가 정보가 있을 수 있고 아직 등록하지 않기에 존재하지 않는 정보들도 있을 것이다. 그리고, 이런 엔티티를 사용자에게 노출시키는 것은 보안상으로도 문제가 많다. 그렇기에 노출시켜도 되는 필드를 모아 View 객체를 만들어 이를 통해 데이터를 주고받고는 한다.
@Valid
검증 애노테이션은 groups
라는 속성이 없기 때문에 해당 기능을 사용할 수 없다.
그렇기에 이 기능을 사용하기 위해서는 @Validated
를 사용해야 한다.
이 방식은 요약하면 다음과 같다.
즉, 각각에 상황에 맞는 전용 폼 객체를 따로 만들어서 상황에 맞는 검증을 하고, 전송 객체이기에 사용자에게 노출해도 상관이 없는 객체가 된다. 물론, 이렇게 구현할 경우 도메인 객체로 한 번 더 변환을 해서 등록이든 수정이든 해야 한다는 추가 과정이 생기지만, 이 과정을 줄이고자 엔티티를 그대로 사용하는 것보다 장점이 더 크다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
private Integer price;
// 수정일 경우 제약은 사라진다.
private Integer quantity;
}
@PostMapping("/add")
public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form,
BindingResult bindingResult) {
...
}
이 역시 동작해 보면 제대로 동작할 것이다.
뿐만 아니라 상품 등록 시와 수정 시 각각 상황에 맞는 검증도 제대로 분리되어 검증된다.
근데 몇 가지 ❗ 주의점이 있다.
이전과 다르게 컨트롤러에서 @ModelAttribute
에 item
이라는 value
속성을 작성해 줬다. 만약 이를 작성해 주지 않으면 규칙에 따라 MVC Model
에는 itemSaveForm
라는 이름으로 담기게 된다. 그렇게 되면 기존에 뷰 템플릿에서 th:object
이름을 item
으로 선언해 줬는데 이를 itemSaveForm
으로 수정해 줘야 한다.
폼 객체를 기반으로 Item
객체를 생성 및 수정해야 하기 때문에 변환 과정이 작성돼야 하는데,
폼 객체와 도메인 객체 간의 커플링을 최소한으로 할 수 있도록 설계에 주의해야 한다.
보통 폼 객체와 같은 DTO
에서 도메인을 의존하는 것은 괜찮지만 반대의 경우는 괜찮지 않다.
의존의 방향은 변경이 많은 곳에서 변경이 적은 곳으로 향하는 게 바람직하다.
Bean Validation
의 groups
기능과 DTO
를 이용한 전송 객체 분리가 있다. groups
보다는 전송 객체 분리를 권장한다. 지금까지는 Form을 이용한 페이지 이동 방식에서 검증을 했다.
하지만, ajax
, fetch
, axios
등등 프론트 영역에서 API JSON을 요청하는 경우는 어떨까❓
@Valid
, @Validated
는 HttpMessageConvert(@RequestBody
)에서도 사용할 수 있다.
URL 쿼리 스트링
, POST Form
)을 다룰 때 사용.HTTP Body
의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용. @Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@Validated @RequestBody ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
POST http://localhost:8080/validation/api/items/add
Content-Type: application/json
{"itemName":"hello", "price": 1000,"quantity": 10}
API의 경우 다음과 같은 3가지 경우가 발생할 수 있다.
성공하는 상황은 원래 문제가 아니기에 괜찮다.
검증 오류 요청은 내가 의도한 검증에 걸려 실패한 것이기에 괜찮다. 검증 실패 내역도 BindingResult
클래스에 들어있기 때문에 적절히 꺼내 담아 반환하면 된다. 이는 폼 전송 객체를 이용한 방식에서도 동일하기에 문제 될 것이 없다.
그런데 HttpMessageConverter
에서 요청 JSON
을 객체로 생성하는 것 자체가 실패하는 경우는 문제다. 지정한 객체(ex: Item
)로 만들지 못하기 때문에 컨트롤러 호출이 되지 않기 때문에 Validator
도 실행되지 않는다.
vs
@RequestBody어째서 폼 전송 방식으로 할 때 @ModelAttribute
를 사용할 때는 타입이 불일치해도 발생하지 않는 문제가 @RequestBody
를 사용할 때는 발생하는 것일까❓
HTTP
요청 파라미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용되기에 특정 필드가 타입이 맞지 않더라도 나머지 필드를 정상 처리할 수 있다.
하지만, HttpMessageConverter
는 @ModelAttribute
과는 다르게 필드 단위가 아닌 객체 전체 단위로 적용되기 때문에 메시지 컨버팅이 성공해서 객체가 만들어진 다음에나 검증 애노테이션(@Valid
, @Validated
)이 적용된다.