본문 바로가기
Web/spring study

[Spring] 검증 Validation

by 장인이 2024. 1. 30.

1. 개요

 웹사이트를 만들 경우, 가장 경계해야 하는 것 중 하나가 올바른 데이터를 받도록 하는 것이다. 테스트 상황과 다르게 실제 유저들은 정해진 기준에 맞지 않는 데이터를 입력하며, 이를 그대로 받을 경우 큰 오류 상황으로 이어진다. 따라서 이를 방지하기 위해 데이터를 검증해야 한다. 이번 게시물에서는 직접 검증을 처리하는 것부터 스프링의 도움을 받아 Validator를 분리하는 것까지 쭉 정리해 볼 것이다.

 

 

2. 검증 요구사항

모든 설명에 나오는 예시 프로젝트의 설계 방식은 아래 링크를 참고
https://imgzon.tistory.com/114

 

 해당 예시 프로젝트의 상품 관리 시스템새로운 요구사항이 추가되었다고 가정해보자.

 

1) 타입 검증

- 가격, 수량에 문자가 들어가면 검증 오류 처리

2) 필드 검증

- 상품명은 필수이며, 공백 X

- 가격은 1000원 이상, 백만원 이하

- 수량은 최대 9999

3) 특정 필드의 범위를 넘어서는 검증

- 가격 * 수량의 합은 10000원 이상이어야 함

 

 검증 기능을 구현하지 않은 상태에서 검증 오류가 발생하면, 오류 화면으로 바로 이동한다. 그러면 사용자는 다시 폼으로 이동해서 입력을 해야 하는데, 사용자 입장에서는 다시 하나하나 정보를 입력해야 된다. 이런 사이트를 경험해본 사람들은 알겠지만, 그냥 뒤로가기를 누르고 사이트를 이용 안하게 된다... 따라서 웹 서비스는 폼 입력 시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 오류 안내를 해야한다.

 

 

3. 검증 직접 처리

 우선 검증을 직접 처리해보자. 상품 등록폼을 받는 controller를 수정해보자.

@PostMapping("/add")
public String addItem(@ModelAttribute Item item,
		RedirectAttributes redirectAttibutes, 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()) {
    	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}";
}

 

하나하나 확인해 보자.

 

