반응형

Bean validation 소개

1. Bean Validation이란?

 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준
 검증 애노테이션과 여러 인터페이스의 모음
 Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator임

 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없음


2. 하이버네이트 Validator 관련 링크

 공식 사이트: http://hibernate.org/validator/
 공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
 검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

 

 

Bean validation 사용하기

1. 의존성 추가 및 에노테이션 확인

 Bean Validation 기능은 라이브러리를 추가해서 사용해야 함
build.gradle에 추가

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

※ spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가 됨

 Jakarta Bean Validation추가 확인

- jakarta.validation-api : Bean Validation 인터페이스

- hibernate-validator : 구현체

 

 

 

 

 

 

 

 

 

 

 

2. Item -Bean Validation 에노테이션 적용 예제

package hello.itemservice.domain.item;

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

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

import lombok.Data;

@Data
public class Item {
	private Long id;

	// 빈값 + 공백만 있는경우를 허용하지 않음
	@NotBlank
	private String itemName;

	// null을 허용하지 않음
	@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을 허용하지 않음
 @Max(최대값): 최대값 초과를 허용하지 않음
 @Range(min, max): 범위 안의 값이여야 함 

 


※ javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스
※ org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증
기능

※ 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 됨

 

 

3. 테스트코드로 동작 확인

@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>> validate = validator.validate(item);
    for (ConstraintViolation<Item> violation : validate) {
        System.out.println("violation = " + violation);
        System.out.println("violation.getMessage() = " + violation.getMessage());
    }

}

※ 임의로 validator를 꺼내서 테스트 진행 (아이템의 모든 필드의 유효성을 어긴 테스트 코드)

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 사이여야 합니다

※ 스프링 부트는 자동으로 글로벌 Validator로 등록함
 - spring-boot-starter-validation 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator를 인지하고 스프링에 통합함

 - LocalValidatorFactoryBean이 글로벌 Validator로 등록되며 @Valid , @Validated 만 적용하면 됨.

 - 위에서 사용해봤던 @NotNull과 같은 애노테이션 검증을 수행.

 - 검증 오류 발생시 FieldError, ObjectError를 생성해 BindingResult에 담아줌. 

 - 임의로 글로벌 Validator를 등록해주면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않기에 위의 검증 애노테이션들이 동작하지 않음.

 

 

필드 검증하기(FieldError)

1. 필드 검증 요구 사항

 이름은 공백이여선 안됨
 가격은 빈 값이면 안되고, 1000원 이상 100만원 이하여야 함
 수량은 빈 값이면 안되고, 9999개까지만 가능

 

 

2. 검증 요구사항 에노테이션 적용

@Data
public class Item {
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1_000_000)
    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;
    }
}

 

3. 컨트롤러에 적용

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
			BindingResult bindingResult, 
			RedirectAttributes redirectAttributes,
			Model model) {
		...
}

 @Validated 검증 애노테이션을 붙혀주고 검증결과를 담기위해 BindingResult 클래스를 바로 다음 위치에 매개변수 선언

스프링에서는 자동으로 필드에 적용된 검증 애노테이션을 수행

 

4. 검증 순서

① @ModelAttribute 각각의 필드에 타입 변환 시도
 - 성공하면 다음 필드 진행
 - 실패하면 typeMismatch로 FieldError 추가 
② Validator 적용


 Bean Validation 적용 

 - 각각의 필드에 바인딩이 성공한 필드만 Bean Validation이 적용

 - BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않음
 - 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있음

※ price 에 문자 "A" 입력 → "A"를 숫자 타입 변환 시도 실패  typeMismatch FieldError 추가   price 필드는 BeanValidation 적용 안됨

 

5. 검증 메세지 수정

 Bean Validation을 사용하면서 따로 messages.properties를 설정해주거나 작성해준적이 없는데도, 메세지가 출력됨. 

 해당 라이브러리에서 지정한 기본 메세지인데, 만약 이를 임의로 바꾸고 싶다면 MessageCodeResolver의 메세지 코드를 보면 됨.
 메세지 설정에서 MessageCodeResolver는 다음과 같이 각각의 애노테이션에 대한 메세지코드가 생성됨


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

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

 

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

 

※ 애노테이션의 message 사용 예

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

 

 

객체 검증 하기 ObjectError

