Spring Validation

Lee yun bok·2021년 8월 12일

validation

목록 보기
2/2
post-thumbnail

고객이 입력한 데이터를 유지한 상태로 무엇이 잘못되었는지 친절하게 알려줘야 한다.
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.

참고) 클라이언트 Validation vs 서버 Validation
클라이언트 단 Validation은 고객 사용성이 훌륭하지만 조작이 가능하다.
반면에 서버 단 Validation은 조작이 불가능하지만 고객 사용성이 좋지 않다.
따라서 시스템에서는 둘을 적절히 조합시켜 고객 사용성도 좋고, 조작이 불가능한 검증 로직을 구현해야한다.


Spring이 제공하는 방법을 사용하지 않고 직접 구현하기

Item.java

@Data
public class Item {

    private Long id;
    private String itemName;
    private int price;

    public Item(String itemName, int price) {
        this.itemName = itemName;
        this.price = price;
    }
}

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

    //성공 로직
    ...
}

view.html

<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>

위 방법의 문제점

  1. 뷰의 일부만 뽑아왔는데도 쓸데 없는 중복이 많다.
  2. 타입 에러는 컨트롤러에 접근하기도 전에 오류가 발생해버린다.
    -> 위의 방법으로는 타입 에러를 처리할 수가 없다.
  3. 불편하다.. 코드가 너무 길어진다. 개발자는 이러한 상황을 절대 좋아하지 않는다.

물론 어떻게든 고객들에게 서비스를 제공할 수 있다. 하지만 그 이후에 개발자들에게 검증처리는 굉장히 힘들고 끔찍한 일이 될 것이다.
다행히도 Spring은 자체적으로 검증처리를 제공한다. 한번 확인해보자!


BindingResult

Controller.java

@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() < 10000) {
        bindingResult.addError(new ObjectError("item", "가격은 10,000원 이상이여야 합니다.));
    }

    //검증 실패 로직
    if (!bindingResult.hasErrors()) {
        return "/addForm";
    }

    //성공 로직
	  ...
}

Spring은 좀 더 편리하게 검증 로직을 처리할 수 있도록 BindingResult 클래스를 제공한다. 특정 필드에 오류를 잡아내는 경우에는 FieldError 객체를 추가시키고, 특정 필드에 국한되는 것이 아닌 전체적인 오류를 잡아내는 경우에는 ObjectErorr 객체를 추가시킨다.
참고로 FieldErrorObjectError의 자식이다.

view.html

<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>

타임리프는 Spring의 BindingResult를 이용한 편리한 기능을 제공한다! 타임리프는 th:field의 필드 네임을 보고 오류가 발생했는지, 안했는지를 판단하여 다양한 기능을 제공한다.

그렇다면 타입 에러는 어떻게 처리할까?

BindingResult가 존재한다면 @ModelAttribute에서 데이터를 매핑하면서 오류가 발생해도 요청 자체가 죽지 않는다. 이때 발생한 오류(Field Error)를 BindingResult에 담은 후에 컨트롤러 로직을 정상적으로 타게 된다.

이제 모든 에러를 처리할 수 있게 됐다. 그런데 필드 오류가 발생하면 입력했던 데이터가 전부 사라진다! 고객들에게 사용성이 좋은 서비스를 제공하기 위해서는 입력한 데이터가 사라지면 좋지 않다. 어떻게 해야할까?


FieldError, ObjectError

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

FieldError는 세번째 인자로 rejectedValue를 받게된다. rejectedValue는 사용자가 잘못 입력한 데이터를 의미한다.
-> 얘는 무려 타입 오류도 잡아준다. Spring이 bindingResult에 타입에러 정보를 넣어줄 때 자동으로 rejectedValue도 넣어주기 때문이다.


오류 메시지 처리

에러는 모두 잡을 수 있게 됐다. 그런데 위와 같이 작은 시스템에서는 에러 메시지를 일일히 수정할 수 있다고 하지만, 엄청나게 큰 시스템에서 몇백개의 에러 메시지를 수정해야 하는 상황이 온다면 어떻게 할까? 이러한 상황을 피해가기 위해 우리는 Spring이 제공하는 메시지 매커니즘을 활용하면 된다 :) (이전에 배웠던 메시지, 국제화 파트를 한번 살펴보자!)

그렇다면 Spring Message를 통해서 어떻게 처리할 수 있을까? 일단 동일하게 에러 메시지 정보를 담아줄 properties 파일을 생성해야 한다.

erros.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.

