관리 메뉴

나구리의 개발공부기록

검증2 - Bean Validation, 소개/시작/프로젝트준비V3, 스프링 적용, 에러 코드, 오브젝트 오류, 수정에 적용, Bean Validation의 한계, groups, Form 전송 객체 분리(프로젝트준비V4/소개/개발), HTTP 메시지 컨버터 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

검증2 - Bean Validation, 소개/시작/프로젝트준비V3, 스프링 적용, 에러 코드, 오브젝트 오류, 수정에 적용, Bean Validation의 한계, groups, Form 전송 객체 분리(프로젝트준비V4/소개/개발), HTTP 메시지 컨버터

소소한나구리 2024. 9. 4. 20:24

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/GMo43


1. 소개/시작/프로젝트준비V3

1) Bean Validation 소개

  • 검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다(기존의 ItemValidator 클래스 처럼)
  • 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화, 표준화 한것이 Bean Validation이며 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있음

(1) Bean Validation 예시

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;

	//...
}

 

(2) Bean Validation이란?

  • Bean Validation 2.0(JSR-380)이라는 기술 표준 (검증 애노테이션과 여러 인터페이스의 모음)
  • 일반적으로 사용하는 구현체는 하이버네이트 Validator임 (이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다고함)

(3) 하이버네이트 Validator 관련링크

2) 스프링과 통합 Bean Validation

(1) 기존에 작업했던 validation 프로젝트의 build.gradle에 Bean Validation 의존관계 추가

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

 

(2) Jakarta Bean Validation 라이브러리

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

3) 테스트 코드 작성

(1) Item - Bean Validation 애노테이션 적용 (위 예시 코드와 동일함)

  • @NotBlank: 빈값 + 공백만 있는 경우를 허용하지 않음
  • @NotNull: null을 허용하지 않음
  • @Range(min = 1000, max = 1000000): 범위 안의 값이어야 함
  • @Max(9999): 최대 9999까지만 허용
  • import에서 vajax.validation.~ 으로 시작하면 구현에 관계없이 제공되는 표준 인터페이스
  • org.hibernate.validator.~로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능(대부분 실무에서 하이버네이트 validator를 사용하므로 자유롭게 사용해도 됨, 스프링부트도 자동으로 넣어줌)
@Data
public class Item {

    private Long id;

    @NotBlank
//    @NotBlank(message = "공백 X") // 메시지를 직접 입력 할 수도 있음(default 메시지로 적용됨)
    private String itemName;

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

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

 

(2) BeanValidationTest

  • test하위 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 = " + violation.getMessage());
        }
    }
}
// 실행 결과
// violation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
// violation = 9999 이하여야 합니다
// violation = ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
// violation = 1000에서 1000000 사이여야 합니다
// violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
// violation = 공백일 수 없습니다

4) 프로젝트 준비 V3

(1) ValidationItemControllerV2 복사 붙혀넣기로 ControllerV3 생성

  • URL경로 변경 validation/v2/ -> validation/v3/

(2) ~/validation/v2/ 템플릿 파일을 ~/validation/v3 디렉토리로 복사 후 모든 하위 파일의 URL경로를 변경(위와 동일)


2. 스프링 적용 

1) ValidationItemControllerV3의 필요없는 코드 정리

  • 주석된 코드들 전부 삭제
  • addItemV6() -> addItem()
  • private final ItemValidator itemvalidator; 와 @InitBinder init() 코드들 삭제

(1) 스프링 MVC의 Bean Validator를 사용하는 원리

  • spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합됨
  • 스프링 부트가 자동으로 글로벌 Validator로 LocalValidatorFactoryBean을 등록하고 @NotNull 같은 애노테이션을 보고 검증을 수행함
  • 글로벌로 적용되어있기 때문에 @Valid, @Validated만 파라미터에 적용하면 동작함
  • 그래서 직접 Validator를 등록하면 스프링 부트가 Bean Validator를 글로벌 Validator로 등록하지 않기 때문에 굳이 등록할 필요는 없어 보임
  • 이미 이전 시간에 검증 애노테이션을 등록했고, addItem()에 @Validated를 적용했기 때문에 바로 동작함(global error는 제외되었는데 이부분은 나중에 설명)

2) 검증 순서

(1) @ModelAttribute 각각의 필드에 타입 변환 시도 

  • 성공하면 다음으로넘어가고 실패하면 typeMismatch로 FieldError 추가

