
검증 로직을 하나하나 코드로 작성하는건 너무 비효율적이고 중복이 끊임없이 발생한다.
특정 필드에 대한 검증 로직은 대부분 빈값인지, 특정 크기를 넘는지 안넘는지와 같은 굉장히 일반적인 로직이다.
검증로직도 단순하게 에노테이션으로 처리할 수 없을까?
에노테이션으로 전반적으로 검증로직을 처리할 수 있는 것이 바로 Bean Validation이다.
Bean Validation이란?
Bean Validation은 구현체가 아닌 기술 표준이다. 검증 에너테이션과 인터페이스의 모음인데, JPA처럼 구현체가 따로 있다. 보통 일반적으로 사용하는 구현체는Hibernate의 Validator이다. ORM과는 전혀 관련이 없다!
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
우선 Bean Validation 사용을 위해 build.gradle에 해당 implementation을 추가해주자
WardrobeSaveRequestDto.java
@Getter
@NoArgsConstructor
public class WardrobeSaveRequestDto {
@NotNull @NotEmpty
private String name; // 옷장 이름
@NotNull @Setter
private Member member; // 옷장 주인
@NotNull @Setter
private Image image; // 옷장 이미지
@NotNull
private String isPublic; // 공개 여부
@Range(min = 1000, max = 1000000)
private Integer price; // 옷장 가격
@Max(100)
private Integer space; // 옷장에 들어갈 수 있는 품목 수
@Builder
public WardrobeSaveRequestDto(String name, Member member, Image image, String isPublic, Integer price, Integer space) {
this.name = name;
this.member = member;
this.image = image;
this.isPublic = isPublic;
this.price = price;
this.space = space;
}
public Wardrobe toEntity() {
return Wardrobe.builder()
.name(name)
.member(member)
.isPublic(isPublic)
.image(image)
.build();
}
}
위의 코드는 옷장 정보를 저장하는 WardrobeSaveRequestDto에 BeanValidation을 지정한 것이다. 원래는 Controller에서 검증 로직을 모두 처리해주고 있었지만 단순히 DTO에 검증 에노테이션을 추가함으로써 Controller에 검증 로직을 구현할 필요가 없어졌다.
위에 추가된 검증 에노테이션에 대해 짤막하게 설명하자면
@NotBlank : 빈값, 공백만 있는 경우를 허용하지 않는다.
@NotNull : 말그대로 Not Null이다.
@Range(min = oooo, max = oooo) : 범위를 설정할 수 있다.
@Max(oooo) : 최대를 설정할 수 있다.
참고 1) @Range와 @Max
@Range,@Max는 하이버네이트 구현체를 사용할 때만 제공된다. 실무에서는 보통Hibernate Validation구현체를 사용하기 때문에 알아두는 것이 좋다.
참고 2) @NotEmpty vs @NotBlank vs @NotNull
@NotEmpty : null, “” 금지
@NotBlnak : null, “”, “ “ 금지
@NotNull : null 금지
어떻게 동작하는지 확인하기 위해 대충 테스트 코드를 작성해보자.
@Test
public void beanValidaiton() {
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
Validator validator = validatorFactory.getValidator();
WardrobeSaveRequestDto wardrobeSaveRequestDto = new WardrobeSaveRequestDto("", null, null, "true");
// violationSet에 오류 정보가 담기게 된다.
Set<ConstraintViolation<WardrobeSaveRequestDto>> violationSet = validator.validate(wardrobeSaveRequestDto);
for (ConstraintViolation<WardrobeSaveRequestDto> wardrobeSaveRequestDtoConstraintViolation : violationSet) {
System.out.println("wardrobeSaveRequestDtoConstraintViolation = " + wardrobeSaveRequestDtoConstraintViolation);
}
}
@NotEmpty인 name 필드에 “” 을 넣었고, @NotNull인 member, image 필드에 null을 넣어보았다. 물론 Spring과 통합하게 되면 위와 같이 오류 정보를 가져오는 코드를 작성 할 필요는 없다.
그러자 다음과 같이 출력되었다.
wardrobeSaveRequestDtoConstraintViolation = ConstraintViolationImpl{interpolatedMessage='반드시 값이 있어야 합니다.', propertyPath=image, rootBeanClass=class com.cloth.wardrobe.dto.clothes.WardrobeSaveRequestDto, messageTemplate='{javax.validation.constraints.NotNull.message}'}
wardrobeSaveRequestDtoConstraintViolation = ConstraintViolationImpl{interpolatedMessage='반드시 값이 존재하고 길이 혹은 크기가 0보다 커야 합니다.', propertyPath=name, rootBeanClass=class com.cloth.wardrobe.dto.clothes.WardrobeSaveRequestDto, messageTemplate='{javax.validation.constraints.NotEmpty.message}'}
wardrobeSaveRequestDtoConstraintViolation = ConstraintViolationImpl{interpolatedMessage='반드시 값이 있어야 합니다.', propertyPath=member, rootBeanClass=class com.cloth.wardrobe.dto.clothes.WardrobeSaveRequestDto, messageTemplate='{javax.validation.constraints.NotNull.message}'}
메시지를 따로 설정한 적이 없는데 아주 친절한 메시지가 들어가있다. 저 메시지들은 라이브러리 자체에서 기본적으로 제공하는 메시지이며, 원하는 메시지를 출력하기 위해서는 따로 설정을 해줘야 한다.
Spring MVC에서는 Bean Validation을 어떻게 적용할 수 있을까? 기존 프로젝트에 있던 Item 도메인에 Bean Validation을 적용시켜보자.
Item.java
@Data
@NoArgsConstructor
public class Item {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull()
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull()
@Max(value = 9999)
private Integer quantity;
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
한번 서버를 구동시켜보자. 신기하게도 Bean Validation을 적용시키기만 했는데 검증 로직이 제대로 구현되었다. 어떻게 단순히 설정도 없이 검증 에너테이션을 추가시키기만 했는데, Controller에 로직을 구현한 것처럼 정상 동작하는 것일까?
Spring Boot는 친절하게도 spring-boot-starter-validation 라이브러리를 넣으면 Bean Validator를 인지하고 스프링에 통합한다.
자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록하는데, 얘는 에노테이션을 보고 검증을 수행한다. 특정 구현 없이 글로벌 Validator가 적용되어 있기 때문에 단순히 라이브러리를 추가하고 에노테이션을 붙여줌으로써 검증 로직이 정상 동작하게 되는 것이다.
물론 @Validated or @Valid 가 있어야 Validator가 자동으로 추가된다!
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
....
}
참고) 글로벌 Validator
글로벌 Validator는 특정 컨트롤러에 등록된 Validator가 아닌 모든 컨트롤러에서 사용할 수 있도록 웹 에플리케이션 전체적으로 등록된 Validator를 의미한다. 만약 이전에 구현했던ItemValidator가 글로벌 Validator로 등록이 되어 있다면LocalValidatorFactoryBean이 등록되지 않아 에노테이션 검증을 사용할 수 없게 된다.
@ModelAttribute 각각의 필드에 데이터 바인딩을 진행typeMismatch로 FieldError를 추가한다. Validator 적용 (Validation 로직은 바인딩에 성공한 필드만 수행한다.) Bean Validation에서 에러코드는 어떻게 생성될까? BindingResult의 codes를 한번 살펴보자. itenName은 @NotBlank 에노테이션이 지정되어 있다.
log.info("errors={}", bindingResult);
codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백일 수 없습니다]
NotBlank라는 오류 코드를 기준으로 오브젝트명, 필드명을 이용하여 에러 코드가 자동 생성되는 것을 볼 수 있다. 이는 전 장에서 언급한 rejectValue()와 동일하게 MessageCodesResolver에 의해 생성되는 것이다.
만약 오류 메시지를 설정하려면 위의 코드를 활용하여 errors.properties를 수정하면 된다.
NotBlank=이름을 입력해주세요.
이렇게 필드 에러를 설정하면 되는 것은 잘 알겠다, 그렇다면 오브젝트 에러는 어떻게 처리할 수 있을까? 오브젝트 에러는 필드에 국한되는 것이 아니라 에노테이션으로 설정할 수 없을 것 같은데.. 오브젝트 에러 설정법에 대해 알아보자.
단순하게 오브젝트 에러에 대한 Bean Validation을 설정할 도메인 클래스 바로 위에 @SciprtAssert()를 추가하면 된다.
Item.java
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 이상이여야 합니다.")
@NoArgsConstructor
public class Item {
...
}
이런 경우 메시지 코드는 ScriptAssert.item, ScriptAssert로 생성된다. 이 메시지 코드를 이용하여 메시지를 따로 errors.properties에 지정해줘도 된다. 그런데 실무에서는 오브젝트 에러가 특정 오브젝트 범위를 벗어나는 경우가 많기 때문에 제약조건이 굉장히 많다. 또 결정적으로 사용하기에 굉장히 복잡하다. 따라서 오브젝트 에러는 다음과 같이 기존 방법처럼 자바 코드에 직접 작성하는 것을 권장한다.
Controller.java
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
- 등록용, 수정용 DTO를 두개 생성한다.
- Bean Validation의 groups 기능을 사용한다.
실무에서는 위의 두가지 중 한 가지를 선택하여 진행하게 된다. 우선 Bean Validation에서 제공하는 groups 기능을 알아보도록 하자.
등록시에 검증을 적용할 그룹, 수정시에 검증을 적용할 그룹으로 분리한다.
- 등록 :
SaveCheck.class- 수정 :
UpdateCheck.class
UpdateCheck.class
public interface UpdateCheck {
}
SaveCheck.class
public interface SaveCheck {
}
Item.class
@Data
@NoArgsConstructor
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;
... 생략
}
Controller.class
// 추가
@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
...
}
// 수정
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
...
}
참고)
groups 기능은@Validated에서만 지정이 가능하다.@Valid는 사용이 불가능하다.
사실 Bean Validation의 groups 기능은 보기에도 굉장히 복잡하여 실무에서 잘 사용하지 않는다. 그리고 프로젝트를 진행하다보면 등록시 폼이나, 수정시 폼에서 다루는 데이터는 같을 수가 없다. 그렇기 때문에 보통은 다음과 같이 용도에 따라 DTO를 분리하여 사용하게 된다.
ItemUpdateForm.java : 수정용 DTOItemSaveFom.java : 저장용 DTOItemUpdateForm.java
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
private Integer quantity;
}
ItemSaveForm.java
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
DTO를 위와 같이 분리하게 되면 전체적인 흐름은 어떻게 되는 것일까? 분명 Repository에서 DTO가 아닌 Entity를 활용하여 데이터를 수정하거나 저장하거나 할텐데, 변경 과정은 어디서 거치게 될까?
DTO로 분리하게 될 경우 보통 일반적인 과정은 다음과 같다.
과정
HTML Form -> DTO -> Controller -> 엔티티 생성 -> Repository
그렇다면 단순히 Web 서비스가 아닌 API 서비스를 제공하는 경우에는 검증 로직을 어떻게 구현할 수 있을까? HTTP 메시지 컨버터를 활용하는 경우에도 Bean Validation을 동일하게 적용시킬 수 있다.
참고) HTTP 메시지 컨버터
@ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, HTML Form)을 다룰 때 사용 한다.
@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 JSON.
Controller.java
@Slf4j
@RestController
@RequestMapping("/api/v1/items")
public class ApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// 오류 로직
}
// 성공 로직
}
}
HTTP 메시지 컨버터는 @ModelAttribute와 다르게 필드 단위가 아닌 객체 단위로 이루어지기 때문에 객체를 성공적으로 생성해야 @Validated가 적용이 된다.
만약 JSON이 잘못 넘어와서 객체 자체를 생성하지 못하는 상황에서는 어떻게 처리할 수 있을까?
-> 컨트롤러 자체가 호출이 안되는 경우
이 경우는 Advice로 처리하면 된다. 후에 Exception 파트에서 다뤄보도록 하겠다.
참고) Hibernate Bean Validation 공식 메뉴얼
Hibernate Validator 6.2.0.Final - Jakarta Bean Validation Reference Implementation: Reference Guide
출처
Inflearn 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 김영한님 강의를 수강하며 정리한 내용입니다