-> 블로그 이전

[Spring] 검증(Validation) 1. 직접 구현하기

2022. 7. 3. 20:41Language`/Spring

MVC 패턴에서 Controller는 request로 들어온 URL에 대한 처리도 하지만, HTTP Request 자체가 정상인지 "검증"하는 역할도 수행한다

 

물론 Front쪽에서 검증을 할 수 있지만, 이는 Client가 의도적인 조작을 통해서 우회해서 접근할 수 있기 때문에 Front쪽에서 1차 검증을 한 후, 2차적으로 Server에서 검증을 해야 한다

BindingResult

BindingResult를 활용하면 request로 들어온 data에 대한 오류를 간단하게 담을 수 있고 model을 통해서 자동으로 넘겨줄 수 있다

  • BindingResult가 "없다면" 400 error가 발생하면서 컨트롤러 자체가 호출되지 않는다
  • 반면에 BindingResult가 "있다면" 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출할 수 있다

 

bindingResult의 "addError()"를 활용하면 로직을 검증해서 발생한 에러를 bindingResult에 담을 수 있다

ObjectError에는 크게 2개의 생성자를 넣을 수 있다

 

1. FieldError 생성자

Entity나 DTO의 각 field에 대한 error를 검증하고 필드에 오류가 존재한다면 FieldError 객체를 생성해서 bindingResult에 담아주면 된다

<생성자 1 : {objectName, field, defaultMessage}>
param 1) objectName : 필드가 속한 객체 이름
param 2) field : 필드 이름
param 3) defaultMessage : 에러 메시지

<생성자 2 : {objectName, field, [rejectedValue], bindingFailure, [codes], [arguments], [defaultMessage]}>
param 1) objectName :  field의 객체 이름
param 2) field : 필드 이름
param 3) rejectedValue : 사용자가 입력한 값 (검증에 실패된 값이다) 
param 4) bindingFailure : 바인딩 자체가 실패한지에 대한 여부
-> true = 데이터 바인딩 자체가 실패
-> false = 데이터 바인딩 자체는 성공 & 비즈니스 로직 검증 실패
param 5) codes : 메시지 코드
param 6) arguments : 메시지에서 사용하는 인자
param 7) defaultMessage : 기본 오류 메시지

 

2. ObjectError

특정 필드를 넘어서서 여러 필드에 대해서 합친 검증을 하는 경우 오류가 존재한다면 ObjectError 객체를 생성해서 bindingResult에 담아주면 된다

<생성자 1 : {objectName, defaultMessage}>
param 1) objectName : 객체 이름
param 2) defaultMessage : 에러 메시지

<생성자 2 : {objectName, [codes], [arguments], [defaultMessage]}>
param 1) objectName : 객체 이름
param 2) codes : 메시지 코드
param 3) arguments : 메시지에 사용하는 인자
param 4) defaultMessage : 기본 오류 메시지

 

그리고 주의할 점은 "BindingResult"는 반드시 검증할 객체 뒤에 위치해야 한다 (파라미터 상)

public String validation(
            @ModelAttribute MemberDTO validationMember,
            BindingResult bindingResult
    )

>> MemberDTO에 대한 검증을 하기 위해서 BindingResult를 MemberDTO 뒤에 위치시켰다

 

@RequestMapping("/members")
    public String validation(
            @ModelAttribute MemberDTO validationMember,
            BindingResult bindingResult
    ){
        // 1. name이 정상적으로 들어온지
        if(!StringUtils.hasText(validationMember.getName())){
            bindingResult.addError(new FieldError(
                    "validationMember",
                    "name",
                    "이름을 입력해주세요"
            ));
        }

        // 2. email이 입력 되었는지 -> 입력 되었으면 지정된 패턴대로 들어왔는지
        if(!StringUtils.hasText(validationMember.getEmail())){
            bindingResult.addError(new FieldError(
                    "validationMember",
                    "email",
                    "이메일 입력해주세요"
            ));
        } else if(!isValidEmail(validationMember.getEmail())){
            bindingResult.addError(new FieldError(
                    "validationMember",
                    "email",
                    "이메일 형식에 맞춰서 다시 입력해주세요"
            ));
        }

        // 3. phone num이 입력 되었는지 -> 입력 되었으면 지정된 패턴대로 들어왔는지
        if(!StringUtils.hasText(validationMember.getPhone())){
            bindingResult.addError(new FieldError(
                    "validationMember",
                    "phone",
                    "핸드폰 번호를 입력해주세요"
            ));
        } else if(!isValidPhone(validationMember.getPhone())){
            bindingResult.addError(new FieldError(
                    "validationMember",
                    "phone",
                    "핸드폰 번호 형식에 맞춰서 다시 입력해주세요"
            ));
        }

        StringBuffer sb = new StringBuffer();
        sb.append("Name : ").append(validationMember.getName())
                .append("\nEmail : ").append(validationMember.getEmail())
                .append("\nPhone : ").append(validationMember.getPhone())
                .append("\n\nName Validation >> ").append(bindingResult.getFieldError("name"))
                .append("\n\nEmail Validation >> ").append(bindingResult.getFieldError("email"))
                .append("\n\nPhone Validation >> ").append(bindingResult.getFieldError("phone"));

        return sb.toString();
    }

    private boolean isValidEmail(String email){
        String regex = "^[a-zA-Z0-9]+@[a-zA-Z]+.[a-z]+$";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(email);

        return matcher.find();
    }

    private boolean isValidPhone(String phone){
        String regex = "^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(phone);

        return matcher.find();
    }

Member의 {name, email, phoneNum}에 대한 간단한 검증 로직이다

 

일반적으로 어떤 reqeust data에 대해서 domain entity를 그대로 @ModelAttribute로 받는 경우도 있는데 이것은 별로 좋지 못한 방식이다
왜냐하면 항상 모든 경우에 domain entity의 모든 필드의 값을 입력받는게 아니기 때문이다

예를 들어서 회원가입/회원수정의 경우 reqeust data type이 서로 다르기 때문에 이러한 경우에 문제가 된다

따라서 검증 로직을 위한 DTO를 따로 설계해서 DTO로 request data를 받는 것이 더 나은 방법이다

 

(1) {name, email, phoneNum} 전부 없는 채로 request

Name : null
Email : null
Phone : null

Name Validation >> Field error in object 'validationMember' on field 'name': rejected value [
    null
]; codes []; arguments []; default message [이름을 입력해주세요
]

Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
    null
]; codes []; arguments []; default message [이메일 입력해주세요
]

Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
    null
]; codes []; arguments []; default message [핸드폰 번호를 입력해주세요
]

 

(2) name은 입력받고, 나머지 필드(email, phone)을 입력받지 않은 경우

Name : Hong Gil Dong
Email : null
Phone : null

Name Validation >> null

Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
    null
]; codes []; arguments []; default message [이메일 입력해주세요
]

Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
    null
]; codes []; arguments []; default message [핸드폰 번호를 입력해주세요
]

 

(3) email이 정한 규약에 맞지 않는 경우

Name : Hong Gil Dong
Email : test@
Phone : 010-1234-5678

Name Validation >> null

Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
    null
]; codes []; arguments []; default message [이메일 형식에 맞춰서 다시 입력해주세요
]

Phone Validation >> null

 

(4) phoneNum이 정한 규약에 맞지 않는 경우

Name : Hong Gil Dong
Email : test@gmail.com
Phone : 010-1234

Name Validation >> null

Email Validation >> null

Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
    null
]; codes []; arguments []; default message [핸드폰 번호 형식에 맞춰서 다시 입력해주세요
]

 

(5) ObjectError 예제 - 아이템 등록 : {개수 최소 5개 / 가격 최소 1000 / 개수 * 가격은 최소 10000}

@RequestMapping("/items")
    public String validation2(
            @ModelAttribute ItemDTO validationItem,
            BindingResult bindingResult
    ){
        if(validationItem.getQuantity() == null){
            bindingResult.addError(new FieldError(
                    "validationItem",
                    "quantity",
                    "수량을 입력하세요"
            ));
        } else if(validationItem.getQuantity() < 5){
            bindingResult.addError(new FieldError(
                    "validationItem",
                    "quantity",
                    "수량은 최소 5개 입니다. 다시 입력해주세요"
            ));
        }

        if(validationItem.getPrice() == null){
            bindingResult.addError(new FieldError(
                    "validationItem",
                    "price",
                    "가격을 입력하세요"
            ));
        } else if(validationItem.getPrice() < 1000){
            bindingResult.addError(new FieldError(
                    "validationItem",
                    "price",
                    "가격은 최소 1000원 입니다. 다시 입력해주세요"
            ));
        }

        if(validationItem.getQuantity() != null && validationItem.getPrice() != null){
            int result = validationItem.getPrice() * validationItem.getQuantity();

            if(result < 10000){
                bindingResult.addError(new ObjectError(
                        "validationItem",
                        "가격*수량은 최소 10000원입니다. 다시 입력해주세요"
                ));
            }
        }

        StringBuffer sb = new StringBuffer();
        sb.append("Quantity : ").append(validationItem.getQuantity())
                .append("\nPrice : ").append(validationItem.getPrice())
                .append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
                .append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
                .append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());

        return sb.toString();
    }

 

아무것도 입력하지 않았을 경우 검증

Quantity : null
Price : null

Quantity Validation >> Field error in object 'validationItem' on field 'quantity': rejected value [
    null
]; codes []; arguments []; default message [수량을 입력하세요
]

Price Validation >> Field error in object 'validationItem' on field 'price': rejected value [
    null
]; codes []; arguments []; default message [가격을 입력하세요
]

Quantity*Price Validation >> []

price가 요구사항을 충족시키지 못했을 경우

Quantity : 5
Price : 999

Quantity Validation >> null

Price Validation >> Field error in object 'validationItem' on field 'price': rejected value [
    null
]; codes []; arguments []; default message [가격은 최소 1000원 입니다. 다시 입력해주세요
]

Quantity*Price Validation >> [Error in object 'validationItem': codes []; arguments []; default message [가격*수량은 최소 10000원입니다. 다시 입력해주세요
    ]
]

모든 요구사항을 만족했을 경우

Quantity : 10
Price : 1001

Quantity Validation >> null

Price Validation >> null

Quantity*Price Validation >> []

 

 

BindingResult에 검증 오류 적용 방법

1. @ModelAttribute로 request data를 받을 때 "타입 자체에 오류"가 존재하는 경우

이 경우 데이터 바인딩 자체의 오류이므로 Client가 데이터를 다시 보내야 한다

 

2. 개발자가 직접 에러 넣어주기

이 경우 데이터 바인딩은 정상적으로 처리되었지만, 비즈니스 로직 자체에 대한 검증을 통과하지 못한 경우

 

3. Validator 사용


reject() & rejectValue()

BindingResult는 애초에 검증할 객체 뒤에 파라미터상 위치하기 때문에 어떤 객체를 검증하는지 이미 알고있다

따라서 FieldError나 objectError에서 어떤 객체인지 명시해줄 필요가 없다

 

1. reject()

user가 입력한 값을 유지할 필요가 없을 경우

  • password의 경우 user가 입력하고 나서 검증 오류가 발생했을 경우 자동으로 유지되지 않고 제거된다
param 1) errorCode : 오류 코드
param 2) errorArgs : 오류 메시지에 필요한 파라미터들
param 3) defaultMessage : 오류 메시지를 찾을 수 없을 경우 기본 적용되는 메시지

 

2. rejectValue()

user가 입력한 값을 유지할 필요가 있을 경우

  • 로그인의 경우 user가 입력한 ID는 맞지만 password가 잘못되었을 경우 굳이 form data에서 입력한 ID를 지울 필요는 없기 때문에 rejectValue()를 통해서 유지시킬 수 있다
param 1) field : 오류 필드명
param 2) errorCode : 오류 코드
param 3) errorArgs : 오류 메시지에 필요한 파라미터들
param 4) defaultMessage : 오류 메시지를 찾을 수 없을 경우 기본 적용되는 메시지

 

※ errorCode & errorArgs

여기서 errorCode & errorArgs는 이전에 fieldError나 objectError에서 사용하던 것과는 약간 다르다

여기서는 따로 메시지 처리를 원활하게 하기 위해서 모아놓은 파일이 필요하다

 

이렇게 따로 ~~.properties에 메시지 규약을 하나 만들어 놓으면 이제 사용할 수 있다

 

여기서 "MessageCodeResolver"가 errorCode를 통해서 메시지 코드를 생성해낸다

  • MessageCodeResolver는 interface이고 이것의 구현체는 "DefaultMessageCodesResolver"이다
<객체 오류 - 생성 규칙>
1순위 : code + "." + <객체 이름>
2순위 : code

<필드 오류 - 생성 규칙>
1순위 : code + "." + <객체이름> + "." + <필드이름>
2순위 : code + "." + <필드이름>
3순위 : code + "." + <필드타입>
4순위 : code

BindingResult는 어떤 객체를 검증하는지 이미 알고있고, 따라서 위와 같은 생성 규칙에 의해서 "errorCode"만 알아도 메시지 코드를 생성해낼 수 있다

 


Validator Interface

지금까지 필드, 객체에 대한 검증을 시도했는데 Spring에서는 역할 분리를 위해서 Validator라는 인터페이스를 제공해준다

  • 위의 코드를 보면 Controller의 대부분이 검증 로직인것을 확인할 수 있다
@Component
public class MyItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return ItemDTO.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ItemDTO itemDTO = (ItemDTO) target;

        if(itemDTO.getQuantity() == null){
            errors.reject(
                    "required",
                    "상품 수량은 필수입니다."
            );
        } else if(itemDTO.getQuantity() < 5){
            errors.rejectValue(
                    "quantity",
                    "min",
                    "상품 수량은 최소 5개입니다."
            );
        }

        if(itemDTO.getPrice() == null){
            errors.reject(
                    "required",
                    "상품 가격은 필수입니다."
            );
        } else if(itemDTO.getPrice() < 1000){
            errors.rejectValue(
                    "price",
                    "min",
                    "상품 가격은 최소 10000원입니다."
            );
        }

        if(itemDTO.getQuantity() != null && itemDTO.getPrice() != null){
            int result = itemDTO.getPrice() * itemDTO.getQuantity();

            if(result < 10000){
                errors.reject(
                        "totalPriceMin",
                        "가격*수량은 최소 10000원이 되어야 합니다. [현재 값 = " + result + "]"
                );
            }
        }
    }
}
private final MyItemValidator validator;

@RequestMapping("/items")
    public String validationV3(
            @ModelAttribute ItemDTO itemDTO,
            BindingResult bindingResult
    ){
        validator.validate(itemDTO, bindingResult);

        StringBuffer sb = new StringBuffer();
        sb.append("Quantity : ").append(itemDTO.getQuantity())
                .append("\nPrice : ").append(itemDTO.getPrice())
                .append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
                .append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
                .append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());

        return sb.toString();
    }

Controller에서 검증 로직을 호출하는 것 이외에는 거의 사라졌음을 확인할 수 있다

 

 

WebDataBinder

현재 Controller에서 검증기를 직접 호출해서 사용했지만 이제 Spring의 도움을 받아서 검증기조차 호출하지 않아도 된다

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

@RequestMapping("/items")
public String validationV3(
        @Validated @ModelAttribute ItemDTO itemDTO,
        BindingResult bindingResult
){
    StringBuffer sb = new StringBuffer();
    sb.append("Quantity : ").append(itemDTO.getQuantity())
            .append("\nPrice : ").append(itemDTO.getPrice())
            .append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
            .append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
            .append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());

    return sb.toString();
}

 

일단 main이 존재하는 @SpringBootApplication에 WebMvcConfigurer를 implements하고 난 후, "Validator getValidator"를 Override해서 내가 만든 Validator를 return해주면 된다

 

@InitBinder를 통해서 내가 만든 검증기를 스프링 부트 시작 할 때 추가해주면 된다

  • @InitBinder는 컨트롤러 내부에서만 영향을 미치고 글로벌하게 영향을 미치기 위해서는 아래 설정처럼 해줘야 한다
@SpringBootApplication
public class MvcPracticeApplication implements WebMvcConfigurer {

	...
    ...

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

 

이제 컨트롤러를 보면 검증기를 호출하는 로직자체가 사라져서 완전히 "검증기 & 컨트롤러"가 분리된 채로 개발이 되었다

 

여러 검증기가 등록된 상태라면 validator를 implements할 때 override해야 하는 "boolean supports(Class<?> clazz)"를 통해서 검증 대상 객체를 검증할 수 있는 검증기를 찾을 수 있다

 

그리고 검증기를 사용하려면 검증을 원하는 객체의 앞에 @Validated를 붙여줘야 검증기가 실행된다