Spring/[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]

[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 검증 - Validation

h2boom 2024. 10. 14. 14:20

Validation

검증 요구사항

  • 검증 로직 예시
    • 타입 검증 - 가격, 수량에 문자가 들어가면 검증 오류 처리
    • 필드 검증 - 상품명(필수, 공백x), 가격(1,000원이상, 100만원 이하), 수량(최대 9999개)
    • 특정 필드의 범위를 넘어서는 검증 - 가격 * 수량은 10,000원 이상

 

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

 

  • 클라이언트 검증 vs 서버 검증
    • 클라이언트 검증은 조작할 수 있기에 보안에 취약
    • 서버 검증은 즉각적인 고객 사용성이 부족
    • 둘을 적절히 섞어서 사용하되 최종적인 서버 검증 필수!

 

//검증 오류 결과를 보관
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";
}

//성공 로직
...
  • 컨트롤러에 검증 로직을 작성
    • Map에 검증 시 발생한 오류 정보를 담아둔다.
    • 검증 실패 시 다시 입력폼으로 돌아가도록 작성
      • 검증에 실패하더라도 고객이 작성한 내용 + 검증 오류 메시지를 Model에 담아서 입력 폼으로 다시 돌아가도록 해야한다.
    • !errors.isEmpty() => 부정의 부정은 가독성이 좋지 않기에 사용하는 것이 좋지 않다.
      • 리팩토링을 통해 hasError()와 같이 별도의 메소드로 만들어 가독성을 높여주는 것이 좋다.
    • 검증 로직을 다 통과 시 성공 로직 실행

 

<div th:if="${errors?.containsKey('globalError')}">
  • ?. : Safe Navigation Operator이다. (SpringEL이 제공하는 문법)
    • errors가 null인 경우 errors.containsKey()를 호출하면 NPE가 발생한다.
    • errors?.는 errors가 null일 때 NPE 대신 null을 반환하는 문법이다.
      • th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

 

//리팩토링 전
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"

//리팩토링 후
th:classappend="${errors?.containsKey('itemName')} ? 'fielderror': _" class="form-control"
  • 삼항 연산자 대신 classappend를 사용해서 더 효율적으로 코드를 작성할 수 있다.

 

  • 만약 잘못된 타입을 입력하는 경우 (ex 숫자 타입에 문자 입력)
    • 스프링 MVC에서 컨트롤러에 진입하기도 전에 400 예외가 발생하기에 컨트롤러가 호출되지 않기에 문제가 생긴다.
    • 고객이 입력한 문자를 화면에 남겨야하는데 숫자 타입에는 문자를 보관할 수 없기에 문제가 생긴다.
    • 결국 고객이 입력한 값을 별도로 관리가 필요하다. => 이후 BindingResult로 해결

BindingResult

  • BindingResult : 스프링이 제공하는 검증 오류를 보관하는 객체이다.
@PostMapping("/add")
public String addItem(@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() > 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("bindingResult ={}", bindingResult);

        return "/validation/v2/addForm";
    }

    //성공 로직
    ...
}
  • 스프링에서 제공하는 검증 오류 처리 방법 (BindingResult)
    • BindingResult를 파라미터로 받아서 사용 => @ModelAttribute 객체 다음에 BindingResult가 와야한다.
    • 필드에 오류가 있으면 FieldError 객체 생성 후 bindingResult에 담으면 된다.
      • 생성자(객체명, 필드명, 오류 메시지)
        • 객체명은 @ModelAttribute 객체명으로
        • 필드명은 오류가 발생한 필드명
    • 특정 필드를 넘어서는 글로벌 오류는 ObjectError 객체를 생성해서 BindingResult에 담는다.
    • BindingResult는 스프링에서 제공하기 때문에 Model에 자동으로 포함된다.

 