(2) Validator 적용

3) 바인딩에 성공한 필드만 Bean Validation이 적용됨

  • 즉, 바인딩에 실패한 필드는 Bean Validation이 적용되지 않음
  • 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있기 때문에 바인딩에 성공한 필드여야 Bean Validation 적용이 의미가 있음
  • @ModelAttribute -> 각각의 필드 타입 변환 시도 -> 변환에 성공한 필드면 BeanValidation 적용

** 예시

  • price에 문자 "A"를 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 -> price필드는 beanValidation 적용 x (기존에 error.properties에 등록해놓은 에러 메시지가 출력됨)

3. 에러 코드

1) 에러 코드

  • 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶다면?
  • 오류 코드가 애노테이션 이름으로 등록되기 때문에 error.properties에 추가해주면 변경됨
  • 상세 순서는 기존과 동일함

(1) @NotBlank 오류 코드

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

 

(2) errors.properties에 오류 메시지 등록

  • 등록 후 실행해보면 아래 등록된 메시지가 정상 적용됨
#Bean Validation 추가
#{0}은 필드명, {1}, {2} ... 등은 각 애노테이션 마다 다르니 문서 참고
NotBlank={0} 공백 X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

 

(3) BeanValidation메시지 찾는 순서

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

(4) 애노테이션의 message 사용 예

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

4. 오브젝트 오류

1) @ScriptAssert() 사용 - 비추천

  • message없이 적용하면 상당히 개발자스러운 에러 메시지가 default로 출력되고 message 기능을 넣어서 입력하고자 하는 메시지를 입력 할 수 있음
  • 그러나! 실제로 사용해 보면 제약이 많고 복잡하다고함(검증시 해당 객체의 범위를 넘어서는 경우도 있고, DB의 값을 받아오는 경우도 있는데 그런 경우 대응이 어려움)
  • 실무에서는 이것보다 훨씬 더 복잡하게 구성되어있기 때문에 자바 코드로 작성하는 것을 권장함
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")

//  이렇게 메시지도 적용 가능
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000",
	message = "총합이 10000원 넘게 입력해 주세요")
public class Item {
	// ...
}

2) 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);
        }
    }
    // ... 
}

5. 상품수정에 적용하기

1) ValidationItemControllerV3 - edit() 변경 (@PostMapping)

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

2) edimForm.html 수정

...
<form action="item.html" th:action th:object="${item}" method="post">

    <div th:if="${#fields.hasGlobalErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러</p>
    </div>

    <div>
        <label for="id" th:text="#{label.item.id}">상품 ID</label>
        <input type="text" id="id" th:field="*{id}" class="form-control" readonly>
    </div>
    <div>
        <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
        <input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control">
        <div class="field-error" th:errors="*{itemName}">상품명 오류</div>

    </div>
    <div>
        <label for="price" th:text="#{label.item.price}">가격</label>
        <input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control">
        <div class="field-error" th:errors="*{price}">가격 오류</div>
    </div>
    <div>
        <label for="quantity" th:text="#{label.item.quantity}">수량</label>
        <input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control">
        <div class="field-error" th:errors="*{quantity}">수량 오류</div>

    </div>
...

6. Bean Validation의 한계

1) 수정시 검증 요구사항 변경

(1) 수정시 요구사항

  • 데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있기에 수정 시 요구사항이 아래와 같다고 가정
  • 등록시에는 quantity수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있음
  • 등록시에는 id에 값이 없어도 되지만 수정시에는 id 값이 필수

2) 수정사항을 적용 -> 실패

(1) Item - 수정

  • 이렇게 등록해 버리면 수정은 잘 작동하지만 등록이 동작하지 않음
  • 등록시 id에 값이 없고, 수량 제한 최대값이 9999도 적용이 안됨 -> 에러 발생으로 계속 상품 등록 폼으로 리다이렉트 됨
  • 등록과 수정은 같은 BeanValidation을 적용할 수 없음
@Data
public class Item {
	
    @NotNull	// 수정에는 id값이 필수 적용
    private Long id;

    @NotBlank
    private String itemName;

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

    @NotNull
    // @Max(9999) // 수정은 Max 제외
    private Integer quantity;

// ...
}

 

** 참고

  • 현재 구조에서 수정시 item의 id값은 항상 들어있도록 로직이 구성되어있어 검증하지 않아도 된다고 생각할 수 있음
  • 그러나 POST MAN 등으로 HTTP 요청은 얼마든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 최종 검증을 진행해야 안전함