properties 파일을 생성하여 프로젝트에서 사용할 수 있는 에러 메시지를 정의하였다. 그러면 이걸 어떻게 활용할 수 있을까?
FiledError 생성자를 살펴보자.

public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @nullable Object[] arguments, @Nullable String defaultMessage)

FieldErrorcodes, argument 인자를 통해서 우리는 오류 메시지를 처리할 수 있다.

Controller.java

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에 메시지를 가리키는 Key 값을 배열 형태로 넣어주고 argument에 만약에 메시지에 인자가 있다면 해당되는 인자를 배열 형태로 넣어주면 된다. 여기서 codes를 배열 형태로 넣어주는 이유는 우선순위 설정이라고 볼 수 있다. 첫번째 key를 찾지 못하면 다음 key로 넘어가게 된다. 만약 아예 찾지 못하면 defaultMessage를 출력하게 된다.

그런데 너무 복잡다. 인자도 너무 많고, 작성해야할 부분이 너무 많다. 위에서 말했듯이 개발자는 이러한 상황을 절대 좋아하지 않는다.
좀 더 좋은 방법이 없을까? Spring은 그런 개발자들을 위해서 좀 더 편리한 rejectValue를 제공한다. :)

BindingResult의 rejectedValue

Controller.java

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, 10000000}, null);
}

rejectValue는 자신만의 규칙을 통해 자동으로 required.item.itemName로 인식하여 에러 메시지를 처리한다. (이를 위해 messageResolver가 있다.)만약 인자가 있는 경우에는 이전에 사용하던 FieldError와 동일하게 배열 형태로 넣어준다.
-> rejectValue를 열심히 까다보면 결국 대신 FieldError를 생성해주는 것을 볼 수 있다!

그렇다면 에러코드는 어떻게 설계해야 할까?

이 물음은 굉장히 중요한 부분이다. 설계를 잘해야 에러 메시지에 변경이 일어나더라도 코드의 변동없이 요청을 적용할 수 있기 때문이다. 일단 우리가 목표로 해야하는 좋은 설계 방법에 대해서 알아보자.

required.item.itemName = 상품 이름은 필수입니다. vs required = 필수 값 입니다.

오류 메시지 코드는 전자처럼 자세하게 만들 수 있고, 후자처럼 간단하게 만들수도 있다.
후자는 범용성이 좋아서 여러곳에 사용할 수 있지만 메시지가 자세하지 않고, 전자는 자세하지만 범용성이 좋지 않다.

그렇다면 가장 좋은 방법은 둘 다 섞어서 쓰는 것이 아닐까?

가장 좋은 방법은 두가지를 섞어서 쓰는 방법이다. 처음에는 범용성이 좋은 방법으로 사용하다가 특별히 세밀하게 작성해야 하는 경우는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.

errors.properties

#Level1 
required.item.itemName = 상품 이름은 필수입니다.

#Level2
required = 필수 값 입니다.

Level1이 있으면 Level1 출력, Level1이 없으면 Level2를 선택한다고 해보자. 그렇다면 개발자는 단순히 bindingResult.rejectValue("itemName", "required") 로 구현 해놓게 되면 메시지 변경이 일어나도 message.properties에 새로 새로운 에러 메시지만 추가 하면 된다.

물론 이렇게 이상적인 방법으로 구현하기 위해서는 우선 자세한 에러코드부터 탐색하고, 점점 더 범용적인 에러코드를 탐색하도록 개발자가 추가적으로 구현해야 한다. 하지만 다행스럽게도 Spring은 이러한 기능을 MessageCodesResolver를 통해 제공한다.

MessageCodesResolver에 대해 알아보자

ResolveMessageCodesSimulation.java

MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

void messageCodesResolverField() {
    String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);

    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }

    /**
    bindigResult.rejectValue("itemName", "required");
    == new FieldError("item", "itemName", null, false, messageCodes, ....);

    rejectValue를 호출하게 되면 rejectValue는 내부적으로 인자로 받은 itemName과 required를 이용하여 codesResolver의 resolveMessageCodes를 호출한다.
    그 다음 new FieldError()를 호출하여 Validation을 수행한다. */
}

위의 코드를 보면 MessageCodesResolver를 통해 messageCodes를 생성하는 것을 볼 수 있다. resolveMessageCodes(“required”, “item”, “itemName”, String.class) 를 호출하게 되면 아래와 같이 메시지 코드들을 출력한다.

messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required

