Spring

Validation , Bean Validation, 오류 코드 설계 (2/2)

Lea Hwang 2022. 6. 30. 21:18

Validation 포스팅에 이어서 이번에는 Bean Validation에 대해 알아보도록 하겠습니다.

Validation에 대해 잘 모르신다면 가볍게 보고 오시는 것을 추천합니다.

Validation , Bean Validation, 오류 코드 설계 (1/2)

 

Validation , Bean Validation, 오류 코드 설계 (1/2)

Validation 이란 예를 들어 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 합니다. 이렇게 검증에 실패

lealea.tistory.com

 

 

검증 기능을 저번 포스팅처럼 매번 코드로 작성하는 것은 상당히 번거롭습니다.

특정 필드에 대한 검증 로직은 주로 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같은 매우 일반적인 로직입니다.

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

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation입니다.

Bean Validation을 잘 활용하면, 어노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있습니다.

 

 

Bean Validation 이란?

먼저 Bean Validation은 특정한 구현체가 아닌 Bean Validation 2.0(JSR-380)이라는 기술 표준입니다.

  검증 어노테이션과 여러 인터페이스의 모음입니다.

마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같습니다.

 

Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator입니다.

 

Bean Validation은 인터페이스이고
이걸 구현하는 구현체를 갈아 끼울 수 있는데 대부분 사용하는 건 하이버네이트 validator입니다.
하이버네이트 Validator 관련 링크
공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/
 html_single/#validator-defineconstraints-spec

 

의존관계 추가

Bean Validation을 사용하려면 다음 의존관계를 추가해야 합니다.

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

 

[참고]
검증 시 @Validated, @Valid 둘 다 사용 가능합니다.
@Validated는 스프링 전용 검증 어노테이션이고, @Valid는 자바 표준 검증 어노테이션입니다.

 

자주 쓰는 검증 어노테이션

@NotNull, @NotEmpty, @NotBlank 의 차이점 및 사용법

개발하는 API의 request parameter의 null 체크를  쉽게 할 수 있는 방법에는 @NotNull, @NotEmpty, @NotBlank 
가 있습니다.  3가지 Annotiation 은 Bean Validation (Hibernate Validation)에서 제공하는 표준 Validation입니다.

1. @NotNull
 null만 허용하지 않기에  ""이나 " " 은 허용하게 됩니다.

2. @NotEmpty
null +  "" 둘 다 허용하지 않게 합니다.

3. @NotBlank
null +  "" + " " 모두 허용하지 않습니다.
 
참고 : https://sanghye.tistory.com/36?category=732716

 

 

스프링 MVC가 Bean Validator 사용하는 방법

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합합니다.

 

스프링 부트는 자동으로 글로벌 Validator로 등록합니다.

LocalValidatorFactoryBean을 글로벌 Validator로 등록하는데 이 Validator는 @NotNull 같은 어노테이션을 보고 검증을 수행합니다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, 우리는 @Valid , @Validated 만 적용하면 됩니다.

그리고 검증 오류가 발생하면, FieldError , ObjectError를 생성해서 BindingResult에 담아줍니다.

 

 


 

검증 순서

1. @ModelAttribute 각각의 필드에 타입 변환 시도(바인딩)

    a. 성공하면 다음으로 (2번)

    b. 실패하면 typeMismatch로 FieldError 추가

        예) 가격란에 qqq입력

2. Validator 적용

 

 

바인딩에 성공한 필드만 Bean Validation 적용

BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않습니다.

 

 

Bean Validation - 에러 코드

errors.properties에 레벨별 오류 메세지 작성

1. 필드는 각각 어노테이션 적용
2. 오브젝트(전체 관련)는 컨트롤러 > 메세지에서 적용
@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        //특정 필드가 아닌 복합 룰 검증
        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}";
    }

 

 

RedirectAttributes
 : redirect 하여 페이지 이동할 때 값 전달하기

Home Controller → Board Controller

.addAttribute 메서드를 통해 키, 값을 추가해줍니다.

@RequestMapping(value = "/", method = RequestMethod.GET)
    public String home(RedirectAttributes redirect) {
        
        redirect.addAttribute("pageNum", 1);        
 
        return "redirect:/board/getBoardMain";
    }

 

받는 쪽에서는 RequestParam으로 매핑해서 value값을 key값으로 받습니다.

@RequestMapping(value="/getBoardMain", method=RequestMethod.GET)
    public String getBoardMain(@RequestParam("pageNum") int pageNum, Model model) {
        
        model.addAttribute("boardsNum",pageNum);
        
        return "home";
        
    }

참고 : https://gdtbgl93.tistory.com/108

 

Bean Validation - 한계

등록과 수정 시 요구사항 변경된다면 어떻게 해야 할까요?

예를 들어 등록과 다르게 수정 시에는 수량을 무제한으로 풀 수 도 있고

무엇보다 등록할 때는 id에 값이 없어도 되지만, 수정 시에는 id 값이 필수입니다. 

 

결과적으로 item 은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없습니다. 이 문제를 실무에서는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하는 것으로 해결할 수 있습니다.

 

 

Form 전송 객체 분리

Form 전송 객체를 분리하는 이유는 등록 시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문입니다.

회원 등록시 회원과 관련된 데이터만 전달받는 것이 아닌 약관 정보도 추가로 받는 등 Item과 관계없는 수많은 부가 데이터가 넘어오기 때문에  Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달합니다.

 

예를 들어

HTML Form → ItemSaveForm → Controller → Item 생성 → Repository

 

전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달받을 수 있습니다.

보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않습니다.

 

 

수정의 경우 등록과 완전히 다른 데이터가 넘어옵니다.

