스프링

[Spring] 검증(Validation)

gilssang97 2021. 9. 23. 17:03

검증이란?

검증(Validation)이란 어떤 데이터의 값이 유효한 값인지 혹은 타당한 값인지를 확인하는 것을 말한다.

이를 살펴보기 위해 Thymeleaf - Form에서 다뤘던 예제를 가지고 설명하려고 한다.

여기서, Form은 이름, 나이 등으로 구성되어 있었다.

개발자인 우리는 이름과 이메일이 필수로 들어가야하고 나이는 숫자로 입력받아야한다고 하자.

그렇다면 우리는 검증을 통해 사용자들이 입력한 값이 우리가 요구하는 사항과 일치하는 지를 확인한다.

만약, 우리의 요구사항과 맞지 않아 검증에 실패하게 된 경우에는 사용자가 이를 인식하고 다시 입력할 수 있게 만들어줘야 할 것이다.

그렇다면 우리는 스프링에서 검증을 어떻게 구현해야할까?

이에 대해서 알아보자.

BindingResult

우리가 검증을 진행할 때, 이름이면 이름 혹은 이메일이면 이메일에 대한 검증 결과들이 저장되어 이를 통해 결과를 처리해야 할 것이다.

이를 위해 검증 결과에 대한 결과 정보들을 담는 객체인 BindingResult가 존재한다.

BindingResult는 다음과 같이 검증을 원하는 객체에 뒤에 선언해줘야 한다.

먼저, BindingResult에서는 검증을 원하는 객체와 입력받은 값들의 타입을 비교한다.

Person객체의 age가 Integer인데 인풋박스에서 age는 String으로 들어온 것처럼 타입이 서로 일치하지 않는다면 이에 대한 오류 값을 저장해준다.

오류에 대한 메세지 내용은 우리가 Custom하게 작성할 수 있는데 이 부분은 조금 뒤에서 알아보자.

그리고 우리는 실제로 우리가 실행하는 비지니스 로직에서 검증을 진행할 수 있어야한다.

이 부분에 대해서 실제로 우리가 코드로 작성해보자.

if (!StringUtils.hasText(person.getName())) {
    // 에러 로직 추가
}

if (!StringUtils.hasText(person.getEmailId())) {
    // 에러 로직 추가
}

if (!StringUtils.hasText(person.getDepartment()) && !StringUtils.hasText(person.getMajor())) {
    // 에러 로직 추가
}
  • 우리는 입력폼을 받을 때, 사람의 이름과 이메일 인풋 박스에 입력된 값이 존재하는지 확인한다.

위에서 본 코드와 같이 우리는 우리가 해당되는 로직에 맞지 않을 시 에러 로직을 추가해야한다.

그런데 이것처럼 "이름은 꼭 기입해야한다."와 같이 하나의 필드에서만 오류가 있을 수 있으나 "학과 혹은 전공 둘 중 하나는 꼭 기입해야한다."와 같이 복합적인 오류가 있을 수 있다.

다음과 같이 필드에서 일어나는 에러를 FieldError, 복합적인 오류를 GlobalError라고 한다.

우리는 이를 실제로 해결할 때 다음과 같이 처리한다.

