본문 바로가기

Dev/Spring Boot

[Validation] 정규식 여부에 따른 @Email 동작 이슈

목차

1) 상황

  • @Email 인터페이스에 검증 과정이 없음
  • @Email 인터페이스의 기본 정규식은 String regexp() default ".*"임.
  • 위 두 조건임에도 테스트 진행 시 ‘@’ 유무 검증이 이루어짐

2) 목표

  • 어디서 어떻게 이메일 검증이 이루어지는 지 찾기
  • @Email(regexp=) 값을 지정 여부에 대한 동작 흐름 찾기

3) 방법

  1. email 케이스 별 테스트 코드 작성
  1. EmailValidator 동작 흐름

4) 결론

 

테스트 코드

1. email = null

@Test
@DisplayName("Email : null")
void numberDto8() throws Exception {
        PatternDto patternDto = PatternDto.builder().build();

        mvc.perform(post(EndPoint.PATTERN_ENDPOINT.getEndPoint())
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(patternDto)))
                .andExpect(status().isOk());
}

@Email, @Email(regexp = "^[A-Za-z0-9+_.-]+@naver.com$") 결과 동일

 

  • 검증 통과 : EmailValidator 에서 email == null 이면 valid 로 간주한다.
  • 디버깅 : EmailValidator >>> isValid()

 

 

2. email = “”

@Test
@DisplayName("Email : \\"\\"")
void numberDto9() throws Exception {
        PatternDto patternDto = PatternDto.builder()
                .email("")
                .build();

        mvc.perform(post(EndPoint.PATTERN_ENDPOINT.getEndPoint())
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(patternDto)))
                .andExpect(status().isOk());
}

@Email

  • 검증 통과 : AbstractEmailValidator 에서 email.length() == 0 이면 valid 로 간주한다.
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid()

@Email(regexp = "^[A-Za-z0-9+_.-]+@naver.com$")

  • 검증 실패 : AbstractEmailValidator 에서 email.length() == 0 이면 valid 로 간주하지만, EmailValidator 의 정규식을 만족하지 않는다.
    • pattern == null : false
    • super.isValid() : true
    • if (false || false : true) → if 문 통과
    • return pattern.matcher( value );

 

3. email = journi@naver.com

@Test
@DisplayName("Email : journi@naver.com")
void numberDto10() throws Exception {
        PatternDto patternDto = PatternDto.builder()
                .email("journi@naver.com")
                .build();

        mvc.perform(post(EndPoint.PATTERN_ENDPOINT.getEndPoint())
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(patternDto)))
                .andExpect(status().isOk());
}

@Email

  • 검증 통과 : AbstractEmailValidator (부모 클래스) 의 검증 기준을 충족
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid())
    • pattern == null : true
    • super.isValid() : true
    • if (true || false : true) return true

@Email(regexp = "^[A-Za-z0-9+_.-]+@naver.com$")

  • 검증 통과 : AbstractEmailValidator + EmailValidator (자기 자신) 의 검증 기준을 충족
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid()
    • pattern == null : false
    • super.isValid() : true
    • if (false || false : false) → if 문 통과
    • return pattern.matcher(value);
    • AbstractEmailValidator 검증 후에 EmailValidator 검증 진행

 

4. email = journi@gmail.com

@Test
@DisplayName("Email : journi@gmail.com")
void numberDto11() throws Exception {
        PatternDto patternDto = PatternDto.builder()
                .email("journi@gmail.com")
                .build();

        mvc.perform(post(EndPoint.PATTERN_ENDPOINT.getEndPoint())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(patternDto)))
                .andExpect(status().isOk());
}

@Email

  • 검증 통과 : AbstractEmailValidator의 검증 조건을 만족했기 때문에
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid()
    • pattern == null : true
    • super.isValid : true
    • if (true || false : true) return true

@Email(regexp = "^[A-Za-z0-9+_.-]+@naver.com$")

  • 검증 실패 : AbstractEmailValidator의 검증 조건을 만족했지만, EmailValidator의 검증 조건 불 만족했기 때문에
  • 디버깅 EmailValidator.java >> isValid()
    • pattern == null : false
    • super.isValid : true
    • if (false || false : false) → if 문 통과
    • return pattern.matcher(value); 진행

 