<!-- 글로벌 오류 처리 -->
<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>
  • 타임리프 스프링 검증 오류 통합 기능
    • #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
    • th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다. (th:if와 유사)
      • 오류 메시지 출력도 해준다.
    • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

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

 

  • @ModelAttribute에 바인딩 시 타입 오류 발생하면?
    • BindingResult가 없으면 - 400 오류 발생, 컨트롤러가 호출되지 않고 오류 페이지로 이동
    • BindingResult가 있으면 - 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출

 

  • BindingResult에 검증 오류를 적용하는 방법 3가지 
    1. @ModelAttribute의 객체에 타입 오류등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult에 넣어준다.
    2. 개발자가 직접 넣어준다.
    3. Validator를 사용한다.
  • 결국 BindingResult에서 발생하는 오류는 바인딩 자체가 실패하는 오류, 비지니스 검증 오류로 나뉜다.

 

  • BindingResult는 인터페이스로 Erros 인터페이스를 상속받고 있기에 둘 중 아무거나 사용해도 된다.
    • Errors는 단순한 오류 저장, 조회 기능만을 제공하기에 BindingResult가 추가적인 기능이 더 많다.
    • 관례상 BindingResult를 더 많이 사용한다.

 

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)
  • FieldError 생성자
    • rejectedValue : 사용자가 입력한 값 (거절된 값)
    • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    • codes : 메시지 코드
    • arguments : 메시지에서 사용하는 인자

 

  • 사용자 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 Model에 사용자 입력 값을 유지하기 어렵다.
    • 숫자 타입에 문자가 입력되는 경우 보관할 수 있는 방법이 없다.
    • FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다. (rejectedValue 필드)
    • 타임리프에서 th:field는 정상적인 상황에서는 Model 값을 사용하지만 오류가 발생 시 FieldError에서 보관한 값을 사용해서 값을 출력한다.
    • 스프링에서는 타입 오류로 바인딩 실패 시 FieldError를 생성하면서 사용자 입력 값을 넣어 BindingResult에 담아 컨트롤러를 호출한다.
      • 그렇기에 타입 오류로 바인딩 실패 시에도 사용자 오류 메시지를 정상 출력할 수 있다.

 

  • 오류 메시지도 MessageSource의 메시지 기능을 통해 하나의 파일에서 관리할 수 있다.

 

//메시지 기능을 사용하기 위한 application.properties 설정
spring.messages.basename=messages,errors

//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));
  • FieldError, ObjectError의 필드
    • codes : required.item.itemName을 사용해서 메시지 코드를 지정했다.
      • 메시지 코드는 String 배열로 여러 값을 전달할 수 있으며 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
        => 먼저 적은 값일수록 우선순위가 높다.
    • arguments : Object 배열로 여러 파라미터 값을 전달할 수 있으며 {0}, {1}...로 치환할 값을 전달한다.

 

  • BindingResult는 검증해야할 객체 바로 다음에 온다.
    • 그렇기에 BindigResult는 본인이 검증해야할 객체 target을 알고 있다.
      => 검증할 객체 명과 같은 코드를 더 간소화할 수 있다.

 

void rejectValue(@Nullable String field, String errorCode,
	@Nullable Object[] errorArgs, @Nullable String defaultMessage);
    
//사용 예시
bindingResult.rejectValue("itemName", "required");

//FieldError 비교
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(),
                    false, new String[]{"required.item.itemName"}, null, null));
  • addError() 대신 rejectValue()를 사용하면 코드를 간소화할 수 있다.
    • 객체명을 따로 명시하지 않아도된다.
    • errorCode : 오류 코드(메시지에 등록된 코드가 아닌 messageResolver를 위한 오류 코드)
  • FieldError()를 사용할 때는 오류 코드를 모두 입력하지만 rejectValue()는 오류 코드를 간단하게 입력하면 MessageResolver가 규칙에 따라 찾아서 오류 메시지를 출력한다.

 

  • 오류 코드 예시
    • 자세한 오류 코드
      required.item.itemName : 상품 이름은 필수 입니다.
      range.item.price : 상품의 가격 범위 오류 입니다.
    • 단순한 오류 코드
      required : 필수 값 입니다.
      range : 범위 오류 입니다.
  • 단순한 오류 코드는 범용성이 좋지만 메시지를 세밀하게 작성하기 어렵다, 너무 자세하게 만들면 범용성이 떨어진다.

 

//errors.properties 설정

#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다
  • required라는 오류 코드를 사용했을 때 범용적인 required 보다 required.item.itemName과 같이 객체와 필드명을 조합한 세밀한 메시지 코드가 있으면 더 높은 우선순위로 사용한다.
    • 스프링에서 MessageCodesResolver라는 것을 통해 이런 기능을 지원한다.

 

MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
  • MessageCodesResolver의 resolverMessageCodes()를 사용하면 검증 오류 코드로 메시지 코드를 규칙에 맞게 생성한다.
    • 예제의 결과 값
      messageCode = required.item.itemName
      messageCode = required.itemName
      messageCode = required.java.lang.String
      messageCode = required
    • 구체적(세부적)일수록 우선순위가 높다.

 

  • rejectValue(), reject() 동작 방식
    • rejectValue() / reject() 호출 시 내부에서 MessageCodesResolver를 사용해서 메시지 코드를 생성한다.
    • 생성한 메시지 코드로 FieldError / ObjectError 를 생성한다.
      • MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.
      • FieldError, ObjectError의 필드인 오류 코드 String 배열에 생성한 메시지 코드들을 넣는다.
    • 타임리프에서 화면을 렌더링할 때 th:errors가 실행된다.
      • 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.
        해당 메시지가 없다면 디폴트 메시지를 출력한다.

 

  • MessageCodesResolver의 기본 메시지 생성 규칙
    • 객체 오류 ObjectError
      1. code + "." + object name
      2. code
    • 필드 오류 FieldError
      1. code + "." + object name + "." + field
      2. code + "."  + field
      3. code + "." + field type
      4. code