if (!StringUtils.hasText(person.getName())) {
    bindingResult.addError(new FieldError("person", "name", person.getName(), false, new String[]{"required.person.name", null))
}

if (!StringUtils.hasText(person.getEmailId())) {
    bindingResult.addError(new FieldError("person", "emailId", person.getEmailId(), false, new String[]{"required.person.emailId"}, null, null))
}

if (!StringUtils.hasText(person.getDepartment()) && !StringUtils.hasText(person.getMajor())) {
    bindingResult.addError(new ObjectError("person", new String[] {"DepartmentMajorError"}, null, null));
}
  • 필드오류는 FieldError(객체명, 속성명, 검증실패값, 바인딩 실패여부, 메세지 소스, 메세지 소스 파라미터값, 디폴트 메세지) 의 형태로 이루어져 오류를 저장한다.
  • 글로벌오류는 ObjectError(객체명, 메세지 소스, 메세지 소스 파라미터값, 디폴트메세지)의 형태로 이루어져 오류를 저장한다.

위에서 언급했다싶이 이미 파라미터를 입력받을 때부터 바인딩이 되기 때문에 굳이 객체를 입력을 받을 필요가 없다.

그에 따라, 스프링은 다음과 같은 형태로도 처리할 수 있도록 다음의 형태를 제공한다.

if (!StringUtils.hasText(person.getName())) {
    bindingResult.rejectValue("name", "required", null);
}

if (!StringUtils.hasText(person.getEmailId())) {
    bindingResult.rejectValue("emailId", "required", null);
}

if (!StringUtils.hasText(person.getDepartment()) && !StringUtils.hasText(person.getMajor())) {
    bindingResult.reject("DepartmentMajorError", null);
}
  • 필드오류는 rejectValue(속성명, 메세지소스, 메세지 소스 파라미터 값(생략가능), 디폴트 메세지)와 같은 형태로 이루어져 오류를 저장한다.
  • 글로벌오류는 reject(메세지소스, 메세지 소스 파라미터 값(생략가능), 디폴트 메세지)와 같은 형태로 이루어져 오류를 저장한다.

에러에 대해 알아보았으니 이제 필요한 메세지 소스를 작성해보자.

required.person.name = 이름을 입력하세요.
required.person.emailId = 이메일을 입력하세요.
DepartmentMajorError = 학과나 전공 둘 중 하나를 입력하세요.
typeMismatch.java.lang.Integer = 숫자를 입력하세요.
typeMismatch.java.lang.String = 문자를 입력하세요.
  • 메세지 소스에 대한 이해가 부족하다면 메세지 소스, 국제화를 참조하자.
  • 위에서 언급한 것처럼 타입이 일치하지 않는 것은 스프링에서 자동으로 처리해주는데 이 때 발생하는 에러코드는 typeMismatch이다.

그런데 여기서 눈여겨볼 점이 존재한다.

FieldError를 보면 메세지 코드를 "required.person.emailId"과 같이 작성을 하는 반면, rejectValue에서는 단순 속성과 메세지 코드의 앞단만 적고 있다.

그런데도 둘은 동일한 메세지 코드를 사용하게 된다.

그렇다는 뜻은 rejectValue에서 단순 속성을과 코드 앞단만을 이용해 FieldError와 같은 메세지 코드를 구성해준다는 뜻이다.

이건 바로 스프링에서 제공하는 MessageResolver의 힘이다.

이를 사용하기 전에 먼저 이에 대해서 알아보자.

MessageResolver

MessageResolver의 구현체인 DefaultMessageCodesResolver를 살펴보자.

해당 클래스에 다음과 같은 메소드가 존재하고 이를 통해 실제로 메세지 코드를 체크한다.

resolverMessageCodes를 통해 다음과 같이 메세지 코드를 체크한다.

  • required.person.emailId (code.object_name.field_name)
  • required.emailId(code.field_name)
  • required.java.lang.String(code.field_type)
  • required(code)

이 중 세부적인 순 (위에서 아래 순)으로 일치하는 메세지 코드가 존재하면 해당 메세지 코드로 출력하게 된다.

없다면, 우리가 설정해둔 디폴트 메세지로 출력하게 된다.

이제 우리는 오류를 bindingResult에 설정하고 메세지 코드를 설정하여 저장하는 것까지 알아보았다.

이제 실제로 검증처리를 진행해보자.

검증처리

우리는 검증을 진행하고 bindingResult에 하나라도 저장되면 입력 URL을 다시 띄워서 오류창과 함께 입력해줄 수 있게 해줘야한다.

그 전에 Validator에 대해서 알아보고 가자.

우리는 아까 위에서 설명한 검증로직을 컨트롤러 안에 구성할 수 있지만 그렇다면 컨트롤러는 더욱 복잡해진다.

또한, 컨트롤러의 책임이 늘어나 좋지 않다.

그에 따라 우리는 다음과 같이 Validator를 구성할 수 있다.

@Component
public class PersonValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Person.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Person person = (Person) target;

        if (!StringUtils.hasText(person.getName())) {
            errors.rejectValue("name", "required", null);
        }

        if (!StringUtils.hasText(person.getEmailId())) {
            errors.rejectValue("emailId", "required", null);
        }

        if (!StringUtils.hasText(person.getDepartment()) && !StringUtils.hasText(person.getMajor())) {
            errors.reject("DepartmentMajorError", null);
        }
    }
}
  • Validator 인터페이스를 상속하여 검증기가 해당 객체를 검사하는 검증기가 맞는지를 체크 후 검증을 시작한다.
  • target은 우리가 검증을 진행하는 대상이며 errors는 우리가 에러를 저장하는 bindingResult에 해당한다.
  • BindingResultErrors 상속받아 구현한 구현체이다.

