검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다
이전에 Validaotor(검증기)를 만들면서 컨트롤러를 깔끔하게 만들었는데
하지만 Validator 내부코드는 좀 지저분하고, 반복적인 검증이 많다.
@Component //스프링빈에 등록시켜서 컨트롤러에서 쉽게 가져다 쓰기위함
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
//매개변수로 들어오는 클래스타입과 item 클래스타입이 같은지 체크하는 부분
//매개변수로 들어오는 클래스타입이 item 클래스의 자식클래스인지 체크하는 부분
}
@Override
public void validate(Object target, Errors errors) { // target으로 검증할 객체가 넘어온다. Errors는 BindingResult의 부모클래스이다.
Item item = (Item) target; // Item 으로 캐스팅해서 사용
//컨트롤러에서 진행하던 검증부분을 그대로 가져왔다.
// Errors는 BindingResult의 부모클래스이므로 Errors에 BindingResult를 저장할 수있고
// errors안에도 reject,rejectValue가 존재하므로 그대로 메소드를 사용할 수 있다.
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price","range",new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity","max",new Object[]{9999}, null);
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin",new Object[]{10000, resultPrice}, null);
}
}
}
}
보면
값이 비어있는지 아닌지,
값의 범위가 어디서 부터 어디까지인지
이런 흔한 검증은 어느 객체의 필드에서도 사용될법하다.
이런 흔한 검증은 어노테이션으로 처리하자는 생각에서 출발하게 되었다.
위에서 필드에러에 대한 검증은 없애고 복합검증인 오브젝트에러만 냅두고, Item객체의 내부 코드를 다음과 같이 바꿀 수 있다.
@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;
}
}
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation이다.
Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. (기술은 인터페이스느낌이라고 보면됨)
마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다
하이버네이트 Validator 관련 링크
https://hibernate.org/validator/
날짜,이메일,범위등 다양한 검증어노테이션을 확인 가능하다.
Bean Validation - 시작
Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자.
먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법 부터 테스트 코드로 알아보자.
Bean Validation 의존관계 추가
Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가 된다.
spring-boot-starter-validation를 추가하게 되면
jakarta.validation이라는 라이브러리도 알아서 추가가 되고 그안에 어노테이션들이 있다.
어노테이션들은 인터페이스이므로 사용하려면 구현체가 필요한대
그것들은
hibernate.validator라이브러리 안에 들어있다.
spring-boot-starter-validation를 추가하면 알아서 추가되는 라이브러리들이므로 편리하게
검증 어노테이션을 사용할 수 있다
Jakarta Bean Validation
jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator 구현체
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까지만 허용한다.
보면 @NotNull,@NotBlank 등은 javax.validation.~ 인데
@Range는 hibernate.validator~이다.
즉 @NotNull,@NotBlank같은 기본 Bean Validation은 어떤 구현체(validator)에서도 동작하는데
@Range같은 hibernate.validator~ Bean Validation은 하이버네이트 validator에서만 동작한다.
BeanValidationTest - Bean Validation 테스트 코드
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.getMessage() = " + violation.getMessage());
}
}
}
검증기 생성
다음 코드와 같이 검증기를 생성한다.
이후 스프링과 통합하면 우리가 직접 이런 코드를 작성하지는 않으므로, 이렇게 사용하는구나 정도만 참고하자.
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
검증 실행
검증 대상( item )을 직접 검증기에 넣고 그 결과를 받는다.
Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다
Set<ConstraintViolation<Item>> violations = validator.validate(item);// 검증오류가 있으면 여기 담긴다.
실행 결과
ConstraintViolation 출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.
메시지는 기본으로 제공하는 메시지이다.
메세지는 검증어노테이션의 파라미터로 추가해서 설정해줄 수 있다.
@Data
public class Item {
private Long id;
@NotBlank(message = "공백 x")
private String itemName;
이렇게 빈 검증기(Bean Validation)를 직접 사용하는 방법을 알아보았다.
아마 지금까지 배웠던 스프링 MVC 검증 방법에 빈 검증기를 어떻게 적용하면 좋을지 여러가지 생각이 들 것이다.
스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.
Bean Validation 적용 - 프로젝트 V3
기존에 사용하던 상품관련 프로젝트를 그대로 사용한다. Bean Validation을 적용해볼것이다.
현재 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)
@Min(1)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
현재 컨트롤러의 addItem()
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
log.info("erros = {}", 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}";
}
스프링빈으로 등록한 검증기를 가져오고, WebDataBinder에 검증기를 추가하는 부분을 삭제하였다.
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
그런데 어떻게 검증이 동작하는것일까?
Item의 필드에 검증을 위해 Bean Validation을 적용했다. Bean Validation을 진행해줄 Validator(검증기)는 추가하는 코드를 넣지않았는데 어떻게 Validator가 동작해서 Bean Validation을 진행했을까?
스프링 MVC는 어떻게 Bean Validator를 사용하는가?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면
자동으로 Bean Validator를 인지하고 스프링에 통합한다.(LocalValidatorFactoryBean 을 글로벌 Validator로 등록)
스프링 부트는 자동으로 글로벌 Validator로 등록한다
(이전에 하나의 컨트롤러안에서 WebDataBinder에 Validator를 등록했었고, 그 이후로 모든 컨트롤러에 적용하려고 Application.java에서 global로 validator를 등록하는 작업을 해보았었다. 하지만 스프링은 알아서 Bean Validation을 처리할 validator를 global로 적용한다)
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
(@Valid,@Validated 이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다.)
@Validated @Valid 둘다 같은 애노테이션이다.
@Valid: 자바 진영에서 공통으로 사용 가능
@Validated: 스프링 안에서만 사용가능
@PostMapping("/add")
public String addItem(@Valid @ModelAttribute Item item
이렇게 @Validated 로 설정해놨던것을 @Valid로 바꾸어도 동작한다.
검증기는 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다
주의!
다음과 같이 직접 글로벌 Validator를 직접 등록하면 (이전시간에 직접 글로벌 validator를 등록해봤던 코드)
스프링 부트는 Bean Validator를 글로벌 Validator 로 등록하지 않는다.
따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다. 다음 부분은 제거하자.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
// 글로벌 검증기 추가
@Override
public Validator getValidator() {
return new ItemValidator();
}
// ...
}
어떤 글로벌 validator가 등록되어있으면 스프링은 다른 글로벌 validator를 등록하지않는다.
@Validate , @Valid
검증시 @Validated @Valid 둘다 사용가능하다.
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다. (아래의)
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.
둘중 아무거나 사용해도 동일하게 작동하지만,
@Validated 는 내부에 groups 라는 기능을 포함하고 있다.
이 부분은 조금 뒤에 다시 설명하겠다
(@Validated, @Valid 어떤것을 사용해도 상관없다. spring를 사용하는데 다른것으로 바꿀일이 잘 없기 때문이다.
만약 groups의 기능을 사용할것이라면 @Validated를 사용하고 아니라면 둘중 아무거나 사용한다.)
검증 순서
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
1. @ModelAttribute 각각의 필드에 타입 변환 시도
@ModelAttribute가 있으면 스프링이 RequestParam(http요청으로 넘어온 param데이터)을 해당 객체의 필드에 넣어준다.
필드에 값을 넣는데 실패하면 typeMismatch 로 FieldError 추가
성공하면 넘어간다.
2. Validator 적용 (검증)
바인딩에 성공한 필드만 Bean Validation 적용
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
@ModelAttribute --> 각각의 필드 타입 변환시도 --> 변환에 성공한 필드만 BeanValidation 적용
예)
1. itemName 에 문자 "A" 입력 타입 변환 성공 itemName 필드에 BeanValidation 적용
2. price 에 문자 "A" 입력 "A"를 숫자 타입 변환 시도 실패
typeMismatch FieldError 추가 price 필드는 BeanValidation 적용 X
Bean Validation - 에러 코드, 에러 메시지
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
log.info("erros = {}", bindingResult);
itemName에 공백을 넣었을때 발생하는 필드에러 내용
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 [공백 x]
오류코드 부분을 보면
codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank];
오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch 와 유사하다.
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
코드(에러).오브젝트명.필드명
코드(에러).필드명
코드(에러).필드타입
코드(에러)
즉 reject(),rejectValue()를 호출하면 MessageCodesResolver가
알아서 메시지 코드들을 생성해서 필드에러,오브젝트에러를 만드는것처럼
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의 에러코드를 추가하고 메시지를 등록하자.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
이전에 메시지 {0},{1} ~ 는 전달인자를 의미하고 숫자가 순서를 의미했다. 직접 필드에러를 생성할때
메시지에 대한 전달인자로
new Object[]{1000,1000000}
이런식으로 전달했다
하지만 BeanValidation은 MessageCodesResolver를 통해 알아서 에러들이 생성되고
{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.
메시지 등록전 기본 메시지
스프링이 알아서 전달인자도 줘서 오류 메시지를 구성해주었다.
그 자동으로 들어오는 전달인자를 이용해서 메시지를 구성해 주면된다.
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
BeanValidation 메시지 찾는 순서
(1번 부터 진행해서 찾으면 그걸로 메시지를 출력한다 (== 우선순위가 높다))
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 --> @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 --> 공백일 수 없습니다
애노테이션의 message 사용 예
@Data
public class Item {
private Long id;
@NotBlank(message = "공백 x")
private String itemName;
Bean Validation - 오브젝트 오류(Object Error)
Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는
어떻게 처리할 수 있을까?
다음과 같이 @ScriptAssert() 를 사용하면 된다.
Bean Validation는 객체의 필드에 어노테이션으로 들어가서 필드에러는 잡아주는데
그럼 필드에러가 아닌 오브젝트 오류는 어떻게 처리하는걸까?
@ScriptAssert() 적용
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000원이 넘게 입력해주세요")
public class Item {
private Long id;
@NotBlank(message = "공백 x")
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
@Min(1)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
결과
@ScriptAssert()로 인해 Obejct Error가 생성될때 메시지 코드
- ScriptAssert
- ScriptAssert.item
그런데 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데,
(다른 객체의 필드값을 가져온다던지, DB를 조회해야한다던지)
그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는
다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
다음과 같이 addItem() 수정 ( 오브젝트 에러 처리 부분 추가)
@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("erros = {}", 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}";
}
물론 저 오브젝트 에러 처리하는 부분을 메소드로 뽑아서 깔끔하게 처리하는것이 좋다 !!
Bean Validation - 한계
수정시 검증 요구사항
데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
등록시 기존 요구사항
타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
수정시 요구사항
- 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
- 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.
수정 요구사항 적용시도
기존의 Item에다가 수정 요구사항을 해결하기위해 Bean Validation을 추가하거나 삭제하였다.
@Data
public class Item {
@NotNull // 수정 요구사항 추가
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;
}
}
이렇게 수정하고 나면 수정할때 수량을 마음껏 적어도 잘 동작한다.
하지만
상품등록에서 문제가 발생한다.
문제1. 상품등록할때 수량제한은 9999인데, 수량검증이 동작하지않아 9999이상도 저장된다.
문제2. id가 @NotNull로 되어있어 무조건 전달되야하는 값인데 , 상품등록에서는 id값을 줄 수 있을리없다.
검증에 실패하고 다시 폼 화면으로 넘어온다. 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.
결과적으로 item 은 등록과 수정에서 검증 조건의 충돌이 발생하고,
등록과 수정은 같은 BeanValidation 을 적용할 수 없다.
이 문제를 어떻게 해결할 수 있을까?
Bean Validation - groups
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자
방법 2가지
- BeanValidation의 groups 기능을 사용한다.
- Item을 직접 사용하지 않고,
ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
BeanValidation groups 기능 사용
이런 문제를 해결하기 위해 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(message = "공백 x", groups = {UpdateCheck.class, SaveCheck.class})
private String itemName;
@NotNull(groups = {UpdateCheck.class, SaveCheck.class})
@Range(min = 1000, max = 1000000, groups = {UpdateCheck.class, SaveCheck.class})
private Integer price;
@NotNull(groups = {UpdateCheck.class, SaveCheck.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;
}
}
해당 검증어노테이션이 어떤 group에서 동작할것인지 설정해주면 된다.
수정 메소드 그룹적용( edit() )
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @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}";
}
@Validated의 전달인자로 어떤 그룹으로 검증할것인지를 전달한다.
@Validated(value = UpdateCheck.class)
value를 스킵해도된다.
저장 메소드 그룹적용(addItem())
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @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("erros = {}", 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}";
}
등록,수정시 각각 검증이 잘동작한다.
정리
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데,
그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
(group을 사용하려니 코드의 복잡도가 올라갔다, 지저분하다)
Form 전송 객체 분리(DTO 생성) - 프로젝트 V4
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법 중
이번에는 두번째 방법인
- Item을 직접 사용하지 않고,
ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
를 해보도록 하자.
실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라,
약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item을 직접 전달받는 것이 아니라,
복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. (DTO를 만들어서)
예를 들면
ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
(Item --> 도메인 객체)
비교
1. 폼 데이터 전달에 Item 도메인 객체 사용하는 경우
HTML Form -> Item -> Controller -> Item -> Repository
장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
(groups를 사용하면서 아이템 등록할때 검증했던것을, 수정할때 또 검증해야하는 중복이 생긴다)
2. 폼 데이터 전달을 위한 별도의 객체 사용 (DTO 사용)
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
(컨트롤러가 아닌 다른곳에서 생성할수도있음)
(만약 아이템저장메소드가 item객체를 받게 설계하는것이 좋은데
그때 각각 다른 requestDTO를 가지고 item객체를 만들어야할수가 있다.
그렇지않고 아이템저장 메소드의 매개변수를 DTO마다 따로 만드는것은 코드가 복잡해질것같다.)
수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다.
생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다.
예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다.
그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는 것이 좋다.
Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만,
앞에서 설명한 것과 같이 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다.
그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게
폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면
등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다
이름은 어떻게 지어야할까?
이름은 의미있게 지으면 된다.
ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest , ItemSaveDto 등으로 사용해도 된다.
중요한 것은 일관성이다.
등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다.
각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에
나중에 유지보수에서 고통을 맛본다. 이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.
(하나의 뷰 템플릿에서 등록,수정을 다하려면 계속 if와 같이 조건을 체크해야하는 코드가 들어갈것이고,
그런 코드가 많이 들어가게되면 복잡성이 올라가 유지보수할때 힘들것이다.)
Form 전송 객체 분리 (DTO) - 개발
이제 Item 의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다
(DTO를 만들어서 거기서 검증을 할것이니까.)
Item은 이제 도메인 객체로서 필요할때 생성해서 사용한다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Item 원래대로 수정하기!
ItemSaveForm (아이템 저장용 DTO)
@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 (아이템 수정용 DTO)
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000,max = 1000000)
private Integer price;
//수정에서 수량은 자유롭게 변경할 수 있다.
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("erros = {}", bindingResult);
return "validation/v4/addForm";
}
//itemRepository의 save는 item객체를 받아 저장하므로 item객체를 생성해준다.
Item item = new Item(form.getItemName(),form.getPrice(),form.getQuantity());
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
@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";
}
//itemRepository의 update는 item객체를 받아 저장하므로 item객체를 생성해준다.
Item item = new Item(form.getItemName(),form.getPrice(),form.getQuantity());
itemRepository.update(itemId, item);
return "redirect:/validation/v4/items/{itemId}";
}
주의할점
@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자.
이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다.
이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
@ModelAttribute 의 전달인자로 모델 속성명을 적지않으면 클래스명에서 맨 앞글자를 소문자로 바꾼 이름으로 저장된다.
즉
@ModelAttribute ItemSaveForm form
이렇게 되어있었다면 @ModelAttribute는 클래스이름인 ItemSaveForm의 앞글자를 소문자로 바꿔서
model.addAttribute("itemSaveForm", form);
이렇게 모델에 데이터를 넣게된다.
그러면 뷰 템플릿에서 item이라는 이름을 사용하는 부분을 수정해줘야한다.
뷰템플릿의 수정이 싫다면
@Validated @ModelAttribute("item") ItemSaveForm form
이렇게 모델 속성명을 지정해준다.
정리
Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.
Bean Validation - HTTP 메시지 컨버터 (@RequestBody에 검증 적용)
@ModelAttribute는 메시지 컨버터를 통해 request message를 해석하고,
파라미터를 받아서 , 프로퍼티접근법으로 객체를 생성해서 변환해주고
@RequestBody는 HTTP 메시지 컨버터를 이용해서 body로 들어온값을 객체로 변환해준다.
위에서는 @ModelAttribute를 사용했으므로 이번에는 @RequestBody를 사용해서 검증처리를 해보자.
@Valid , @Validated 는 @RequestBody에도 적용할 수 있다.
참고
@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@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;
}
}
@RequestBody도 @Validated 어노테이션 붙여줘서 검증해주고 , BindingResult에는 에러들이 담긴다.
발생한 필드,오브젝트 에러가 있다면 모든 에러(리스트)를 반환해준다 (메시지컨버터가 json으로 변환해준다)
발생한 에러가 없다면 객체를 반환한다.
테스트
API의 경우 3가지 경우를 나누어 생각해야 한다.
성공 요청: 성공
실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
성공케이스
실패케이스
스프링이 남긴 예외로그만 있고, 컨트롤러가 실행됬을때 생기는 로그가 없다!
그 뜻은 컨트롤러조차 실행이 되지않았다는것이다. 왜그럴까?
컨트롤러의 메소드를 호출하기위해 전달인자를 만들어야한다.
그때 http메시지컨버터가 ItemSaveForm객체를 json을 이용해서 만들때 오류가 발생했기때문에
컨트롤러의 메소드 조차 실행되지않은것이다.
HttpMessageConverter 에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
물론 Validator도 실행되지 않는다.
검증 오류 케이스
[
{
"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"
}
]
return bindingResult.getAllErrors();
위 코드는
ObjectError 와 FieldError 를 반환한다. 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다.
실제 개발할 때는 이 객체들을 그대로 사용하지 말고,
필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
(즉 따로 반환객체를 만들어서 에러객체 내부에서 중요한 필드만 꺼내서 저장하고 그 객체를 반환해야한다.
== 응답용 객체를 말하시는거같다.)
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다.
그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
(즉, 컨트롤러 메소드가 실행되서
그런 오류가 발생했을경우 다시 뷰템플릿으로 보낸다던지와 같이 컨트롤러 내부에서 처리가 가능했다)
-> 즉 컨트롤러까지 진행이 된다는것이다. BindingResult에 오류가 담기면서(타입오류같은)
(그리고 나머지 타입오류가 발생하지않은 필드는 검증까지 잘 처리된다.)
HttpMessageConverter으로 json을 처리하는것은 파라미터를 처리하는 @ModelAttribute 와 다르게
각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
-> (즉 json으로 한번에 객체를 생성하는걸 성공해야 그 객체를 인자로 넘겨서 컨트롤러가 호출된다는것이다, json으로 한번에 변환이 성공해야함 -> BindingResult가 있든말든 메시지컨버터가 json으로 객체변환을 성공해야 컨트롤러까지 진행가능)
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.
(HttpMessageConverter가 ItemSaveForm객체를 만드는것을 성공해야, 그것을 전달인자로 줘서 컨트롤러 메소드를 호출할 수 있기 때문이다. )
@ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다.
특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다
@RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가
진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
참고!
@RequestBody경우 HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
(@ModelAttribute인 경우 , BindingResult를 인자로 받는다면, 거기에 에러가 담겨서 예외 발생하지않고 컨트롤러까지 호출된다.)
예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다.
'인프런 > 스프링 MVC 2편' 카테고리의 다른 글
12) 로그인처리 - 쿠키, 세션 (실전) (0) | 2023.02.08 |
---|---|
11) 로그인처리 - 쿠키, 세션 직접 개발,적용해보기(개념단계) (0) | 2023.02.06 |
9) Validator , @Validated (@Valid) (0) | 2023.02.04 |
8)Validation(검증) , 오류 코드와 메시지처리 (4 ~ 6) (0) | 2023.02.03 |
7) Validation (검증) , 프로젝트 (V1 ~V2) ,오류 코드와 메시지처리 (1~3) (0) | 2023.02.03 |
댓글