public String addItem(..., Model model) {
...

 검증에 실패하게 되면 view에 에러 정보를 전달해야 하므로, Model 객체를 만들어 파라미터로 받는다.

 

Map<String, String> errors = new HashMap<>();

 검증 오류 결과를 보관하기 위함이다. 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.

 

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

 검증 시 오류가 발생하면, error에 담아둔다. 이때, 오류가 발생한 필드명을 key로, 오류 메시지를 value로 저장한다.

 

if (item.getPrice()!=null && item.getQuantity()!=null) {
    int resultPrice = item.getPrice()*item.getQuantity();
    if (resultPrice < 10000) {
        errors.put("globalError", ...);
}

 특정 필드만 관련되지 않은 오류를 처리할 경우도 있다. 이때는 넣을 필드 이름이 없으므로, globalError라는 key 이름을 사용한다.

 

if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

 검증 후 오류 메시지가 하나라도 담겨있으면, model에 오류 메시지 정보가 있는 errors를 담고, 상품 등록 폼 뷰로 보낸다.

 

View 템플릿

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

 타임리프의 th:if를 사용하여 errors에 관련 내용이 있을 때만, 출력하게 된다. globalError 같은 경우는 특정 필드에 종속되는 개념이 아니므로, 별도로 표시해 주자.

 

<input type="text" th:classappend="${errors?.containsKey('itemName')} ?
	'fielderror' : _" class="form-control">

 필드 에러가 발생할 경우, 입력 폼에 field-error라는 클래스 정보를 더한 후 css를 추가하여 폼의 색깔을 빨간색으로 강조해보자. 만약 값이 없다면, _을 사용해서 아무것도 하지 않도록 만든다.

 

<div class="field-error" th:if="${errors?.containsKey('itemName')}"
	th:text="${errors['itemName']}">
 상품명 오류
</div>

 글로벌 메시지와 동일한 내용이며, 단지 대상이 필드 오류로 바뀌었다.

 

결과 및 문제점

 직접 검증한 내용을 추가하고 실행해보면, 검증 오류 발생시 입력 폼을 다시 보여주게 된다. 또한 고객이 입력한 데이터가 유지되고, 오류 문구가 성공적으로 출력된다.

 하지만, 여러가지 문제점들이 남아있다...

 

타입 오류 처리가 되지 않는다. price, quantity는 타입이 Integer이므로, 문자 데이터가 들어갈 수 없다. 하지만 이런 오류는 컨트롤러에 진입하기 전에 예외가 발생하므로, 컨트롤러가 호출이 되지 않은 채 400 예외가 발생한다.

- 또한, 타입 오류가 발생하면 고객이 입력한 내용을 보관할 장소가 없다. 만일 위 상황을 해결한다고 하여도, Item의 price는 Integer이므로 문자를 보관할 수가 없고, 고객이 입력한 정보가 소실된다.

- 뷰 템플릿에서 중복 처리가 많다. 위에 예시에서는 하나의 필드만 적어놓았지만, 모든 필드마다 비슷한 코드를 작성해야 한다.

 

 

4. BindingResult

 타입 오류를 처리하기 위해 스프링이 제공하는 검증 오류 처리 방법인 BindingResult를 사용해 보자.

 

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
	RedirectAttributes redirectAttributes) {
...

 3.의 예제와 비교해보면, BindingResult bindingResult 파라미터가 추가되었다. 단, 해당 파라미터는 반드시 @ModelAttribute Item item 다음에 와야 한다. 이제부터는 오류 메시지를 BindingResult 객체에 넣어서 전달할 것이므로, Model 객체는 필요없어지게 된다.

 

//Map<String, String> errors = new HashMap<>();

 이제 따로 오류를 저장할 Map을 만들 필요 없이, 파라미터로 받은 bindingResult 인스턴스에 오류 메시지를 추가하면 된다.

 

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

 필드 오류일 경우에, FieldError 객체를 생성해서 bindingResult에 담으면 된다. FieldError의 생성자는 다음과 같다.

 

public FieldError(String objectName, String field, String defaultMessage) {}

- objectName: @ModelAttribute 이름

- field: 오류가 발생한 필드 이름

- defaultMessage: 오류 기본 메시지

 

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

 특정 필드에만 국한되어 있는 오류가 아닐 경우에는, ObjectError 객체를 생성해서 bindingResult에 담으면 된다. ObjectError의 생성자는 다음과 같다.

 

public ObjectError(String objectName, String defaultMessage) {}

- objectName: @ModelAttribute 이름

- defaultMessage: 오류 기본 메시지

 

View 템플릿

 타임리프스프링의 BindingResult 값을 편리하게 활용하는 기능을 제공한다.

- #fields: 이를 통해 BindingResult가 제공하는 검증 오류 값에 접근할 수 있다.

- th:errors: 해당 필드에 오류가 있는 경우 태그를 출력하며, th:if의 편의 버전이라 생각하면 편하다.

- th:errorclass: th:field에서 지정한 필드에 오류가 있다면, class 정보를 자동으로 추가해준다.

 

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

 글로벌 오류 처리시, #fields을 활용하였다. 또한 예시에는 globalError의 종류가 한 개지만, 추후 에러가 추가될 것을 대비해 th:each를 사용하였다.

 

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

 필드 오류 처리시, 기존의 th:classappend, th:if 대신 th:errors, th:errorclass을 활용하였다.

 

결과 및 문제점

 코드를 적용해본 후, 숫자가 입력되어야 하는 곳에 문자를 입력한 타입 오류를 발생시키면 오류 페이지로 넘어가지 않고 다시 입력 폼으로 돌아간다! 즉, BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생하도, 컨트롤러가 호출이 된다.

 

 + 사실 BindingResult는 Errors 인터페이스를 상속받는 인터페이스이다. 실제 넘어오는 구현체는 BeanPropertyBindingResult이며, Errors를 사용해도 된다. 단, addError()는 BindingResult가 제공하는 기능이며, 주로 BindingResult를 많이 사용한다.

 

 이제 타입 오류가 발생해도 오류 메시지를 처리할 수 있게 되었다! 하지만, 오류가 발생한 경우 고객이 입력한 내용이 모두 사라진다! 이 문제를 FieldError, ObjectError의 다른 생성자를 활용하여 해결해보자.

 

 

5. FieldError, ObjectError

 4번에서 필드 오류는 해결하였지만, 막상 사용자가 입력한 내용을 보존하지 못하였다. 이를 고쳐보자.

 

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는 두 가지 생성자를 제공하며, 아래 생성자를 한번 살펴보자.

objectName오류가 발생한 객체 이름

field오류 필드

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

bindingFailure타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값

codes: 메시지 코드

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

defaultMessage: 기본 오류 메시지

 

+ ObjectError 또한 비슷한 맥락으로 2개의 생성자가 있다.

 

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

 rejectedValue 파라미터에 입력받은 값을 넣어보자. 위 예시에서 바인딩이 실패하고 넣은 오류는 없으므로, bindingFailure는 false를 사용한다.

 

결과 및 문제점

 실행해보면, 이제 오류 발생 시 사용자가 입력했던 정보가 잘 보존된다! 하지만, 코드가 너무 난잡하다. 오류 메시지가 컨트롤러 사이사이에 끼여있으며, 만일 동일한 오류메시지를 적을 경우 코드가 중복된다. 따라서 오류 메시지를 조금 더 체계적으로 다루어보자.

 

 

6. 오류 코드와 메시지 처리

 위 5번에서 사용하지 않은 파라미터가 있다. FieldError, ObjectError의 생성자 codes, arguments를 제공한다. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

errors 메시지 파일 생성

 메시지, 국제화때 사용한 messages.properties를 사용해도 되지만, 오류 메시지를 구분하기 위해 errors.properties라는 파일로 관리해보자.

 우선 application.properties 설정을 추가해주자.

spring.messages.basename=messages,errors

 

resources/errors.properties 를 생성하고 아래 내용을 입력해보자.

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

 

 이렇게 설정을 해두었다면, controller를 다음과 같이 변경할 수 있다.

 

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

new FieldError("item", "price", item.getPrice(),
    false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null);

codesrequired.item.itemNamerange.item.price등을 사용해서 메시지 코드를 지정한다. 코드는 배열로 여러 값을 한번에 전달 할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

argumentsObject[]{1000, 1000000}을 사용해서 메시지의 {0}, {1}로 치환할 값을 전달한다.

 

+ 메시지 파일을 통하여 에러 메시지를 찾으므로, defaultMessage는 null 처리해주자.

 

결과 및 문제점

 실행해 보면, 정상적으로 잘 동작한다. 하지만 아래 코드를 봐보자..

 

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

 객체 생성시 생성자가 요구하는 파라미터가 너무 많고, 너무 길다. 해당 코드를 한번 줄여보자.

 

rejectValue(), reject()

 위에서 주의사항으로 적었던 내용이기도 하지만, BindingResult는 검증해야 하는 객체 바로 다음에 파라미터로 온다. 따라서, BindingResult는 이미 본인이 검증해야 하는 객체를 알고 있다!

 

 rejectValue(), reject()를 사용하면 개발자가 직접 FieldError, ObjectError를 생성하지 않아도, 검증 오류를 다룰 수 있다.

 

void rejectValue(@Nullable String field, String errorCode, 
	@Nullable Object[] errorArgs, @Nullable String defaultMessage);
void rejectValue(@Nullable String field, String errorCode);

field: 오류 필드명

errorCode오류 코드

errorArgs: 오류 메시지에서 {0}, {1}, ...을 치환하기 위한 값

defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 

bindingResult.rejectValue("itemName", "required");

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

 훨씬 짧아졌다! 실행해보면 오류 메시지 또한 정상 출력된다. 그런데 required.item.itemName 을 정확하게 적지 않았는데도, 오류 메시지를 잘 찾는 것을 볼 수 있다. 이는 스프링이 제공하는 MessageCodesResolver를 활용하고 있기 때문이다.

 

DefaultMessageCodesResolver

 Default MessageCodesResolver의 기본 메시지 생성 규칙은 다음과 같다. 

 

객체 오류

객체 자체의 오류의 경우 다음 순서로 2가지 메시지를 생성한다.

1) code + "." + object name

2) code

