[Spring] Bean Validation

bien·2023년 9월 20일
1

Spring_MVC2

목록 보기
6/7

소개

검증기를 일일이 매번 작성하는 것은 상당히 번거로운 일이다. 특히 특정 필드에 대한 검증 로직은 '값이 비었는지', '특정 범위 안인지'와 같이 매우 일반적인 로직이다.

이런 로직들을 애노테이션화 해서 아래와 같이 간단히 줄일 수 있다.

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;

	//...
    
}

너무 좋다

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다. 이를 잘 활용해, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

Bean Validation이란?

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.

Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.

하이버네이트 Validator 관련 링크


시작

스프링과 통합하지 않고 순수한 Bean Validation 사용법 부터 테스트 코드로 알아보자!

Bean Validation 의존관계 추가

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

의존관계를 추가하면 라이브러리가 추가된다.

대략적으로 어떤 종류가 있는지 확인할 수 있다.

Jakarta Bean Validation

jakarta.validation-api Bean Validation 인터페이스
hibernate-validator 구현체

테스트 코드 작성

Item.class

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
   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까지만 허용한다.

참고

import문을 보면 출처가 다르다.

  • javax.validation.constraints.NotNull
  • org.hibernate.validator.constraints.Range

javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고, org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능이다. 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 된다.

BeanValidationTest

한번정도 따라해보면 좋다. 외울필요 없다.

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;

public class BeanValidationTest {

	@Test
	void beanValidation() {
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
		Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" "); //공백
        item.setPrice(0);
        item.setQuantity(10000);
 
        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation=" + violation);
            System.out.println("violation.message=" + violation.getMessage());
		}
	}
}

검증기 생성

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

이렇게 검증기를 생성한다. 스프링에서 사용하면 이 코드는 작성하지 않으므로, 참고만 하자.

검증 실행

검증 대상(Item)을 직접 검증기에 넣고 그 결과를 받는다. Set에는 ConstraintViolation이라는 검증 오류가 담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다.

Set<ConstraintViolation<Item>> violations = validator.validate(item);

실행결과

violation={interpolatedMessage='공백일 수 없습니다', 
propertyPath=itemName, 
rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{javax.validation.constraints.NotBlank.message}'} 
violation.message=공백일 수 없습니다

violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, 
rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{javax.validation.constraints.Max.message}'} 
violation.message=9999 이하여야 합니다

violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', 
propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{org.hibernate.validator.constraints.Range.message}'} 
violation.message=1000에서 1000000 사이여야 합니다.

ConstraintViolation 출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.

스프링은 이미 개발자를 위한 빈 검증기를 스프링에 완전히 통합해두었다. 매우 간단히 사용할 수 있다!


스프링 적용

코드제거

ItemValidator를 제거하자. 오류 검증기가 중복된다.

private final ItemValidator itemValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
	log.info("init binder {}", dataBinder);
    dataBinder.addValidators(itemValidator); 
}

코드 검증 기능을 수행하는 Item Validator 코드를 제거했는데도 검증 기능이 정상 작동한다.

스프링 MVC는 어떻게 Bean Valitor를 사용?

스프링 부트가 sprin-bott-starter-validation라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

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

LocalValidatorFactoryBean : 완전 공용 검증기. 애노테이션 기반으로 모든 객체를 다 검증 해준다. @Valid, @Validated 애노테이션만 있으면, 스프링이 애노테이션 기반으로 검증해주는 LocalValidatorFactoryBean 검증기를 가져와서 검증기를 다 돌려준다.

주의!

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

	// 글로벌 검증기 추가
	@Override
	public Validator getValidator() {
		return new ItemValidator(); 
    }
	// ...
}

다음과 같이 직접 글로벌 Validator를 등록하면 스프링 부트가 LocalValidatorFactoryBean를 공용 검증기로 등록하지 않는다. 따라서 애노테이션 기반 스프링 제공 검증기를 사용하려면 위 글로벌 Validator 코드는 제거되어야 한다!

📗 @Validated vs @Vlid

  • @Valid
    • 자바 표준 검증 애노테이션.
    • 사용하려면 build.gradle에 의존관계 추가가 필요하다.
      • implementation 'org.springframework.boot:spring-boot-starter-validation'
  • @Validated
    • 스프링 전용 검증 애노테이션.
    • 내부에 groups라는 기능을 포함. 이 기능을 사용하려면 이 애노테이션을 사용해야 한다.