7. Bean Validation - groups

1) 동일한 모델 객체를 수정과 등록할 때 각각 다르게 검증하는 방법 2가지

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

2) Bean Validation groups 기능 사용하기

(1) 체크를 위한 SaveCheck, UpdateCheck 인터페이스를 각각 생성

public interface SaveCheck {
}

public interface UpdateCheck {
}

 

(2) Item 도메인에 groups 적용

  • 각 적용할 애노테이션에 groups = 를 적용
  • 하나만 적용할 시 groups = 적용할인터페이스.class
  • 여러개 적용할 시 groups = {적용할인터페이스1.class, 적용할인터페이스2.class}
@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;
 	
    // ...
}

 

(3) ValidationItemControllerV3 - addItem과 edit에 각각 groups 적용

  • Validdated()에 파라미터 값으로 각 적용시킬 그룹을 입력
  • addItem2 : SaveCheck.class
  • edit2 : UpdateCheck.class
  • @Valid에는 groups를 적용할 수 기능이 없으므로 groups를 사용하려면 @Validated를 사용해야함
// ... 기존 코드 동일

// groups를 적용한 addItem2 - @Validated(SaveCheck.class)
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item,
        BindingResult bindingResult, RedirectAttributes redirectAttributes) {

}

// ... 기존 코드 동일

// groups를 적용한 edit2 - @Validated(UpdateCheck.class)
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId,
    @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

// ... 기존 코드 동일

}

 

(4) 적용 후 실행

  • 실행하면 모두 잘 작동하는 것을 볼수 있음, 그러나!
  • domain/Item 클래스를 보면 굉장히 복잡해진 것을 볼 수 있고, 전반적인 코드들의 복잡도가 올라감
  • 실무에서는 groups 기능을 잘 사용되지 않음 -> 실무에서는 등록용 폼 객체와 수정용 폼객체를 분리해서 사용하기 때문

8. Form 전송 객체 분리(프로젝트준비V4/소개/개발) - 최종버전

1) 프로젝트 준비 V4

(1) ValidationItemControllerV3 복사 붙혀넣기로 ControllerV4 생성

  • URL경로 변경 validation/v3/ -> validation/v4/

(2) ~/validation/v3/ 템플릿 파일을 ~/validation/v4 디렉토리로 복사 후 모든 하위 파일의 URL경로를 변경(위와 동일)

2) Form 전송 객체 분리

  • 실무에서는 groups를 잘 사용하지 않는데, 이유는 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문
  • 예제와 같은 간단한 프로젝트에서는 잘 맞지만 실무에서는 Item과 관계없는 수많은 부가 데이터가 회원 등록시 같이 넘어오게 됨
  • 그래서 직접 Item을 전달 받는 것이 아닌 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달함

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

  • HTML Form -> ITem -> Controller -> Item -> Repository
  • 장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달 -> 간단함
  • 단점: 매우 간단한 경우에만 적용가능, 수정 시 검증이 중복될 수 있음(groups를 이용해야함)

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

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

(3) 등록과 수정의 차이

  • 수정의 경우 등록과 완전히 다른 데이터가 넘어오는데, 보통 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있음
  • 로그인 id, 주민번호 등을 등록시에는 받을 수 있지만, 수정시에는 이런 부분이 빠지게 되고 검증 로직도 많이 달라짐
  • 심지어 Item을 생성하는데 필요한 추가 데이터를 DB나 다른 곳에서 찾아와야 할 수도 있음
  • 그래서 각각 별도의 객체로 데이터를 전달 받는 것이 좋기 때문에 별도의 폼 객체를 나누게 되고 groups를 적용할 일이 드물어짐

(4) 네이밍

  • 이름은 의미있게 지으면됨, 중요한것은 일관성임(소속 팀이나 프로젝트 등에 일관성에 맞춰서 작명)
  • ItemSave, ItemSaveForm, ItemSaveRequest, ItemSaveDto 등등..

(5) 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋은가.

  • 각각의 장단점이 있어 고민하는게 좋지만 어설프게 합치면 수많은 분기문(등록, 수정 등등) 때문에 if문이 많아지면 나중에 유지보수가 매우 어려워짐
  • 이런 어설픈 분기문들이 보이기 시작하면 분리해야할 신호라고 봐야함

