스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 [Bean Validation]
👩💻 검증2 - Bean Validation
📌Bean Validation 소개
✏️Bean Validation이란?
: 검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. Bean Validation은 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.
📌Bean Validation 사용 방법
✏️Bean Validation 의존관계 추가
:Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.

✏️Item 클래스에 "검증 애노테이션" 추가

**검증 애노테이션**
@NotBlank: 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull: `null` 을 허용하지 않는다.
@Range(min = 1000, max = 1000000): 범위 안의 값이어야 한다.
@Max(9999): 최대 9999까지만 허용한다.
✏️Bean Validation 스프링 적용
:ValidationItemControllerV3를 코드 수정해야한다.

기존의 addItemV1() ~ addItemV5() 메서드를 삭제하고 addItemV6코드를 변경하여 addItem()라는 메서드를 생성하였다.
실행해보면 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다.
✏️스프링 MVC는 어떻게 Bean Validator를 사용할 수 있는걸까?
:스프링 부트가 `spring-boot-starter-validation` 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합
"스프링 부트는 자동으로 글로벌 Validator로 등록한다."
:'LocalValidatorFactoryBean'을 글로벌 Validator로 등록한다. 이 Validator는 "@NotNull" 같은 애노테이션을보고 검증을 수행
한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, "@Valid" , "@Validated" 만 적용하면 된다.
검증 오류가 발생하면, "FieldError" , "ObjectError" 를 생성해서 "BindingResult" 에 담아준다.
📌 검증 순서
✏️검증 순서
:검증 순서를 알아보자.
- @ModelAttribute 각각의 필드에 타입 변환 시도 (@ModelAttribute의 역할을 바인딩을 하는 역할이다. 예를들어 Item의 price는 int 타입인데, 사용자가 문자 'qqq'를 입력한다면 필드 타입이 일치하지 않는다.)
- 성공하면 다음으로
- 실패하면 typeMismatch로 FieldError 추가 (그러므로 typeMismatch 추가함. 필드의 타입이 일치하지 않으면 Validator을 적용 할 필요도 없음(타입이 일치해야 Max값을 9999로 해놓았을시, 값이 9999를 넘어가는지 등 알 수 있음).)
- Validator 적용
**바인딩에 성공한 필드만 Bean Validation 적용**
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
'@ModelAttribute' ➡️ 각각의 필드타입 변환 시도 ➡️ 변환에 성공한 필드만 BeanValidation 적용
예시)
1) 'itemName' 에 문자 "A" 입력 ➡️ 타입 변환 성공 ➡️ 'itemName' 필드에 BeanValidation 적용
2) 'price' 에 문자 "A" 입력 ➡️ "A"를 숫자 타입 변환 시도 실패 ➡️ typeMismatch FieldError 추가 ➡️ 'price'
필드는 BeanValidation 적용 X
📌Bean Validation - 에러 코드
✏️Bean Validation 에러코드
:Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?


"상품 등록 화면에서 상품명을 입력 하지 않을 시"
상품명 필드인 ItemName에 Null값이 들어왔으므로, rejected가 message를 하나 만들어준다.
@NotBlank를 썼으므로, 'NotBlank.item.itemName'으로 만들어준다 (애노테이션 이름 + class이름 + 필드명)
(typeMismatch와 매우 유사함)
ex) @Range를 썼을 경우, 'Range.item.itemName'


"에러코드 추가"
Bean Validation 애노테이션 이름으로 에러코드를 생성해주었더니 등록한 메시지가 정상 적용 되는 것을 확인할 수 있다.
{0}은 필드명을 뜻한다.
혹은 아래처럼 애노테이션 옆에 (message = "") 값으로 메세지 값을 정의해주어도 된다.

📌Bean Validation - 오브젝트 오류
✏️Bean Validation 오브젝트 오류
:Bean Validation에서 특정 필드( 'FieldError' )가 아닌 해당 오브젝트 관련 오류('ObjectError' )는 어떻게 처리할 수 있을까?
다음과 같이 '@ScriptAssert()' 를 사용하면 된다.

