BackEnd/Spring MVC

[Spring] 검증2 - Bean Validation - 2

샤아이인 2022. 3. 7.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

5. Bean Validation - 오브젝트 오류

Bean Validation 에서 특정 FieldError가 아닌, 오브젝트 관련 에러 또한 처리가 가능하다. (다만 권장하지 않는 방식이다)

바로 @ScriptAssert()를 사용하면 된다.

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000원을 넘어야 합니다")
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

    // 생략 ... 
}
 

실행해보면 정상적으로 작동한다.

 

메시지 코드는 다음과 같이 생성된다.

ScriptAssert.item
ScriptAssert
 

그런데 실제 사용해보면 제약이 많고 복잡하다.

그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.

 

따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 를 억지로 사용하는 것 보다는 기존과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

// 복합 룰 검증
if(item.getPrice() != null && item.getQuantity() != null){
    int resultPrice = item.getPrice() * item.getQuantity();
    if(resultPrice < 10000){
        bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
    }
}
 

 

6. Bean Validation - 한계

데이터를 등록할때와 수정할때의 요구사항이 다를수도 있다.

 

● 기존 요구 사항

타입 검증

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

 

필드 검증

- 상품명: 필수, 공백X

- 가격: 1000원 이상, 1백만원 이하

- 수량: 최대 9999

 

특정 필드의 범위를 넘어서는 검증

- 가격 * 수량의 합은 10,000원 이상

 

● 수정시 요구사항

- 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.

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

 

바뀐 요구사항을 적용하기 위해 다음과 같이 코드가 변경되었다.

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

    //...
}
 

수정후 실행해보면 수정 Form에서는 정상작동 하지만, 등록 Form에서 문제가 발생하였다.

첫 등록시에는 게시물의 id에 값도 없고, quantity 수량 제한값이 적용되지 않고있기 때문이다.

 

등록시 로그에서 다음과 같은 오류를 확인할수 있다.

'id': rejected value [null];
 

왜냐하면 등록시에는 id 에 값이 없기 때문이다. 따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다.

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

 

Item의 등록과 수정 과정에서 검증 조건이 서로 충돌하고있는 상황이다. 이 문제를 어떻게 해결할까?

 

7. Bean Validation - groups

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

 

● 2가지 방법

1) BeanValidation의 groups 기능을 사용한다.

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

 

우선 groups 기능을 사용해보자.

우선 저장용과 수정용 각각 interface를 만들어 주어야 한다.

 

● 저장용 groups 생성

package hello.itemservice.domain.item;

public interface SaveCheck {
}
 

 

● 수정용 groups 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {
}
 

 

● Item - groups 적용

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

 

● Controller 의 저장 로직과 수정 로직에 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에 적용할 interface를 선택해주면 된다.

 

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

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

실문에서는 groups 보다는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

8. Form 전송 객체 분리 - 소개

실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.

바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.

처음 등록할때는 회원 데이터 뿐만 아니라, 약관 정보도 추가로 받는 둥 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.

 

따라서 Item 을 직접 받기보다는, 복잡한 폼의 데이터를 컨트롤러 까지 전달할 별도의 객체, 예를 들어 ItemSaveForm 이라는 전용 폼을 만들어 @ModelAttribute로 사용한다.

 

이후 전용 폼 객체를 통해 받아온 폼의 데이터를 컨트롤러 내부에서 필요한 데이터를 추출하여 Item 을 생성하면 된다.

출처 - 인프런 김영한 스프링MVC2

또한 수정은 등록과 완전히 다른 데이터가 전달된다.

생각해보면 가입할때 주민등록 번호, id 등을 입력하지만, 일반적으로 수정할때 주민등록번호를 수정하지는 않는다.

따라서 검증 로직도 많이 달라진다.

 

그래서 ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는 것이 좋다.

 

 

9. Form 전송 객체 분리 - 개발

저장 과 업데이트를 위한 각각의 전용 객체를 만들었다.

 

● ItemSaveForm - ITEM 저장용 폼

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;
}
 

 

● ItemUpdateForm - ITEM 수정용 폼

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    // 수정에서는 수량은 자유
    private Integer quantity;
}
 

(shift + F6 => 함수 내 변수 이름 변경)

 

● 변경된 컨트롤러 (수정된 폼 객체를 사용)

1) addItem

@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(form.getItemName(), form.getPrice(), form.getQuantity());

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

우선 폼 객체를 바인딩 하는 인자부분부터 달라졌다.

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, 
    BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // 생략
}
 

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

 

또한 @ModelAttribute("item") 과 같이 사용했는데, 이는 model에 담기는 객체의 이름을 지정해준 것 이다.

이렇게 지정해주면 이전에 사용하던 뷰 템플릿을 수정하지 않아도 된다.

 

폼 객체를 Item으로 변환한 부분은 다음과 같다.

Item item = new Item(form.getItemName(), form.getPrice(), form.getQuantity());
 

 

2) edit

@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(form.getItemName(), form.getPrice(), form.getQuantity());

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

수정도 등록과 똑같다.

 

Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.

댓글