<Spring MVC> 검증1 - Validation

Intro

웹 애플리케이션에서 폼 입력시 숫자를 문자로 작성하는 등의 검증 오류가 발생해서 오류 화면으로 바로 이동하게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 이 경우 사용자 입장에선 매우 불편하다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려줘야 한다.

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

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

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

검증 과정

  1. 사용자가 상품 등록 페이지에 접근(HTTP GET /add)
  2. 사용자가 상품 정보를 입력 후 서버로 전송(HTTP POST /add)
  3. 상품이 성공적으로 등록된 후 Location 정보로 상품 정보 상세 경로를 Redirect로 응답
  4. 클라이언트에선 응답 받은 정보에 있는 Location 정보로 Redirect하여 신규 상세 페이지로 이동

  1. 사용자가 상품 등록 페이지에 접근(HTTP GET /add)
  2. 사용자가 상품 정보를 입력 후 서버로 전송(HTTP POST /add)
  3. 상품의 유효성 검증이 실패하며 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동

검증에 실패하는 경우?

  • Null
  • TypeMissMatch
  • 비즈니스 요구사항에 맞지 않음

다양한 검증 방식

검증을 하는 방식은 다양하다. 단순히 Map에 에러 내용을 담아 모델에 넣어 반환하는 방식도 있고, BindingResult를 사용하여 담아 보낼수도 있고, Validator라는 인터페이스를 구현하여 사용하는 방식도 있다. 더 나아가 애노테이션 기반으로 편리하게 검증하는 방식도 있다.

Map

서버에서 전달받은 데이터를 직접 검증하여 Map에 담아 RedirectAttributes에 담아 보내는 방법

@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() > 100000) {
        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()) {
        model.addAttribute("errors", errors);
        return "validation/v1/addForm";
    }
    
    // 성공 로직
    Item saveItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", saveItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v1/items/{itemId}";
}

검증시 오류가 발생하면 errors에 담아둔다. 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다. 이후 뷰에서 이 데이터를 사용해 사용자에게 오류 메시지를 출력할 수 있다.

하지만, 이 방식은 타입 오류 처리가 안된다. Itemprice, quantity 같은 숫자 필드는 타입이 Integer 이므로 문자 타입으로 설정하는 것이 불가능하다. 이런 오류는 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 Bad Request 에러가 발생하면서 오류 페이지를 띄워준다.

타입 에러가 발생하더라도 오류 페이지로 넘기지 않고 사용자가 입력한 문자를 화면에 남겨야 한다.

BindingResult

BindingResult는 스프링이 제공하는 검증 오류 처리 방법이다.
이를 사용하면 컨트롤러의 매핑 메서드에서 타입 불일치에 대한 대응도 가능해진다.

@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() > 100000) {
        bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }
    if (item.getQuantity() == null || item.getQuantity() >= 9999) {
        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 saveItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", saveItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아둔다.

  • objectName: @ModelAttribute 이름
  • field: 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지
public FieldError(String objectName, String field, String defaultMessage) {}

특정 필드를 넘어서는 글로벌 오류인 경우 ObjectError 객체를 이용해 담는다.

public ObjectError(String objectName, String defaultMessage) {}

thymeleaf 에선 다음과 같이 사용한다.

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

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

  • #fields: BindingResult가 제공하는 검증 오류에 접근이 가능하다.
  • th:errors: 해당 필드에 오류가 있는 경우 태그를 출력한다. th:if의 편의 버전이다.
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다. BindingResult가 있으면 @ModelAttribute에 바인딩 시 타입 오류가 발생해도 컨트롤러가 호출된다.

게다가 BindingResultModel에 자동으로 포함된다.
하지만, 오류가 발생하는 경우 사용자가 입력한 내용이 모두 사라진다.

FieldError, ObjectError

FieldError는 두 가지 생성자를 제공한다. (ObjectError도 유사함)

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) {}
  • objectName: 오류가 발생한 객체 이름
  • field: 오류 필드
  • rejectedValue: 사용자가 입력한 값(거절된 값)
  • bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

다음과 같은 방식으로 사용하면, 오류 발생 시 사용자 입력 값이 유지된다.

new FieldError("item", "price", item.getPrice(), false, null, null,
               "가격은 1,000 ~ 1,000,000 까지 허용합니다.");

오류가 발생한 경우 사용자의 입력 값을 보관하는 별도의 방법이 필요하고, 보관한 사용자 입력 값을 검증 오류 발생 시 화면에 다시 출력해야 한다. rejectedValue가 바로 오류 발생 시 사용자 입력 값을 저장하는 필드이다. bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 바인딩이 실패한 것이 아니면 false를 사용한다.

