1. Bean Validation 어노테이션 적용해보기
Bean Validation 개념
자바 if 코드로 작성했던 특정값이 null이거나 blank가 있으면 안된다. , 숫자값은 최소값 ~ 최대값 사이여야 한다는 검증 조건 등은 매우 기본적인 조건이다. 이런 간단한 제약조건은 어노테이션으로 지원이 되는데, 이런 검증 내용을 표준화하고 자동화해주는 것이 Bean Validation 이다.
Bean Validation은 구현체가 아니라, Bean Validation 이라는 JSR-380의 표준기술이다. 하이버네이트 Validator의 공식사이트는 다음과 같다. https://beanvalidation.org/2.0/
2. Bean Validation 테스트
라이브러리 추가하기
본격적인 사용에 앞서, Bean Validation을 import 해오고 테스트 코드를 통해 어떻게 Bean Validation이 구성되고 사용할 수 있는지 알아본다. 아래 implementation 코드를 build.gradle에 추가하고, 새로고침을 한다. 이후 External Libraries에 보면, hibernate의 validator가 구현체로 추가되어있는 것을 확인할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-validation'

어노테이션 적용하기
그리고 사용하는 Item 객체에 다음 어노테이션들을 추가한다. 각 어노테이션의 의미는 다음과 같다.
@NotBlank : 빈값 + 공백만 있는 경우 비허용
@NotNull : null 비허용
@Range, @Max : 범위, 최대값까지만 허용
그리고 다른 어노테이션들은 javax에서 import 되어 자바 표준이므로 어떠한 구현체에도 적용이 가능하지만, @Range는 hibernate 구현체에서만 적용이 가능하다는 점도 알아두자. 그런데 보통 대부분 하이버네이트 Validator를 사용하고 스프링이 이 구현체를 넣어주므로 그냥 사용해도 문제될일은 거의 없다.
java/hello/itemservice/domain/item/Item.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@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;
}
}
|
cs |
테스트
테스트에 필요한 코드를 기억할 필요는 없다. 단순히 Validator가 어떻게 작동하는지 살펴보기 위함이다.
java/hello/itemservice/validation/BeanValidationTest.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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("message = " + violation.getMessage());
}
}
}
|
cs |
출력 결과는 다음과 같다. 어떤 객체인지, 어떤 path에서 발생한 것인지에 대한 정보와 함께, 기본으로 지정된 오류 메시지들이 출력된다. 물론 이 메시지들도 바꿀 수 있다. 어노테이션 옆에 messages 속성을 추가하거나, errors.properties에 어노테이션의 이름을 코드로해서 등록하면 되는데, 상세한 내용은 뒤에서 살펴보자.

3. 스프링 코드에 적용하기
프로젝트 준비
Bean Validation을 실습해보기 위해, 기존 코드를 복사 붙여넣기 한다. 컨트롤러를 복사하여 ValidationItemControllerV3로 바꾸고, '/validation/v2' 를 '/validation/v3'로 replace[Ctrl + R] 해준다. 그리고 templates의 v2 패키지도 v3로 복사하고, 경로를 replace(패키지 클릭 후 [Ctrl + Shift + R]) 해준다.
혹시나 ItemServiceApplication에서 WebMvcConfigurer를 상속받고, ItemValidator의 validate 메서드를 @Overried 해줬던 부분은 삭제해줘야 한다!
스프링에 적용하기
@Validated 어노테이션
이제 이전글에서 적용했던 @InitBinder과 해당 메서드를 삭제한다! 그리고 실행을 하면, 정상적으로 작동되는 것을 확인할 수 있다. 사실 @Validated 어노테이션만 있어도 Item 객체에 적용해뒀던 @Range 등의 검증 코드가 작동한다. 다만, 특정 필드를 검증하는 검증오류는 출력되지만, 가격*수량 값을 보는 에러는 제대로 검증되지 않는다. 이 부분은 뒤에서 다시 설명한다.
@Validated가 적용되면 스프링부트는 글로벌 Validator인 LocalValidatorFactoryBean을 Validator로 등록한다. 이 Validator가 @NotNull과 같은 어노테이션을 기반으로 검증을 수행하는 것이다. 에러가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
@Valid 어노테이션
@Validated 대신 @Valid 라는 자바 표준 어노테이션을 사용해도 된다. 그러나 @Valid는 @Validated가 지원하는 groups 속성을 지원하지 않는다. 그런데 뒤에서 살펴보겠지만, groups 자체도 잘 안쓰는 기능이라, 필요하다면 @Valid 든 @Validator든 사용하면 된다.
검증 순서 및 원리
스프링부트의 Validator는 다음 순서로 검증을 실시한다.
1. @ModelAttribute가 달린 객체의 각 필드의 타입 검증(필드에 정의된 타입으로 타입 변환을 시도한다.)
2. 성공 시 Validator를 적용하여 조건에 맞는 에러를 생성한다.
3. 실패 시 typeMismatch로 FieldError를 추가한다.
예를 들어, Integer로 정의된 가격 필드의 경우 숫자값이 들어왔을 때만 그 필드가 최소값, 최대값이 몇인지 등을 검사한다. 만약 해당 필드에 타입이 다른 문자 등이 입력된다면, 조건을 살펴볼 필요없이 typeMismatch 에러를 반환하는 것이다.
4. Bean Validation 활용하기
좀 더 깊이있게 Bean Validation이 제공하는 기능들을 알아보자.
에러 메시지 변경하기
발생하는 에러의 로그를 출력해보면, codes가 앞에서 공부했던 FieldError를 구성하는 순서대로 4개씩 출력되는 것을 확인할 수 있다. 따라서, errors.properties에 적절한 codes, objectName을 기반으로 하는 에러메시지들을 등록하면 등록한 에러메시지로 에러 메시지를 변경할 수 있다.


