Spring

[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션4 검증1 - Validation

고쩡이 2024. 5. 6. 13:55

본 내용은 인프런 김영한T 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 정리한 내용입니다:)

[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션4 검증1 - Validation

◼️ 검증 요구사항

요구사항: 검증 로직 추가

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

컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.

참고: 클라이언트 검증, 서버 검증
클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수 API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

◼️ 검증 직접 처리

서버검증 로직이 실패하면 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려줘야한다.

 

검증 오류 발생시 errors에 담고, 뷰에서 해당 데이터를 사용해 오류메시지를 출력해보자.

  • 만약 검증 오류가 발생하면 오류들과 입력 폼을 다시 보여준다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {

    // 검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())){errors.put("itemName","상품 이름은 필수입니다.");}

    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
            1000000) {
        errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
    }
    if (item.getQuantity() == null || item.getQuantity() > 9999) {
        errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
    }
    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
        }
    }

    // 검증에 실패하면 다시 입력 폼으로
    if (!errors.isEmpty()) {
        log.info("errors = {}",errors);
        model.addAttribute("errors",errors);
        return "validation/v1/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v1/items/{itemId}";
}

HTML은 아래와같은 형식으로 출력한다.

?는 errors가 null이면 NullPointerException대신 null을 반환한다.

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

 

오류 메시지 확인가능

🟢 남은 문제점

  • 뷰 템플릿에서 중복 처리가 많다.
  • 하지만 위 문제점은 타입 오류 처리가 안된다는 것이다. 타입오류는 MVC에서 컨트롤러 진입전 예외가 발생하기 때문에, 컨트롤러가 호출되지도않고 400예외가 발생한다.
  • 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.

◼️ BindingResult1

ValidationItemControllerV2 - addItemV1

    @PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
        }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
                1000000) {
            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() >= 10000) {
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
        }
        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }
        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v2/addForm";
        }
        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.

 

🟢 FieldError 생성자

// @ModelAttribute명,오류발생 필드명,오류기본메시지
public FieldError(String objectName, String field, String defaultMessage) {}

사용 예시 (필드 오류 - FieldError)

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}

 

🟢 ObjectError 생성자

// @ModelAttribute명, 오류 기본 메시지
public ObjectError(String objectName, String defaultMessage) {}

사용 예시 (글로벌 오류 - ObjectError)

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야합니다. 현재 값 = " + resultPrice));

 

 

이제 validation/v2/addForm.html 을 아래와 같은 형식으로 수정한다.

 

글로벌 오류 처리 예시

<div th:if="${#fields.hasGlobalErrors()}">
	 <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>

필드 오류 처리 예시

<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
 상품명 오류
</div>

 

타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
검증과 오류 메시지 공식 메뉴얼
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages

 

오류 실행 화면

◼️ BindingResult2

BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

  • BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.
  • BindingResult 는 검증할 대상 바로 다음에 와야한다.
  • BindingResult 는 Model에 자동으로 포함된다.
  • BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.

🟢 BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 FieldError 생성해서BindingResult 에 넣어준다.
  • 개발자가 직접 넣어준다.
  • Validator 사용 (뒤에서 다룸)

◼️ FieldError, ObjectError

사용자 입력 오류 메시지와 고객이 입력한 내용이 모두 화면에 남도록 하자.

@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false,null,null, "상품 이름은 필수입니다."));
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
            1000000) {
        bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,null,null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity",item.getPrice(),false,null,null, "수량은 최대 9,999 까지 허용합니다."));
    }
    //특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
        }
    }
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

🟢 FieldError 생성자

FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

  • objectName: 오류가 발생한 객체 이름
  • field: 오류 필드
  • rejectedValue: 사용자 입력값(거절된 값. 오류 발생시 사용자 입력 값을 저장하는 필드다.)
  • bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 (타입 오류 같은 바인딩이 실패했는지 여부를 적는다. 바인딩실패가 아니면 false 를 사용한다)
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable 
Object[] arguments, @Nullable String defaultMessage)

 

오류 발생시 사용자 입력 값 유지

사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자입력 값을 유지하기 어렵다. (ex_타입에러) 그래서 오류가 발생한 경우 사용자 입력 값을 따로 별도 보관했다가 검증 오류시 화면에 다시 출력한다.

 

🟢 타임리프의 사용자 입력 값 유지

th:field="*{price}"

타임리프의 th:field 는 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

 

🟢 스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.

따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

◼️ 오류 코드와 메시지 처리1

🟢 FieldError 생성자

public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
    rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable 
    Object[] arguments, @Nullable String defaultMessage)

 

errors 메시지 파일 생성

오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리해보자.

( errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.)

 

먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 

 

application.properties

spring.messages.basename=messages,errors

이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (messages.properties 를 기본으로 인식한다.)

 

errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

이제 errors 에 등록한 메시지를 사용하도록 코드를 변경해보자.

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false,new String[]{"required.item.itemName"},null, null));
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,new String[]{"range.item.price"},new Object[]{1000,1000000}, null));
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity",item.getQuantity(),false,new String[]{"max.item.quantity"},new Object[]{9999}, null));
    }
    //특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
        }
    }
    ...
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, 
	new Object[]{1000, 1000000});
  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

errors.properties에서 2222를 붙이면 변경되어 표시됨을 확

◼️ 오류 코드와 메시지 처리2

🟢 목표
FieldError , ObjectError 는 다루기 너무 번거롭다.
오류 코드도 좀 더 자동화 해보자. 예) item.itemName 처럼

 

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult 는 이 미 본인이 검증해야 할 객체인 target 을 알고 있다.

