[Spring MVC 2편] 검증2 - Bean Validation

wony·2024년 4월 15일

Spring

목록 보기
17/33

개요

주제 : 스프링 MVC의 기본 기능을 공부하면서 부족했던 기능 학습한다.
내용 : 스프링 MVC2를 통해서 검증과 예외 처리에 대해 학습한다.

1. Bean Validation - 시작

검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.

  • 의존관계 추가
    • Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
  • 이후 Item코드 수정
    • Item - Bean Validation 애노테이션 적용
@Data
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;

    public Item() {
    }

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

검증 애노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

2. Bean Validation 적용

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
    /** 필요없음
    @InitBinder
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder);
        dataBinder.addValidators(itemValidator);
    }
    **/
    private final ItemRepository itemRepository;
    // private final ItemValidator itemValidator; // Bean validator 때문에 ItemValidator 필요없어짐

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

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

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult
            bindingResult, RedirectAttributes redirectAttributes) {
        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}";
    }

    @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 edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
}

스프링 MVC는 어떻게 Bean Validator를 사용하나?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid ,@Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.

주의

  • 직접 글로벌 Validator를 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다.

검증 순서

    1. @ModelAttribute 각각의 필드에 타입 변환 시도
      1. 성공하면 다음으로
      1. 실패하면 typeMismatch 로 FieldError 추가
    1. Validator 적용

결론 : BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다. 즉, 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.

3.Bean Validation - 에러 코드

BeanValidation이 기본으로 제공하는 오류 메시지를 좀 더 자세하게 변경하고 싶으면 어떻게 하면 될까? Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보자.

오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch 와 유사하다.
NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.(순서대로!!)

@NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank

@Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

메시지 등록
이제 메시지를 등록해보자.(errors.properties에 등록)
실행해보면 등록한 메시지가 정상 적용되는 것을 확인할 수 있다.

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
  • 정리 : BeanValidation 메시지 찾는 순서
  1. 생성된 메시지 코드 순서대로 messageSource(errors.properties) 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.

4. Bean Validation - 오브젝트 오류

Bean Validation에서 특징 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?
-> @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);
	}
}   

5. Bean Validation - 수정에 적용

이제 Bean Validation 관련 내용은 다 끝났다.
등록에 적용하였던 Bean Validation을 수정에도 적용해보자.

ValidationItemControllerV3 - edit() 변경

   @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @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}";
    }
  • eidt() : Item 모델 객체에 @ValidateBindingResult` 추가
  • 오브젝트 오류(전체 예외) 코드, 검증 오류가 발생하면 editForm 으로 이동하는 코드 추가
  • validation/v3/editForm.html 변경

6. Bean Validation - 한계

수정시 검증 요구사항
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

등록시 기존 요구사항

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

수정시 요구사항

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

이렇게 동일한 모델 객체를 등록할 때와 수정할 때는 각각 다르게 검증해야 한다.
방법 2가지

  • BeanValidation의 groups 기능을 사용한다.
  • Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.(1번 말고 이렇게 만들어야!)

7. Form 전송 객체 분리 - V4(핵심!)

1) Form 전송 객체 분리

실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

폼 데이터 전달에 Item 도메인 객체 사용

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

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

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

수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다. 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는 것이 좋다.

Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.

따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.

2) Form 전송 객체 분리 - 개발

ITEM 원복
Item 클래스의 검증은 사용하지 않으므로 검증 코드 제거(애노테이션들과 생성자 전부 제거)

@Data
public class Item {
    private Long id;

    // @NotBlank(message = "공백X")
    private String itemName;

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

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

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

ItemSaveForm - ITEM 저장용 폼

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

등록, 수정용 폼 객체를 사용하도록 컨트롤러 수정
ValidationItemControllerV4

@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {
    private final ItemRepository itemRepository;
    // private final ItemValidator itemValidator; // Bean validator 때문에 ItemValidator 필요없어짐

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v4/items";
    }

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

    @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) {

        // @ScriptAssert 대신 특정 필드 예외가 아닌 전체 예외 코드 추가
        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}";
    }
    @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}";
    }
}
  • Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.

8. Bean Validation - HTTP 메시지 컨버터

@Valid, @ValidateHttpMessageConverter(@RequestBody)에도 적용할 수 있다.

참고(중요!!)

  • @ModelAttributeHTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
  • @RequestBodyHTTP 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("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }
        log.info("성공 로직 실행");
        return form;
    }
}

Postman을 사용해서 테스트 해보자
성공 요청

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

Postman에서 Body raw JSON을 선택해야 한다.

@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter@ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated가 적용된다.

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

0개의 댓글