class에 '@ScriptAssert' 애노테이션을 사용해주었다.
script의 _this는 자기 자신을 뜻한다.

검증되어 문구가 나오긴하나, 문구가 복잡하여 마음에 들지 않음

Field 검증을 할 때 사용했던 'message'를 '@ScriptAssert'에서도 사용할 수 있다.

"message"의 문구가 그대로 출력된 것을 볼 수 있다.
그런데 실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데,
그런 경우 대응이 어렵다. 따라서 오브젝트 오류(글로벌 오류)의 경우 '@ScriptAssert' 을 억지로 사용하는 것 보다는
다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

📌Bean Validation - 한계
✏️데이터를 등록할 때와 수정할 때의 요구사항이 다르다면?
:만약 상품을 등록할 때와 수정할 때의 요구사항이 다르다면 어떻게 해야할까?
"등록 시 요구사항"

"수정 시 요구사항"

✏️수정 시 요구사항을 추가
:수정 페이지의 요구사항을 추가할 경우 생기는 문제

수정 페이지에서의 요구사항을 추가하기 위해 @Max 애노테이션을 주석처리하고, id에 @NotNull을 넣어줬음
(등록 페이지에서는 수량이 9999까지 제한되지만, 수정 페이지에서는 수량 제한이 없음, 수정 시 id 값이 필수임)
→ "실행 시 수정은 잘 동작하지만 등록에서 문제가 발생한다"
등록시에는 `id` 에 값도 없고, `quantity` 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다
→ "등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다."
`'id': rejected value [null]` 오류 발생
왜냐하면 등록시에는 `id` 에 값이 없다.
따라서 `@NotNull` `id` 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으 로 넘어온다.
결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.
결과적으로 'item' 은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다.
이 문제를 어떻게 해결할 수 있을까?
📌Bean Validation - groups
✏️"groups"
:동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.

**BeanValidation groups 기능 사용**
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
✏️groups 사용 방법
**저장용 groups 생성**


**수정용 groups 생성**


**Item - groups 적용**

SaveCheck.class = 등록 페이지에서 사용 될 애노테이션
UpdateCheck.class = 수정 페이지에서 사용 될 애노테이션
ex) @Max(9999)의 값은 등록 페이지에서만 사용 되기 때문에 groups에 SaveCheck.class만 주었다.
(수정할 때에는 수량의 값이 제한이 없도록 요구사항이 변경되었기 때문에)
**ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용**

**ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용**

**정리**
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
그런데 groups 기능을 사용하니 `Item` 은 물론이고, 전반적으로 복잡도가 올라갔다. groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
📌Bean Validation - Form 전송 객체 분리 (프로젝트 V4)
✏️Form 전송 객체를 분리해보자
: 실무에서는 'groups' 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 'Item' 도메인 객체와 딱 맞지 않기 때문이다.
실무에서는 회원 등록시, 회원과 관련 된 데이터만 전달 받는 것이 아니라,약관정보도 추가로 받는 등 'Item'과 관계없는 수 많은 부가 데이터가 넘어온다. 그래서 보통 'Item' 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예시) 'ItemSaveForm' 이라는 폼을 전달받는 전용 객체를 만들어서 `@ModelAttribute` 로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 `Item` 을 생성한다.

✏️Form 전송 객체 분리 - 개발
:전송 객체를 분리하여 개발을 해보자
1) Item 원복
이제 Item의 검증은 사용하지 않으므로 검증 코드를 제거하거나, 주석처리 한다.

2)ItemSaveForm - Item 저장용 폼 만들기
:Item을 저장할 때 사용할 폼을 만들어보자.

Item을 저장할 때 필요한 Item 필드들을 넣는다.
상품을 저장할 때, Id는 필요없으므로 id필드는 주석처리 해주었다.
또한 상품을 저장할 때, 요구사항 중 itemName은 공백이거나 비어있으면 안되고, price값은 1,000원 이상이며 1,000,000원 이하여야하며, 수량은 최대 9999를 넘으면 안되므로 검증 코드로 @NotBlank, @NotNull, @Range, @Max 등을 사용해주었다.
(수정에 필요한 검증은 포함하지 않는다.)
3)ItemUpdateForm - ITEM 수정용 폼
:Item을 수정할 때 사용할 폼을 만들어보자.

Item을 수정할 때 필요한 Item 필드들을 넣는다.
상품을 수정할 때, 요구사항 중 id는 Null값이면 안되며, itemName은 공백이거나 비어있으면 안되고, price값은 1,000원 이상이며 1,000,000원 이하여야하며, 수량은 저장할 때와 다르게 수량 제한이 없으므로 @NotNull만 넣어 검증 코드로 @NotBlank, @NotNull, @Range, @Max 등을 사용해주었다.
(저장에 필요한 검증은 포함하지 않는다.)
4)ValidationItemControllerV4
:컨트롤러를 수정해보자.

상품을 저장할 때 사용하는 로직이다.
기존 groups를 사용했을 때와 달리, 매개변수를 변경해주었다.
@ModelAttribute를 통해 Item 객체를 바인딩 하는 것이 아니라, 2번에서 만들어두었던 ItemSaveForm을 객체로 바인딩 하고, 변수명은 form으로 해주었다. 그리고 `@Validated` 로 검증도 수행하고, `BindingResult` 로 검증 결과도 받는다.
또, ItemRepository에는 Item객체만 저장할 수 있기 때문에, Item 객체를 생성하여 form의 필드를 넘겨주었다.
(주의!): addForm.html을 변경하고싶지 않으면, model의 이름을 ("item")이라고 지정해줄것.
(@ModelAttribute ItemSaveForm form)이라고 해줄 시, @ModelAttribute는 객체의 첫 글자만 소문자로 바꾸어 model에 담아 넘기게 된다. 즉 아래와 같은 형태가 된다.
-> model.addAttribute("itemSaveForm", form)

상품을 수정할 때 사용하는 로직이다.
기존 groups를 사용했을 때와 달리, 매개변수를 변경해주었다.
@ModelAttribute를 통해 Item 객체를 바인딩 하는 것이 아니라, 3번에서 만들어두었던 ItemUpdateForm을 객체로 바인딩 하고, 변수명은 form으로 해주었다.
또, ItemRepository에는 Item객체만 저장할 수 있기 때문에, Item 객체를 생성하여 form의 필드를 넘겨주었다.
📌Bean Validation - HTTP 메시지 컨버터
✏️`@Valid` , `@Validated` 는 `HttpMessageConverter` ( `@RequestBody` )에도 적용할 수 있다.
: @RequestBody에 검증 사용하는 방법
"중요!!!!"
"@ModelAttribute" : HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
"@RequestBody' : HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
✏️**ValidationItemApiController 생성**

- API의 경우 3가지 경우를 나누어 생각해야 한다.
- 성공 요청: 성공
- 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
- 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
1) 성공 요청


2) 실패 요청


Post맨을 통해서 'price'값으로 문자열 'qqq'를 입력해보았다.
`HttpMessageConverter` 에서 요청 JSON을 `ItemSaveForm` 객체로 생성하는데 실패한다.
이 경우는 `ItemSaveForm` 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
물론 Validator도 실행되지 않는다.
3) 검증 오류 요청



수량( `quantity` )이 `10000` 이면 BeanValidation `@Max(9999)` 에서 걸리도록 수량을 10000을 입력했다.
`return bindingResult.getAllErrors();` 는 `ObjectError` 와 `FieldError` 를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
로그를 보면 검증 오류가 정상 수행된 것을 확인할 수 있다.
✏️"@ModelAttribute" vs "@RequestBody"
:둘의 차이점에 대해 알아보자.
HTTP 요청 파리미터를 처리하는 `@ModelAttribute` 는 각각의 필드 단위로 세밀하게 적용된다.
그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
`HttpMessageConverter` 는 `@ModelAttribute` 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용되므로, 메시지 컨버터의 작동이 성공해서 `ItemSaveForm` 객체를 만들어야 `@Valid` , `@Validated` 가 적용된다.

"참고"
HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리할 수 있다.