강의 링크는 아래와 같습니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
저번 시간에는 Validation에 대해 공부했다. 이번에는 한 단계 나아간 Bean Validation에 대해 공부해보겠다.
먼저 저번 시간처럼 일일이 검증 로직을 매번 코드로 작성하면 번거로울 것이다. 우선 본격적으로 시작하기 전에 아래 코드를 보자.
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;
}
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다.
Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다. Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.
Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자. 먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법부터 테스트 코드로 확인해보자.
먼저 Bean Validation을 사용하려면 다음 의존 관계를 build.gradle에 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-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까지만 허용한다.
테스트 코드를 작성해보자.
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.message=" + violation.getMessage());
}
}
}
검증기를 생성한다. 이후 스프링과 통합하면 이런 코드는 직접 작성하지는 않는다. 참고만 하자.
검증 실행은 검증 대상인 Item을 직접 검증기에 넣고 그 결과를 받는다.
Set 에는 ConstraintViolation이라는 검증 오류가 담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다.
출력 결과를 학 인하면 다양한 정보를 확인할 수 있다.
이렇게 빈 검증 기를 검증 기를 직접 사용한다. 직접 사용하려면 꽤 귀찮을 것 같다. 그런 개발자들을 위해 스프링은 빈 검증 기를 이미 스프링에 완전하게 통합했다.
아래는 컨트롤러 코드다.
실행해보면 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다.
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError를 생성해서 BindingResult에 담아준다.
이제 검증 순서는 아래와 같다.
- @ModelAttribute 각각의 필드에 타입 변환 시도
- 성공하면 다음으로
- 실패하면 typeMismatch로 FieldError 추가
- Validator 적용
바인딩에 성공한 필드만 Bean Validation이 적용된다.
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩받는 값이 정상으로 들어와야 검증도 의미가 있다.)
@ModelAttribute -> 각각의 필드 타입 변환 시도 -> 변환에 성공한 필드만 BeanValidation 적용
예)
itemName 에 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용
price 에 문자 "A" 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 ->
price 필드는 BeanValidation 적용 X
Bean Validation - 에러 코드
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보자. 오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch와 유사하다.
NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.
@NotBlank
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
@Range
- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
이제 저번 시간처럼 errors.properties에 메시지를 등록하자.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
실행하면 방금 등록한 메시지가 정상 적용되는 것을 확인할 수 있다.
BeanValidation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.
@NotBlank(message="공백은 입력할 수 없습니다.")
private String itemName;
Bean Validation - Object errors
Bean validation에서 특정 필드가 아닌 해당 오브젝트 관련 오류는 @ScriptAssert를 사용하면 된다.
결론부터 말하자면 제약도 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어선다고 하는데, 그런 경우 대응이 어렵다고 한다.
그래서 선생님께서는 오브젝트 오류 관련 부분만 직접! 자바 코드로 작성하는 것을 권장한다고 하신다.
위에서 언급은 안했지만 위 컨트롤러 메서드는 오브젝트 오류는 못 잡고 필드 오류만 잡았다. 이제 코드를 수정하면 오브젝트 오류를 잡을 수 있다. 어디서 많이 본 코드인 건 저번 시간에 사용한 자바 코드이기 때문이다.
@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);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
상품 수정에도 Bean Validation을 적용할 수 있다. 코드는 addItem 메서드와 비슷하다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item
item, BindingResult bindingResult) {
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
이런 코드로 상품 수정이 구현이 가능하다. 타임리프는 addForm과 유사하니 생략하겠다.
그러나 여기서 사소하지만 생각해 볼 것이 있다.
우리는 Item 클래스를 동일하게 사용하지만 데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
예를 들어 상품을 등록할 때는 id가 필요 없지만, 상품을 수정할 때는 id가 필요하다고 생각해보자. 이런 경우가 발생할 수 있다. 이러면 요구 사항이 달라서 Item 클래스에서 검증 애노테이션을 걸어야 할 때 문제가 발생한다. 왜냐하면 상품을 등록할 때는 id가 필요 없지만 상품 수정 때는 id 프로퍼티가 @NotEmpty가 걸려야 하기 때문이다.
이제 이런 문제를 해결하기 위한 2가지 방법을 살펴보겠다.
해결 방법은 아래 2가지다.
- BeanValidation의 groups 기능을 사용한다.
- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
먼저 groups에 대해 알아보자. 이런 문제를 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정 시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
먼저 그룹을 만들어보자.
//저장용 groups 생성
public interface SaveCheck {
}
//수정용 groups 생성
public interface UpdateCheck {
}
다음은 groups 기능을 더한 Item 클래스다.
@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;
}
}
마지막으로 메서드 2개를 아래와 같이 수정한다.
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
//...
}
@Validated에 수정된 코드를 확인할 수 있다. 이제 groups 기능을 통해 등록과 수정 시에 각각 다르게 검증할 수 있다.
그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다. 사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. 소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다.
하지만 실무에서는 회원 등록 시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수많은 부가 데이터가 넘어온다. 그
래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.
수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입 시 다루는 데이터와 수정 시 다루는 데이터는 범위에 차이가 있다. 예를 들면 등록 시에는 로그인 id, 주민번호 등등을 받을 수 있지만, 수정 시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는 것이 좋다.
Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다고 한다. 그리고 더 나아가서 Item을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups를 적용할 일은 드물다.
즉 클래스를 여러 개 만들어서 역할을 부여하는 것이다. 간단명료하다!!!!
Bean - Validation HTTP 메시지 컨버터
@Valid , @Validated는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
컨트롤러를 하나 만들자.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form,
BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
API의 경우 3가지 경우를 나누어 생각해야 한다.
- 성공 요청: 성공
- 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
- 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
itemName에 문자열을 넣고 price, quantity에 숫자를 넣어서 전달하면 잘 동작한다.
이제는 price에 숫자가 아닌 문자를 넣어서 전달해보자.
그러면 HttpMessageConverter에서 요청 JSON을 Item 객체로 생성하는데 실패한다.
이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그전에 예외가 발생한다.
물론 Validator도 실행되지 않는다.
이번에는 수량을 10000으로 정하고 보내보자. 이전에 @Max(9999)로 Bean Validation을 설정해서 여기서 걸릴 것이다.
그러면 return bindingResult.getAllErrors(); 는 ObjectError와 FieldError를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
@ModelAttribute vs @RequestBody
HTTP 요청 파리 미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.
@ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 적용할 수 있다.
@RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
참고
HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 공부해보겠다.
이상 포스팅을 마칩니다!!
댓글