9 분 소요

1. 커맨드 객체의 값 검증과 에러 메시지 처리

폼 값 검증과 에러 메시지 처리는 어플리케이션을 개발할 때 놓쳐서는 안 된다.

폼에 입력한 값을 검증하지 않으면 잘못된 값이 시스템에 입력되어 어플리케이션이 비정상 동작할 수 있다.
또한 에러 메시지를 제대로 보여주지 않으면 사용자는 서비스를 제대로 이용할 수 없게 된다.

스프링은 이 두 가지 문제를 처리하기 위해 다음 방법을 제공하고 있다.

  • 커맨드 객체를 검증하고 결과를 에러 코드로 저장
  • JSP에서 에러 코드로부터 메시지를 출력

1.1 커맨드 객체 검증과 에러 코드 지정하기

스프링 MVC에서 커맨드 객체의 값이 올바른지 검사하려면 다음의 두 인터페이스를 사용한다.

  • org.springframework.validation.Validator
  • org.springframework.validation.Errors

객체 검증할 때 사용하는 Validator 인터페이스는 다음과 같다.

1
2
3
4
5
6
package org.springframework.validation;

public interface Validator{
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

위 코드에서 supports() 메서드는 Validator가 검증할 수 있는 타입인지 검사한다.
validate() 메서드는 첫 번재 파라미터로 전달받은 객체를 검증하고 오류 결과를 Errors에 담는 기능을 정의한다.

다음은 객체를 검증하기 위한 Validator 구현 클래스의 작성 예이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import org.springframework.validation.Errors;
import org.springframework.validation.Validation.ValidationUtils;
import org.springframework.validation.Validator;

public class RegisterRequestValidator extends Validator {
    private static final String emailRegExp =
        "^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@"+
        "[A-Za-z0-9-]+(\\.[_A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$";

    private Pattern pattern;

    public RegisterRequestValidator(){
        pattern = Pattern.complile(emailRegExp);
    }

    @Override
    public boolean supports(Class<?> clzz){
        return RegisterRequest.class.isAssignableForm(clazz);
    }

    @Override
    public void validate(Object target, Errors eorrs){
        RegisterRequest regReq = (RegisterRequest) target;
        if(regReq.getEmail() == null || regReq.getEmail().trime().isEmpty()){
            errors.rejectValue("email", "required");
        }else{
            Matcher matcher = pattern.matcher(regReq.getEmail());
            if(!matcher.matches()){
                errors.rejectValue("email", "bad");
            }
        }
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "name", "required");
        ValidationUtils.rejectIfEmpty(errors, "password", "required");
        ValidationUtils.rejectIfEmpty(errors, "confirmPassword", "required");
        if(!regReq.getPassword().isEmpty()){
            if(!regReq.isPasswordEqualToConfirmPassword()){
                errors.rejectValue("confirmPassword", "nomatch");
            }
        }
    }
}

17~19행의 supports() 메서드는 파라미터로 전달받은 clazz 객체가 RegisterRequest 클래스로 타입 변환이 가능한지 확인한다. 이 코드에서는 supports() 메서드를 직접 실행하진 않지만 스프링 Mvc가 자동으로 검증 기능을 수행하도록 설정하려면 supports()메서드를 올바르게 구현해야 한다.

22~39행의 validate() 메서드는 두 개의 파라미터를 갖는다. target 파라미터는 검사 대상 객체이고 errors 파라미터는 검사 결과 에러 코드를 설정하기 위한 객체이다. validate() 메서드는 보통 다음과 같이 구현한다.

  • 검사 대상 객체의 특정 프로퍼티나 상태가 올바른지 검사
  • 올바르지 않다면 Errors의 rejectValue() 메서드를 이용해서 에러 코드 저장

검사 대상의 값을 구하기 위해 23행처럼 첫 번째 파라미터로 전달받은 target을 실제 타입으로 변환한 뒤 24행과 같이 값을 검사한다.

24~31행은 “email” 프로퍼티의 값이 유효한지 검사한다.

“email”프로퍼티 값이 존재하지 않으면 25행 코드를 실행해서 “email” 프로퍼티의 에러 코드로 “required”를 추가한다.

27~30행에서는 정규 표현식을 이용해서 이메일이 올바른지 확인한다. 정규 표현식이 일치하지 않으면 29행에서 “email”프로퍼티의 에러 코드로 “bad”를 추가한다.

