[Spring MVC 2편] 4. Validation

HJ·2023년 1월 16일
0

Spring MVC 2편

목록 보기
4/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. 검증( Validation )

  • 타입, 필드, 범위 등 여러 가지에 대해 검증할 수 있다

  • 검증 오류가 발생했을 때 오류 화면으로 이동하면 사용자가 입력한 데이터가 사라져 처음부터 다시 작성해야하는데 이런 방식은 좋지 않은 방식

  • 입력 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려주는 것이 좋은 방식

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

  • 클라이언트 검증, 서버 검증

    • 클라이언트 검증은 주로 자바스크립트로 하는 검증, 조작이 가능해서 보안에 취약

    • 서버 검증은 HTTP 요청 데이터가 서버로 넘어와서 컨트롤러나 다른 로직을 활용해서 검증하는 방식, 즉각적인 고객 사용성이 부족

    • 클라이언트와 서버를 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수

  • API 방식을 사용하는 경우, API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다




2. 검증 처리

2-1. 상품 등록 로직 살펴보기

  1. /add에 GET 방식으로 상품 등록 폼을 요청
    ➜ Controller가 상품 등록 폼을 부르면 HTML로 렌더링돼서 웹 브라우저에 전달
  1. 데이터를 입력 후 저장 버튼을 누르면 /add에 POST 방식으로 요청
    ➜ Controller에서 폼으로 넘어온 데이터를 저장하고 상품 상세로 리다이렉트
  • 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과

  • 사용자가 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패

  • 검증에 실패한 경우 고객이 입력한 정보를 유지하면서 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다


2-2. 오류 메세지 보관하기

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

// 검증 로직
// ModelAttribute를 통해 넘어온 item에 itemName이 없는 경우
if (!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "상품 이름은 필수입니다.");
}

// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
    }
}
  • 어떤 오류인지 담아주는 객체가 필요 ➜ Map 객체 생성

  • POST 방식으로 넘어와 @ModelAttribute에 담긴 데이터를 보고 Map 객체에 오류와 오류 메세지를 저장한다


2-3. 입력 폼 다시 보여주기

// 검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
    log.info("errors = {}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}
  • 모든 오류를 담고 난 후, 오류가 있는 경우 Map 객체에 담긴 오류 내용을 Model에 담고 상품 등록 폼을 다시 반환한다

  • 기존에 작성되어 있던 로직 ( 상품 저장 및 상품 상세로 리다이렉트 )은 검증이 성공헀을 때만 수행되게 된다


2-4. 기존 데이터 유지하기

@GetMapping("/add")
public String addForm(Model model) {
    model.addAttribute("item", new Item());
    return "validation/v1/addForm";
}

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
    ...
}
<!-- Thymeleaf ( addForm.html ) -->
<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
        <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
    </div>
</form>
  • GetMapping을 처리할 때 Model에 새로운 Item 객체를 만들어서 넘겨줬는데 사용자가 특정 값을 입력하면 Item 객체( item )에 데이터가 담긴다

  • 데이터를 저장하면 PostMapping을 처리하는 메서드의 @ModelAttribute를 통해 item에 데이터가 들어오고 model에 자동으로 item이 들어간다

    • 즉, 자동으로 model.addAttribute("item", item);이 수행된다
  • 그렇기 때문에 오류가 발생한 경우, 오류 메세지에 대한 처리만 하고 상품 등록 폼을 반환하면 model에서 기존에 입력된 데이터를 꺼내서 화면에 표시해준다

    • 즉, 개발자가 직접 model에 담을 필요 없이 오류 메세지에 대한 부분만 처리해서 상품 등록 폼을 반환하면 된다
  • BUT> 타입이 다른 경우 Controller에 진입하기 전에 400 예외가 발생하면서 오류 페이지로 넘어가게 된다

    • ex> Item의 price는 Integer 타입인데 문자를 작성하고 저장버튼을 누르는 경우 Controller 호출 안됨 + 사용자가 입력한 값 사라짐

2-5. 화면에 오류 메세지 띄우기

<!-- Thymeleaf ( addForm.html ) -->
<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">전체 오류 메세지</p>
</div>
<!-- 페이지 소스 보기 -->
<div>
    <p class="field-error">가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = 100</p>
