관리 메뉴

Rootable의 개발일기

스프링 MVC 검증 - Validation 본문

Spring

스프링 MVC 검증 - Validation

dev-rootable 2023. 7. 22. 19:53

📌 필요성

 

고객이 브라우저에서 타입에 맞지 않는 값을 입력하거나 정해진 범위를 벗어난 값을 입력했을 때, 입력 값에 대한 검증이 필요하다. 이유는 다음과 같다.

 

1. 고객이 입력했던 값을 유지해야 한다.

 - 언제든지 고객에게 재입력 기회를 줘야 서비스의 연속성이 유지될 수 있다.

 

2. 올바른 입력인지 안내해야 한다.

 - 고객의 올바른 입력을 유도해야 한다.

 

3. 타입 미스매치와 같은 바인드 실패 상황에서도 컨트롤러가 실행되도록 해야 한다.

 - 페이지에서 강제로 이탈시키는 것을 막아야 서비스 연속성이 유지될 수 있고, 이는 고객 만족도와 연결된다.

 

💡 클라이언트 및 서버에서의 검증

고객과 가까운 클라이언트 검증은 조작할 수 있어 보안에 취약하다. 또한, 서버만으로 검증하는 것은 고객 사용성이 떨어진다. 따라서, 둘을 적절히 섞어서 사용하되, 최종 방화벽으로서 서버에서의 검증은 필수로 한다.

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

 

📌 Java를 통해 구현

 

아래 코드는 폼 등록 컨트롤러다.

 

    @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", "가격은 1000 ~ 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 savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }

 

errors라는 Map에 값을 보관하고, 보관된 값을 템플릿(HTML)에서 사용하는 방식이다. 해당 Map은 Key로서 필드명, Value로서 에러 메시지를 담았다.

 

복합 룰 검증두 개 이상의 필드가 필요한 조건에서 검증을 실시하는 경우다. 위 코드를 보면 가격과 수량의 곱을 검증하므로 이 범위에 들어간다. 이러한 복합 룰에서 발생하는 오류GlobalError라고 한다. 마찬가지로 조건식 내에서 Map에 값을 담는 식이다. 다만, 단일 필드 에러와 차이점은 템플릿 측에서 해당 값을 꺼내는 코드만 다를 뿐이다.

 

검증에 실패하면 해당 페이지에 머물러야 한다. 따라서, Map에 값이 있을 경우 그대로 머무르도록 한다.

 

이 과정을 넘어왔다면, 검증 성공으로 간주하고 저장해야 할 Item이라는 도메인을 저장 및 생성한다. PRG 원칙에 따라 저장 후에는 저장 완료 페이지로 이동해야 하므로 @PathValiable처럼 동적 URL 생성이 필요하다. 이를 위해 RedirectAttributes를 사용하면 GET 요청이 나오더라도 URL에 값을 노출시키지 않고 Model 객체처럼 값을 보관할 수 있다.

 

 

아래는 등록 페이지에 대한 HTML 코드다.

 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
        <div th:if="${errors?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
        </div>
        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}"
                   th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="이름을 입력하세요">
            <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
                상품명 오류
            </div>
        </div>
        
        <!-- 가격, 수량은 상품명 코드와 동일하여 생략 -->

        <hr class="my-4">

        <!-- 등록 및 취소 영역 -->

    </form>

</div> <!-- /container -->
</body>
</html>

 

컨트롤러로부터 받은 errors에 boolean 조건문을 사용할 수 있다. 타임리프 문법은 "th:if"이고, 값은 "${errors?.containsKey('필드명')}"이다. 글로벌 에러의 경우 필드명이 아닌 'globalError'이다.

 

🔎 문제점

 

에러 케이스

 

바인드 실패 시, 페이지 이탈

 

위와 같은 전개는 검증의 필요성 조건 어느 하나도 충족시키지 못한다.

 

📌 BindingResult를 통해 구현

 

ModelAttribute를 이용해 파라미터를 객체에 바인딩할 때 발생한 오류 정보를 받기 위해 스프링이 제공하는 검증 오류를 보관하는 객체

 

BindingResult는 반드시 @ModelAttribute 바로 뒤에 위치해야 한다.

 

🔎 BindingResult의 역할

 

1. 바인딩에 실패하더라도 컨트롤러를 실행하여 페이지 이탈을 방지

 - 흐름을 진행할 수 있도록 설계된 것

 

2. FieldError와 ObjectError로부터 받은 정보를 보관

 

3. 템플릿에서 BindingResult에 보관된 검증 오류에 접근 가능하도록 함

 

🔎 FieldError와 ObjectError

 

✔ FieldError

 

FieldError 생성자

 

  • objectName ➡ 검증 객체(@ModelAttribute에서 바인딩한 객체)
  • field ➡ 검증 필드
  • defaultMessage ➡ 검증(안내) 메시지
  • rejectedValue ➡ 검증 실패 전 폼에 입력했던 값(고객의 입력 값 유지)
  • bindingFailure ➡ 바인딩 실패인지(true), 검증 실패인지(false) 구분하는 값
  • codes ➡ MessageSource에서 인식할 수 있는 코드 값
  • arguments ➡ 해당 메시지 코드에 배치할 파라미터

 

스프링이 단일 필드에서 발생한 에러를 처리하기 위해 만든 객체로 BindingResult에 담을 수 있음

 

✔ ObjectError

 

매개변수 의미는 위와 동일

 

public ObjectError(String objectName, String defaultMessage) {
   ...
}

public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, 
                   @Nullable String defaultMessage) {
   ...
}

 

스프링이 글로벌 에러를 처리하기 위해 만든 객체로 BindingResult에 담을 수 있음

 