25행과 29행에서 사용한 Erros의 rejectValue() 메서드는 첫 번째 파라미터로 프로퍼티의 이름을 전달받고, 두 번째 파라미터로 에러 코드를 전달받는다. JSP 코드에서는 여기서 지정한 에러 코드를 이용해서 에러 메시지를 출력한다.

32행 코드는 다음과 같이 ValidationUtils 클래스를 사용하고 있다.

1
ValidationsUtils.rejectIfEmptyOrWhitespace(erros,"name", "required");

ValidationUtils 클래스는 객체의 값 검증 코드를 간결하게 작성할 수 있도록 도와준다.
위 코드는 검사 대상 객체의 “name” 프로퍼티가 null이거나 공백문자로만 되어 있는 경우 “name” 프로퍼티의 에러 코드로 “required”를 추가한다.

여기서 궁금증이 생긴다. ValidationUtils.rejectIfEmptyOrWhitespace() 메서드를 실행할 때 검사 대상 객체인 target을 파라미터로 전달하지 않았는데 어떻게 target 객체의 “name”프로퍼티의 값을 검사할까?

비밀은 Errors 객체에 있다. 스프링 MVC에서 Validator를 사용하는 코드는 아래 코드의 9행처럼 요청 매핑 애노테이션 적용 메서드에 Errors 타입 파라미터를 전달받고, 이 Errors 객체를 10행과 같이 Validator의 validate() 메서드에 두 번째 파라미터로 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.validation.Errors;

@Controller
public class Registercontroller {
    private MemberRegisterService memberRegisterService;
    ...

    @PostMapping("/register/step3")
    public String handleStep3(RegisterRequest regReq, Errors errors){
        new RegisterRequestValidator().validate(regReq, errors);
        if(erros.hasErrors())
            return "register/stpe2";
        try{
            memberRegisterService.regist(regReq);
            return "register/step3";
        }catch(DuplicateMemberException ex){
            errors.rejectValue("email", "duplicate");
            return "register/step2";
        }
    }
}

9행처럼 요청 매핑 애노테이션 적용 메서드의 커맨드 객체 파라미터 뒤에 Errors 타입 파라미터가 위치하면, 스프링 MVC는 handleStep3() 메서드를 호출할 때 커맨드 객체와 연결된 Errors 객체를 생성해서 파라미터로 전달한다. 이 Errors 객체는 커맨드 객체의 특정 프로퍼티 값을 구할 수 있는 getFieldValue() 메서드를 제공한다.

따라서 ValidationUtils.rejectIfEmptyOrWhitespace() 메서드는 커맨드 객체를 전달받지 않아도 Errors 객체를 이용해서 지정한 값을 구할 수 있다.

위 코드의 10행을 보면 앞서 작성한 RegisterRequestValidator 객체를 생성하고 validate() 메서드를 실행한다. 이를 통해 RegisterRequest 커맨드 객체의 값이 올바른지 검사하고 그 결과를 Errors 객체에 담는다.

11행에서는 Errors의 hasErrors() 메서드를 이용해서 에러가 존재하는지 검사한다. validate()를 실행하는 과정에서 유효하지 않은 값이 존재하면 Errors의 rejectValue()메서드를 실행한다. 이 메서드가 한 번이라도 불리면 Errors의 hasErrors() 메서드는 true를 리턴한다.

- 커맨드 객체 자체가 잘못된 경우

커맨드 객체의 특정 프로퍼티가 아닌 커맨드 객체 자체가 잘못될 수도 있다. 이런 경우에는 rejectValue() 메서드 대신에 reject() 메서드를 사용한다.

예를 들어 로그인 아이디와 비밀번호를 잘못 입력한 경우 아이디와 비밀번호가 불일치한다는 메시지를 메시지를 보여줘야 한다. 이 경우 특정 프로퍼티에 에러를 추가하기 보다는 커맨드 객체 자체에 에러를 추가해야 하는데, 이때 reject() 메서드를 사용한다.

1
2
3
4
5
6
7
try{
    ...인증 처리 코드
}catch(WrongIdPasswordException ex){
    // 특정 프로퍼티가 아닌 커맨드 객체 자체에 에러 코드 추가
    errors.reject("noMatchingIdPassword");
    return "login/loginForm";
}