</div>
  • errors?

    • errors 가 null 일때 NullPointerException 이 발생하는 대신 null 을 반환

    • 처음 상품 등록 폼에 들어갔을 때는 errors 자체가 없기 때문에 null이 반환되고 th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다

  • th:if="${errors?.containsKey('globalError')}" : errors 객체에 globalError라는 key가 있는 경우

  • th:text="${errors['globalError']}" : 프로퍼티 접근법으로 errors 객체에서 globalError에 해당하는 Value를 꺼낸다

  • 필드 오류도 위와 동일한 방식으로 처리한다




3. BindingResult

3-1. 설명

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    ...
}
  • BindingResult는 검증 오류를 보관하는 객체이다

  • @ModelAttribute 다음에 위치하여 ModelAttribute의 객체에 바인딩된다


public interface BindingResult extends Errors {
    void addError(ObjectError error);
}
  • addError()를 통해 BindingResult 객체에 FieldError 과 ObjectError 추가한다

  • addError()는 ObjectError 를 파라미터로 받는데 FieldError 가 들어갈 수 있는 이유는 FieldError 가 ObjectError 를 상속받았기 때문이다


3-2. 오류 메세지 보관하기 - FieldError

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
  • 필드 단위의 에러는 스프링이 제공하는 FieldError 객체에 넣는다

  • 파라미터는 오브젝트 이름, 필드명, 기본 오류 메세지인데 오브젝트 이름에는 @ModelAttribute로 지정한 이름을 넣는다


3-3. 오류 메세지 보관하기 - ObjectError

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
    }
}
  • 필드를 넘어서는 오류가 있으면 생성하는 객체

3-4. 입력 폼 다시 보여주기

if (bindingResult.hasErrors()) {
    log.info("bindingResult = {}", bindingResult);
    return "validation/v2/addForm";
}
  • bindingResult에 에러가 담긴 경우 실행되는 코드 ( bindingResult.hasErrors() )

  • BindingResult는 저장된 내용을 모델에 담지 않아도 자동으로 View에 같이 넘어간다


3-5. 화면에 오류 메세지 띄우기

<!-- Thymeleaf ( addForm.html ) -->
<!-- 글로벌 오류 처리 -->
<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err: ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메세지</p>
</div>

<!-- 필드 오류 처리 -->
<div class="field-error" th:errors="*{itemName}">
    상품명 오류
</div>

<!-- 오류 발생 시 CSS 처리 -->
<input type="text" id="itemName" th:field="*{itemName}"
    th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
  • #fields : #fields로 bindingResult가 제공하는 검증 오류에 접근 가능
  • th:each="err: ${#fields.globalErrors()}"

    • globalErrors()로 반환되는 객체가 컬렉션 객체이기 때문에 th:each로 반복한다

    • th:text="${err}"로 오류 메세지를 출력한다

  • th:errors="*{필드명}"

    • 지정된 필드에 오류가 있는 경우 태그를 출력한다

    • 위의 예시의 경우 bindingResult에 itemName이라는 필드명을 가진 FieldError가 있는 경우 해당 태그가 출력된다

  • th:field="*{필드명}" th:errorclass="field-error"

    • th:field에 지정된 필드명으로 된 오류가 있는 경우, th:errorclass가 "field-error"라는 class 정보를 해당 태그에 추가해준다

3-6. BindingResult 동작 과정 정리

  • Item 객체에는 itemName, price 등의 필드가 있다

  • BindingResult 객체에 FieldError 혹은 ObjectError를 생성하면서 Error를 추가해준다

  • FieldError는 오브젝트 이름, 필드명, 기본 에러 메세지를 파라미터로 받는다

    • 오브젝트 이름이란 @ModelAttribute를 통해 넘어온 이름을 의미하고, 필드명은 아마 넘어온 오브젝트의 필드명을 의미하는 것 같다
  • BindingResult는 따로 View에 넘겨주지 않아도 자동으로 넘어간다 & 타임리프에서 사용할 수 있도록 #fieldsth:errors와 같은 것들이 제공된다

  • th:objectth:field에 들어가는 값들은 @ModelAttrribute가 item 객체를 자동으로 model에 담아 넘겨주기 때문에 오류가 발생해서 페이지를 다시 보내는 경우, 자동으로 정보가 유지된다


3-7. BindingResult에 검증 오류 적용하는 3가지 방법

  • 자동 처리 : @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 자동으로 FieldError를 생성해서 BindingResult 에 넣어준다

  • 수동 처리 : 개발자가 검증 로직을 구성하여 직접 FieldError이나 ObjectError를 생성해서 넣어주는 방법

  • Validator를 사용하는 방법


