본문 바로가기
Spring/스프링 MVC 활용

#5 Bean Validation

by 히포파타마스 2021. 7. 28.

Bean Validation

 

객체나 특정 상황에 대해 검증 로직을 매번 구현하고 Validator를 만드는 것은 상당히 번거롭다.

 

특히 특정 필드에 대한 검증로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

 

여기서 애노테이션을 활용한 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다.

Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 사용할 수 있다.

 

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 표준 기술이다.

쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.

Bean Validation을 구현한 기술중 일반적으로 사용하는 구현체는 하이버네이트 Validator이다(ORM과 관련 없음).

 

 

 

 

1. Bean Validation -  기본

Bean Validation을 사용하려면 다음 의존관계를 build.gradle에 추가해야 한다.

 

[Bean Validation 추가]

 

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

 

Bean Validation을 사용하면 애노테이션으로 각 필드를 검증할 수 있다.

[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 : 범위 안의 값이어야 한다.

· @Max : 최대 값을 제한한다.

 

 

 

 

2. Bean Validation - 스프링 통합

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

 

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

이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.

이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid 또는 @Validated만 적용하면 된다.

※ 기존에 WebDataBinder로 Validator를 추가했다면 중복이 발생하기 때문에 해당 부분은 제거해두어야 한다.

※ @Valid는 자바표준, @Validated는 스프링 전용 검증 애노테이션

 

 

 

■ Bean Validation - 에러 코드

rejectValue나 reject 메서드를 사용할 때는 errorCode를 인자로 받을 수 있었고 errorCode를 기반으로 메시지 코드를 생성하고 메시지 파일에서 메시지 코드를 적용하였다.

 

Bean Validation은 애노테이션을 사용해서 검증을 하기 때문에 errorCode를 사용할 수 없다.

대신 Bean Validation은 애노테이션 이름을 기반으로 메시지 코드를 생성한다.

예를 들어 @NotBlank 가 적용된 객체가 item이고 필드가 itemName이라면 Bean Validation은 다음과 같이 메시지 코드를 생성한다.

 

· NotBlank.item.itemName
· NotBlank.itemName
· NotBlank.java.lang.String
· NotBlank

 

메시지 코드 생성 규칙은 rejectValue & reject 와 거의 유사하다.

 

내가 원하는 메시지를 적용하고 싶다면 위 예시의 규칙을 적용해 메시지 파일에 메시지 코드를 추가하면 된다. 

 

또한, 애노테이션에 직접 message를 입력할 수 있다.

 

[Bean Validation 애노테이션 message 사용]

@NotBlank(message = "공백은 입력할 수 없습니다.") 
private String itemName;

메시지 파일에서 순서대로 메시지 코드를 찾아도 메시지 코드를 찾을 수 없을 경우 애노테이션의 message 속성의 메시지가 사용된다.

 

 

 

■ Bean Validation - 오브젝트 오류

Bean Validation의 애노테이션을 이용해 필드 오류를 아주 간편하게 처리할 수 있었다.

 

Bean Validation에서 특정 필드(FieldError)가 아닌 오브젝트 관련 오류(ObjectError)는 다음과 같이 처리할 수 있다.

 

[Bean Validation - 오브젝트 오류 처리]

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

이 방식은 실제로 제약이 많고 복잡하다.

게다가 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우가 있는데, 그런 경우 대응이 어렵다.

 

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

 

[컨트롤러에서 오브젝트 오류 처리]

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

컨틀롤러에서 reject 메서드를 사용해서 직접 글로벌 오류를 처리한다.

 

 

 

 

3. Bean Validation - groups

데이터를 등록할 때 같은 객체여도 상황에 따라 검증해야 하는 필드가 다를 수 있다.

예를 들어 상품을 등록할 때는 수량이 최대 9999를 넘어서는 안되지만 수정할 때는 수량에 제한이 없을 수 있다.

이 경우, 객체는 하나고 단일 검증기(Bean Validation)를 사용하기 때문에 특별한 방법이 없이는 이를 해결할 수 없다.

 

이를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.

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

 

[저장용 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, 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;
	} 
}

id는 수정시에만 Null체크를, 수량은 등록 시에만 최대 9999개 제한을 두었다.

 

[컨트롤러에 groups 적용]

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

@Validated에 속성으로 그룹을 지정해주면 그룹이 적용된다.

※@Valid에는 groups를 적용할 수 없다.

 

이처럼 groups 기능을 사용해서 등록과 수정시, 상황에 따라 각각 다르게 검증을 할 수 있다.

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

 