reject() 메서드는 개별 프로퍼티가 아닌 객체 자체에 에러 코드를 추가하므로 이 에러를 글로벌 에러라고 부른다.

요청 매핑 애노테이션을 붙인 메서드에 Errors 타입의 파라미터를 추가할 때 주의할 점은 Errors 타입 파라미터는 반드시 커맨드 객체를 위한 파라미터 다음에 위치해야 한다는 점이다.
그렇지 않고 Errors 타입 파라미터가 커맨드 객체 앞에 위치하면 요청 처리를 올바르게 하지 않고 익셉이 발생하게 된다.

1.2 Errors와 ValidationUtils 클래스의 주요 메서드

- Errors 인터페이스

Errors 인터페이스가 제공하는 에러 코드 추가 메서드는 다음과 같다.

  • reject(String errorCode)
  • reject(String errorCode, String defaultMessage)
  • reject(String errorCode, Object[] errorArgs, String defaultMessage)
  • rejectValue(String field, String errorCode)
  • rejectValue(String field, String errorcCode, String defaultMessage)
  • rejectValue(String filed, String errorCode, Object[] errorArgs, String defaultMessage)

에러 코드에 해당하는 메시지가 {0}이나 {1}과 같이 인덱스 기반 변수를 포함하고 있는 경우 Object 배열 타입의 errorArgs 파라미터를 이용해서 변수에 삽입될 값을 전달한다.

defaultMessage 파라미터를 가진 메서드를 사용하면, 에러코드에 해당하는 메시지가 존재하지 않을 때 익셉션을 발생시키는 대신 defaultMessage를 출력한다.

- ValidationUtils 클래스

ValidationUtils 클래스는 다음의 rejectIfEmpty() 메서드와 rejectIfEmptyOfWhitespace() 메서드를 제공한다,

  • rejectIfEmpty(Errors errors, String filed, String errorCode)
  • rejectIfEmpty(Errors errors, String field, String errorcode, Object[] errorArgs)
  • rejectIfEmptyOrWhitespace(Errors errors, String field, String errorcode)
  • rejectIfEmptyOrWhitespace(Errors errors, String field, String errorcode, Object[] errorArgs)

rejectIfEmpty() 메서드는 filed에 해당하는 프로퍼티 값이 null이거나 빈 문자열(““)인 경우 에러 코드로 errorCode를 추가한다. rejectIfEmptyOrWhitespace() 메서드는 null이거나 빈 문자열인 경우 그리고 공백 문자(스페이스, 탭 등)로만 값이 구성된 경우 에러 코드를 추가한다.

에러 코드에 해당하는 메시지가 {0}이나 {1}과 같이 인덱스 기반 플레이스홀더를 포함하고 있으면 errorArgs를 이용해서 메시지의 플레이스홀더에 삽입할 값을 전달한다.

1.3 커맨드 객체의 에러 메시지 출력하기

에러 코드를 지정한 이유는 알맞은 에러 메시지를 출력하기 위함이다. Errors에 에러 코드를 추가하면 JSP는 스프링이 제공하는 < form:errors> 태그를 사용해서 에러에 해당하는 메시지를 출력할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
...
<body>
    <h2><spring:message code="meember.info"/></h2>
    <form:form action="step3" commandName="registerRequest">
    <p>
        <label><spring:message code="email"/>:<br>
        <form:input path="email"/>
        <form:errors path="email"/>
        </label>
    </p>
    ...
</body>
...

< form:errors> 태그의 path 속성은 에러 메시지를 출력할 프로퍼티 이름을 지정한다.
예를 들어 8행에서 “email” 프로퍼티에 에러 코드가 존재하면 < form:errors> 태그는 에러 코드에 해당하는 메시지를 출력한다. 에러 코드가 두 개 이상 존재하면 각 에러 코드에 해당하는 메시지가 출력된다.

- 특정 프로퍼티

에러 코드에 해당하는 메시지 코드를 찾을 때에는 다음 규칙을 따른다.

  1. 에러코드 + “.” + 커맨드객체이름 + “.” + 필드명
  2. 에러코드 + “.” + 필드명
  3. 에러코드 + “.” + 필드타입
  4. 에러코드