3-8. BindingResult를 사용하는 이유

Q1.
2번에서 하는 방식은 타입이 다른 경우 Controller에 진입하기 전에 400 예외가 발생하면서 오류 페이지로 넘어가는 문제가 존재

ex> Item의 price는 Integer 타입인데 문자를 작성하고 저장버튼을 누르는 경우 Controller 호출 안됨 + 사용자가 입력한 값 사라짐

  • @ModelAttribute에 데이터 바인딩 시 타입 오류가 발생하는 경우, Controller가 호출되지 않고 오류 페이지로 넘어가는 문제를 해결하기 위해서 사용

    • BindingResult가 없으면 400 오류가 발생하면서 Controller가 호출되지 않고, 오류 페이지로 이동한다고 하였다

    • BindingResult를 사용하면 바인딩이 실패하는 경우 오류 정보( FieldError )를 BindingResult에 담아서 Controller를 정상 호출하기 때문에 이런 문제를 해결할 수 있다


Q2.
BUT> BindingResult를 사용하면 2번과 다르게 오류 발생 시 사용자가 입력한 값이 사라지는 문제가 존재한다

그렇다면 왜 2번에서는 유지되던 값이 갑자기 3번에서 유지되지 않았는가?

  • th:field는 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다

  • 하지만 2번에서는 BindingResult와 FieldError를 사용하지 않았기 때문에 model에 담긴 데이터가 그대로 출력된 것이고

  • 3번의 경우 BindingResult와 FieldError를 사용했지만 FieldError 객체 생성 시, rejectedValue가 null인 생성자를 사용했기 때문에 사용자의 값이 유지되지 않아 사라지게 되는 것이다

  • FieldError와 rejectedValue에 관한 내용은 아래에 있는 4번에 작성해두었다


Q3.
바인딩 시 타입 오류가 발생했을 때 BindingResult와 FieldError가 동작하는 과정은 어떻게 되는가?

  • 타입 오류가 발생해 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려운데 이와 같은 상황에서도 사용자가 입력한 값을 유지할 수 있도록 BindingResult와 FieldError 객체를 사용한다

  • 과정을 살펴보면 타입 오류가 발생하면 스프링이 자동으로 FieldError를 생성해서 BindingResult 에 넣어준다

  • 바로 이 때, FieldError의 rejectedValue에 사용자가 입력했던 값을 넣어주기 때문에 바인딩 시점에 타입 오류가 발생해도 사용자가 입력한 값을 유지할 수 있는 것이다




4. FieldError

4-1. 설명

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

    • 거절된 값 ( 사용자가 입력한 값 )

    • 위에서 사용자가 입력한 값이 사라지는 문제가 존재한다고 했는데 bindingResult의 rejectedValue에 item 객체의 값을 추출하여 넣어준다

    • 이렇게하면 오류가 발생했을 때 사용자가 입력한 값이 사라지는 문제를 해결할 수 있다

  • bindingFailure

    • 타입오류와 같은 바인딩 실패인지, 검증 실패인지를 구분하는 구분 값 ( 바인딩이 실패했는지에 대한 여부 )

    • 값은 잘 넘어왔기 때문에 false로 설정

  • code : 메세지 코드

  • arguments : 메세지에서 사용하는 인자


4-2. 궁금증

  • 위의 코드를 보면 3 번째 파라미터에 rejectedValue 가 들어가는데 여기에 item.getItemName() 을 전달했다

  • 타입 오류가 발생한다면 item 에 제대로 된 값이 저장되지 않을텐데 실행시켜보면 입력했던 문자가 그대로 출력된다

  • 이게 어떻게 가능한 것인지 궁금해서 Q & A 를 찾아보았는데 내가 찾은 정답은 아래와 같다

    • 오류가 없으면 Model의 값을 가져오고, 오류가 있다면 BindingResult 안에서 값을 가져오게 된다

    • 이 때, BindingResult 에 해당 정보들이 문자로 남아있고, 해당 정보를 찾아서 뿌려주게 되는 것이다




5. 오류 코드와 메세지 처리