ex) 오류 코드는 required, object name은 item이면

1) required.item

2) required

 

필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드를 생성한다.

1) code + "." + object name + "." + field

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(), reject()는 내부적으로 MessageCodesResolver를 사용한다. 이를 통해 메시지 코드를 생성하며, rejectValue()는 위에 적은 필드 오류시 4가지 메시지를 사용하고, reject()는 객체 오류 시 2가지 메시지를 사용한다.

 

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

 지금까지 BindingResult를 사용함으로서 스프링이 타입 검증을 대신 처리해 주었다. 대신, 아마 이런 에러 문구가 출력되었을 것이다.

Failed to convert property value of type java.lang.String to required type
java.lang.Integer for property price; nested exception is
java.lang.NumberFormatException: For input string: "A"

 

 직접 로그를 찍어보면, 스프링은 타입 오류가 발생할 시 typeMismatch라는 오류 코드를 사용한다는 걸 확인할 수 있다. 따라서 error.properties에 직접 내용을 추가하면, 스프링이 만든 오류 메시지 또한 처리 가능하다.

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

 

 

7. Validator 분리

 현재 등록 컨트롤러는 너무 많은 일을 할당받았다. 데이터를 받고, 검증하고, 저장하고 있기 때문에, 이런 경우에 검증 로직을 별도로 분리하는 것이 좋다. 또한, 분리할 경우 해당 검증 로직을 재사용할 수도 있다.

 