📗 검증 순서

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

바인딩에 성공한 필드만 Bean Validation적용

BeanValidator는 바인딩에 실패한 필드는 Bean Validation을 적용하지 않는다. 생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 Bean Validation 적용이 의미 있다. (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)

@ModelAttribute > 각각의 필드 타입 변환 시도 > 변환에 성공한 Bean Validation 적용

예)

  • itemName에 문자 "A" 입력 > 타입 변환 성공 > itemName 필드에 BeanValidation 적용
  • price에 문자 "A" 입력 > "A"를 숫자 타입 변환 시도 실패 > typeMismatch FieldError 추가 > price 필드는 Bean Validation 적용 X

결론

  • @Valid, @Validated만 쓰면 스프링이 검증해준다!

에러 코드

Bean Validation이 제공하는 기본 오류메시지를 좀 더 자세히 변경하고 싶다면 어떻게 하면 될까?

스프링에서 검증 시, 오류코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch처럼.

따라서, (@NotBlank의 경우) 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.properteis

#Bean Validation
추가 NotBlank={0}
공백X Range={0}, {2} ~ {1} 허용 
Max={0}, 최대 {1}

{0}은 필드명이고, {1}, {2} ...은 각 애노테이션 마다 다르다.

BeanValidation 메시지 찾는 순서.

  1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
  2. 애노테이션의 message속성 사용 -> @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.

오브젝트 오류

애노테이션들이 전부 필드 단위로 적용되어 있다.
특정 필드가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?

public class Item { 

	private Long id; 
    
    @NotBlank
	private String itemName;
    
    @NotNull
	@Range(min = 1000, max = 1000000) 
    private Integer price;
    
    //...

}

다음과 같이 @ScriptAssert()를 사용하면 된다.

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
	//...
}

실행해보면 정상 수행된다.

메시지 코드

  • ScriptAsset.item
  • ScriptAssert

그러나 실제 사용해보면 제약이 많고 복잡하다. 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 있어 그런 경우 대응이 어렵다.

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

ValidationItemControllerV3: 글로벌 오류 추가

@PostMapping("/add")
public String addItem(@Validated @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);
		return "validation/v3/addForm"; 
    }
    
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());     
    redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v3/items/{itemId}"; 
}

한계

수정 시 검증 요구사항

데이터 등록 시와 수정 시 요구사항이 다를 수 있다.

등록 시 기존 요구사항

  • 타입검증
    • 가격, 수량에 문자 가들어가면 검증 오류 처리
  • 필드검증
    • 상품명: 필수, 공백X
    • 가격: 1000원이상, 1백만원이하
    • 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    • 가격 * 수량의합은 10,000원이상

수정 시 요구사항

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

참고
로직 상 수정시 항상 id값이 들어있도록 로직을 구성했더라도, HTTP 요청은 언제든 악의적으로 변경 가능함을 고려해야 한다. 따라서, 서버에서 항상 값을 검증해야 한다. 예를 들어서 HTTP 요청을 변경하여 item의 id값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전하다.

수정 요구사항 적용

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) 제거

결과적으로, 수정은 잘 작동하나 등록에서 문제가 발생한다.
등록에는 id값도 없고, qunatity 수량 최대 값인 9999도 적용되지 않는다.

하나의 객체에 대해 두개의 검증 조건(등록 or 수정)이 발생한 경우, 같은 Bean Validation을 적용할 수 없다. 이 문제를 어떻게 해결할 수 있을까?


groups

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

방법 2가지

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

BeanValidation groups 기능 사용

이런 문제를 다루기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어 '등록시에 검증할 기능'과 '수정시에 검증할 기능'을 각각 그룹으로 나누어 적용 가능하다.

groups 적용

저장용 groups 생성

package hello.itemservice.domain.item; 

public interface SaveCheck {
}

수정용 groups 생성

package hello.itemservice.domain.item;
public interface UpdateCheck {
}

Item - groups 적용

package com.study.admin.dto;

import lombok.Data;

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

