본문 바로가기
Web/spring study

[Spring] Bean Validation

by 장인이 2024. 1. 30.

1. 개요

 지난 게시물에서 스프링의 도움을 받아 검증 기능을 구현해보았다. 하지만 이렇게 매번 코드로 작성하는 것은 너무 번거로운 일이다. 특히 왠만한 검증 로직은 빈 값인지, 특정 크기를 넘는지 아닌지 등등 일반적인 로직으로 이루어져있다. 따라서 간단한 어노테이션을 통해 검증 로직을 적용할 수 있도록 해주는 것이 Bean Validation이다.

 

 

2. Bean Validation?

 우선 Bean Validation은 특정한 구현체는 아니며, 검증 어노테이션과 여러 인터페이스의 모음이라고 할 수 있다. 일반적으로 사용하는 구현체는 하이버네이트 Validator이며, 이름에 하이버네이트가 붙지만 ORM과는 관련이 없다.

 

<하이버네이트 Validator 검증 어노테이션 모음>

https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

 

Hibernate Validator 6.2.5.Final - Jakarta Bean Validation Reference Implementation: Reference Guide

Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th

docs.jboss.org

 

 

3. Bean Validation 적용

 우선, Bean Validation을 사용하기 위해서 의존관계를 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 추가했다면, 상품 객체에 Bean Validation 어노테이션을 적용해보자. 검증 요구사항은 Validation 게시물을 참고하면 좋을 것 같다.

@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: null을 허용하지 않는다

@Range(min = 1000, max = 1000000)범위 안의 값만 허용한다

@Max(9999): 최대 9999만 허용한다

 

 그러면 상품 등록 컨트롤러로 넘어보자.

private final ItemRepository itemRepository;

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
	BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) {
        return "validation/v3/addForm";
    }
    
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v3/items/{itemId}";
}

 

 해당 코드에서 검증과 관련된 것은 @Validated 어노테이션 단 하나뿐이다. 이 상태로 실행해보면, Bean Validation이 정상적으로 동작하는 것을 확인할 수 있다!

 따로 오류 메시지를 설정 안했음에도 불구하고, 스프링이 오류 메시지를 생성해준다. 단, 글로벌 오류 검증이 빠졌는데, 이는 뒤에서 추가해보도록 하자.

 

 스프링 부트spring-boot-starter-validation 라이브러리를 넣으면, 자동으로 Bean Validation을 인지한 후 스프링에 통합시킨다. LocalValidatorFactoryBean을 글로벌 Validator로 등록하며, 개발자가 작성한 어노테이션에 따라 검증을 수행한다. 따라서 @Valid 혹은 @Validated만 적용하면, 검증 오류에 따라 FieldError 혹은 ObjectError를 생성해서 BindingResult에 담아준다.

 

 

4. 검증 순서, 에러 메시지

 스프링이 검증하는 순서에 대해 다시 한번 생각해보자.

 

1) @ModelAttribute 각각의 필드에 타입 변환 시도

  - 성공하면 다음으로

  - 실패하면 typeMismatch FieldError 추가

2) Validator 적용

 

 즉, 바인딩에 성공한 필드만 Bean Validation이 적용된다. 생각해보면 당연한 현상이다! 전달받은 값이 객체로 정상적으로 들어와야, Bean Validation을 진행할 수 있기 때문이다.

 따라서 타입 변환에 실패한 경우에는 BindingResult가 typeMismatch FieldError를 추가하는 거라는 점을 인지하면 된다. 따라서 typeMismatch같은 경우는 별도의 에러 메시지를 등록하는 것을 추천한다.

 

 만일 Bean Validation이 기본으로 제공하는 오류 메시지를 변경하고 싶다면, errors.properties 에 값을 추가하면 된다. Bean Validation 또한 어노테이션 이름을 기반으로 MessageCodesResolver를 통해 메시지 코드가 순서대로 생성된다.

(단, application.properties에 spring.messages.basename=messages,errors 는 꼭 붙여야 함)

 

ex) @NotBlank

1) NotBlank.item.itemName

2) NotBlank.itemName

3) NotBlank.java.lang.String

4) NotBlank

 

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

errors.properties에 추가하면 되고, {0}은 필드명, {1}, {2}, ... 는 각 어노테이션마다 다르다.

 

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

위와 같이 어노테이션에 직접 메시지를 추가할 수도 있다. Bean Validation 메시지를 찾는 순서는 다음과 같다.

 

1) 어노테이션 이름을 기반으로 messageSource에서 메시지 찾기

2) 어노테이션의 message 속성 사용 (바로 위 예시)

3) 기본 값 사용

 

 

5. ObjectError

 Bean Validation에서 FieldError가 아닌 ObjectError는 어떻게 처리해야 할까?

 

@Data
@ScriptAssert(lang = "javascript", script = "_this.price*_this.quantity>=10000")
public class Item {
    ...
}

 @ScriptAssert()라는 어노테이션이 있다. 이를 사용하면 되지만, 제약이 많고 복잡하다. 따라서 오브젝트 오류의 경우 직접 자바 코드로 작성하는 것을 권장한다.

 

 

6. Bean Validation의 한계

 개발을 하다 보면, 데이터를 등록할 때와 수정할 때의 검증 요구사항이 다를 수 있다. 한번 예시를 들어보자.

 

- 등록시에는 quantity를 최대 9999까지 등록할 수 있지만, 수정 시 개수 제한이 없어진다.