1. 객체 검증

 하나의 필드에 붙힐 수 없는 이런 로직상의 검증

ex) 가격과 수량의 합은 10000원 이상이어야 한다.

 

2. @ScriptAssert

2.1 사용 예제

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

 정상 수행이 되고 다음과 같은 순서로 메세지 코드도 찾음
 - ScriptAssert.item
 - ScriptAssert

 

※ 이 방식은 다음과 같은 이유로 실무에서 잘 사용되지 않음
 애노테이션의 기능자체가 강하지 않아 제약이 많고 복잡
 실무에선 검증 기능이 해당 객체의 범위를 벗어나는 경우도 있는데 이 경우 대응이 어려움
 제약조건이 많아질수록 코드가 길어지는데 속성에 로직을 넣기엔 가독성이 너무 떨어지게 됨.

 

3. 직접 코드로 구현하기

@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[]{10_000, resultPrice}, null);
		}
	}
	// ... //
}

※ 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장

 

 

Bean validation의 한계

1. 상황에 따라 달라지는 검증 조건

 지금까지 예제로 다뤄 본 코드는 상품 등록(POST)에 대한 부분이였고, 검증까지 무사히 완료함
상품 수정(Fetch or Put)의 검증이 등록일 경우와 달라질 수 있음

상품 등록시 전송될 내용과 수정시 전송될 내용도 상이할 확률이 높음
ex) 상품 등록시에는 아직 등록이 되지 않았기에 아이디(id)가 존재하지 않지만, 수정시에는 이미 등록된 상품을 수정하는 것이기에 id가 null이여서는 안됨(NotNull)

상품 등록시에는 수량을 1~9999개까지만 허용했지만, 등록후에는 그 외의 값으로 수정을 해도 제약이 없도록 할 수도 있음

 등록과 수정의 상이한 제약조건은 지금 기존에 작성된 상품 엔티티에서는 적용이 불가능
 이처럼 상황에 따라 달라지는 검증 조건을 스프링에서는 다음과 같이 두 가지 방법으로 이를 해결할 수 있음
 - Bean Validation의 groups 기능을 사용하기
 - 전송 객체 분리하기(ItemSaveForm, ItemUpdateForm)

groups는 한계가 명확하기에 전송 객체 분리가 일반적으로 옳은 선택지

 

2. Bean Validation - groups를 사용해 검증 분리

2.1 사용법

등록과 수정 각각의 group을 인터페이스로 만들어서 groups 라는 속성을 사용하면 됨

 

2.2 예제

등록 인터페이스 생성

package hello.itemservice.domain.item;

public interface SaveCheck {}

 

수정 인터페이스 생성

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 = 1_000_000)
    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;
    }
}

groups는 다수의 그룹도 설정할 수 있으며 필요에따라 맞는 그룹을 선택해 검증할 수 있음

 

controller에서 필요한 검증 groups 선택

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

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
		//...
}

 - addItem에서는 상품 저장이기에 @Validated 애노테이션에 속성으로 SaveCheck.class 사용
 - editV2에서는 상품 갱신이기에 @Validated 애노테이션 속성으로 UpdateCheck.class 사용.
 - Item 객체에서는 각각 @Validated 애노테이션에 작성된 인터페이스가 선언된 검증만 수행 

 

※ 이 방식은 사실 잘 사용되지 않음
- 해당 애노테이션자체가 문제가 있는것은 아니고 등록,수정시 전달되는 내용이 상품 도메인 객체(Item)과 딱 일치하지 않음.
ex) 회원 가입을 한다고 할 땐 회원 정보에 더해 약관정보같은 추가 정보가 있을 수 있고 아직 등록하지 않기에 존재하지 않는 정보들도 있을 수 있음 

- 이런 엔티티를 사용자에게 노출시키는 것은 보안상으로도 문제가 많음

- 노출시켜도 되는 필드를 모아 View 객체를 만들어 이를 통해 데이터를 주고받고는 함.

 

※ @Valid 검증 애노테이션은 groups라는 속성이 없기 때문에 해당 기능을 사용할 수 없음

 

 

3. Bean Validation - 등록과 수정의 Form전송 객체 분리로 검증 분리

