유효성 검사로직은 클라이언트와 서버 애플리케이션 모두에서 필수적으로 진행되어야 하는 요소입니다. 스프링 부트에서 제공하는 spring-boot-starter-validation 모듈을 통해서 Java 표준에서 제공하는 어노테이션으로 쉽게 유효성 검사를 수행할 수 있습니다. 스프링 부트는 유효성 검사 표준의 구현체로 hibernate-validator 를 사용하고 있으며 2.4.2 모듈을 기준으로 6.1.7 버전의 hibernate-validatior 를 사용하고 있습니다.

 

프로젝트에 무의식적으로 적용한 Java 표준 유효성검사 어노테이션들은 표준이라는 이유로 검증 없이 사용되는 경우가 많습니다. 하지만 돌다리도 두들겨 보고 건너라는 말이 있듯이 유효성 검사가 올바르게 수행되고 있는지 개발자들은 항상 의심하고 이를 검증해볼 필요성이 있습니다.

 


 

표준 Validation 어노테이션을 적용한 회원 엔티티 클래스

회원을 저장할 간단한 클래스를 생성해봅시다. Member라는 클래스는 사용자의 이름과, 나이, 이메일 주소를 필드로 가지고 있으며 각각의 필드에 유효성 검사 어노테이션을 적용하였습니다.

public class Member {

  @NotBlank(message = "이름은 반드시 입력되어야 하며 null 또는 공백문자일 수 없습니다.")
  private String name;

	@Max(value = 50, message = "50살 이하의 회원만 가입할 수 있습니다.")
  @Min(value = 20, message = "20살 이상의 회원만 가입할 수 있습니다.")
  @NotNull(message = "나이는 반드시 입력되어야 하며 null값일 수 없습니다.")
  private Integer age;

  @Email(message = "유효하지 않은 이메일 주소입니다.")
  private String email;

  public Member(String name, Integer age, String email) {
      this.name = name;
      this.age = age;
      this.email = email;
  }

}

 

Member 클래스 필드 유효성 검사 테스트 작성

이제 적용한 어노테이션들이 정상적으로 동작하는지 간단한 테스트 코드를 작성해봅시다. 테스트 코드는 JUnit 5를 기준으로 작성하였으며 Validator 클래스의 validate 메서드를 수행한 결과를 통해 유효성 검사에 실패하였을 때 메세지가 출력되는지를 확인할 것입니다.

 

private Validator validator;

@BeforeEach
void setUp() {
	validation = Validation.buildDefaultValidatorFactory().getValidator();
}

먼저 모든 각각의 테스트에서 사용할 Validator 객체를 생성하는 공통 메서드를 작성하겠습니다.

 

@Test
@DisplayName("@사용자 나이에 대한 유효성 검사가 정상적으로 동작하는지 테스트")
void memberAgeValidationTest() {
    Member m1 = new Member("둘리", 100, "doollee@gmail.com");
    Member m2 = new Member("둘리", 50, "doollee@gmail.com");
    Member m3 = new Member("둘리", 20, "doollee@gmail.com");
    Member m4 = new Member("둘리", 4, "doollee@gmail.com");
    Member m5 = new Member("둘리", null, "doollee@gmail.com");

    List<Member> members = List.of(m1, m2, m3, m4, m5);

    for (Member member : members) {
        Set<ConstraintViolation<Member>> violations = validator.validate(member);

        if(violations.isEmpty()) {
            System.out.println("member = " + member.toString() + "은/는 유효한 회원입니다.");
        } else {
            System.out.println("member = " + member.toString() + "은/는 유효하지 않은 회원입니다.");
            for (ConstraintViolation<Member> violation : violations) {
                System.out.println(violation.getMessage());
            }
        }
    }
}

먼저 @Max@Min 어노테이션이 정상적으로 동작하는지 검증하는 테스트를 작성하였습니다. 해당 테스트의 실행 결과는 아래와 같으며 정상적으로 유효성 검사를 수행하고 있는 것을 확인할 수 있습니다.

 