프로퍼티 타입이 List나 목록인 경우 다음 순서를 사용해서 메시지 코드를 생성한다.

  1. 에러코드 + “.” + 커맨드객체이름 + “.” + 필드명[인데스].중첩필드명
  2. 에러코드 + “.” + 커맨드객체이름 + “.” + 필드명.중첩필드명
  3. 에러코드 + “.” + 필드명[인덱스].중첩필드명
  4. 에러코드 + “.” + 필드명.중첩필드명
  5. 에러코드 + “.” + 중첩필드명
  6. 에러코드 + “.” + 필드타입
  7. 에러코드

예를 들어 errors.rejectValue(“email”, “required”) 코드로 “email” 프로퍼티에 “required” 에러 코드를 추가했고 커맨드 객체의 이름이 “registerRequest”라면 다음 순서대로 메시지 코드를 검색한다.

  1. required.registerRequest.email
  2. required.email
  3. required.String
  4. required

이 중에서 먼저 검색되는 메시지 코드를 사용한다.

- 커맨드 객체

특정 프로퍼티가 아닌 커맨드 객체에 추가한 글로벌 에러 코드는 다음 순서대로 메시지 코드를 검색한다.

  1. 에러코드 + “.” + 커맨드객체이름
  2. 에러코드

메시지를 찾을 때에는 MessageSource를 사용하므로 에러 코드에 해당하는 메시지를 메시지 프로퍼티 파일에 추가해주어야 한다.

1.4 < form:errors> 태그의 주요 속성

< form:erros> 커스텀 태그는 프로퍼티에 추가한 에러 코드 개수만큼 에러 메시지를 출력한다. 다음의 두 속성을 사용해서 각 에러 메시지를 구분해서 표시한다.

  • element : 각 에러 메시지를 출력할 때 사용할 HTML 태그 , 기본 값은 span이다.
  • delimeter : 각 에러 메시지를 구분할 때 사용할 HTML 태그, 기본 값은 < br/>이다.

다음 코드는 두 속성의 사용 예이다.

1
<form:errors path="userId" element="div" delimeter=""/>

path 속성을 지정하지 않으면 글로벌 에러에 대한 메시지를 출력한다.

2. 글로벌 범위 Validator와 컨트롤러 범위 Validator

스프링 MVC는 모든 컨트롤러에 적용할 수 있는 글로벌 Validator와 단일 컨트롤러에 적용할 수 있는 Validator를 설정하는 방법을 제공한다. 이를 사용하면 @Valid 애노테이션을 사용ㅎ새ㅓ 커맨드 객체에 검증 기능을 적용할 수 있다.

2.1 글로벌 범위 Validator 설정과 @Valid 애노테이션

글로벌 범위 Validator는 모든 컨트롤러에 적용할 수 있는 Validator이다. 글로벌 범위 Validator를 적용하려면 다음 두 가지 설정하면 된다.

  • 설정 클래스에서 WebMvcConfigurer의 getValidator() 메서드가 Validator 구현 객체를 리턴하도록 구현
  • 글로벌 범위 Validator가 검증할 커맨드 객체에 @Valid 애노테이션 적용

- 글로벌 범위 Validator 설정

이를 위해 해야 할 작업 WebMvcConfigurer 인터페이스에 정의된 getValidator() 메서드를 구현하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer{

    @Override
    public Validator getValidator(){
        return new RegisterRequestValidator();
    }
}

스프링 MVC는 WebMvcConfigurer 인터페이스의 getValidator() 메서드가 리턴한 객체를 글로벌 범위 Validator로 사용한다. 글로벌 범위 Validator()를 지정하면 @Valid 애노테이션을 사용해서 Validator를 적용할 수 있다.

위 코드에서 설정한 글로벌 범위 Validator인 RegisterRequestValidator는 RegisterRequest 타입에 대한 검증을 지원한다.
(RegissterRequest 클래스의 support() 메서드를 보자)

- 커맨드 객체에 @Valid 애노테이션 적용

RegisterRequest 타입 커맨드 객체를 사용하는 메서드에 다음과 같이 파라미터에 @Valid 애노테이션을 붙여서 글로벌 범위 Validator를 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.validation.Valid;

@Controller
public class RegisterController{
    ...