🔎 구현

 

   @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.getQuantity(),
                    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,
                        "가격 x 수량의 합은 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에 FieldError 또는 ObjectError를 담는 방식으로 구현할 수 있다. 또한, BindingResult에 값이 담겼는지 확인할 수 있어 담겼을 경우 해당 페이지를 유지한다.

 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <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>
        
        <!-- 가격, 수량은 상품명 코드와 동일하여 생략 -->

        <hr class="my-4">

        <!-- 저장 및 취소 영역 -->

    </form>

</div> <!-- /container -->
</body>
</html>

 

템플릿에서 지원하는 스프링 검증 오류 통합 기능으로 BindingResult에 담긴 오류에 접근할 수 있다.

 

글로벌 오류의 경우, "#fields.hasGlobalErrors()" 통해 접근 가능하다.

 

필드 오류의 경우, "th:errors"로 해당 필드에 오류가 있는 경우 담긴 검증 메시지를 출력할 수 있다. 또한, "th:errorclass"를 통해 "th:field"에서 지정한 필드에 오류가 있으면 class 정보를 추가할 수 있다.

 

🔎 사용자의 입력 값 유지

 

FieldError의 rejectValue 파라미터에 사용자 입력 값이 저장된다. 또한, 타임리프의 th:field는 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다. 따라서, 사용자의 눈에도 입력 값이 그대로 유지된 상태로 보이는 것이다.

 

🔎 메시지 기능 사용

 

메시지 기능을 사용하여 에러 메시지를 재활용하고, MessageSource에서 생성한 에러 메시지를 사용할 수 있다.

 

이를 위해 errors.properties를 생성하고, application.properties에 등록한다.

 

#application.properties
spring.messages.basename=messages,errors

 

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

 

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

        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 - rejectedValue()와 reject() 사용

 

Errors 인터페이스가 제공하는 rejectValue()reject()를 사용하여 직접 FieldError와 ObjectError를 생성하지 않고도 BindingResult에 입력 및 오류 정보를 담을 수 있다. 그 이유는 BindingResult가 Errors 인터페이스를 상속받았기 때문이다.

 

  • rejectValue() ➡ FieldError 대체
  • reject() ➡ ObjectError 대체

 

   @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())) {
            ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "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);
            }
        }

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

    }

 

rejectValue()와 reject()를 보면 필드, 오류 코드, 매개 변수 정보만 들어있다. 이전과 다르게 객체 이름과 입력 값에 대한 정보가 없다. 하지만 위 코드의 로그를 보면 BindingResult는 컨트롤러 시작 시점에 이미 객체 정보를 모두 알고 있음을 알 수 있다.

 

로그 내용

 

💡 ValidationUtils.rejectIfEmptyOrWhitespace(BindingResult, field, errorCode)

내부 로직에 if문이 들어 있어 공백이나 whitespace 등 검증 가능
rejectValue() 대신 사용 가능

 

🔎 rejectValue()와 reject()

 

✔ rejectValue()

 

 

  • field ➡ 도메인의 필드
  • errorCode ➡ 오류 코드
  • errorArgs ➡ 오류 코드에서 사용할 매개 변수
  • defaultMessage ➡ 검증(안내) 메시지

 

✔ reject()

 

파라미터의 의미는 위와 동일

 

 

rejectValue()와 reject()는 MessageCodesResolver를 통해 간략한 오류 코드만으로 오류 메시지를 찾아온다.

 

MessageCodesResolver에 대한 자세한 내용은 아래 글에서 참고할 수 있다.

 

https://dev-rootable.tistory.com/82

 

MessageCodesResolver

📌 단순한 오류 메시지 vs 자세한 오류 메시지 🔎 단순한 오류 메시지 # 단순한 오류 메시지 required : 필수 값입니다. range : 범위 오류입니다. 범용성이 좋아 여러 곳에서 사용할 수 있다. 메시지

dev-rootable.tistory.com

 

📌 검증 로직 분리 - Validator 검증기 생성

 

위 구현을 보면 컨트롤러에서 검증 로직이 차지하는 부분이 많다. 이를 별도로 분리할 수 있다.

 

Validator라는 인터페이스를 구현한 클래스에 검증 로직을 작성한다. 해당 인터페이스는 다음 메서드를 구현해야 한다.

 

  • supports() : 해당 검증기의 지원 여부
  • validate() : 검증 로직

 

🔎 supports()

 

Item과 그 자식 클래스에 대한 검증을 지원한다.

 

@Component
public class ItemValidator implements Validator {

@Override
public boolean supports(Class<?> clazz) {
	return Item.class.isAssignableFrom(clazz);
	//item == clazz
	//item == subItem(자식)
}
...

 

🔎 validate()

 

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

 

🔎 컨트롤러

 

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

    }

}

 

🔎 WebDataBinder

 

WebDataBinder는 Validator를 통해 만든 검증기를 찾아서 호출해 준다. 이것이 작성된 컨트롤러는 영역 내 모든 메서드마다 @Validator를 통해 검증기를 적용할 수 있다. 또한, 전역 설정을 할 경우 어떤 컨트롤러든 @Validator만 넣으면 검증기가 동작한다.

 

만약 여러 검증기를 등록한다면 supports 메서드를 통해 어떤 검증기가 실행되어야 할지 구분이 필요하다.

 

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

    }

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }

}

 

//전역 설정
@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를 제거해도 정상 동작한다.

 

Reference:

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

 

 

 

'Spring' 카테고리의 다른 글

MessageCodesResolver  (0) 2023.07.25
스프링 MVC 검증 - Bean Validation  (0) 2023.07.22
요청 매핑 핸들러 어뎁터 구조  (0) 2023.07.18
HTTP 메시지 컨버터(HTTP Message Converter)  (0) 2023.07.17
HTTP 요청 파라미터  (0) 2023.07.15