log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)

 

rejectValue() , reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

/*오류필드명, 오류코드(메시지등록코드X.messageResolver를 위한 오류코드),
치환값,오류메시지 찾을 수 없을때 사용하는 기본메시지*/
void rejectValue(@Nullable String field, String errorCode,
	@Nullable Object[] errorArgs, @Nullable String defaultMessage);

사용예시

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다. (errors.properties)
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}",bindingResult.getTarget());

    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.rejectValue("itemName","required");
    }
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.rejectValue("price","range",new Object[]{1000,1000000},null);
    }
    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
        bindingResult.rejectValue("quantity","max",new Object[]{9999},null);
    }
    //특정 필드 예외가 아닌 전체 예외
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000,resultPrice},null);
        }
    }

◼️ 오류 코드와 메시지 처리3

세밀한 메시지 코드가 있으면 해당 메시지를 높은 우선순위로 사용한다.

 

errors.properties

#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.

◼️ 오류 코드와 메시지 처리4

🟢 MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
  • 주로 다음과 함께 사용 ObjectError , FieldError
public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode ="+messageCode);
        }
        Assertions.assertThat(messageCodes).containsExactly("required.item","required");
    }

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }

//        new FieldError("item","itemName",null,false,messageCodes,null,null);
        Assertions.assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

 

🟢 DefaultMessageCodesResolver의 기본 메시지 생성 규칙

 

객체 오류: 객체 오류의 경우 다음 순서로 2가지 생성
1. code + "." + object name
2. code

 

예) 오류 코드: required, object name: item
1: required.item
2: required


필드 오류: 필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1. code + "." + object name + "." + field
2. code + "." + field
3. code + "." + field type
4. code


예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

 

🟢 동작 방식

  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성
  • FieldError , ObjectError 의 생성자를 보면, 여러 오류 코드를 가질 수 있다.
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
  • 이 부분을 BindingResult 의 로그를 통해서 확인해보자.
codes [range.item.price, range.price, range.java.lang.Integer, range]

 

- 자동생성 오류 코드 예시

FieldError rejectValue("itemName", "required") ObjectError reject("totalPriceMin")
required.item.itemName
required.itemName
required.java.lang.String
required
totalPriceMin.item
totalPriceMin

 

오류 메시지 출력
타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대 로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.

◼️ 오류 코드와 메시지 처리5

🟢 오류코드 관리 전략

  • 구체적인 것에서 덜 구체적인 것으로! required.item.itemName, required
  • 크게중요X 메시지는 범용성있는 required같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할때 구체적으로  적어 사용하자.

전략을 적용한 errors.properties 예시

#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

 

🟢 ValidationUtils

Empty,공백 같은 단순 기능 제공

if (!StringUtils.hasText(item.getItemName())) {
     bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 

🟢 동작방식 정리

1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

 

◼️ 오류 코드와 메시지 처리6

🟢 검증 오류 코드 종류

  • 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
  • 스프링이 직접 검증 오류에 추가 (주로 타입 정보가 맞지 않음)

스프링은 타입오류가 발생하면 typeMismatch라는 오류코드를 사용한다.

MessageCodesResolver 는 이를 통해 4가지 메시지 코드를 생성한다.

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

숫자필드에 문자를 넣으면 아래 오류가 출력됨을 확인 가능하다.

errors.properties에 메시지 코드가 없어 스프링이 생성한 기본 메시지가 출력된다.

이제 error.properties에 아래를 추가하고 실행해본다.

 

errors.properties

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다

 

소스코드를 건들지않고 원하는 메시지를 단계별로 설정할 수 있다. 

메시지 코드 생성 전략은 Bean Validation을 학습하면 진가를 확인할 수 있다.

◼️ Validator 분리1

🟢 목표

  • 복잡한 검증로직을 별도 클래스로 분리하자.

스프링은 체계적 검증을 위해 검증 인터페이스를 제공한다.

public interface Validator {
    boolean supports(Class<?> clazz); // 해당 검증기 지원 여부 확인
    void validate(Object target, Errors errors); // 검증 대상 객체와 BindingResult
}

ItemValidator

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }
    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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() > 10000) {
            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);
            }
        }
    }
}

이렇게 하면 컨트롤러 검증부분을 깔끔히 코드 작성할 수 있다!

private final ItemValidator itemValidator;

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {
    itemValidator.validate(item, bindingResult);

    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}...

◼️ Validator 분리2

🟢 WebDataBinder

  • 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
  • WebDataBinder에 검증기를 추가하면 컨트롤러에서 검증기를 자동 적용할수 있다.
@InitBinder //해당 컨트롤러에만 영향을 줌.글로벌 X
public void init(WebDataBinder dataBinder) {
     log.info("init binder {}", dataBinder);
     dataBinder.addValidators(itemValidator);
}

@Validated 어노테이션을 넣는다.이는 검증기를 실행하는 애노테이션이다.

WebDataBinder에 등록한 검증기를 찾아 실행한다.

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
        bindingResult, RedirectAttributes redirectAttributes) {
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

🟢 글로벌 설정 - 모든 컨트롤러에 다 적용

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
     public static void main(String[] args) {
     SpringApplication.run(ItemServiceApplication.class, args);
     }
     @Override
     public Validator getValidator() {
     return new ItemValidator();
     }
}
글로벌 설정을 하면 다음에 설명할 BeanValidator가 자동 등록되지 않는다. 글로벌 설정 부분은 주석처리해둔다. 참고로 글로벌 설정을 직접 사용하는 경우는 드물다.
검증시 @Validated @Valid 둘다 사용가능
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.