rejectValue or reject 를 호출하게 되면 rejectValue or reject는 내부적으로 인자를 이용하여 MessageCodesResolverresolveMessageCodes를 호출한다. 그 다음 생성된 메시지 코드들을 이용하여 new FieldError()를 호출하여 Validation을 수행한다.

MessageCodesResolver의 메시지 코드 생성 규칙

객체 오류(resolveMessageCodes("code", "object")), reject("code")

code + "." + object 
code

필드 오류(resolveMessageCodes("code", "object", "field", "type")), rejectValue("field", "code")

code + "." + object + "." field #Level1 
code + "." + field #Level2
code + "." + type  #Level3
code #Level4

메시지 코드는 자세한 순서대로 Validation의 에러메시지로 선택 된다. Level1이 가장 먼저 선택되며, Level1이 주석처리가 된다면(존재 하지 않는다면) Level2 선택, Level2가 주석처리가 된다면 Level3… 자세한 메시지 코드에서 범용적인 메시지 코드로 순차적으로 탐색한다.
-> 위와 같은 매커니즘에 의해 개발자는 어플리케이션의 코드 수정 없이 erros.properties만 수정을 해도 에러 메시지를 손쉽게 변경할 수 있다.

Validation 로직 분리

Controller.java

 
    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, 10000000}, null);
    }

    //검증 실패 로직
    if (!bindingResult.hasErrors()) {
        return "/addForm";
    }

    //성공 로직
    ...

위의 코드는 컨트롤러 내에 존재하는 Validation 로직이다. 아무리 서비스를 제공할 때 Validation이 중요하다고는 하지만 로직에서 Validation이 차지하는 비중이 너무 크다. 개발자들은 복잡한 코드를 좋아하지도 않고 자신의 코드가 더러워지는 것을 굉장히 안좋아한다. 그렇다면 Validation 로직을 다른 곳으로 분리시켜 좀 더 깔끔한 코드를 만들 수 없을까?

Validation 로직을 Validator로 분리시켜보자(역할 분리)

ItemValidator.java

@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())) {
            bindingResult.rejectValue("itemName", "required");
	}
	if (item.getPrice() == null || item.getPrice() < 10000) {
    	    bindingResult.rejectValue("price", "range", new Object[]{1000}, null);
	}


    //검증 실패 로직
   	if (!bindingResult.hasErrors()) {
            return "/addForm";
    	}
    }
}

Controller.java

itemValidator.validate(item, bindingResult);

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

Validator를 구현하는 클래스는 Validation 로직을 대신 처리해줄 수 있다.
supports는 지원해줄 수 있는 클래스인지 체크하는 로직을 담당한다. Item.class.isAssignableFrom는 자식까지 모두 체크해주는 장점이 있다.
validate는 말그대로 검증 로직을 담당해준다. target은 검증 대상인 데이터 클래스, errors에는 BindingResult를 넣어줘야 한다. (참고로 ErrorsBindingResult의 부모이다.)

굳이 왜 Validator를 상속 받아서 구현해줘야 할까?

그냥 클래스 하나 생성해서 분리 시켜도 동작 할텐데? 왜 굳이 Validator를 상속 받아서 구현해줘야 할까?
-> Spring의 추가적인 도움을 받기 위해서다.

public class ItemController {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

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

WebDataBinder는 스프링의 파라미터 바인딩 역할과 검증 기능을 가지고 있는 SpringMVC에서 사용하는 내부 객체이다. 이러한 WebDataBinderValidator를 추가하면 Spring이 해당 컨트롤러에 Validator 자동으로 적용시켜준다.

public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

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

    //성공 로직
	  ...
}

단순히 @Validated를 Validation할 객체 앞에 붙여주면 자동으로 Validator가 적용된다. 만약 한 컨트롤러에서 여러개의 Validator가 등록된다면 그때 Validatorsupports 메서드 덕분에 어떤 Validator가 사용되어야 하는지 판단이 가능하다.

참고) @Validated vs @Valid
@Validated는 Spring이 제공, @Valid는 JAVA가 제공

ValidationUtils

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

다음과 같이 ItemitemName 필드 안에 내용이 입력됐는지 확인하는 조건문이 있다. ValidationUtils라는 제공하는 클래스를 사용하게 되면 다음과 같이 한줄로 간단히 표현할 수 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

참고) Validation 공식 메뉴얼
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages

출처
Inflearn의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 김영한님 강의를 수강하며 정리한 내용입니다

아이콘 제작자 Freepik from www.flaticon.com
profile
https://ybdeveloper.tistory.com/ 로 이동했습니다 : )

0개의 댓글