@Component
public class ItemValidator implements Validator {
	
    @Override
    public boolean supports(Class<?> clazz) {
    	return Item.class.isAssignableFrom(clazz);
    }
    
    //BindingResult는 Errors를 상속받은 인터페이스
    @Override
    public void validate(Object target, Errors errors) {
    	Item item = (Item) target;
        if (item.getItemName() == null) {
            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()>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);
            }
        }
    }
}

 스프링은 Validator를 사용하려는 곳이 해당 검증기를 지원하는지 여부를 확인하기 위해 supports()라는 함수를 사용한다.

 

private final ItemValidator itemValidator;

@PostMapping("/add")
public String addItem(...) {

    itemValidator.validate(item, bindingResult);
    
    ...
}

 이런식으로 컨트롤러에서 ItemValidator를 빈으로 주입 받은 후, 직접 호출할 수 있다.

 

WebDataBinder

 혹은, 스프링의 WebDataBinder를 사용하면 스프링의 추가적인 도움을 받을 수 있다.

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

 

 이 경우, 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. 한번 살펴보자.

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

 validator를 직접 호출하는 부분이 사라지고, 검증 대상 앞에 @Validated 어노테이션이 붙었다. 해당 어노테이션이 붙으면 앞에 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 이때 여러 검증기가 등록되었을 경우, 앞서 작성했던 supports() 함수가 사용된다.

 

+ 검증시 @Validated, @Valid 둘다 사용 가능하다. 둘의 차이점으로는

- @Validated : 스프링 전용 검증 어노테이션, groups 기능 사용 가능

- @Valid : 자바 표준 검증 어노테이션, 사용하려면 build.gradle 의존관계 추가 필요

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

8. 정리

 이번 게시물에서는 직접 검증부터 시작하여 Validator를 분리하는 과정까지 정리해보았다. 그런데 스프링 부트로 코딩, 혹은 예제를 학습해 본 사람들은 알 것이다. 이렇게까지 안해도 되던데? 맞다. 스프링은 여러 다양한 어노테이션을 통해 더 편리하게 검증기능을 제공해주며, 해당 내용은 다음 게시물에서 다룰 예정이다.

 

 

 

 

 


위 내용은 김영한 님의 인프런 강의 "스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술"의 내용과 강의자료를 토대로 작성된 게시글입니다.

강의 링크:

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

 

댓글