3) From 전송 객체 분리 개발

(1) domain - Item 원복

  • 적용했던 모든 annotation 제거 -> 완전 초기상태로 복귀(Item의 검증은 사용하지 않음)

(2) Item 저장용 폼 - ItemSaveForm 생성

  • validation 패키지 하위에 작성
  • 저장용 폼에는 id가 없어도 됨
package hello.itemservice.web.validation.form;

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

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

 

(3) Item 수정용 폼 - ItemUpdateForm 생성

  • 수정용 폼에는 수량에 대한 검증을 제외
package hello.itemservice.web.validation.form;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    // 수정에서는 수량은 자유롭게 변경하도록 변경
    private Integer quantity;
}

 

(4) ValidationItemControllerV4 컨트롤러 수정

  • Item 대신에 ItemSaveForm, ItemUpdateForm을 전달 받도록 변경
  • 뷰 템플릿 변경을 하지 않기 위해 @ModelAttribute("item")으로 적용 (생략하면 규칙에의해 itemSaveForm이라는 이름으로 MVC Model에 담기게 됨
// ... 기존 코드 생략

    // @ModelAttribute("item") 이렇게 명시하지않으면(생략시) itemSaveform 이라는 이름으로 넘어가게됨
    // addAttribute("itemSaveform", form) 처럼 동작함
    // 뷰 템플릿의 item을 모두 바꿔서 적용해도 되지만 지금은 이렇게 작성
    @PostMapping("/add")
    public String addItem2(@Validated @ModelAttribute("item") ItemSaveForm form,
                           BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        // ... 기존 코드 생략

        // 저장할 Item 객체 생성
        Item item = new Item();
        // 게터세터말고 생성자로 하는것이 더 좋지만 여기선 getter setter를 활용
        
        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}";
    }

	// ... 기존 코드 생략

    // addform과 마찬가지
    @PostMapping("/{itemId}/edit")
    public String edit2(@PathVariable Long itemId,
                        @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

        // ... 기존 코드 생략

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

}

9. HTTP 메시지 컨버터

1) HTTP 메시지 컨버터

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

** 참고

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

(1) ValidationItemApiController 생성

  • 실제로 개발할 때는 getAllErrors()로 객체를 그대로 사용하지 않고 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 함
@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();    // bindingResult에 담긴 모든 에러를 반환
        }

        log.info("성공 로직 실행");
        return form;    // form 그대로 반환
    }
}

2) Postman으로 테스트

(1) API의 경우 3가지 경우를 나누어 생각해야 함

  1. 성공 요청: 성공
  2. 실패 요청: JSON을 객체로 생성하는 것 자체가 실패 -> 예외 터짐
  3. 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했으나, 검증에서 실패

(2) 실패 요청

  • {"itemName":"hello", "price":qqq, "quantity":10 }: price의 값에 숫자가 아닌 문자를 전달해서 실패하도록 요청
  •  [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Invalid UTF-8 start byte 0x85 ....... 의 에러 로그가 뜸
  • HttpMessageConverter에서 JSON을 ItemSaveForm 객체로 생성하는데 실패했기 때문에 컨트롤러 자체가 호출 되지 않고 그전에 예외가 발생해버리기 때문에 Validator자체가 실행 되지 않음

(3) 실패요청 결과

 
{
"timestamp": "2024-12-12T10:36:47.784+00:00",
"status": 400,
"error": "Bad Request",
"path": "/validation/api/items"
}

 

(4) 검증 오류 요청

  • {"itemName":"hello", "price":1234, "quantity":10000}
  • 수량이 10000이면 BeanValidation @Max(9999)에서 걸림
  • 로그 및 결과를 보면 검증 오류가 정상 수행 된 것을 확인할 수 있음

(5) @ModelAttribute VS @RequestBody

  • HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 동작하므로 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 바인딩이 되어 Validator를 사용한 검증을 적용할 수 있음
  • 그러나 HttpMessageConverter는 전체 객체 단위로 적용되어 메시지 컨버터의 작동이 성공해서 객체를 만들어야만 @Valid, @Validated가 적용됨
  • 즉, @RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이우 단계 자체가 진행되지 않고 예외가 발생하며 컨트롤러도 호출되지 않고 Validator도 적용할 수 없음

** 참고

  • HttpMessageConverter 단계에서 실패하면 예외가 발생하는데, 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룰 예정