Bean Validation - 소개 검증 기능을 항상 코드로 작성하는 것은 매우 번거롭다. 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화된 것이 Bean Validation
이다. Bean Validation
을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Bean Validation
은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)
이라는 검증 애노테이션과 여러 인터페이스의 모음으로 이루어진 기술 표준이다.
Bean Validation - 시작 우선 build.gradle에 아래 의존관계를 추가해주자.implementation 'org.springframework.boot:spring-boot-starter-validation'
해당 라이브러리를 추가하면 빈 검증 애노테이션을 사용할 수 있다. Item 클래스에 적용해보자. package hello.validation.domain.item ;
import jakarta.validation.constraints.Max ;
import jakarta.validation.constraints.NotBlank ;
import jakarta.validation.constraints.NotNull ;
import lombok.Data ;
import org.hibernate.validator.constraints.Range ;
@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
@Range(min = 1000, max = 1000000)
범위 안(min ~ max)의 값이어야 한다. @Max(9999)
테스트 코드를 작성해보자. package hello.validation ;
import hello.validation.domain.item.Item ;
import jakarta.validation.ConstraintViolation ;
import jakarta.validation.Validation ;
import jakarta.validation.Validator ;
import jakarta.validation.ValidatorFactory ;
import org.junit.jupiter.api.Test ;
import java.util.Set ;
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 ());
}
}
}
Bean Validation - 프로젝트 준비 V3 이전 버전과의 차이점을 확인하기 위해 이번에는 v3를 생성해보자. ValidationItemControllerV3 컨트롤러 생성hello.itemservice.web.validation.ValidationItemControllerV2
복사hello.itemservice.web.validation.ValidationItemControllerV3
로 붙여넣기URL 경로를 validation/v2/
에서 validation/v3/
로 변경 템플릿 파일 복사validation/v2
디렉토리의 모든 템플릿 파일을 validation/v3
디렉토리로 복사 ValidationItemControllerV3에서 리소스 경로를 모두 validation/v2/
에서 validation/v3/
로 변경 Bean Validation - 스프링 적용 불필요한 코드 제거 ValidationItemControllerV3에서 addItemV1() ~ addItemV5() 제거하기 ValidationItemControllerV3에서 addItemV6()를 addItem()로 변경하기 ValidationItemControllerV3에서 @InitBinder 제거하기제거하지 않으면 오류 검증기가 중복 적용된다. http://localhost:8080/validation/v3/items
로 접속해보면 빈 유효성 검사가 정상 동작하는 것을 확인할 수 있다.스프링 MVC는 어떻게 Bean Validator를 사용하는 걸까? 스프링 부트에 spring-boot-starter-validation
에 대한 의존성이 있다면 자동으로 Bean Validatior를 인지하고 스프링에 등록한다. LocalValidatorFactoryBean
을 글로벌 Validator
로 자동으로 등록하기 때문에 애노테이션만 적용하면 된다.글로벌 Validator
는 @NotNull
같은 애노테이션을 보고 검증을 수행한다.이렇게 글로벌 Validator
가 적용되어 있기 때문에 @Valid
나 @Validated
만 적용하면 된다. 검증 오류가 발생하면 FieldError
나 ObjectError
를 생성해서 BindingResult
에 담아준다. 주의점글로벌 Validator
를 직접 등록하면 스프링 부트는 Bean Validatior를 글로벌 Validator
로 등록하지 않는다.그래서 글로벌 Validator
를 직접 등록하면 애노테이션 기반의 빈 검증기가 동작하지 않는다. @Valid와 @Validated 검증 시 @Valid
와 @Validated
둘 다 사용가능하다. @Valid
를 사용하려면 build.gradle에 의존관계 추가가 필요하다.@Valid
는 자바 표준 검증 애노테이션이다.@Validated
는 스프링 전용 검증 애노테이션이다.둘 중 아무거나 사용해도 동일하게 동작한다. 그저 차이점은 @Validated
는 내부에 groups
라는 기능을 포함하고 있다는 것 뿐이다. Bean Validation - 에러 코드 Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경할 방법은 없을까? Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 확인해보면 마치 typeMismatch처럼 오류 코드가 애노테이션 이름으로 등록된다. 그래서 MessageCodesResolver를 통한 다양한 메시지 코드가 순서대로 생성된다. errors.properties에 추가해보자. #Bean Validation 추가
NotBlank = {0} 공백X
Range = {0}, {2} ~ {1} 허용
Max = {0}, 최대 {1}
{0} 은 필드명이고, {1} , {2} …은 각 애노테이션 마다 다르다. BeanValidation 메시지 찾는 순서 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기 애노테이션의 message 속성 사용 라이브러리가 제공하는 기본 값 사용 Bean Validation - 오브젝트 오류 Bean Validation에서 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리해야 할까? @ScriptAssert()를 사용하면 된다. @Data
@ScriptAssert ( lang = "javascript" , script = "_this.price * _this.quantity >= 10000" )
public class Item {
//...
}
실행해보면 메시지 코드가 아래와 같이 생성된다.ScriptAssert.item ScriptAssert 문제점 실제 사용해보면 제약이 많고 복잡하다. 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다. ValidationItemControllerV3에 글로벌 오류를 추가해보자. @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 - 수정에 적용 ValidationItemControllerV3의 edit() 변경 //상품 수정
@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}" ;
}
Item 모델 객체에 @Validated
를 추가하였다. 검증 오류가 발생하면 editForm으로 이동하는 코드 추가하였다. <!DOCTYPE HTML>
<html xmlns:th= "http://www.thymeleaf.org" >
<head>
<meta charset= "utf-8" >
<link th:href= "@{/css/bootstrap.min.css}" href= "../css/bootstrap.min.css" rel= "stylesheet" >
<style>
.container {
max-width : 560px ;
}
.field-error {
border-color : #dc3545 ;
color : #dc3545 ;
}
</style>
</head>
<body>
<div class= "container" >
<div class= "py-5 text-center" >
<h2 th:text= "#{page.updateItem}" > 상품 수정</h2>
</div>
<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>
</div>
<div>
<label for= "price" th:text= "#{label.item.price}" > 가격</label>
<input type= "text" id= "price" th:field= "*{price}"
th:errorclass= "field-error" class= "form-control"
placeholder= "가격을 입력하세요" >
<div class= "field-error" th:errors= "*{price}" >
가격 오류
</div>
</div>
<div>
<label for= "quantity" th:text= "#{label.item.quantity}" > 수량</label>
<input type= "text" id= "quantity" th:field= "*{quantity}"
th:errorclass= "field-error" class= "form-control"
placeholder= "수량을 입력하세요" >
<div class= "field-error" th:errors= "*{quantity}" >
수량 오류
</div>
</div>
<hr class= "my-4" >
<div class= "row" >
<div class= "col" >
<button class= "w-100 btn btn-primary btn-lg" type= "submit"
th:text= "#{button.save}" > 저장
</button>
</div>
<div class= "col" >
<button class= "w-100 btn btn-secondary btn-lg"
onclick= "location.href='item.html'"
th:onclick= "|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|"
type= "button" th:text= "#{button.cancel}" > 취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
.field-error
css를 추가하였다.글로벌 오류 메시지를 추가하였다. 상품명, 가격, 수량 필드에 검증 기능을 추가하였다. Bean Validation - 한계 상품을 등록하거나 수정할 때 현재는 동일한 모델 객체를 사용하고 있다. 그런데 상품을 등록할 때와 수정할 때의 요구사항이 다를 수가 있다. 두 경우의 요구사항이 다를 때 한 쪽에만 맞추게 된다면 다른 한 쪽에서 문제가 발생할텐데 이런 경우에는 어떻게 해야 할까? Bean Validation - groups 동일한 모델 객체를 사용할 때 등록할 때와 수정할 떄 각각 다르게 검증하는 방법은 2가지가 있다.BeanValidation의 groups 기능을 사용한다. Item을 직접 사용하지 않고, ItemSaveForm나 ItemUpdateForm처럼 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다. groups 검증할 기능을 그룹으로 나누어서 적용하는 기술이다. groups 적용해보기 package hello.validation.domain.item ;
public interface SaveCheck {
}
package hello.validation.domain.item ;
public interface UpdateCheck {
}
package hello.validation.domain.item ;
import jakarta.validation.constraints.Max ;
import jakarta.validation.constraints.NotBlank ;
import jakarta.validation.constraints.NotNull ;
import lombok.Data ;
import org.hibernate.validator.constraints.Range ;
@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 ;
}
}
addItem()를 복사해서 addItemV2() 생성 후 SaveCheck.class를 적용해보자. @PostMapping ( "/add" )
public String addItemV2 ( @Validated ( SaveCheck . class ) @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}" ;
}
edit()를 복사해서 editV2() 생성 후 UpdateCheck.class를 적용해보자. //상품 수정
@PostMapping ( "/{itemId}/edit" )
public String editV2 ( @PathVariable Long itemId , @Validated ( UpdateCheck . class ) @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}" ;
}
알아야 하는 점 groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.그런데 groups 기능을 사용하니 Item은 물론이고, 전반적으로 복잡도가 올라갔다. 그래서 실무에서는 보통 groups를 사용하지는 않고, 그냥 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용한다. 이전 버전과의 차이점을 확인하기 위해 이번에는 v4를 생성해보자. ValidationItemControllerV4 컨트롤러 생성hello.itemservice.web.validation.ValidationItemControllerV3
복사hello.itemservice.web.validation.ValidationItemControllerV4
로 붙여넣기URL 경로를 validation/v3/
에서 validation/v4/
로 변경 템플릿 파일 복사validation/v3
디렉토리의 모든 템플릿 파일을 validation/v4
디렉토리로 복사 ValidationItemControllerV4에서 리소스 경로를 모두 validation/v3/
에서 validation/v4/
로 변경 http://localhost:8080/validation/v4/items
로 접속해서 잘 동작하는지 확인해보자.폼 데이터를 받는 방법 데이터 전달에 Item 도메인 객체 사용HTML Form -> Item -> Controller -> Item -> Repository
장점Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달한다. 중간에 Item을 만드는 과정이 없어서 간단하다. 단점 수정시 검증이 중복될 수 있고, groups를 사용해야 한다. 폼 데이터 전달을 위한 별도의 객체 사용HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다. 단점폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다. 폼 데이터를 받는 객체를 분리하는 이유 정말 간단한 단일 서비스거나 등록과 수정이 동일한 경우면 굳이 분리하지는 않아도 된다. 하지만 실무에서는 복잡한 케이스가 많고, 등록과 수정이 다른 경우가 많은 편이라 어지간하면 분리하는게 좋다. 폼 데이터 객체 작명하기 사실 의미가 있는 이름이면 크게 상관없다. ItemSaveForm이든 ItemSaveDto든 뭐든 상관없다. 다만 해당 회사나 프로젝트에 업무 규칙이 있다면 그건 반드시 지켜야 한다. (중요★) 폼 데이터가 발생하는 뷰 템플릿은 분리해야 할까? 단순한 페이지면 분리하지 않아도 상관없다. 다만 조건문이 좀 많아지기 시작했다? 그러면 분리하는게 정신건강에 이롭다. Item 원복 이제 Item에서는 검증을 사용하지 않으니 검증 코드를 제거하자. Item 등록용 폼 생성하기 package hello.validation.domain.item ;
import jakarta.validation.constraints.Max ;
import jakarta.validation.constraints.NotBlank ;
import jakarta.validation.constraints.NotNull ;
import lombok.Data ;
import org.hibernate.validator.constraints.Range ;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName ;
@NotNull
@Range ( min = 1000 , max = 1000000 )
private Integer price ;
@NotNull
@Max ( value = 9999 )
private Integer quantity ;
}
Item 수정용 폼 생성하기 package hello.validation.domain.item ;
import jakarta.validation.constraints.NotBlank ;
import jakarta.validation.constraints.NotNull ;
import lombok.Data ;
import org.hibernate.validator.constraints.Range ;
@Data
public class ItemUpdateForm {
@NotNull
private Long id ;
@NotBlank
private String itemName ;
@NotNull
@Range ( min = 1000 , max = 1000000 )
private Integer price ;
//요구사항 : 수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity ;
}
ValidationItemControllerV4에서 기존 코드 제거하기 addItem() addItemV2() edit() editV2() ValidationItemControllerV4에 신규 코드 추가하기 addItem()Item 대신에 ItemSaveform을 전달 받는다. @Validated로 검증을 수행한다. BindingResult 로 검증 결과를 받는다. @PostMapping ( "/add" )
public String addItem ( @Validated @ModelAttribute ( "item" ) ItemSaveForm form , BindingResult bindingResult , RedirectAttributes redirectAttributes ) {
//특정 필드 예외가 아닌 전체 예외
if ( form . getPrice () != null && form . getQuantity () != null ) {
int resultPrice = form . getPrice () * form . getQuantity ();
if ( resultPrice < 10000 ) {
bindingResult . reject ( "totalPriceMin" , new Object []{ 10000 ,
resultPrice }, null );
}
}
if ( bindingResult . hasErrors ()) {
log . info ( "errors={}" , bindingResult );
return "validation/v4/addForm" ;
}
//성공 로직 (폼 객체를 Item으로 변환)
Item item = new Item ();
item . setItemName ( form . getItemName ());
item . setPrice ( form . getPrice ());
item . setQuantity ( form . getQuantity ());
//저장
Item savedItem = itemRepository . save ( item );
redirectAttributes . addAttribute ( "itemId" , savedItem . getId ());
redirectAttributes . addAttribute ( "status" , true );
return "redirect:/validation/v4/items/{itemId}" ;
}
@PostMapping ( "/{itemId}/edit" )
public String edit ( @PathVariable Long itemId , @Validated
@ModelAttribute ( "item" ) ItemUpdateForm form , BindingResult bindingResult ) {
//특정 필드 예외가 아닌 전체 예외
if ( form . getPrice () != null && form . getQuantity () != null ) {
int resultPrice = form . getPrice () * form . getQuantity ();
if ( resultPrice < 10000 ) {
bindingResult . reject ( "totalPriceMin" , new Object []{ 10000 , resultPrice }, null );
}
}
if ( bindingResult . hasErrors ()) {
log . info ( "errors={}" , bindingResult );
return "validation/v4/editForm" ;
}
Item itemParam = new Item ();
itemParam . setItemName ( form . getItemName ());
itemParam . setPrice ( form . getPrice ());
itemParam . setQuantity ( form . getQuantity ());
itemRepository . update ( itemId , itemParam );
return "redirect:/validation/v4/items/{itemId}" ;
}
주의사항 @ModelAttribute(“item”)에 item
이라고 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 경우 규칙에 의해 자동으로 itemSaveForm이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다. Bean Validation - HTTP 메시지 컨버터 @Valid
, @Validated
는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.ValidationItemApiController 생성 package hello.validation.web.item.validation ;
import hello.validation.domain.item.ItemSaveForm ;
import lombok.extern.slf4j.Slf4j ;
import org.springframework.validation.BindingResult ;
import org.springframework.validation.annotation.Validated ;
import org.springframework.web.bind.annotation.PostMapping ;
import org.springframework.web.bind.annotation.RequestBody ;
import org.springframework.web.bind.annotation.RequestMapping ;
import org.springframework.web.bind.annotation.RestController ;
@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 ;
}
}
@ModelAttribute vs @RequestBody HTTP 요청 파리미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다.그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. HttpMessageConverter
는 @ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid
나 @Validated
가 적용된다. @ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다.특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다. @RequestBody
는 HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다.컨트롤러도 호출되지 않고, Validator도 적용할 수 없다. 출처