본문 바로가기

Tech/Spring

[Spring] Validation @Email은 null을 허용한다.

배경

회원가입 및 로그인 시 이메일 형식, 닉네임 글자 수 제한, 비밀번호 규칙 등 사용자가 입력한 데이터를 DB에 저장하기 전에 데이터에 대한 유효성을 체크하는 작업은 프론트단뿐 아니라 백엔드에서도 반드시 필요한 작업입니다.

Spring 에서는 spring boot validation starter에 대한 의존성을 추가함으로써 어노테이션을 기반으로 쉽게 유효성 체크를 할 수 있습니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

 

문제 인식

(세부적인 어노테이션 소개는 각설하고) 이메일 형식에 대한 유효성을 체크할 때 @Email을 사용하는데 이상한 점을 발견했습니다. 아래 테스트 코드를 주목해 주세요.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.validation.constraints.Email;
import java.util.Set;

class Test {
  private Validator validator;

  @BeforeEach
  void setUp() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
  }

  @Test
  void test() {
    class Data {
      @Email
      private String email;

      public String getEmail() {
        return email;
      }

      public void setEmail(String email) {
        this.email = email;
      }
    }

    Data data = new Data();
    Set<ConstraintViolation<Data>> violations = validator.validate(data);

    assertEquals(1, violations.size());
  }

test() 메소드에서 내부적으로 Data라는 클래스를 만들었습니다. (이 클래스는 @Email 어노테이션을 달고 있는 email이라는 이름을 가진 변수를 가지고 있습니다.)

그 다음 Data 클래스의 인스턴스를 생성하고, 유효성을 검증하도록 vaildate() 메소드의 파라미터로 담아 실행했습니다.

violations은 유효성을 통과하지 못한 경우, 해당 에러 메시지를 element로 가집니다.

파라미터로 넘긴 data의 email 값은 현재 null 이고, 이는 이메일 형식에 어긋나기 때문에 violations의 size는 1으로 예상됩니다. 하지만 0이 나옵니다!

 

구글링 해보니 @Email은 null을 유효하다고 판단한다고 합니다. [링크 참고]

 

디버깅

혹시 몰라 디버깅을 해봤습니다. 코드를 까보긴 했는데 다 이해하지는 못했지만...ㅠㅠ

어쨌든 @Email 어노테이션이 붙은 변수의 유효성을 검사하는 객체로 추정되는 EmailValidator라는 녀석을 만났습니다.

 

[EmailValidator.java]

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.hibernate.validator.internal.constraintvalidators.bv;

import java.lang.invoke.MethodHandles;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import javax.validation.ConstraintValidatorContext;
import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern.Flag;
import org.hibernate.validator.internal.constraintvalidators.AbstractEmailValidator;
import org.hibernate.validator.internal.util.logging.Log;
import org.hibernate.validator.internal.util.logging.LoggerFactory;

public class EmailValidator extends AbstractEmailValidator<Email> {
    private static final Log LOG = LoggerFactory.make(MethodHandles.lookup());
    private Pattern pattern;

    public EmailValidator() {
    }

    public void initialize(Email emailAnnotation) {
        super.initialize(emailAnnotation);
        Flag[] flags = emailAnnotation.flags();
        int intFlag = 0;
        Flag[] var4 = flags;
        int var5 = flags.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            Flag flag = var4[var6];
            intFlag |= flag.getValue();
        }

        if (!".*".equals(emailAnnotation.regexp()) || emailAnnotation.flags().length > 0) {
            try {
                this.pattern = Pattern.compile(emailAnnotation.regexp(), intFlag);
            } catch (PatternSyntaxException var8) {
                throw LOG.getInvalidRegularExpressionException(var8);
            }
        }

    }

        // 이 메소드가 중요한 부분입니다!
    public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        } else {
            boolean isValid = super.isValid(value, context);
            if (this.pattern != null && isValid) {
                Matcher m = this.pattern.matcher(value);
                return m.matches();
            } else {
                return isValid;
            }
        }
    }
}

위 코드에서 주목할 부분은 isValid()라는 메소드입니다.

파라미터로 CharSequence타입의 value가 email의 값인데, value == null이면 true를 반환하도록 되어있습니다. 진짜 null을 허용하네요..

 

그렇다면 빈 문자열("")과 공백(" ")은 어떤 결과가 나올까?

> 결론을 먼저 말씀드리면 빈 문자열("")은 null과 같이 true를 반환, 공백(" ")은 false를 반환합니다.

 

EmailValidator의 슈퍼클래스인 AbstractEmailValidator.java의 isValid() 메소드를 살펴보면 확인할 수 있습니다.

value == "" 인 경우에는 메소드 내 첫 if문에서 걸러져 true가 반환되고

value == " " 인 경우에는 첫번째 if문은 통과하지만 두번째 if문에서 걸러져 false가 반환됩니다.

 

 

결론

@Email로 이메일 형식에 대한 모든 유효성을 체크할 수 없다!

@NotNull 등과 함께 사용해야 완전하게 유효성을 검증할 수 있을 것 같습니다.

 

+) 왜 null을 포함하도록 구현했을까?

> 스터디 팀원들과 해당 내용을 공유하면서 "왜 그렇다면 null을 허용하도록 구현했을까?"에 대해 이야기를 나눴습니다. 회원가입 정책상 이메일 같은 경우는 nullable한 값으로 저장할 수도 있음을 고려한 것이 아니냐는 의견이 가장 납득할만하다고 생각했습니다.

 

(혹시 틀린 부분이나 덧 붙일 내용 있다면 자유롭게 피드백 부탁드립니다 🙏)

반응형