본문 바로가기
스프링/스프링 웹 개발 활용

[Spring] 등록, 수정 시 Bean Validation 중 Conflict가 발생하는 한계와 이를 개선한 groups

by drCode 2023. 10. 11.
728x90
반응형

Bean Validation - 한계

 

수정에서 검증할 때와 등록 시 요구사항이 다른 경우가 있다.

 

등록 시에는

타입 검증가격, 수량에 문자가 들어가면 검증 오류 처리

필드 검증상품명은 필수이고, 공백은 없어야 하며, 가격은 1,000원 이상, 1,000,000원 이하, 수량은 최대 9,999개

특정 필드범위를 넘어서는 검증은 가격 * 수량의 합은 10,000원 이상

이었다면

 

수정 시에는

등록 시에는 quantity 수량을 최대 9,999개까지 등록할 수 있지만 수정 시에는 수량을 무제한으로 변경할 수 있게끔 수정

등록 시에는 id에 값이 없어도 되지만, 수정 시에는 id값이 필수이게끔

요구사항이 있다고 할 때,

 

Item.java를 아래와 같이 수정할 수 있다.

package hello.itemservice.domain.item;
@Data
public class Item {
    @NotNull //수정 요구사항 추가
    private Long id;
    
    @NotBlank
    private String itemName;

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

    @NotNull
    //@Max(9999) //수정 요구사항 추가
    private Integer quantity;
    
    //...
}

 

수정 요구사항을 적용하기 위해 다음을 적용했다.

- id : @NotNull 추가

- quantity : @Max(9999) 제거

 

참고로, 현재 구조에서는 수정시 item 의 id 값은 항상 들어있도록 로직이 구성되어 있다.

그래서 검증하지 않아도 된다고 생각할 수 있다.

그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다.

예를 들어서 HTTP 요청을 변경해서 item 의 id 값을 삭제하고 요청할 수도 있다.

따라서 최종 검증은 서버에서 진행하는 것이 안전한다

 

수정을 실행해보자.

정상 동작을 확인할 수 있다.

 

그런데 수정은 잘 동작하지만 등록에서 문제가 발생한다.

등록시에는 id 에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다.

 

등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.

'id': rejected value [null];

왜냐하면 등록시에는 id 에 값이 없다.

따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다.

결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.

 

결과적으로 item 은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation 을 적용할 수 없다.

 

이 문제를 어떻게 해결할 수 있을까?

 

바로 groups 아니면, Form 객체를 이용하는 것이다.

 

Bean Validation - groups

 

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.

 

방법 2가지

 - BeanValidationgroups 기능을 사용한다.

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

 

BeanValidation groups 기능 사용

이런 문제를 해결하기 위해 Bean Validationgroups라는 기능을 제공한다.

예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

 

groups 적용

 

저장용 groups 생성

package hello.itemservice.domain.item;

public interface SaveCheck {

}

 

수정용 groups 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {

}

 

Item - groups 적용

package hello.itemservice.domain.item;

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 Item {
    private Long id;

    @NotBlank(groups = 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;
    }
}

 

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

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

    // 특정 필드가 아닌 복합 룰 검증
    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);
        // bindingResult 는 model Attribute에 안담아도 자동으로 view로 넘겨준다
        return "validation/v3/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v3/items/{itemId}";
}

- addItem() 를 복사해서 addItemV2() 생성, SaveCheck.class 적용

- 기존 addItem() @PostMapping("/add") 주석처리

 

ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용

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

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

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

    itemRepository.update(itemId, item);
    return "redirect:/validation/v3/items/{itemId}";
}

- edit() 를 복사해서 editV2() 생성, UpdateCheck.class 적용

- 기존 edit() @PostMapping("/{itemId}/edit") 주석처리

 

※ 참고: @Valid 에는 groups를 적용할 수 있는 기능이 없다.

따라서 groups를 사용하려면 @Validated 를 사용해야 한다.

 

groups 가 적용된 수정 화면 등록 시 수량의 최대 갯수는 9999였다.

 

정리

groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.

그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.

사실 groups 기능은 실제 잘 사용되지는 않는데,

그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다

728x90
반응형

댓글