@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})
    @Max(value = 9999, groups = SaveCheck.class) // 등록시에만 적용
    private Integer quantity;
    
    //...
    
}

저장로직에 SaveCheckGroups 적용

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, 
						BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	//...
}
  • @ValidatedSaveCheck.class를 적용했다.

수정 로직에 UpdateCheck Groups 적용

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) 
					@ModelAttribute Item item, BindingResult bindingResult) {
	//...
}
  • @ValidatedUpdateCheck.class를 적용했다.

참고
groups를 사용하려면 @Validated를 사용해야 한다. @Valid에는 해당 기능이 없다.

정리
groups를 사용하니 전반적으로 복잡도가 올라갔다. 이런 이유로 groups 기능은 실제 잘 사용되지 않는다. 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용한다.


Form 전송 객체 분리: 소개

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

현재 예제로 사용중인 프로젝트에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 정확히 일치한다. 하지만 실무에서는 회원 등록 시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등, Item(도메인)과 관계없는 수 많은 부가 데이터가 넘어온다.

그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면, ItemSaveForm이라는 폼을 전달받는 전용 객체를 생성해 @ModelAttribute로 사용. 이것을 통해 컨트롤러에서 폼 데이터를 전달받고, 이후 컨트롤러에서 필요한 데이터를 사용해 Item을 생성한다.

1. 폼 전달에 Item 도메인 객체 사용

HTML Form -> Item -> Controller -> Item -> Repository

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

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

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repostiory

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

답은 2번!

등록과 수정은 완전히 다른 데이터가 넘어온다. 검증 로직에도 차이가 있는 경우가 많다. 그래서 ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는 것이 좋다.

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

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

Q: 작명은 어떻게 해야 하나요?
의미있게 지으면 된다. ItemSave, ItemSaveForm, ItemSaveRequest, ItemSaveDto 등등 모두 사용 가능하다. 중요한 것은 일관성이다.

Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때 if문으로 구분) 때문에 나중에 유지보수에서 고통을 맛본다. 이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.


Form 전송 객체 분리: 개발

Item 원복

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;
    
}

이제 Item의 검증은 사용하지 않는다.

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

이제 save에는 사용되지 않던 id 필드를 제거할 수 있다. (이 부분이 특히 직관적이고 좋은 것 같다.)

ItemUpdateForm

@Data
public class ItemUpdateForm {

    @NotNull 
    private Long id;

    @NotBlank
    private String itemName; 

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

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

이제 update에 요구되는 id 필드를 검증할 수 있다.

프로젝트를 수행하면서 그런 생각을 많이했다. 내가 작성한 코드의 방향성에 대해 모르는 사람이 내 코드를 보면 이 코드를 이해할 수 있을까? 프로젝트를 하면서 종종 내가 작성하고 있는 이 코드를 누군가 타인이 본다면, 아주 오랜시간을 할애해야 나의 의중을 파악할 수 있겠다는 생각이 든 적이 몇번 있었다. 그리고 그런 것이 참 싫었지만, 나는 그걸 해결할 능력이 없었다...(나도 모두가 착 보면 착 이해하는 완벽간단한 코드를 작성하고 싶다ㅜㅜ)
Save, Update DTO를 분리하는 것은 나도 프로젝트를 수행하면서 고민해본 문제였다. 내 프로젝트는 너무 단순했기에 분리로 인해 증가하는 코드 양이 분리하지 않는 것에 대한 번거로움보다 크다고 생각했다. 그러나 updateDTO에서 id 필드가 존재하고, saveDTO에서 id필드가 존재하지 않는 것을 보며 분기가 매우 큰 강점을 가진다고 느꼈다. 불필요한 필드가 존재하는 DTO는 처음 프로젝트 코드를 접하는 이에게 얼마나 많은 혼란을 줄까? 저 단순한 행위 하나가 얼마나 많은 오류를 야기할 수 있을까? 분기의 여지가 보인다면 분기하는것이 맞겠다, 그런 생각을 했다.
왜 나는 이 생각을 못했을까? 분기하고, 필요한 정보만 Entity로 담아서 사용하는것 까지 너무 깔끔하다. 왜 이렇게 다룰 생각을 못했을까? 고민이 짧았던 것 같다. 2가지 방향이 있었다면, 2가지 방향을 모두 구현해보고 더 나은 방법에 대해 생각해보는 것도 방법이겠다.

ValidationItemController

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

HTTP 메시지 컨버터

Bean Validation(@Validate, @Validate)는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.

참고
@ModelAttribute : HTTP 요청 파라미터(URL 쿼리 스트링, POST From)를 다룰 때 사용한다.
@RequestBody : HTTP 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;
	}
}