    @PostMapping("/register/step3")
    public String handleStep3(@Valid RegisterRequest regReq, Errors errors){
        if(errors.hasErrors()){
            return "register/step2";
        }
        try{
            memberRegisterService.regist(regReq);
            return "register/step3";
        }catch(DuplicateMemberException ex){
            errors.rejectValue("email", "duplicate");
            return "register/step2";
        }
    }
}

커맨드 객체에 해당하는 파라미터에 @Valid 애노테이션을 붙이면 글로벌 범위 Validator가 해당 타입을 검증할 수 있는지 확인한다.
검증 가능하면 실제 검증은 수행하고 그 결과를 Errors에 저장한다. 이는 요청 처리 메서드 실행 전에 적용된다.

따라서 handleStep3() 메서드는 RegisterRequest 객체를 검증하는 코드를 작성할 필요가 없다. 파라미터로 전달받은 Errors를 이용해서 검증 에러가 존재하는지 확인하면 된다.

@Valid 애노테이션을 사용할 때 주의할 점은 Errors 타입 파라미터가 없으면 검증 실패 시 400 에러를 응답한다는 점이다.

글로벌 Validator의 범용성
RegisterRequestValidator 클래스는 RegisterReqeust 타입의 객체만 검증할 수 있으므로 모든 컨트롤러에 적용할 수 있는 글로벌 범위 Validator로 적합하지 않다. 스프링 MVC는 자체적으로 제공하는 글로벌 Validator가 존재하는데 이 Validator를 사용하면 Bean Validation이 제공하는 애노테이션을 이용해서 값을 검증할 수 있다.

2.2 @InitBinder 애노테이션을 이용한 컨트롤러 범위 Validator

@InitBinder 애노테이션을 이용하면 컨트롤러 범위 Validator를 설정할 수 있다. 다음은 그 예이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import javax.validation.Valid;

import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;

@Controller
public class RegisterController {
    ...
    @PostingMapping("/register/step3")
    public String handleStep3(@Valid RegisterRequest regReq, Errors errors){
        if(errors.hasErrors())
            return "register/step2";
        ...
    }

    @InitBinder
    protected void initBinder(WebDataBinder binder){
        binder.setValidator(new RegisterRequestValidator());
    }
}

어떤 Validator가 커맨드 객체를 검증할지는 17~20행에 정의 initBinder() 메서드가 결정한다. @InitBinder 애노테이션을 적용한 메서드는 WebDataBinder 타입 파라미터를 갖는데 WebDataBinder#setValidator() 메서드를 이용해서 컨트롤러 범위에 적용할 Validator를 설정할 수 있다.

RegisterRequest 타입을 지원하는 RegisterRequestValidator를 컨트롤러 범위 Validator로 설정했으므로 11행의 @Valid 애노테이션을 붙인 RegisterRequest를 검증할 때 이 Validator를 사용한다.

참고로 @InitBinder가 붙은 메서드는 컨트롤러의 요청 처리 메서드를 실행하기 전에 매번 실행된다.
즉 RegisterRequest를 커맨드 객체로 가지지 않는 요청 처리 메서드들도 실행하기 전에 initBinder() 메서드를 매번 호출해서 WebDataBinder를 초기화한다.

글로벌 범위 Validator와 컨트롤러 범위 Validator의 우선 순위
@InitBinder 애노테이션을 붙인 메서드에 전달되는 WebDataBinder는 내부적으로 Validator 목록을 갖는다. 이 목록에는 글로벌 범위 Validator가 기본을 포함된다.

WebDataBinder#setValidator(Validator validator) 메서드
이 메서드는 WebDataBinder가 갖고 잇는 Validator를 목록에서 삭제하고 파라미터로 전달받은 Validator를 목록에 추가한다. 즉 글로벌 범위 Validator 대신에 컨트롤러 범위 Validator를 사용하게 된다.

WebDataBinder#addValidator(Validator … validators) 메서드
이 메서드는 기존 Validator 목록에 새로운 Validator를 추가한다. 순서상 글로벌 범위 Validator 뒤에 새로 추가한 컨트롤러 범위 Validator가 추가된다. 이 경우 글로벌 범위 Validator를 먼저 적용한 뒤에 컨트롤러 범위 Validator를 적용한다.

Ref.

  • 최범균, 스프링프로그래밍입문5, 가메출판사.

카테고리:

업데이트: