Spring

스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 [Bean Validation]

haenni 2024. 2. 14. 15:02
 

👩‍💻 검증2 - Bean Validation


 

 

📌Bean Validation 소개

✏️Bean Validation이란? 

검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. Bean Validation은 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.

 

 

 

 

 

 

 

 

 

 


 

📌Bean Validation 사용 방법

✏️Bean Validation 의존관계 추가
:Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.

 

build.gradle에 추가

 

 

 

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

Item.class
**검증 애노테이션**
@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" 에 담아준다.

 

 

 

 

 

 

 

 

 


 

📌 검증 순서

✏️검증 순서
:검증 순서를 알아보자.

 

  1. @ModelAttribute 각각의 필드에 타입 변환 시도 (@ModelAttribute의 역할을 바인딩을 하는 역할이다. 예를들어 Item의 price는 int 타입인데, 사용자가 문자 'qqq'를 입력한다면 필드 타입이 일치하지 않는다.)
    1. 성공하면 다음으로
    2. 실패하면 typeMismatch로 FieldError 추가 (그러므로 typeMismatch 추가함. 필드의 타입이 일치하지 않으면 Validator을 적용 할 필요도 없음(타입이 일치해야 Max값을 9999로 해놓았을시, 값이 9999를 넘어가는지 등 알 수 있음).)
  2. 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'

 

 

errors.properties

"에러코드 추가"

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의 검증은 사용하지 않으므로 검증 코드를 제거하거나, 주석처리 한다.

Item.class

 

 

 

2)ItemSaveForm - Item 저장용 폼 만들기

:Item을 저장할 때 사용할 폼을 만들어보자.

ItemSaveForm.class

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

:컨트롤러를 수정해보자.

ValidationItemController.class

상품을 저장할 때 사용하는 로직이다.
기존 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 생성**

ValidationItemApiController.class

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

 

1) 성공 요청

PostMan
성공 로그

 

 

 

2) 실패 요청

PostMan
실패 로그

Post맨을 통해서 'price'값으로 문자열 'qqq'를 입력해보았다.

`HttpMessageConverter` 에서 요청 JSON을 `ItemSaveForm` 객체로 생성하는데 실패
한다.
이 경우는 `ItemSaveForm` 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 
물론 Validator도 실행되지 않는다.

 

 

 

3) 검증 오류 요청

PostMan
PostMan Body
검증 오류 요청 로그

수량( `quantity` )이 `10000` 이면 BeanValidation `@Max(9999)` 에서 걸리도록 수량을 10000을 입력했다.

`return bindingResult.getAllErrors();` 는 `ObjectError` 와 `FieldError` 를 반환한다.
 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다. 

로그를 보면 검증 오류가 정상 수행된 것을 확인할 수 있다.

 

 

 

 

✏️"@ModelAttribute" vs  "@RequestBody"

:둘의 차이점에 대해 알아보자.

 

HTTP 요청 파리미터를 처리하는 `@ModelAttribute` 는 각각의 필드 단위로 세밀하게 적용된다. 
그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.

`HttpMessageConverter` 는 `@ModelAttribute` 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용되므로,  메시지 컨버터의 작동이 성공해서 `ItemSaveForm` 객체를 만들어야 `@Valid` , `@Validated` 가 적용된다.

 

참고

"참고"

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