여러 이유 때문에 사실 실무에서는 groups 기능을 사용하기보다는, 각 상황에 따라 폼 객체를 분리해서 사용하는 방식이 선호된다.

 

 

 

 

4. Form 전송 객체 분리

실무에서는 각 폼에서 전달하는 데이터가 실제 객체와 딱 맞지 않기 때문에 groups를 잘 사용하지 않는다.

예를 들어, 회원을 등록할 시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 실제 회원 정보와 관계없는 수많은 부가 데이터가 넘어온다. 

반면 회원을 수정할 때는 약관 정보와 같은 몇몇 부가적인 데이터가 필요하지 않을 수 있다.

때문에 보통 회원 객체를 직접 전달받는 것이 아니라 폼에서 받는 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

 

Form 전송 객체를 분리할 경우 Item 저장의 경우 다음과 같은 흐름을 갖는다.

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

HTML Form에서 받는 데이터를 ItemSaveForm에 담아 컨트롤러에 전달한 후 검증을 마치고 Item 객체를 생성해 ItemSaveForm의 데이터를 추가한 후 저장한다.

 

위와 같이 각 폼에 데이터 전달을 위한 별도의 객체를 만들어서 검증을 적용하면 검증이 중복되는 문제도 해결된다.

 

원래 Item 객체의 Bean Validation 검증 애노테이션을 제거하고 상품 저장 폼의 데이터를 받을 객체를 만든다.

 

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

상품을 저장할 때 필요한 검증 애노테이션을 추가해준다.

 

[상품 저장 컨트롤러 - 폼 분리]

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

이제 @ModelAttribute로 Item을 받지 않고 ItemSaveForm으로 받는다.

ItemSaveForm에 검증을 적용하고 Item 객체를 만들어 ItemSaveForm의 데이터를 추가해서 itemRepository에 저장한다.

 

상품 수정의 경우도 등록과 동일게 변경하면 된다.

 

 

 

 

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

@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.

 

[Bean Validation - 메시지 컨버터 컨트롤러]

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

데이터를 받아 ItemSaveForm을 JSON 형태로 응답하는 컨트롤러이다.

ItemSaveForm에 검증이 적용된다.

 

위 예제의 컨트롤러에 다음과 같은 요청을 보낸다.

 

[타입 오류 요청]

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

price에 숫자가 아닌 문자가 전달되서 타입 오류가 발생한다.

 

[타입 오류 요청 결과]

{
	"timestamp": "2021-04-20T00:00:00.000+00:00", "status": 400,
	"error": "Bad Request", "message": "",
	"path": "/validation/api/items/add"
}

HttpMessageConverter에서 요청 JSON을 Item 객체로 생성하는데 실패한다.

이 경우는 Item 객체도 만들어지지 않기 때문에 컨트롤러가 호출되지 않고 그전에 예외가 발생한다.

물론 Validator도 실행되지 않는다.

 

이번에는 검증 오류가 발생하는 요청을 보내본다.

 

[검증 오류 요청]

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

수량제한이 9999이기 때문에 Bean Validation @Max(9999)에서 걸리게 된다.

 

[검증 오류 요청 결과]

[
	{
	"codes": [
		"Max.itemSaveForm.quantity", 
		"Max.quantity",
		"Max.java.lang.Integer", 
		"Max"
	],
	"arguments": [
		{
			"codes": [
				"itemSaveForm.quantity", 
				"quantity"
			],
			"arguments": null,
			"defaultMessage": "quantity",
			"code": "quantity"
		},
		9999
	],
	"defaultMessage": "9999 이하여야 합니다", 
	"objectName": "itemSaveForm",
	"field": "quantity",
	"rejectedValue": 10000, 
	"bindingFailure": false, 
	"code": "Max"
	}
]

검증 오류가 날 경우 FieldError나 ObjectError가 JSON으로 변환돼서 클라이언트에게 전달된다.

 

@ModelAttribute와 달리, HttpMesageConverter는 전체 객체 단위로 적용되기 때문에 정상적으로 작동해서 Item 객체를 만들어야만 @Valid, @Validated가 적용된다.

※ @ModelAttribute는 특정 필드가 바인딩 되지 않더라도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용된다.

'Spring > 스프링 MVC 활용' 카테고리의 다른 글

#7 로그인 처리 - 필터, 인터셉터  (0) 2021.08.01
#6 로그인 처리 - 쿠키, 세션  (0) 2021.07.30
#4 검증 - Validation  (0) 2021.07.28
#3 메시지, 국제화  (0) 2021.07.27
#2 타임리프 - 스프링 통합 폼  (0) 2021.07.27

댓글