Spring/Spring MVC

Bean Validation

민철킹 2021. 7. 14. 22:10

대부분의 특정 필드에 대한 검증 로직은 일반적인 로직이고 공통적이다.

이런 검증 로직을 모든 프로젝트에 적용하도록 공통화, 표준화한것이 바로 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 2.0(JSR-380)이라는 기술 표준.

검증 애노테이션과 여러 인터페이스의 모음. 이를 구현한 기술 중 일반적으로 사용되는 구현체는 Hibernate Validator이다.

 


먼저 이를 사용하기 위해서는 build.gradle에 의존관계를 추가시켜준다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

build.gradle에 추가를 완료하고 refresh가 되었다면 위와 같이 jakarta.validation-apihibernate-validator가 라이브러리에 추가되어 있는 것을 확인할 수 있다.

jakarta.validation-api가 Bean Validation 인터페이스이고 hibernate-validator가 바로 구현체이다.

 

이제 직접 애노테이션을 사용해보자.

@NotBlank : 빈값, 공백만 있는 경우를 허용하지 않음

@NotNull : null을 허용하지 않음

@Range : 범위를 설정

@Max : 최대값을 설정

 

제대로 검증이 되는지 테스트해보자.

아이템의 필드들을 허용되지 않는 값들로 설정하고 검증기에 넣었다.

ConstraintViolation에 여러 값들이 담겨 있는 것을 확인할 수 있는데 만약 이것이 비어있다면 오류가 없는 것이다.

오류 메시지 정보는 기본값으로 설정되어 있는 것이다.

 

스프링은 이미 개발자들을 위해 빈 검증기를 스프링에 통합시켜놓았다!!!

 


앞서 검증기를 직접 만들어 사용하고 @InitBinder를 통해 검증기를 추가해주는 방식으로 진행하였다. 하지만 스프링은

이미 이 과정을 통합시켜놓았다.

코드 삭제

검증기를 삭제하고 

서버를 띄워 상품을 저장해보자.

??!! 검증기를 추가하거나 만든적이 없는데 검증 메시지까지 보여지고 검증이 되고 있다.

이것은 Item에 @NotBlank, @NotNull 등 검증 애노테이션을 등록해놨기 때문이다.

 

스프링 부트는 validation 라이브러리를 넣으면 자동으로 Bean Validator를 스프링에 통합해주는데,  LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.

이 Validator는 @NotNull같은 애노테이션을 보고 검증을 수행한다. 글로벌 Validator로 등록되어 있기 때문에 @Valid, @Validated만 적용하면 된다.(@Valid, @Validated가 있으면 검증을 수행할 검증기가 필요한데 스프링 부트가 자동으로 글러벌 Validator를 등록해놓았기 때문에 그것이 사용된다는 의미)

따라서, 검증 오류가 발생하면 FieldError, ObjectError를 BindingResult에 담아준다.

@Valid ==> 자바 표준

@Validated ==> 스프링 전용

 

검증 순서

1. @ModelAttribute가 객체의 각 필드에 값을 넣어준다.

     - 실패하면 typeMismatch로 FieldError 추가

2. Validator 적용

 

==> 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다. 타입 변환에 성공해서 바인딩이 성공해야만 검증에 의미가 있음.

 


에러 코드

 

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶다면?

에러 코드를 살펴보자.(@NotBlank인 상품명에 공백을 입력했다.)

codes 배열을 살펴보면 앞서 공부했던 MessageCodesResolver를 통해 메시지 코드들이 생성되는 것과 동일하다.

@NotBlank

- NotBlank.item.itemName

- NotBlank.itemName

- NotBlank.java.lang.String

- NotBlank

 

typeMismatch 에러 메시지의 기본값을 커스텀하기 위해 추가했던 것과 매우 유사하다.

즉, errors.properties에 생성되는 메시지 코드를 작성하여 추가시켜주면 되는 것이다!!

error.properties