member = Member(name=둘리, age=100, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
50살 이하의 회원만 가입할 수 있습니다.
member = Member(name=둘리, age=50, email=doollee@gmail.com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee@gmail.com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=4, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
20살 이상의 회원만 가입할 수 있습니다. member = Member(name=둘리, age=null, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
나이는 반드시 입력되어야 하며 null값일 수 없습니다.

다음으로 사용자 이름 필드에 대한 유효성검사를 수행하도록 하겠습니다.

 

@Test
@DisplayName("사용자 이름에 대한 유효성 검사가 정상적으로 동작하는지 테스트")
void memberNameValidationTest() {
    Member m1 = new Member("둘리", 20, "doollee@gmail.com");
    Member m2 = new Member("", 20, "doollee@gmail.com");
    Member m3 = new Member(" ", 20, "doollee@gmail.com");
    Member m4 = new Member("\t", 20, "doollee@gmail.com");
    Member m5 = new Member(null, 20, "doollee@gmail.com");

    List<Member> members = List.of(m1, m2, m3, m4, m5);

    for (Member member : members) {
        Set<ConstraintViolation<Member>> violations = validator.validate(member);

        if(violations.isEmpty()) {
            System.out.println("member = " + member.toString() + "은/는 유효한 회원입니다.");
        } else {
            System.out.println("member = " + member.toString() + "은/는 유효하지 않은 회원입니다.");
            for (ConstraintViolation<Member> violation : violations) {
                System.out.println(violation.getMessage());
            }
        }
    }
}

회원 이름에 대한 유효성 검사 테스트 코드는 다음과 같으며 해당 테스트를 실행한 결과 정상적으로 유효성 검사를 수행하고 있음을 확인할 수 있습니다.

 

member = Member(name=둘리, age=20, email=doollee@gmail.com)은/는 유효한 회원입니다.
member = Member(name=, age=20, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
이름은 반드시 입력되어야 하며 null 또는 공백문자일 수 없습니다.
member = Member(name= , age=20, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
이름은 반드시 입력되어야 하며 null 또는 공백문자일 수 없습니다. member = Member(name= , age=20, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
이름은 반드시 입력되어야 하며 null 또는 공백문자일 수 없습니다.
member = Member(name=null, age=20, email=doollee@gmail.com)은/는 유효하지 않은 회원입니다.
이름은 반드시 입력되어야 하며 null 또는 공백문자일 수 없습니다.

마지막으로 회원 이메일에 대한 유효성 검사 테스트를 작성하도록 하겠습니다. 모든 이메일 패턴에 대한 경우를 테스트할 수 없지만 이메일의 도메인 패턴과 @ 문자를 중심으로 테스트를 수행하도록 하겠습니다.

 

@Test
@DisplayName("사용자 이메일 주소에 대한 유효성 검사가 정상적으로 동작하는지 테스트")
void memberEmailValidationTest() {
    Member m1 = new Member("둘리", 20, "doollee@gmail.com");
    Member m2 = new Member("둘리", 20, "doollee.gmail.com");
    Member m3 = new Member("둘리", 20, "doolleegmail.com");
    Member m4 = new Member("둘리", 20, "doollee@gmail/com");
    Member m5 = new Member("둘리", 20, "doollee@gmail//.com");
    Member m6 = new Member("둘리", 20, "doollee@gmail//./com");
    Member m7 = new Member("둘리", 20, "doollee@gmailcom");

    List<Member> members = List.of(m1, m2, m3, m4, m5, m6, m7);

    for (Member member : members) {
        Set<ConstraintViolation<Member>> violations = validator.validate(member);

        if(violations.isEmpty()) {
            System.out.println("member = " + member.toString() + "은/는 유효한 회원입니다.");
        } else {
            System.out.println("member = " + member.toString() + "은/는 유효하지 않은 회원입니다.");
            for (ConstraintViolation<Member> violation : violations) {
                System.out.println(violation.getMessage());
            }
        }
    }
}

사용자 유효성 검사에 대한 테스트 코드는 다음과 같으며 테스트를 수행하기에 앞서서 결과를 예상해보았을 때 정상적으로 유효성 검사를 수행할 경우 1번을 제외한 나머지 경우에 대해서 유효성 검사를 실패해야 합니다.

 

하지만 예상과 다르게 테스트를 수행한 결과를 확인하면 아래와 같은 결과를 얻을 수 있습니다.


member = Member(name=둘리, age=20, email=doollee@gmail.com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee.gmail.com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 주소입니다.
member = Member(name=둘리, age=20, email=doolleegmail.com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 주소입니다.
member = Member(name=둘리, age=20, email=doollee@gmail/com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee@gmail//.com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee@gmail//./com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee@gmailcom)은/는 유효한 회원입니다.

@ 문자가 없는 경우에는 유효하지 않은 이메일로 정상 판단하지만 이메일 도메인 주소를 판단하는데 있어서 비정상적인 패턴이 등장해도 올바른 이메일로 취급하고 있음을 확인할 수 있습니다.

 

표준 유효성검사 어노테이션만 믿고 회원 이메일에 대한 유효성을 검사하여 회원가입 서비스를 만들었다면 어떻게 될까요?

 

만약 회원 이메일을 변경하거나 알람 서비스를 이메일로 보내는 기능을 수행하는 경우에는 당연하게도 기능이 정상적으로 동작하지 않을 것입니다. 단순히 시스템 상의 기능 오류는 해프닝으로 끝날 수 있습니다. 하지만 이러한 버그를 악용한 클라이언트가 있다면 어떻게 될까요? 이는 단순한 해프닝을 넘어서 회사의 금전적인 손실로까지 이어질 수 있습니다.

 

멘토로 계시던 선배 개발자 분께서 해주신 말 중에서 크게 와닿는 말이 있었는데 클라이언트는 개발자가 생각하는 것만큼 착하지 않다는 것입니다. 매번 클라이언트가 개발자가 의도한 입력을 서버로 요청한다는 보장이 없기 때문에 개발자는 서비스를 개발함에 있어서 항상 기능이 올바로 동작하는지 의심하고 이를 반드시 검증해야 합니다.

 


 

@Email를 이용한 유효성 검사는 어떻게 동작할까요?

왜 이러한 문제가 발생하는지 @Email 어노테이션을 통한 유효성검사가 어떠한 과정을거쳐서 수행되는지 디버깅을 통해 알아보도록 하겠습니다.

 

중간 과정을 생략하고 핵심 로직만 살펴보면 내부적으로 EmailValidator 클래스의 isValid 메서드를 호출하는 것을 확인할 수 있습니다.

 

isValid 메서드에서는 다시 부모 클래스인 AbstractEmailValidator 클래스의 isValid 를 호출하는 것을 확인할 수 있습니다.

 

AbstractEmailValidator 클래스에는 각종 패턴에 대한 정규표현식들이 정의되어 있으며 isValid 메서드 내부에서는 먼저 입력받은 문자열이 @ 문자를 가지고 있는지 판단합니다.

 

이후 @ 문자를 기준으로 앞의 부분문자열을 localPart 로 뒤의 부분문자열을 domainPart 로 구분하여 각각을 isValidEmailLocalPartDomainNameUtil.isValidEmailDomainAddress 메서드를 호출하여 유효성검사를 수행하고 있는 것을 확인할 수 있습니다.

 

그 중에서도 문제가 되는 DomainNameUtil.isValidEmailDomainAddress 부분을 살펴보도록 하겠습니다.

DomainNameUtil 클래스의 isValidEmailDomainAddress 메서드를 살펴보면 EMAIL_DOMAIN_PATTERN 이라는 도메인 관련 정규 표현식을 정의하고 있으며 이를 isValidDomainAddress 메서드의 파라미터로 전달하고 있습니다.

 

메서드 자체에서는 해당 정규표현식의 패턴에 부합한지 여부를 확인하고 있기 때문에 별다른 로직이 없지만 이를 통해 확인할 수 있는 것은 해당 정규표현식이 도메인을 검증하기에 충분하지 않다는 사실입니다.

 


 

프로젝트에 올바른 이메일 유효성 검사 적용하기

만약 서비스에 사용자 이메일 관련 유효성검사를 진행해야 한다면 정규 표현식을 재정의함을 통해서 유효성 검사로 인한 문제를 예방할 수 있습니다.

 

public class Member {

	...

	@Email(message = "유효하지 않은 이메일 형식입니다.",
        regexp = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$")
	private String email;

}

먼저 AbstractEmailValidator 클래스의 정규표현식으로 유효성 검사를 수행하는것을 같지만 이후 EmailValidator 클래스의 isValid 메서드에서 사용자정의 정규표현식이 존재할 경우 다시 한번 더 검사를 수행하도록 동작하고 있습니다.

 

이를 적용하여 다시 테스트를 수행해보면 다음과 같이 정상적으로 유효성 검사를 수행하는 것을 확인할 수 있습니다.

 


member = Member(name=둘리, age=20, email=doollee@gmail.com)은/는 유효한 회원입니다.
member = Member(name=둘리, age=20, email=doollee.gmail.com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.
member = Member(name=둘리, age=20, email=doolleegmail.com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.
member = Member(name=둘리, age=20, email=doollee@gmail/com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.
member = Member(name=둘리, age=20, email=doollee@gmail//.com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.
member = Member(name=둘리, age=20, email=doollee@gmail//./com)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.
member = Member(name=둘리, age=20, email=doollee@gmailcom)은/는 유효하지 않은 회원입니다.
유효하지 않은 이메일 형식입니다.

 Java 표준이라고 생각하면 수많은 뛰어난 개발자들에 의해서 설계되고 철저한 검증 과정을 거쳐서 적용된 것이지만 이메일 유효성 검사에 대한 예외 케이스가 존재하는 것 처럼 사람이 만들어낸 결과물 중에 "절대", "완벽"이라는 것은 존재하지 않습니다. 때문에 개발자는 항상 이를 적용하기에 앞서 의심하고 검증해보는 과정이 필요합니다.

 

github.com/f-lab-edu/daangn-market-used-trading

 

f-lab-edu/daangn-market-used-trading

중고 거래부터 동네 정보까지 당근마켓을 모티브로 만든 중고거래 플랫폼 API 서버 토이 프로젝트 - f-lab-edu/daangn-market-used-trading

github.com

 

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기