예를 들면 등록 시에는 로그인 id, 주민번호 등등을 받을 수 있지만, 수정 시에는 이런 부분이 빠지고 검증 로직도 많이 달라지므로 ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는 것이 좋습니다.

 

 

Form 전송 객체 분리 - 개발

Item

이제 Item의 검증은 사용하지 않으므로 검증 코드를 제거합니다.

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

 

web.validation.form > ItemSaveForm

 

package hello.itemservice.web.validation.form;

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 ItemSaveForm { //등록할 때 id 필요없어서 뻄

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}
web 쪽에 만든 이유?
html폼의 내용을 그대로 받고 Controller레벨까지만 쓸 것이므로 (다른 곳에서 안 쓰임)
즉, 화면과 web에 특화된 기술

 

web.validation.form > ItemUpdateForm

package hello.itemservice.web.validation.form;

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 ItemUpdateForm { // id 있어야함!

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    //수정에서는 수량은 자유롭게 변경할 수 있다.
    private Integer quantity;
}

 

이제 컨트롤러에서 이 폼들을 사용할 수 있게 수정해봅시다.

 

❗실무 방식❗

전체 코드

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.SaveCheck;
import hello.itemservice.domain.item.UpdateCheck;
import hello.itemservice.web.validation.form.ItemSaveForm;
import hello.itemservice.web.validation.form.ItemUpdateForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v4/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v4/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v4/addForm";
    }

    @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 = 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}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v4/editForm";
    }

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

}

 

이어서 세부적으로 코드를 분석해보겠습니다.

 

등록

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

	...

}

Item 대신에 ItemSaveform을 전달받습니다. @Validated로 검증도 수행하고, BindingResult로 검증 결과도 받습니다.

view 템플릿까지 수정하고 싶지 않을 경우에는
@ModelAttribate() 괄호 안에 item이라고 적어주어야 합니다.
공백으로 내버려두면 form이 들어갑니다.

 

@ModelAttribate(item)으로 넘어갔을 시, 뷰 템플릿의 빨간 박스에 매칭 →ItemSaveForm에 값이 들어옴 →

이게 모델에 담겨서 넘어감

 

 

성공 로직 수정

리포지토리에서 가져오는 건 Item이므로 아이템을 만들어서 넘겨야 합니다.

//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

 

수정

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form,...) {
	...
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());

itemRepository.update(itemId, itemParam);

 

Bean Validation - HTTP 메시지 컨버터

@Valid , @Validated는 HttpMessageConverter (@RequestBody)에도 적용할 수 있습니다.

 

중요
@ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용합니다.

@RequestBody와 같은 HttpMessageConverter가 동작하는 것은 HTTP Body의 데이터를 그대로 객체로 변환할 때 사용합니다.
주로 API를 JSON요청을 다룰 때(JSON으로 왔다 갔다 할 때) 사용.
  여기서 객체는 ItemSaveForm

 

API를 JSON으로 왔다갔다 할 때 Bean Validation가 어떻게 쓰이는지 알아봅시다

web.validation > ValidationItemApiController

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController // @ResponseBody 포함됨
@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(); // 필드에러, 오브젝트 에러 다 반환 -> @ResponseBody있기에 json객체로 바뀜
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

구동하고 Postman 검증

url로 api호출

 

성공 케이스

 

실패 케이스

price의 값에 숫자가 아닌 문자를 전달

이 경우 Controller 호출이 안 되었습니다. 

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

 

 

그리고 또 다른 실패 케이스인 HttpMessageConverter는 성공하지만 검증(Validator)에서 오류가 발생하는 경우를 확인해봅시다.

수량( quantity )이 10000 이면 BeanValidation @Max(9999)에서 걸립니다.

Json을 객체로 만드는 것에 성공해서 ItemSaveForm이 만들어졌지만 @Validated에서 검증 오류가 생겨서

bindingResult오류가 들어갔습니다.

 

 

[정리]

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

 

 

@ModelAttribute vs @RequestBody

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용됩니다.(바인딩)

그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있고 Validator를 사용한 검증도 적용할 수 있습니다.

 

HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아닌 전체 객체 단위로 적용됩니다. 따라서 메시지 컨버터의 작동이 성공해서 Json을 통해 ItemSaveForm 객체를 만들어야 컨트롤러를 호출하고 그 후 @Valid , @Validated 가 적용됩니다.

즉, HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생합니다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없게 됩니다.

참고
HttpMessageConverter 단계에서 실패하면 예외가 발생하는데요, 예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다뤄보도록 하겠습니다.

 


 

 

 

Bean Validation을 간단하게 정리해보자면

- 어노테이션 모음집 꼭 읽어보기 : https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/
 html_single/#validator-defineconstraints-spec

 

- Bean Validation은 인터페이스, 하이버네이트 Validator가 구현체

 

- 에러코드를 제공함(어노테이션을 메세지코드로 이용),

  직접 errors.properties에 메세지 넣어서 개발자 마음대로 변경 가능

 

<한계>

등록과 수정 요구사항에서 충돌 일어남

<해결>

실무에서는 Form전송 객체를 분리

  장점:  Fit 하게 넣기 가능

  단점 : 컨트롤러에서 변경 로직이 들어감

    Item item = new Item();

    ....

 

- @ModelAttribute 뿐만 아니라 @RequestBody적용 가능

차이점

1. @ModelAttribute  : 각각의 필드 단위로 적용, 오류가 발생해도 나머지 필드 정상 처리, Validator검증도 적용 가능

2. @RequestBody : 전체 객체 단위로 적용, Json을 통해 객체로 변경 못하면 이후 단계 자체가 진행되지 않고 예외가 발생, 컨트롤러도 호출되지 않고, Validator도 적용할 수 없음