5-1. 설명

  • FieldError 객체를 생성할 때 하나씩 메세지를 작성해주었는데 이렇게 하지 않고 한 곳에서 메세지를 관리하는 것이 좋은 방법이다

  • FieldError의 codesargument라는 파라미터를 통해 메세지를 관리하는 파일( XXX.properties )에서 찾아와 일치하는게 없으면 기본 디폴트 오류 메세지를 출력한다

  • 즉, codesargument는 오류 발생 시 오류 코드로 메세지를 찾기 위해 사용된다

  • 메세지 파일을 별도로 만들어 사용하려면 application.properties에 아래와 같이 입력해야한다

    • spring.messages.basename=messages, errors

    • 기본으로 사용되는 messages, 오류 메세지를 관리하는 errors


5-2. 동작 코드

< errors.properties >
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
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));
}
  • codes에는 찾지 못했을 경우, 여러 개를 찾을 수 있도록 String[] 로 넘겨준다 ( 하나인 경우에도 String[] 로 넘겨주어야 한다 )

  • FieldError의 argument에 Object[]로 값을 넘겨주면 메세지 파일의 argument에 값이 들어간다

    • 3번 게시글 메세지, 국제화 참고

5-3. 코드 간소화하기

5-3-1. Target

public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());
}
// 결과
objectName=item
target=Item(id=null, itemName=test, price=10000, quantity=2)
  • BindingResult 는 검증해야 할 객체인 target 바로 다음에 오기 때문에 ( @ModelAttribute 바로 뒤에 오기 때문에 ) 검증해야할 객체가 무엇인지 알고 있다

    ➜ target(item)에 대한 정보는 없어도 된다

  • 로그로 확인해보면 target 에는 객체 자체가 들어있는 것을 확인할 수 있다

  • 즉, objectName 을 알고 있는 것이기 때문에 FieldError 나 ObjectError 를 생성하면서 objectName 을 넘겨주지 않아도 된다

  • 더 나아가서 rejectValue()reject() 를 활용하면 FieldError 나 ObjectError 를 직접 생성하지 않아도 된다


5-3-2. rejectValue() , reject()

// Errors 인터페이스
// Register a field error for the specified field of the current object, using the given error description.
void rejectValue(@Nullable String field, String errorCode);
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

// Register a global error for the entire target object, using the given error description.
void reject(String errorCode);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명

  • errorCode : 오류 코드

    • 메시지 파일에 등록된 코드가 아님

    • messageResolver 가 메세지 코드를 생성할 수 있도록 하기 위한 오류 코드

    • 위에서 넘겨준 errorCode 를 통해 messageResolver 가 메세지 코드를 생성한다


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.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
    }
}
  • 메서드에 넘겨준 errorCode 로 에러 메세지를 찾는 규칙이 존재한다

    • 결론적으로 보자면 errorCode + objectName + 필드명 을 조합해서 오류 메세지가 있는 곳에서 코드를 찾는다

    • 뒤의 MessageCodesResolver에서 자세히 설명


5-4. 참고> ValidationUtils

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

// 위의 코드를 이렇게 한 줄로 적을 수 있다
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");



6. MessageCodesResolver

6-1. 필요한 이유

  • 모든 메세지를 자세히 만들 수도 있고, 단순하게 만들 수도 있다

    • 자세한 버전 : required.item.itemName

    • 단순한 버전 : required

  • 단순하게 만들면 범용성이 좋아 여러 곳에서 사용 가능, 자세히 만들면 사용할 수 있는 곳이 제한

  • 가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야 하는 경우에 세밀한 내용이 적용되도록 메세지에 단계를 두는 것이다

  • 예를 들어, errorCode에는 동일하게 계속 required를 사용하는데 메세지 파일에 required와 required.객체명.필드명인 메세지가 있다고 가정했을 때 자세히 작성한 메세지를 높은 우선순위로 사용하는 것이다

  • 이처럼 개발을 하면 자바 코드를 변경하지 않고 메세지 파일만 수정하면 전체 메세지를 관리할 수 있다

  • ➡️ 스프링이 MessageCodesResolver라는 것으로 위와 같은 기능을 지원한다


6-2. MessageCodesResolver 동작 테스트

6-2-1. 기본 인터페이스

public interface MessageCodesResolver {

    // Build message codes for the given error code and object name. Used for building the codes list of an ObjectError.
    String[] resolveMessageCodes(String errorCode, String objectName);

    // Build message codes for the given error code and field specification. Used for building the codes list of an FieldError.
    String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType);
}
  • resolveMessageCodes() : errorCode를 받으면 여러 개의 메세지 코드를 반환해준다

  • 인터페이스의 기본 구현체는 DefaultMessageCodesResolver


6-2-2. ObjectError

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

    assertThat(messageCodes).containsExactly("required.item", "required");
}
// 테스트 결과
messageCode = required.item
messageCode = required
  • BindingResult.reject()가 내부적으로 MessageCodesResolver를 사용해 messageCodes를 얻는다
  • resolveMessageCodes()가 반환한 값들을 가지고, BindingResult.reject()가 아래의 코드를 실행시키는 것이다

    • new ObjectError("item", new String[]{"required.item", "required"});

    • ObjectError 생성자는 여러 개의 오류 코드를 가질 수 있다

  • 객체 오류의 경우, 기본 메세지 생성 규칙은 아래와 같다 ( 구체적인 것을 먼저 만든다 )

    • errorCode.오브젝트이름

    • errorCode

  • 오류가 발생하면 타임리프 화면을 렌더링할 때 th:errors가 실행되는데 th:errors가 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾게 되고, 찾지 못하면 디폴트 메세지를 출력한다


6-2-3. FieldError

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

    assertThat(messageCodes).containsExactly("required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required");
}
// 테스트 결과
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
  • BindingResult.rejectValue()가 내부적으로 MessageCodesResolver를 사용해 messageCodes를 얻는다
  • 얻은 것으로 rejectValue()new FieldError("item", "itemName", null, false, messageCodes, null, null);를 실행시킨다
  • 필드 오류의 경우, 기본 메세지 생성 규칙은 아래와 같다 ( 구체적인 것을 먼저 만든다 )

    • errorCode.오브젝트이름.필드명

    • errorCode.필드명

    • errorCode.type

    • errorCode

  • 오류가 발생하면 타임리프 화면을 렌더링할 때 th:errors가 실행되는데 th:errors가 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾게 되고, 찾지 못하면 디폴트 메세지를 출력한다



7. 현재까지 흐름 정리

  • 검증이 실패한 경우의 처리를 위해 BindingResult를 사용 ( 3번 )

    • 각 오류에 대해 처리하기 위해 BindingResult.addError()를 이용하여 FieldError, ObjectError 객체를 만들어서 BindingResult에 등록시키는 방식을 사용

  • FieldError, ObjectError객체를 생성하는 과정에서 생성자에 메세지를 직접 입력하는 것이 아닌 메세지 파일을 만들고 그 안에서 오류 메세지 내용을 찾도록 변경 ( 5번 )

    • FieldError, ObjectError 생성자의 codes 부분과 arguments 을 활용하여 FieldError 객체를 생성

  • FieldError, ObjectError 객체를 생성자로 생성하지 않고 메서드를 이용해 간단하게 생성 ( 5-3 번 )

    • BindingResult.rejectValue(), BindingResult.reject()를 이용하는데 기본적으로 필드명과 에러 코드를 파라미터로 받는다

  • rejectValue(), reject() 동작 살펴보기 ( 6번 )

    • 내부적으로 MessageCodesResolver를 이용한다

    • MessageCodesResolverresolveMessageCodes() 메서드를 이용해 에러코드를 바탕으로 메세지 코드들을 생성해 String[]으로 반환 ( 필드 오류의 경우 4가지, 객체 오류의 경우 2가지 )

    • 메세지 코드를 생성할 때 구체적인 것부터 덜 구체적인 것 순으로 생성된다

    • 반환된 메세지 코드들을 이용해 FieldError, ObjectError 객체를 생성한다


  • 그렇게 생성된 FieldError, ObjectError 객체는 codes로 String[] 을 가지고 있는데 th:errors가 실행될 때 이 String[]을 순서대로 돌아가면서 메세지를 찾게 되고 찾지 못하면 디폴트 메세지를 출력하는 것이다

    • 즉, 구체적인 메세지부터 덜 구체적인 메세지 순으로 MessageSoucre에서 메세지를 찾는다 ( 메세지 파일에서 찾는다 )



8. 검증 오류 코드

  • 검증 오류 코드의 두 가지

    • 개발자가 직접 설정 ➜ rejectValue() 를 직접 호출

    • 스프링이 자동으로 추가 ( 타입 정보 불일치 등 )

  • 스프링이 자동으로 추가하는 검증 오류 코드 ( typeMismatch )

    • typeMismatch.객체명.필드명

    • typeMismatch.필드명

    • typeMismatch.타입

    • typeMismatch

  • 메세지 설정 파일에 위의 typeMismatch 검증 오류 코드에 해당하는 메세지 코드가 없으면 스프링이 생성한 기본 메세지가 출력

    ➜ 메세지 파일에 위의 검증 오류 코드와 메세지를 추가하면 스프링이 자동으로 처리하는 에러 메세지도 관리할 수 있다