그리고 우리가 만든 검증기를 등록해줘야 한다.

private final PersonValidator personValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(personValidator);
}
  • 검증기를 주입받아 이를 바인더에 추가해준다.

그렇다면 이제 우리가 실제로 에러가 있는지를 확인하고 에러가 있다면 다시 입력창을 재 호출해야한다.

@PostMapping("/home")
public String receive(@Validated @ModelAttribute Person person, BindingResult bindingResult, Model model) {

    if (bindingResult.hasErrors()) {
        setBoxes(model);
        return "home";
    }

    return "intro";
}
  • 에러가 있다면 입력창 재호출하고 에러가 없다면 요약창으로 호출한다.
private void setBoxes(Model model) {
    Map<String, String> lang = languageMap();
    model.addAttribute("lang", lang);

    model.addAttribute("envs", Environment.values());

    List<String> platforms = platformList();
    model.addAttribute("platforms", platforms);
}
  • 오류가 존재할 시 입력창 뷰를 다시 띄운다.
  • setBoxes는 체크 박스, 라디오 박스 등의 값들을 설정해주는 메소드이다.

자 이제 검증에 대해서 알아보았다.

하지만 아직 남아있는 부분이 존재한다.

현재는 우리만 어떤 에러가 있고 어떤 메세지를 뿌릴지 bindingResult에 저장만 하고 있는 상태이고 이를 실제로 웹페이지에 뿌려 사용자가 볼 수 있도록 해야한다.

Thymeleaf와 Spring은 통합이 잘 되어있어 bindingResult를 간단하게 가져가 쓸 수 있다.

이에 대해서 살펴보자.

Thymeleaf 설정

간단하게 필드에러, 글로벌에러 설정 하나씩 살펴보자.

먼저, 필드에러이다.

<label>
    <p class="label-txt"><b>나이를 입력하세요.</b></p>
    <input type="text" class="input" th:errorclass="err" th:field="*{age}">
    <span class="label-txt err" th:errors="*{age}"></span>
    <div class="line-box">
        <div class="line"></div>
    </div>
</label>
  • th:errorclass를 통해 에러가 있다면 명시한 클래스를 class에 추가시켜준다.
  • th:errors구문을 통해 person.age에 대한 에러가 잡히게 된다면 설정된 메세지 코드 값을 출력해준다.

다음으로는, 글로벌 에러이다.

<label th:if="${#fields.hasGlobalErrors()}">
    <p class="err" th:each="errr : ${#fields.globalErrors()}" th:text="${errr}"></p>
</label>
  • ${#fields]}를 통해 bindingResult에 저장된 오류들에 쉽게 접근할 수 있다.
  • ${#fields]}.hasGlobalErrors()를 통해 글로벌 에러가 있는지 확인하고 있다면 해당 태그를 실행하고 없다면 무시한다.
  • 글로벌에러가 현재는 하나이지만 여러개일 수 있으므로 th:each (for문)를 통해 글로벌 에러를 하나씩 출력해주도록 한다.

결과

자, 이제 모든 검증 구현이 끝이 났다.

이제 실제로 검증이 동작하는 과정을 살펴보자.

  • 이름을 입력하지 않은 경우(필드 에러)

  • 나이를 문자열로 입력한 경우(typeMismatch)

  • 학과와 전공 모두 입력하지 않은 경우(글로벌 에러)

  • 이메일을 입력하지 않은 경우(필드 에러)

  • 정상적인 결과 처리