RE-Heat 개발자 일지

스프링 MVC 2편 - [5] 검증2- Bean Validation(하편) 본문

백엔드/스프링

스프링 MVC 2편 - [5] 검증2- Bean Validation(하편)

RE-Heat 2023. 7. 19. 09:06

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

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

인프런 김영한님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

[7] Bean Validation - 한계

한계점 : 등록할 때와 수정할 때 요구사항이 다르면 기존의 Bean Validation 방식은 적용하기 어렵다.

 

■ 요구사항의 변화 (등록 -> 수정)
① 등록 시 기존 요구사항

  • 타입 검증
    - 가격, 수량에 문자가 들어가면 검증 오류 처리
  • 필드 검증
    - 상품명: 필수, 공백 X
    - 가격: 1000원 이상, 1백만원 이하
    - 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    - 가격 * 수량의 합은 10,000원 이상

② 수정 시 요구사항

  • 등록 시에는 quantity 수량을 최대 9999까지 등록할 수 있지만, 수정 시에는 수량을 무제한으로 변경할 수 있다.
  • 등록 시에는 id에 값이 없어도 되지만, 수정 시에는 id 값이 필수이다.

 

수정을 위해 Item을 수정

id: @NotNull 추가

quantity: @Max(9999) 제거

 

이러면 같은 Item 객체를 쓰는 등록에서 문제가 발생한다.

등록 시 id값이 없으며, 수량 제한도 적용되지 않는다.

 

해결방안

① BeanValidation의 groups 기능을 사용한다.

② Item을 직접 사용하지 않고 ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어 사용한다.

 

 

[8] Bean Validation - groups

① 저장용/수정용 groups 인터페이스 생성

② 인터페이스를 Item 모델 객체에 필드마다 적용

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

ex) id는 수정 시에만 필수 값으로 요구됐으므로 UpdateCheck.class 적용

      itemName @NotBlank는 저장용/수정용 둘 다 적용돼야 함 ...

 

③ 해당 컨트롤러의 저장 로직/수정 로직에 각각의 groups를 적용한다.

 

참고] @Valid는 groups 기능이 없다. 따라서 groups 기능을 활용하려면 @Validated를 써야 한다.

 

사실 groups기능은 잘 사용되지 않는다. groups 기능을 쓰면 전반적으로 복잡도가 올라가고, 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않은 경우가 많기 때문이다.

 

그래서 실무에선 등록용 폼 객체/수정용 폼 객체를 분리해서 사용한다.

 


[9] Form 전송 객체 분리 - 소개

■ 별도 객체 여부 관련 장단점

1. 폼 데이터 전달에 Item 도메인 객체 사용(분리 X)

HTML Form → Item Controller Item Repository

  • 장점: Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
  • 단점: 간단한 경우에만 적용할 수 있다. 수정 시 검증이 중복될 수 있고, groups를 사용해야 한다.

2. 폼 데이터 전달을 위한 별도 객체 사용

HTML Form ItemSaveForm Controller Item 생성 Repository

  • 장점: 전송하는 폼 데이터가 복잡해도 그 상황에 맞춘 별도의 폼 객체를 사용해 데이터를 전달받을 수 있다.
     보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 


[10] Form 전송 객체 분리 - 개발

ItemSaveForm(저장용) & ItemUpdateForm(수정용)

ValidationItemControllerV4

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //특정 필드가 아닌 복합 룰 검증
    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}";
}

① @ModelAttribute("item") 이름을 넣어주지 않으면 itemSaveForm이라는 이름으로 MVC Model에 담긴다. 그러면 뷰 템플릿에 접근하는 th:object이름도 일일이 다 바꿔주어야 하므로 동작하기 편하게 "item"이란 이름을 붙여준다.

② ItemSaveForm을 받아온 후 폼 객체를 Item으로 변환해 넘기는 과정이 필요하다.

 

수정도 위와 같은 방식을 적용하면 된다.

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

 


[11] Bean Validation - HTTP 메시지 컨버터

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

 

참고] 

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

@RequestBody는 HTTP Body 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 쓴다.

 

 

ValidationItemApiController

@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("검증오류 발생", bindingResult);
            return bindingResult.getAllErrors();
        }

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

@ModelAttribute를 @RequestBody로 대체한 것을 확인할 수 있다.

 

API 3가지 상황

■ 성공 요청

 

■ 실패 요청 (Price에 문자를 넣음)

HttpMessageConverter에서 요청 JSoN을 ItemSaveFrom 객체로 생성하는 데 실패한다. 이러면 Validator를 거칠 필요가 없기 때문에 컨트롤러가 호출되지 않고, 곧바로 400번 예외가 발생한다.

 

■ 검증 오류 요청 

HttpMessageConverter가 성공하지만, 검증에서 오류가 발생할 때

 

Postman 입력 값 :

POST http://localhost:8080/validation/api/items/add 
{"itemName":"hello", "price":1000, "quantity": 10000}

 

return bindingResult.getAllErrors();는 FieldError와 ObjectError를 반환한다. 스프링이 이 객체를 JSON으로 변환해 전달한 게 위 사진이다.

물론 실제로 개발할 땐 이 객체를 그대로 사용하지 않고 필요한 데이터만 뽑아서 API 스펙을 정의하고 그에 맞는 객체를 만들어 반환해야 한다.

 

타입 오류가 뜰 때 JSON은 Form과는 달리 바인딩돼서 잘 넘어가지 않는다. 이유는 @ModelAttribute와 @RequestBodydml 차이 때문이다.

 

@ModelAttribute vs @RequestBody

  • @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩 돼 Validator를 사용한 검증도 적용할 수 있다.
  • 반면 @RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 그래서 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

영한님 : JSON으로 하면 예외가 발생할 수밖에 없는데, 예외 처리와 관련한 부분은 섹션 8·9에서 설명할 예정이다.