9. Validator로 검증 로직 분리

9-1. Validator 클래스 생성

@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) {
        // Controller에 있던 검증 로직
        ...
    }
}
  • Controller에 검증 로직과 성공 로직이 있는데 검증 로직을 처리하는 Validator 클래스를 만들어서 따로 관리한다
  • @Component 를 통해 Validator를 스프링 빈으로 등록 ➜ Controller에서 사용할 수 있도록 하기 위해
  • supports()

    • 해당 검증기를 지원하는지 여부를 확인

    • 파라미터로 넘어오는 클래스가 Item 클래스인지, Item 클래스의 자식 클래스인지 판단 ( 언급한 두 경우라면 true가 반환 )

  • validate()

    • 검증 로직을 처리하는 부분

    • target은 Controller에서 @ModelAttribute가 붙은 객체를 전달받아야 한다

    • errors는 Controller의 BindingResult 객체를 전달 받는다 ( Errors가 BindingResult의 부모이기 때문에 가능 )

    • 객체를 Object로 받았기 때문에 형 변환이 필요 ( Item item = (Item) target; )


9-2. Controller에서 호출하기

@Controller
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemValidator itemValidator;

    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        itemValidator.validate(item, bindingResult);
        ...
    }
}
  • 스프링 빈으로 등록된 validator 객체를 주입 받아 사용

  • 주입받은 객체의 validate()를 호출해서 검증기를 실행시킨다




10. 직접 호출 없이 Validator 사용하기

10-1. WebDataBinder

public class ValidationItemControllerV2 {

    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }
}
  • Controller가 호출될 때 @InitBinder 가 붙은 init() 메서드가 호출되면서 WebDataBinder가 만들어진다

    • WebDataBinder는 사용자 요청이 올 때마다 새로 생성된다

    • @InitBinder는 해당 Controller에만 영향을 준다

  • WebDataBinder에 itemValidator를 넣어준다 ➜ 어떤 메서드가 호출되도 항상 검증기를 적용할 수 있다
  • 즉, WebDataBinder에 검증기를 추가하면 해당 Controller에서는 검증기를 자동으로 적용할 수 있다

10-2. @Validated 이용

public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    ...
}
  • @Validated

    • 이 어노테이션이 붙으면 WebDataBinder 에 등록한 검증기를 찾아서 실행한다

    • @ModelAttribute가 붙은 대상에 대해 validate() 를 호출하지 않아도 자동으로 찾아진 검증기가 수행된다

  • 여러 검증기를 등록한다면 어떤 검증기가 실행되어야 할 지 구분이 필요하기 때문에 Validator 인터페이스의 supports() 가 사용된다
  • 참고> @Validated는 스프링 전용 검증 어노테이션이고 @Valid는 자바 표준 검증 어노테이션이다. @Valid를 사용하려면 의존관계 추가가 필요하다

10-3. 참고> 모든 Controller에 검증기 설정 ( 글로벌 설정 )

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }

    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
}
  • Controller 내부에서 @Validated를 사용하면 어떤 Controller 인지에 관계 없이 검증기가 실행

    • 물론 검증기의 supports()가 먼저 수행
  • 글로벌 설정을 하면 다음 게시글의 BeanValidator 가 자동 등록되지 않는다


10-4. 참고> @InitBinder

@InitBinder("targetObject")
public void initTargetObject(WebDataBinder webDataBinder) {
    log.info("webDataBinder={}, target={}", webDataBinder, webDataBinder.getTarget());
    webDataBinder.addValidators(/*TargetObject 관련 검증기*/);
}

@InitBinder("sameObject")
public void initSameObject(WebDataBinder webDataBinder) {
    log.info("webDataBinder={}, target={}", webDataBinder, webDataBinder.getTarget());
    webDataBinder.addValidators(/*SameObject 관련 검증기*/);
}
  • 일반적으로 컨트롤러를 만들 때 하나의 컨트롤러는 하나의 모델 객체(Command 객체)를 사용한다

  • 하지만 여러 모델 객체를 사용하고 싶은 경우, 위의 코드처럼 이름을 지정해야한다

  • @InitBinder 에 이름을 넣어주면 해당 모델 객체에만 영향을 주고, 이름을 넣지 않으면 모든 모델 객체에 영향을 준다

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글