오류 코드와 메시지 처리

FieldError, ObjectError

Spring Boot가 오류 메시지를 구분하기 쉽게 파일로 만드는 방법이다.
해당 메시지 파일을 인식할 수 있도록 다음 설정을 추가한다.

application.properties

spring.messages.basename=messages,errors

src/main/resources/errors.properties

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

이 후 다음과 같이 코드를 변경하면 해당 에러 메시지를 사용할 수 있다.

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false,
                       new String[] {"required.item.itemName"}, null, null));

생성자의 인자를 살펴본다면,

  • codes: required.item.itemName을 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments: Object[] {1000, 100000}을 사용해서 코드의 {0}, {1}로 치환할 값을 전달한다.

이런 방식은 매우 번거롭고, 담아야 할 속성도 너무 많다. messages의 이름을 매번 다 적는 것도 불편하다.

BindingResultrejectValue(), reject()를 사용해서 FieldError, ObjectError를 직접 생성하지 않고 편리하게 처리하는 방식을 사용해보자.

rejectValue(), reject()

이전 방식보다 더 깔끔하게 처리가 가능하다.

bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);

errors.properties에 있는 코드를 직접 입력하지 않았는데 어떤 방식으로 가져오는 것일까?

rejectValue()

void rejectValue(@Nullable String field, String errorCode,
                 @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field: 오류 필드명
  • errorCode: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니라 MessageCodesResolver를 위한 오류 코드이다.)
  • errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

MessageCodesResolver

스프링에서 제공하는 마커 인터페이스인 MessageCodesResolver는 다음과 같은 메서드가 정의되어 있다.

public interface MessageCodesResolver {
    String[] resolveMessageCodes(String errorCode, String objectName);
    String[] resolveMessageCodes(String errorCode, String objectName,
                                 String field, @Nullable Class<?> fieldType);
}

이 인터페이스의 기본 구현체로 DefaultMessageCodesResolver를 제공하기 때문에 이를 이용해서 각종 메시지에 대한 대처가 쉽게 가능하다.

MessageCodesResolver는 메시지의 단계에 따라 범용성이 낮은 순서에서 높은 순서대로 차례대로 찾으면서 처음 매칭되는 결과를 가져온다.

#level 1
required.item.itemName: 상품 이름은 필수입니다.

#level 2
required: 필수 값 입니다.

errors.properties가 위처럼 작성되어 있다면 리졸버는 디테일한 순서부터 차례대로 찾는다.

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

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

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

스프링이 직접 만든 오류 메시지 처리

우리가 정의한 오류 코드는 rejectValue()를 직접 호출해서 담아준다.

타입 정보 불일치와 같이 스프링이 직접 검증 오류에 추가한 경우도 있는데 이는 사용자에게 노출해선 안되는 메시지까지 노출하게 된다.

BindingResult를 확인하면 FieldError에 다음과 같은 메시지 코드가 생성되어 추가되었다.

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

기본 메시지를 사용자에게 노출하지 않기 위해 다음과 같이 errors.properties에 메시지를 선언해준다.

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

Validator 분리

스프링에선 검증에 필요한 Validator라는 인터페이스를 정의해두었다. 이를 활용해서 검증 로직을 분리하여 모듈화 하자.

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

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;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 100 || 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.class.isAssignableFrom(clazz): 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미한다.
  • Errors errors: 매개 변수 타입인 Errors는 BindingResult 클래스의 부모 타입이기 때문에 공변성이 성립한다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    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}";
    }
}

컨트롤러에 있던 많은 검증 로직이 ItemValidator로 분리되었기 때문에 컨트롤러에서는 validate 메서드 호출로 검증이 가능해졌다.

애노테이션 기반 분리

스프링에선 Validator 인터페이스를 구현해서 검증로직을 만들면 추가적으로 애노테이션을 사용하여 검증을 수행할수도 있다. WebDataBinder를 이용하는 방법인데, 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스이다. 이 객체에 직접 만든 검증기를 추가하면 자동으로 검증기 적용이 가능해진다.

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
    private final ItemValidator itemValidator;
    
    @InitBinder
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder);
        databinder.addValidators(itemValidator);
    }
}

이런식으로 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
// @InitBinder: 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.

@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}";
}
  • @Validated 애노테이션을 사용해서 Item의 검증 로직을 수행해준다.
  • 이 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
  • 여러 검증기를 등록한다면 그 중 어떤 검증기가 실행되어야 할지 구분이 필요한데, 이때 supports()가 사용된다.
@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) {...}
}

📌 @Validated, @Valid

javax.validation.@Valid를 사용하려면 build.gradle에 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'을 추가하자.
@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.

References

좋은 웹페이지 즐겨찾기