{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.

입력한 커스텀한 메시지가 잘 출력되고 있는 것을 확인할 수 있다. 

우선순위(level)을 통해 여러 값을 설정할 수 있다. 

 


오브젝트 오류

 

Bean Validation에서 오프젝트 관련오류(Object Error)는 @ScriptAssert()를 사용하여 처리한다.

하지만 이 방식은 제약이 많고 복잡하다. 검증 기능이 해당 객체의 범위를 넘어서는 경우에는 대응이 어렵다.

오브젝트 오류에 @ScriptAssert를 사용하는 것보다 직접 자바 코드로 작성하는 것을 권장

컨트롤러에 추가

 


한계

 

데이터를 등록할 때와 수정할 때의 검증이 달라야한다면?

 

등록 시에는 수량이 최대 9999개이지만, 수정시에는 수량이 제한이 없고, 등록 시에는 id에 값이 없어도 되지만, 수정시에는 id값이 필수라면?

 

 

수정 요구사항에 맞춰 변경하면 등록 또한 이 검증 로직이 적용되기 때문에 문제가 발생한다.

즉, item은 등록과 수정에서 검증 조건의 충돌이 발생한다.

 

어떻게 해결해야할까?

 


groups

 

동일한 모델 객체를 상황에 따라 각각 다르게 검증하는 방법을 알아보자.

 

1. Bean Validation의 groups 기능을 사용

2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어 사용

 

먼저 groups 기능부터 ㄱㄱ

먼저 각각의 그룹을 interface로 생성한다.

Item

그 뒤에 검증 애노테이션의 groups 속성을 통해 위와 같이 구분짓는다.

저장 컨트롤러
수정 컨트롤러

@Validated의 value속성에 위와 같이 지정할 그룹을 넣어주면 해당 그룹으로 설정되어 있는 것만 검증을 진행한다.

이것은 @Valid에서는 제공하지 않는 기능임.(@Validated에서만 지원하는 기능)

 

사실 이 기능보다는 폼 객체를 따로 분리하는 방식을 많이 사용한다.

 


Form 전송 객체 분리

 

groups를 잘 사용하지 않는 이유는 바로 등록시 품에서 전달하는 데이터가 Item 도메인 객체와 딱 맞아 떨어지지 않기 때문이다.

 

회원 등록시 회원과 관련된 데이터만 받는 것이 아니라 약관 정보 등등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.

따라서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어 전달한다. 예를 들어 ItemSaveForm, ItemUpdateForm이라는 전용 객체를 만들어 @ModelAttribute로 사용한다. 이것을 통해 데이터를 전달받고 필요한 데이터만 사용하여 Item 객체를 생성, 수정하는 방식이다!!

 

폼 데이터 전달에 도메인 객체 직접 사용

HTML Form -> 도메인 객체 -> Controller -> 도메인 객체 -> Repository- 장점 : 도메인 객체를 Controller, Repository까지 직접 전달햇거 중간에 도메인 객체를 만드는 과정이 없어 간단- 단점 : 간단한 경우에만 적용 가능, 수정시 검증이 중복될 수 있고, groups 기능을 사용해야함

 

폼 데이터 전달을 위한 별도의 객체 사용

HTML Form -> 별도의 객체 -> Controller -> 도메인 객체 생성 -> Repository- 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 객체를 사용해서 데이터를 전달받을 수 있음. 보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않음.- 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가됨. 

 

위와 같이 별도의 검증로직을 가진 각각의 Form 객체를 생성한다.(등록용, 수정용)

 

컨트롤러

만든 폼 객체를 컨트롤러에서 사용했다. ItemSaveForm을 통해 @ModelAttribute를 사용하고 받은 값을 통해 새로운 아이템을 생성해준 뒤 저장해주었다.(원래는 생성자를 통해 값을 넘기고 Item을 생성해야함. setterX)

뷰에서 item으로 바인딩되어 사용하고 있기 때문에 이를 유지하기 위해 Model에 담긴 객체 명을 item으로 지정해주었다.

지정해주지 않으면 model.addAttribute("itemSaveForm", form)으로 모델에 값이 담긴다.

 


HTTP 메시지 컨버터

 

@Valid와 @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.

@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용(API JSON)

API용 컨트롤러

JSON을 전달받아 검증 오류가 발생하면 오류를 출력하고 검증에 성공하면 JSON을 그대로 전달하는 API 컨트롤러를 @RestConroller로 만들었다.

검증이 잘 동작하는지 오류 데이터를 전송해보자.

가격에 문자열을 전송해보았다.

2021-07-16 17:20:47.330  WARN 9800 --- [nio-8080-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "안녕": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "안녕": not a valid Integer value
 at [Source: (PushbackInputStream); line: 3, column: 13] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]

400 Bad Request가 발생했고 로그에 다음과 같은 메시지가 찍혔는데, 요약하자면 JSON 파싱에 오류가 발생했다고 한다.

또한 컨트롤러 호출 자체가 되지 않았다. JSON이 ItemSaveForm 객체로 변환해야하는데 그 과정에서 타입오류가 발생하여 애초에 컨트롤러 호출까지도 가지 못했다

 

즉, API의 경우에는 3가지 경우를 나누어 생각해야한다.

1. 성공 요청(200)

2. 실패 요청(400) : JSON을 객체로 생성하는 것 자체가 실패(컨트롤러 호출X)

3. 검증 오류 요청 : JSON을 객체로 생성했지만 검증에서 실패

 

이번에는 검증 오류를 발생시켜보자.

수량의 MAX는 9999

2021-07-16 17:28:46.734  INFO 9800 --- [nio-8080-exec-7] h.i.w.v.ValidationItemApiController      : API 컨트롤러 호출
2021-07-16 17:28:46.734  INFO 9800 --- [nio-8080-exec-7] h.i.w.v.ValidationItemApiController      : 검증 오류 발생 errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [10123]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]

JSON이 ItemSaveForm로 생성되고 컨트롤러를 호출했으나 검증 오류가 발생했다는 것을 로그와 respose 응답을 통해 알 수 있다. 실제 개발시에는 위와 같이 getAllErrors가 아닌 필요한 데이터만 뽑아 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야한다.

 

@ModelAttribute vs @RequestBody

@ModelAttribute는 각각의 필드 단위로 세밀하게 적용되므로 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있다.

하지만 HttpMessageConverter는 전체 객체 단위로 적용되기 때문에 MessageConverter의 작동이 성공하여 객체를 만들어야 @Valid, @Validated가 적용된다.

 

 

반응형

'Spring > Spring MVC' 카테고리의 다른 글

필터, 인터셉터  (0) 2021.07.21
쿠키, 세션  (0) 2021.07.17
Validation  (0) 2021.07.07
메시지, 국제화  (0) 2021.07.06
Thymeleaf 파헤치기  (0) 2021.07.01