5. email = journi#naver.com

@Test
@DisplayName("Email : journi#naver.com")
void numberDto12() throws Exception {
        PatternDto patternDto = PatternDto.builder()
                .email("journi#naver.com")
                .build();

        mvc.perform(post(EndPoint.PATTERN_ENDPOINT.getEndPoint())
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(patternDto)))
                .andExpect(status().isOk());
    }

@Email

  •  검증 실패 : AbstractEmailValidator 의 검증 조건을 불 만족했기 때문에
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid()
    • pattern == null : true
    • super.isValid : false
    • if (true || true : true) → retrun false

@Email(regexp = "^[A-Za-z0-9+_.-]+@naver.com$")

  • 검증 실패 : AbstractEmailValidator 의 검증 조건을 불 만족했기 때문에
  • 디버깅 EmailValidator.java >> isValid() >> super.isValid()
    • pattern == null : false
    • super.isValid : false
    • if ( false || true : true ) → return false

 

EmailValidator 동작 흐름

  • EmailValidator 은 AbstractEmailValidator 를 상속 받았다.
  • AbstractEmailValidator 은 ConstraintValidator 를 상속 받았다.

 

ConstraintValidator

/*
지정된 개체 유형 T에 대해 지정된 제약 조건 A를 검증하는 논리를 정의합니다.
구현은 다음 제한 사항을 준수해야 합니다.
T는 매개 변수화되지 않은 유형으로 해결되어야 합니다.
또는 T의 일반 매개 변수는 경계 없는 와일드카드 형식이어야 합니다.
SupportedValidationTarget 주석을 ConstraintValidator 구현에 배치하여 교차 매개 변수 제약 조건을 지원하는 것으로 표시할 수 있습니다. 자세한 내용은 Supported Validation Target 및 제약 조건을 참조하십시오.

매개 변수 유형:
<A> – 구현에 의해 처리되는 주석 유형
<T> – 구현에서 지원하는 대상 유형
*/
public interface ConstraintValidator<A extends Annotation, T> {

	/*
  isValid(Object, ConstraintValidatorContext) 호출을 준비하기 위해 검증기를 초기화합니다.
  주어진 제약 조건 선언에 대한 제약 조건 주석이 전달됩니다.
  유효성 검사를 위해 이 인스턴스를 사용하기 전에 이 메서드를 호출해야 합니다.
  기본 구현은 no-op입니다.
  매개 변수:
  constraintAnnotation - 지정된 제약 조건 선언에 대한 주석 인스턴스
  */
  default void initialize(A constraintAnnotation) {}

  /*
  유효성 검사 로직을 구현합니다. 값 상태는 변경할 수 없습니다.
  이 방법은 동시에 액세스할 수 있으므로 구현에 의해 스레드 안전이 보장되어야 합니다.
  매개 변수:
  value – 검증할 개체
  context - 제약 조건이 평가되는 컨텍스트
  반환:
  값이 제약 조건을 통과하지 않으면 false입니다.
  */
	boolean isValid(T value, ConstraintValidatorContext context);

}

 

AbstractEmailValidator