API의 경우 3가지 겅우의 수가 발생한다.

  • 성공 요청: 성공
  • 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
    • (컨트롤러 호출에 실패하게 됨)
  • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

Postman 사용해 테스트

1. 성공 요청

  • Body -> raw -> JSON 선택
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10}

성공 시 로그

2. 실패 요청

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}
  • price의 값에 숫자가 아닌 문자를 전달했다.

실패 시 결과

스프링이 만들어준 오류 문자가 반환된.

실패 시 로그

-HttpMessageConverter에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패했다.

  • 이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에, 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
    • Validator도 실행되지 않는다.
    • 아예 log.info("API 컨트롤러 호출"); 이 로그가 뜨지 않았다.

3. 검증 오류 요청

이번에는 HttpMessageConverter는 성공하지만 검증(Validator)에서 오류가 발생하는 경우를 확인해보자.
사실 우리가 원하는 경우의 수는 이 경우다.

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

수량(quantity)가 10000이면 @Max(999)에 걸리게 된다.

검증 오류 결과

return bindingResult.getAllErrors();ObjectErrorFieldError 를 반환한다. 스프링이 이 객체를 JSON으로 변환해 클라이언트에게 전달했다. 여기서는 예시로 검증 오류 객체들을 그대로 반환했지만, 실제 개발 시에는 이 객체들 중 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어 반환해야 한다.

검증 오류 로그

컨트롤러가 호출되었고, 오류 검증도 정상 수행되고 있다.

정리) @ModelAttribute vs @RequestBody

🤔 bindingResult 붙이면 알아서 객체 넣어서 만들어주고, 컨트롤러 호출도 됐었던것 같은데? 왜 안되는거지?

  • @ModelAttribute

    • 필드 단위로 정교하게 바인딩이 적용된다.
    • 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있다.
    • 컨트롤러 호출되고, validator 적용도 가능하다.
  • @RequestBody

    • HttpMessageconverter@ModelAttribute와 다르게 각각 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
    • HttpMessageconverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다.
      • 따라서 이 경우에는 따로 API 예외 처리를 해야한다.
    • 컨트롤러가 호출되지도 않고, validator도 적용할 수 없다.

HttpMessageConverter란?

  • Srping Framework에서 HTTP 요청 및 응답 메시지를 다른 미디어 유형으로 변환하고 처리하는 데 사용되는 인터페이스.
  • 이를 이용해 다양한 미디어 유형(JSON, XML, HTML...)의 데이터를 HTTP 요청 및 응답으로 변환하고 역변환할 수 있다.
    • 예를 들어, Spring 컨트롤러에서 메서드의 파라미터가 자바 객체이고 요청의 본문이 JSON 형식으로 올 경우, HTTP 요청 본문의 JSON 데이터를 해당 자바 객체로 변환해주는 역할을 수행한다.
  • Spring의 MediaType과 함께 작동하여 어떤 미디어 유형으로 데이터를 변환할지 결정한다.
    • 예를 들어, application/json 타입의 요청인 경우 MappingJackson2HttpMessageConverter를 사용한다.

@RequestBody란?

  • 주로 Spring 컨트롤러 메서드에 사용되어, HTTP 요청의 본문(body)를 자바 객체로 변환하기 위해 사용된다.
  • 메서드의 파라미터에 적용하면 Spring은 해당 파라미터 타입으로 HTTP 요청 본문의 데이터를 변환한다.
@PostMapping("/create")
public ResponseEntity<User> createUser(@RequestBody User user){
	// user 객체는 HTTP 요청 본문의 JSON 데이터로 자동 변환됨
    // ...
}
  • 이때, @RequestBody가 JSON 형식의 요청 본문을 자바 객체로 변환할 때 JSON을 파싱하는 HttpMessageConverter를 활용한다.
profile
Good Luck!

0개의 댓글