- 등록시에는 id 값이 없어도 되지만, 수정 시 id 값은 필수이다.

 

 만일에 위 내용을 Item class의 어노테이션이 반영한다면? 수정 시에는 잘 작동하겠지만, 등록 검증이 망가질 것이다. 상품 등록시에는 id값을 애초에 받지 않으므로, 상품 등록 자체가 불가능하게 된다.

 그렇다고 Bean Validation을 사용하지 않고 직접 Validator를 만드는 것은... 너무 귀찮은 일이다. 이를 해결할 수 있는 방법은 크게 2가지가 있다.

 

6-1. groups

 우선 Item class와 같은 위치에 2개의 인터페이스를 만들어준다.

public interface SaveCheck{
}
public interface UpdateCheck {
}

 

@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;
    ...
    
}

 Item class의 어노테이션에 다음과 같이 groups를 추가한다. SaveCheck.class가 포함된 어노테이션은 등록 시에만 적용되고, UpdateCheck.class가 포함된 어노테이션은 수정 시에 적용된다.

 

@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item,
		BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    ...
}

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class)
		@ModelAttribute Item item, BindingResult bindingResult) {
    ...
}

 컨트롤러에서는 위와 같이 @Validated(SaveCheck.class)를 통해 적용될 어노테이션을 선택하면 된다. 참고로 groups는 @Validated에서만 동작되며, @Valid는 groups를 적용할 수 없다.

 

 groups 기능을 사용해서 등록, 수정 시 다르게 검증을 할 수 있었다. 하지만 전체적으로 코드를 살펴보면, 복잡도가 너무 올라갔다. 따라서 실제로는 잘 사용되지 않으며, 주로 아래의 방법을 사용하게 된다.

 

6-2. 등록용 폼 객체, 수정용 폼 객체 분리

 두 번째 방법은 등록 시, 수정 시 전달받는 데이터를 저장할 별도의 객체를 만드는 것이다. 실제로는 이 방식을 택하는데, 그 이유는 등록 폼에서 전달하는 데이터가 Item 객체와 딱 맞지 않기 때문이다. 약관 정보 등 Item과 관계없는 수 많은 부가 데이터가 넘어오며, 그래서 복잡한 폼 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록과 수정이 완전히 분리되게 된다. 따라서, groups를 적용할 일은 드물다.

 이름과 같은 경우는 한 프로젝트에서 일관성있게 지으면 된다. ItemSave, ItemSaveForm, ItemSaveDto 등등 상관없다.

 

 그러면 각각 저장용 폼과 수정용 폼을 위한 객체를 만들어보자.

 

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;
}

Item에서 검증은 하지 않으므로, 코드를 제거하자.

 

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

 

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    //수정 시 수량 제한 없음
    private Integer quantity;
}

 

 이렇게 저장, 수정 시 사용할 폼 객체를 만들었으면, 실제 컨트롤러에 바인딩해보자.

 

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, ...) {
    ...
}

 여기서 주의할 점은, 뷰 템플릿을 수정하지 않으려면 "item" 이름을 명시해주어야 한다는 점이다. 이를 넣지 않으면 MVC Model에 itemSaveForm이라는 이름으로 담기게 된다.

 

 

7. HTTP 메시지 컨버터, Bean Validation

 @Valid, @Validated는 HttpMessageConverter, 즉 @RequestBody에도 적용할 수 있다. HTTP Body의 데이터를 객체로 변환할 때 사용하며, 주로 API JSON 요청 시 사용한다.

 

@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, 
    		BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return bindingResult.getAllErrors();
    }
    
    return form;
    }
}

 

API의 경우, 3가지 경우를 나누어서 생각해보아야한다.

 

1) 성공 요청

 성공했을 경우, 입력받은 상품 등록 폼을 다시 return할 것이다.

 

2) 실패 요청

 만일 숫자 입력란에 문자를 적을 경우, 아래와 같은 요청이 나올것이다.

{
    "timestamp": "2024-01-30T00:00:00.000+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/validation/api/items/add"
}

 타입 변환 시 오류가 발생할 경우, HttpMessageConverter에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다. 따라서 컨트롤러 자체가 호출되지 않고, 예외가 발생한다. 물론 Bean Validator또한 실행되지 않는다.

 

3) 검증 오류 요청

 만일 타입 변환 자체는 잘 이루어졌지만, 최대 수량인 9999를 넘은 12000을 입력한다면 어떻게 될까? 위 실패 요청처럼 뜨지는 않고, bindingResult.getAllErrors()에 의해 bindingResult가 지닌 모든 ObjectError와 FieldError를 반환하게 된다. 물론 실제 요청일 경우, 필요한 데이터만 뽑은 후 그에 맞는 객체를 만들어서 반환해야 한다.

 

+ 예외 처리

 결국 API 형식일 경우, 예외 발생 시 관련 메시지를 안내해야 한다. 이와 같이 예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 추후 게시물에서 다룰 예정이다.

 

 

8. 정리

 지난 게시물부터 시작해서 이번 게시물에서 까지 직접 검증 ~ 어노테이션 활용하는 방법을 적어보았다. 물론 실제로는 6-2번에서 설명한 다양한 상황에 따른 입력 폼 객체 분리 방법을 대부분 사용한다. 그거만 알고 있으면 되는게 아닐까? 해당 내용은 아래 적혀있듯이 김영한님의 강의를 듣고 정리하였는데, 강의중에서 이런 말씀을 하셨다. 이렇게 깊이있게 공부를 하게 되면, 여러 상황에 놓였을 때 유연한 대처를 할 수 있으며 더 확장성있는 코드를 짤 수 있다고... 따라서 조금 길었지만 전체 내용을 복습할 겸 정리해 보았다.

 

 

 

 


위 내용은 김영한 님의 인프런 강의 "스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술"의 내용과 강의자료를 토대로 작성된 게시글입니다.

강의 링크:

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2

 

댓글