오류 코드 관리 전략

  • 오류 코드 관리 전략
    • 구체적인 것에서 덜 구체적인 것으로!
      • MessageCodesResolver는 구체적인 것을 먼저 만들고 덜 구체적인 것을 나중에 만든다.
    • 모든 오류 코드에 대해 메시지를 다 정의하고 관리하기 힘들기에 크게 중요하지 않은 메시지는 범용성 있는 메시지로, 중요한 메시지는 구체적으로 적어서 사용하는 방식이 효과적

 

//errors.properties 예제
#==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 사용 전
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

//ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
  • ValidationUtils는 Empty, 공백과 같은 단순한 기능을 제공해준다.

타입 바인딩 오류

  • 타입 정보가 맞지 않는 경우 스프링이 직접 FieldError를 생성해서 BindingResult에 넣어준다.
    • 이 경우 typeMismatch 에러가 발생하는데 에러 코드에 넣어서 BindingResult에 보내기 때문에 MessageCodesResolver에 의해 자동으로 메시지 코드가 생성된다.
      • 일반적인 경우와 마찬가지로 typeMismatch에 대한 메시지 코드를 만들어 넣어놓으면 디폴트 메시지가 아닌 해당 메시지가 출력된다.
    • ex) price라는 가격 (integer)에 문자를 입력한 경우 자동 생성되는 메시지 코드
      typeMismatch.item.price
      typeMismatch.price
      typeMismatch.java.lang.Integer
      typeMismatch
    • ex) errors.properties 설정
      typeMismatch.java.lang.Integer=숫자를 입력해주세요.
      typeMismatch=타입 오류입니다.
      • 우선순위에 따라 디폴트 메시지가 아닌 숫자를 입력해주세요 라는 메시지가 출력되게 된다.

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) {
        Item item = (Item) target;

        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);
            }
        }
    }
}
  • 스프링에서 검증을 제공하기 위해 Validator 인터페이스를 제공한다.
    • supports() : 해당 검증기를 지원하는 여부 확인
      • isAssignableFrom()은 ==과 달리 자식 클래스까지 확인 가능하다.
    • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult를 파라미터로 받아서 검증 로직 수행

 

//별도의 검증 클래스를 통한 검증 예제
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    if (itemValidator.supports(item.getClass())) {
        itemValidator.validate(item, bindingResult);
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("bindingResult={}", bindingResult);
        return "validation/v2/addForm";
    }

    //성공 로직
    ...
}
  • 검증기인 ItemValidator를 스프링 빈으로 주입 받아서 사용

 

  • WebDataBinder 기능
    • 스프링 파라미터 바인딩 역할
    • 검증 기능

 

@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
}
  • 컨트롤러에 WebDataBinder 검증기 추가
    • @InitBinder 어노테이션을 사용하고 WebDataBinder에 ItemValidator 검증기를 추가
    • WebDataBinder 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용할 수 있다.
      • 해당 컨트롤러만이 아닌 글로벌하게 설정하려면 별도의 설정이 필요하다.

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("bindingResult={}", bindingResult);

        return "validation/v2/addForm";
    }

    //성공 로직
    ...
}
  • WebDataBinder를 사용하면 별도의 validator를 직접 호출하지 않고 검증 대상 앞에 @Validated를 붙여주면 된다.
    • 예제에서는 검증 대상인 Item 객체 앞에 @Validated 사용

 

  • @Validated, @Valid 모두 동일하다.
    • @Validated는 스프링 전용 검증 어노테이션
    • @Valid는 자바 표준 검증 어노테이션으로 build.gradle에 별도의 의존관계 추가가 필요하다.

 

  • WebDataBinder 검증 동작 방식
    1. @Validated는 검증기를 실행하는 어노테이션이기에 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
    2. 등록된 검증기가 여러 개인 경우 실행할 검증기를 구분하기 위해 supports() 메소드가 호출된다.
    3. 실행할 검증기를 찾은 후 validate()가 호출된다.

 

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

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

    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
}
  • 검증기를 모든 컨트롤러에 다 적용하는 글로벌 설정
    • 글로벌 설정을 하면 각 컨트롤러마다 별도로 @InitBinder로 설정해 줄 필요가 없다.
    • 글로벌 설정 시 주의할 점
      • 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않기에 직접 사용하는 경우는 드물다.

출처 : [인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com