/*
지정된 문자 시퀀스(예: 문자열)가 올바른 형식의 전자 메일 주소인지 확인합니다.
유효한 전자 메일의 사양은 RFC 2822에서 찾을 수 있으며 규격에 따라 모든 유효한 전자 메일 주소와 일치하는 정규식을 찾을 수 있습니다. 그러나 이 기사에서 설명하는 것처럼 100% 호환 이메일 검증기를 구현하는 것이 반드시 실용적인 것은 아닙니다. 이 구현은 큰따옴표나 주석이 있는 전자 메일과 같은 전자 메일을 무시하면서 대부분의 전자 메일을 일치시키려는 절충입니다.
*/
public class AbstractEmailValidator<A extends Annotation> 
                             implements ConstraintValidator<A, CharSequence> {

	private static final int MAX_LOCAL_PART_LENGTH = 64;

	private static final String LOCAL_PART_ATOM = "[a-z0-9!#$%&'*+/=?^_`{|}~\\u0080-\\uFFFF-]";
	private static final String LOCAL_PART_INSIDE_QUOTES_ATOM = "(?:[a-z0-9!#$%&'*.(),<>\\\\[\\\\]:;  @+/=?^_`{|}~\\u0080-\\uFFFF-]|\\\\\\\\\\\\\\\\|\\\\\\\\\\\\\\")";
	
  /*       
	이메일 주소의 로컬 부분에 대한 정규식('@' 앞의 모든 것)
	*/
	private static final Pattern LOCAL_PART_PATTERN = Pattern.compile(
			"(?:" + LOCAL_PART_ATOM + "+|\\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\\")" +
					"(?:\\\\." + "(?:" + LOCAL_PART_ATOM + "+|\\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\\")" + ")*", CASE_INSENSITIVE
	);

	@Override
	public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
		if ( value == null || value.length() == 0 ) {
			return true;
		}

		String stringValue = value.toString();
		int splitPosition = stringValue.lastIndexOf( '@' );

		if ( splitPosition < 0 ) {
			return false;
		}

		String localPart = stringValue.substring( 0, splitPosition );
		String domainPart = stringValue.substring( splitPosition + 1 );

		if ( !isValidEmailLocalPart( localPart ) ) {
			return false;
		}
		return DomainNameUtil.isValidEmailDomainAddress( domainPart );
	}

	private boolean isValidEmailLocalPart(String localPart) {
		if ( localPart.length() > MAX_LOCAL_PART_LENGTH ) {
			return false;
		}
		Matcher matcher = LOCAL_PART_PATTERN.matcher( localPart );
		return matcher.matches();
	}
}

 

EmailValidator

  • isValid () 에서 검증이 이루어진다.
@Override
	public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
    // # 1
		if ( value == null ) {
			return true;
		}

    // # 2
		boolean isValid = super.isValid( value, context );
    // # 3
		if ( pattern == null || !isValid ) {
			return isValid;
		}

    // # 4
		Matcher m = pattern.matcher( value );
		return m.matches();
}

1. email == null이면 valid 로 간주한다.

2. AbstractEmailValidator isValid() 검증 진행

  • email.length() == 0 이면 valid 로 간주한다.
  • ‘@’ 가 존재하지 않으면 inValid 로 간주한다.
  • ‘@’ 기준으로 localPart, domainPart 를 검증한다. (길이, 정규식)

3. @Email 의 정규식이 있으면 해당 정규식까지 검증한다.

  • pattern == null 인 경우,
    • true || <!isValid> 는 <!isValid>의 값에 관계 없이 true
    • 부모의 검증 기준을 따라 return
  • pattern != null 인 경우
    • false || <!isValid> 는 <!isValid>의 값에 따라 true, false 가 될 수 있음
    • 부모의 검증에서 실패하면, 부모의 검증 결과인 invalid 를 리턴한다.

4. 부모의 검증에서 통과하면, 자기 자신의 검증 기준으로 한 번 더 검증한다.

 

결론

  • @Email 어노테이션을 붙이면 EmailValidator 가 동작한다.
  • @Email 인터페이스가 아닌 EmailValidator 의 isValid() 메소드에 검증 로직이 존재한다.
    • @Email 인터페이스의 기본 정규식은 ".*" 인데 이 경우 EmailValidator 에서 정규식이 null 로 처리된다. 정규식이 null 이라고 해도 AbstractEmailValidator 의 isValid 검증은 진행되므로 간단한 이메일 검증은 가능하다.
  • 정규식이 없으면 부모(AbstractEmailValidator isValid()) 의 기본 정규식으로만 테스트한다.
  • 정규식이 있으면 부모(AbstractEmailValidator isValid()) 의 기본 정규식으로 검증하고 나서, 자기 자신(EmailValidator isValid()) 의 정규식으로 한 번 더 검증이 이루어진다.

 

** cf. @Email(regexp=, flags=) flags?

정규식을 해당 플래그가 있는 패턴으로 컴파일할 때 쓰임

java.util.regex.Pattern.compile( emailAnnotation.regexp(), intFlag )

 

Ref.

  •