3.1 객체 분리의 장점

 각각에 상황에맞는 전용 폼 객체를 따로 만들어서 상황에 맞는 검증을 하고, 전송 객체이기에 사용자에게 노출해도 상관이 없는 객체가 됨.

 이렇게 구현 할 경우 도메인 객체로 한번 더 변환을 해서 등록이든 수정이든 해야한다는 추가 과정이 생기지만, 이 과정을 줄이고자 엔티티를 그대로 사용하는 것보다 장점이 더 큼 

 

3.2 예제

 등록 form 전송 객체 생성

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;

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

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

 

 수정 form 전송 객체 생성

@Data
public class ItemUpdateForm {
    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정일 경우 제약은 사라진다.
    private Integer quantity;
}

 

controller에 적용

@PostMapping("/add")
public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		// ... 생략 ... //

		// 성공 로직
		Item item = new Item();
		item.setItemName(form.getItemName());
		item.setPrice(form.getPrice());
		item.setQuantity(form.getQuantity());
        
		// ... 생략 ... //
}

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
		//...
}

 - @ModelAttribute에 추가되는 value 속성
: 이전과 다르게 컨트롤러에서 @ModelAttribute에 item 이라는 value 속성을 작성해줌. 

만약 이를 작성해주지 않으면 규칙에 따라 MVC Model에는 itemSaveForm라는 이름으로 담기게 됨.

그렇게되면 기존에 뷰 템플릿에서 th:object 이름을 item으로 선언해줬는데 이를 itemSaveForm으로 수정해줘야 함.

 

- Form 객체의 도메인 객체 변환 작업 

: 폼 객체를 기반으로 Item 객체를 생성 및 수정해야 하기 때문에 변환 과정이 작성되야하는데, 폼 객체와 도메인 객체간의 커플링을 최소한으로 할 수 있도록 설계에 주의.
보통 폼 객체와 같은 DTO 에서 도메인을 의존하는것은 괜찮지만 반대의 경우는 괜찮지 않음.
의존의 방향은 변경이 많은곳에서 변경이 적은곳으로 향하는게 바람직.

 

 

Bean validation - HTTP 메세지 컨버터

※ Form 데이터 전송이 아닌 ajax, fetch, axios 등등 프론트 영역에서 API JSON을 요청하는경우에도 @Valid, @Validated는 HttpMessageConvert(@RequestBody)에서도 사용할 수 있음.

 

※ @ModelAttribute, @RequestBody
 - @ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)을 다룰 때 사용
 - @RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용

 

 

1. 예제

1.1 컨트롤러 소스

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
    @PostMapping("/add")
    public Object addItem(@Validated @RequestBody ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");
        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

1.2 postman 테스트 요청 및 응답 정보

※ API의 경우 다음과 같은 3가지 경우가 발생할 수 있다. 

 성공 요청: 성공
 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함


 성공 요청: 성공 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price" : 1000 , "quantity": 100}

 - 응답 데이터

{
    "itemName": "hello",
    "price": 1000,
    "quantity": 10
}


실패 요청: JSON을 객체로 생성하는 것 자체가 실패함 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price": "A", "quantity": 10}

 - 응답 데이터

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

HttpMessageConverter에서 요청 JSON을 객체로 생성하는 것 자체가 실패하는 경우 문제

지정한 객체(ex: Item)로 만들지 못하기 때문에 컨트롤러 호출이 되지 않기 때문에 Validator도 실행되지 않음

 


검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price": 1000, "quantity": 10000}

 - 응답 데이터

[
    {
        "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": 100000,
        "bindingFailure": false,
        "code": "Max"
    }
]

 

 

※ @ModelAttribute vs @RequestBody
 폼 전송방식으로 할 때 @ModelAttribute를 사용할 때는 타입이 불일치해도 발생하지 않는 문제가 @RequestBody를 사용할때는 발생하는 것일까?
 - HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용되기에 특정 필드가 타입이 맞지 않더라도 나머지 필드를 정상 처리할 수 있음.
 - 하지만, HttpMessageConverter는 @ModelAttribute과는 다르게 필드 단위가 아닌 객체 전체 단위로 적용되기 때문에 메세지 컨버팅이 성공해서 객체가 만들어진 다음에나 검증 애노테이션(@Valid, @Validated)이 적용됨

 

※ HttpMessageConverter 단계에서 실패하면 예외가 발생. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룸

반응형

+ Recent posts