Bean Validation

나무·2023년 11월 17일

스프링 MVC

목록 보기
5/12
post-thumbnail

0. 기존 Validator 의 문제

지금 까지 우리는 검증 로직을 손수 자바 코드로 구현을 했었다. 물론 스프링이 제공해주는 여러 기능들을 사용해 정말 순수 자바로 짜는 것에 비하면 굉장히 수월하게 만들 수 있었다.

하지만 인간의 욕심은 끝이 없는법,,, 그것마져도 어느 순간 부터는 조금 번거롭게 느껴지기 시작했다. 그래서 새로운 검증기를 도입하였다.

늘 그래왔듯이 우리는 직접 코드를 짜기보다는 어노테이션을 사용함으로써 이전 보다 훨씬 편리하게 검증을 수행할 수 있다.

Bean Validation 맛보기

Item 객체

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull(message = "반드시 입력하셔야합니다") //수정 요구사항 추가
    private Long id;

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

Controller

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

이제 더이상 WebBinder 도 필요 없고 기존에 만들었던 ItemValidator 도 사용하지 않는다.

단순히 Item 객체의 필드 멤버에 직접 어노테이션들을 등록 함으로써 어노테이션만으로 검증을 수행할 수 있다.
(※ 대신 @Validated 가 꼭 붙어있어야한다)

또한 기본적인 오류메시지를 제공하며, 기본메시지를 따로 설정도 해줄 수 있다.

검증 어노테이션의 종류는 굉장히 많은데

https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

이곳에 방문해보면 굉장히 다양한 어노테이션들을 확인 할 수 있다.

어째서 가능한거지?

사실 스프링 부트가 자동으로 글로벌 Validator를 등록해준다.

무슨 말이냐면 앞 장에서 우리가 임의로 만든 ItemValidator 를 특정 컨트롤러만이 아닌 모든 컨트롤러에서 사용 할 수 있게 글로벌 Validator 로 등록 하는 방법이 있었다.

바로 @SpringBootApplication 에서 WebMvcConfigurerimplementation 해주면 되는데

이 작업을 스프링부트가 알아서 자동으로 해준다는 것이다.

1. Bean Validation과 오류코드

필드 오류

이전과 달리 Bean Validation 을 사용한경우 따로 메시지 코드를 등록하지 않았는데도 자기가 알아서 메시지를 내뱉는다.

왜냐하면 Bean Validation 이 메시지 코드도 자동으로 생성 해주기 때문이다.

Field error in object 'item' on field 'price': rejected value [null]; codes [NotNull.item.price,NotNull.price,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price]]; default message [널이어서는 안됩니다]
Field error in object 'item' on field 'itemName': rejected value []; codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백일 수 없습니다]
Field error in object 'item' on field 'quantity': rejected value [null]; codes [NotNull.item.quantity,NotNull.quantity,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.quantity,quantity]; arguments []; default message [quantity]]; default message [널이어서는 안됩니다]

이 메시지 코드를 그대로 errors.properties 에 등록 해준다면 우리가 임의로 메시지 내용을 바꿀 수도 있다.

errors.properties

#Bean Validation 추가

#NotBlank.item.itemName=상품 이름을 적어주세요.
#NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

오브젝트 오류

그럼 Bean Validation 으로 오브젝트 오류는 어떻게 잡을 수 있을까?

@ScriptAssert

@ScriptAssert 어노테이션을 사용하면 오브젝트 오류도 해결을 할 수 있다.

하지만 제약조건이 많고 많이 복잡하다. 그렇기 때문에 실무에서는 그냥 오브젝트 오류만 따로 자바코드로 구현하는경우가 많다.

2. Bean Validation 과 상품 수정

여지껏 "상품 등록" 만 다뤘었는데 이제 한번 "상품 수정" 에도 검증 로직을 추가해보자

"상품 수정" 요구 사항

수량 : 무제한
id : id값이 반드시 필요함

기존 "상품 등록" 의 경우 수량 의 범위가 9999 개까지 였지만 "상품 수정" 시에는 수량 의 범위가 무제한이 되었다.

또한 등록할 때는 당연히 해당 상품의 id 가 필요가 없지만 수정 시에는 반드시 id 값이 있어야 DB에서 해당 상품을 찾아서 데이터를 수정 할 수 있다.

문제 발생!

"등록""수정" 로직은 같은 Item 객체를 사용한다. 그렇기 때문에 검증 어노테이션을 등록하기가 굉장히 애매해진다.

"등록"quantity 의 범위가 정해져있으므로 @Max(9999) 를 등록해줘야하는데

"수정" 은 무제한이므로 따로 등록을 해줄 필요가 없다.

또한

"등록"id 값이 필요없으므로, 아니 애초에 새로 등록 하는것이기 때문에 id 가 존재할 수가 없다 허나

"수정"은 상품을 찾기위해서 반드시 id 가 존재해야하므로 @NotNull 등록해줘야한다.

이런 경우 충돌이 발생 하게된다.

Bean Validation - groups

라는 기능이 있지만 실무에서는 잘 사용하지 않으므로 그냥 내용은 생략. 궁금하면 직접 찾아보슈!!! (넝담 ㅎ)

그럼 도대체 어떻게 저 충돌 문제를 해결 할 수 있을까?

3. Form 전송 객체 분리

실무에서는 상품을 등록할 때 Item 도메인 객체 "만" 사용 되지 않는다. 대부분 수많은 다른 도메인 객체들의 부가적인 데이터들이 조금씩 꼽싸리 껴서 Item 이랑 같이 넘어오게 된다.

그렇기 때문에 아싸리 "등록""수정" 별로 각각 별도의 객체를 만들어서

"등록" 시에 필요한 데이터들만을 담는 ItemSaveForm

"수정" 시에 필요한 데이터들만을 담는 ItemUpdateForm
따로 따로 만들어 놓은 다음, 클라이언트로 부터 데이터를 전달 받을 때 이것들을 사용한다.

그 다음 컨트롤러에서 각 폼 객체로 부터 데이터들을 전달 받은 다음에 새롭게 Item 객체를 생성하여 이곳에 옮겨 담으면 된다.

등록: HTML Form -> ItemSaveForm -> Controller -> Service -> Repository -> DB

수정 : HTML Form -> ItemUpdateForm -> Controller -> Service -> Repository -> DB

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 {

    @NotBlank
    private String itemName;

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

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

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 {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

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

Controller - 등록

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

Controller - 수정

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

코드 분석

파라미터 @ModelAttribute

@ModelAttribute() 속성에 모델 이름을 "item" 으로 등록 해놨다. 그 이유는 뷰 템플릿에는 현재 th:object= "item"으로 등록 되어있기 때문에 이 코드들을 변경하기 싫어서 위와 같이 설정을 해주었다.

데이터 옮겨 담기

"성공 로직" 의 경우 뷰에서 온 데이터를 ItemSaveForm form 로 저장한다음,
새로운 Item 객체를 하나 생성해서 거기에 옮겨 담는다.

그 다음 repository 에 전달해준다.

"수정" 컨트롤러도 "등록" 과 전부 동일하므로 코드 분석은 생략 하겠다.

본 포스트는
김영한의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 를 보고 정리했습니다.

profile
🍀 개발을 통해 지속 가능한 미래를 만드는데 기여하고 싶습니다 🍀

0개의 댓글