Spring 검증 groups와 객체 분리

바그다드·2023년 5월 15일
0

검증

목록 보기
5/5
  • 지난 포스팅에서 BeaValidation의 한계에 대해서 언급했다.
    상품의 수량에 대해서 등록 시에는 상품 수량이 1~9999사이어야 하지만, 수정 시에는 수량에 제한이 없을 때 각 검증이 다르게 적용해야 한다. 이 문제를 해결하려면 어떻게 해야할까?

1. groups

  • 첫번째 방법은 BeanValidation의 groups 기능을 사용하는 것이다. 코드로 확인해보자.

groups를 위한 인터페이스 생성

public interface SaveCheck {
}
public interface UpdateCheck {
}
  • 각 기능에 대한 검증을 분리하기 위해 아무런 로직이 없는 인터페이스를 생성하였다.

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, udateCheck.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;
  }
}
  • 도메인의 각 BeanValidation 어노테이션에 있는 groups 파라미터에 적용할 인터페이스를 설정을 해준다.

컨트롤러에 적용

// 등록기능
@PostMapping("/add")
public String addItemV2(@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의 value 파라미터에 검증 분리를 위한 객체를 넣어주면 각 기능에 따라 검증을 수행할 수 있다.
  • 하지만 이 방법은 복잡하기도 하고 번거롭다. 그래서 실무에서는 주로 두번째 방법을 사용한다고 한다.

2. Form전송 객체 분리

  • 이 방법은 등록폼에서 값을 받기 위해 사용하는 객체와 수정폼에서 값을 받기 위해 사용하는 객체를 분리하여 사용하는 것이다. 코드로 확인해보자

등록에 사용할 객체

@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, 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";
        }
		// ItemSaveForm을 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}";
    }
  • 그런데 이 두번째 방법을 사용할 경우에는 폼 객체를 본래의 도메인 객체로 변환하기 위한 로직이 필요하다.
    Repository를 롹인해보자
	public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }
    public void update(Long itemId, Item updateParam) {
        Item findItem = findById(itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }
  • Reporitory에서는 등록이든 수정이든 받는 파라미터가 Item객체로 고정되어 있기 때문에 각 폼 객체를 원래의 도메인 객체로 변환하는 작업이 필요하다!!!

3. Http 메세지 컨버터(@RequestBody)

  • 위의 경우들은 form데이터를 받을 때 사용할 수 있는 방법들이다. 그렇다면 JSON데이터를 다룰 떄는 어떻게 해야할까?

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;
    }
}
  • API를 확인하기 위해 RestController를 생성하였다. 이제 포스트맨으로 결과를 확인해보자.


    앞서 @ModelAttribute를 사용했을 때는 컨트롤러가 호출이 되었는데, @RequestBody를 사용했더니 컨트롤러도 호출되지 않고, 메세지도 Spring에서 제공하는 기본 에러 메세지가 반환이 되었다.
  • 그럼 이번에는 타입은 맞추되 검증에는 실패하도록 요청을 보내보자


    이번에는 컨트롤러도 호출이 되고, 우리가 설정했던 메세지도 뜨는 것을 확인할 수 있다. 어째서 이렇게 되는 것일까?

4. @ModelAttribute vs @RequestBody

@ModelAttribute

  • @ModelAttribute의 경우에는 객체의 각 필드 단위로 바인딩이 적용이 된다. 따라서 특정 필드가 바인딩이 되지 않아도 나머지 필드는 정상적으로 바인딩이 되고, 검증도 할 수 있다.

@RequestBody

  • 반면 @RequestBody의 경우에는 전체 객체 단위로 바인딩이 적용된다. 때문에 메세지 컨버터가 정상적으로 작동이 되어야 ItemSaveForm 또는 ItemUpdateForm이 생성되고, @Valid나 @Validated가 적용이 된다.
    만약 정상적으로 작동이 되지 않을 경우에는 예외가 발생한다.

  • 따라서 API검증의 경우 3가지 경우의 수를 생각해야 한다.

  1. 성공 요청
  2. 실패 요청
    • JSON을 객체로 바인딩하는 것 자체가 실패
  3. 검증 오류 요청
    • JSON을 객체로 바인딩하는 것은 성공했지만, 검증에는 실패했을 때
  • 이것으로 검증 완료!!!

출처 : 김영한 스프링MVC2편

profile
꾸준히 하자!

0개의 댓글