에러 메시지 추가(errors.properties)
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

다만 에러메시지에서 정의한 Arguments들은 보통 {0}은 속성명(fieldName), {1}, {2} 는 범위의 숫자 이름이다. 강의 질의 응답에 따르면 이 부분은 메뉴얼에 정확히 정의가 되어있지 않아서, 직접 테스트해보는게 좋다고 한다.
오브젝트 오류 (ObjectError)
스프링에 Bean Validation 적용하기 부분에서, 특정 필드에 적용되는 에러가 아니라, 복합적인 ObjectError인 경우 처리가 힘들었다. 이 부분은 결론적으로, Java 코드를 이용해서 직접 검증 코드를 추가해주는 것이 좋다. 왜냐하면, 우리가 살펴봤던 가격*수량 값이 범위 내에 들어야 된다는 간단한 로직 등은 아래 설명할 @ScriptAssert를 통해서 처리가 가능하지만, 복잡한 로직이 필요한 경우 이 어노테이션을 적용하더라도 처리가 어렵기 때문이다.
@ScriptAssert 적용해보기
Item 객체에 해당 어노테이션을 아래와 같이 적용하면 된다.
1
2
3
|
@Data
@ScriptAssert(lang="javascript", script="_this.price * _this.quantity >= 10000")
public class Item {
|
cs |

이 경우도 ObjectError의 종류 2가지로 순서대로 생성되므로, code 이름인 ScriptAssert를 errors.properties에 등록하여 메시지를 바꿔주면 좀 더 적절할 수 있다. 그러나 앞서 언급한 바와 같이, 기능이 약하기 때문에 굳이 이 어노테이션을 사용하는 것을 권장하지 않는다. @ScriptAssert를 적용했던 부분은 지워버리자.

그래서, 자바코드로 아래와 같이 글로벌 오류를 추가한다.
java/hello/itemservice/web/validation/ValidationItemControllerV3.java
1
2
3
4
5
6
7
8
9
10
11
|
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//글로벌 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int totalPrice = item.getQuantity() * item.getPrice();
if (totalPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, totalPrice}, null);
}
}
... 하략
|
cs |
상품 수정에 Bean Validation 적용하기
우선 컨트롤러상의 수정 메서드에 @Validated 및 BindingResult 파라미터를 추가한다. 그리고 상품 등록에 있던 검증 코드와 라우팅을 위한 코드를 복사해온다. 다만, 14번줄과 같이 에러 발생시 editForm 쪽으로 포워딩을 해줘야한다.
java/hello/itemservice/web/validation/ValidationItemControllerV3.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
//글로벌 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int totalPrice = item.getQuantity() * item.getPrice();
if (totalPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, totalPrice}, null);
}
}
log.info("bindingResult ={}", bindingResult);
if (bindingResult.hasErrors()) {
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
|
cs |
View 쪽에도 addForm.html에 있던 <style> 코드 (.field-error 부분), 각 필드에 에러처리를 하던 부분을 복사해온다.(하략)
resources/templates/validation/v3/editForm.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">총액 오류 메시지</p>
</div>
<div>
<label for="id" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error"
class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
|
cs |

Bean Validation의 한계점
각 View 마다 다른 검증사항을 적용할 수 없다. 이때까지 상품등록에서만 검증 내용을 적용했는데, 만약 상품 수정에서 수정 시에는 상품 수량의 제한이 없고, 수정 시에는 id값을 필수값으로 등록하고 싶다면 어떻게 해야할까?
기존에는 상품 클래스 자체에 검증 어노테이션을 적용했기 때문에, 등록과 수정 페이지별로 따로 검증을 적용할 수 없다.
java/hello/itemservice/domain/item/Item.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
@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;
}
}
|
cs |
이를 해결하기 위한 방법은 2가지가 있다.
1. 등록용 Item 클래스, 수정용 Item 클래스를 따로 만들어서 적용하기
2. Bean Validation의 groups 적용하기
보통 실무에서는 1번 항목을 적용한다. 어짜피 지금 예제처럼 등록과 수정상의 입력값이 같은 경우가 거의 없기 때문이다. 상품 등록 시에는 상품에 대한 정보 외에, 사업자등록번호 등을 상세히 입력하고, 상품 수정 시에는 상품에 대한 직접적인 정보만 수정이 가능하게 할수도 있기 때문이다.
그래도 기능상의 학습을 위해, 다음 글에서부터 groups 기능을 배워보자.
참조
1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Programming-[Backend] > Spring' 카테고리의 다른 글
[스프링 웹MVC-2] 10. 로그인 - 기본 기능, 쿠키 적용 (0) | 2021.08.29 |
---|---|
[스프링 웹MVC-2] 9. 검증(Validation) - groups, 객체 분리 적용, API 전송에서의 성공, 실패 케이스 (0) | 2021.08.29 |
[스프링 웹MVC-2] 7. 검증(Validation) - 오류코드, 메시지 처리 (0) | 2021.08.22 |
[스프링 웹MVC-2] 6. 검증(Validation) 개선 : BindingError, FieldError, ObjectError (0) | 2021.08.22 |
[스프링 웹MVC-2] 5. 검증(Validation) (0) | 2021.08.21 |