개발자로 후회없는 삶 살기

spring PART.Value Object와 Custom Validator를 이용한 검증 개선 본문

[백엔드]/[spring+JPA | 이슈해결]

spring PART.Value Object와 Custom Validator를 이용한 검증 개선

몽이장쥰 2023. 7. 21. 15:21

서론

초기 엔터티 구축 과정에서 발생한 중복 검증을 개선한 방법에 대해 설명합니다.

 

본론

- 초기 엔터티 구축

첫 번째 기능에 필요한 초기 엔터티를 구축합니다.

 

@Entity
@Getter
@Table(name = "student")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Student extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "student_id")
    private Long id;
    
    @Column(name = "login_id", length = 255, nullable = false)
    private String loginId;
    
    @Column(name = "password", length = 255, nullable = false)
    private String password;
    
    @Column(name = "phone_number", length = 255, nullable = false)
    private String phoneNumber;
    
    @Column(name = "email", length = 255, nullable = false)
    private String email;
    
    @Column(name = "student_number", length = 255, nullable = false)
    private String studentNumber;
}

학생은 로그인 id, pw, 전화번호 등을 가지고 더 많은 필드가 있지만 개선한 필드만 표현했습니다.

 

여기서 개선할 점이 무엇일까요? ✅

1. 자체 테스트 불가

@Test
@DisplayName("올바르지 않은 형식의 이메일을 가진 아이디를 입력하면 예외가 발생한다.")
void throwException_invalidEmail() {
    String invalidEmail = "abc";

    assertThatThrownBy(() -> new Student(
            "123#a",
            "****",
            LocalDate.of(2023, 07, 18),
            "컴퓨터공학부",
            Grade.FOURTH,
            "010-1111-1111",
            Sex.FEMAIL,
            "한상범",
            invalidEmail,
            "20181111"
    )).isInstanceOf(IllegalArgumentException.class);
}

바로 의미있는 필드를 기본 자료형으로 나타내어 불필요한 검증의 중복과 자체 테스트가 불가능하다는 점입니다. 위의 코드를 보면 이메일을 유효성 검증할 때 비밀번호, 날짜 등의 필요없는 데이터를 모두 만들어야 합니다.

 

2. 중복된 검증 메서드

private void validateEmail(String email) {
    Pattern pattern = Pattern.compile("^[_a-z0-9-]+(.[_a-z0-9-]+)*@(?:\\w+\\.)+\\w+$");
    Matcher matcher = pattern.matcher(email);
    if (!matcher.matches()) {
        throw new IllegalArgumentException(email);
    }
}

private void validatePassword(String password) {
    validatePasswordSize(password);
    validatePasswordHasSpecialCharacter(password);
}

private void validatePasswordSize(String password) {
    if (isValidSize(password)) {
        throw new IllegalArgumentException(password);
    }
}

private static boolean isValidSize(String password) {
    return password.length() < 2 && password.length() > 16;
}

private void validatePasswordHasSpecialCharacter(String password) {
    Pattern pattern = Pattern.compile("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&.])[A-Za-z[0-9]$@$!%*#?&.]{0,7}$");
    Matcher matcher = pattern.matcher(password);
    if (!matcher.matches()) {
        throw new IllegalArgumentException(password);
    }
}

private void validatePhoneNumber(String phoneNumber) {
    Pattern pattern = Pattern.compile("^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$");
    Matcher matcher = pattern.matcher(phoneNumber);
    if (!matcher.matches()) {
        throw new IllegalArgumentException(phoneNumber);
    }
}

또한 Student 엔터티의 중복된 형태의 검증 메서드가 늘어나게 되는 문제가 있었습니다.

 

3. 다른 엔터티와도 중복된 검증

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "admin")
public class admin {

    @Column(name = "login_id", length = 255, nullable = false)
    private String loginId;

    @Column(name = "password", length = 255, nullable = false)
    private String password;

    private void validatePassword(String password) {
        validatePasswordSize(password);
        validatePasswordHasSpecialCharacter(password);
    }

    private void validatePasswordSize(String password) {
        if (isValidSize(password)) {
            throw new IllegalArgumentException(password);
        }
    }

    private static boolean isValidSize(String password) {
        return password.length() < 2 && password.length() > 16;
    }

    private void validatePasswordHasSpecialCharacter(String password) {
        Pattern pattern = Pattern.compile("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&.])[A-Za-z[0-9]$@$!%*#?&.]{0,7}$");
        Matcher matcher = pattern.matcher(password);
        if (!matcher.matches()) {
            throw new IllegalArgumentException(password);
        }
    }
}

또한 초기 엔터티로 관리자와 학생이 있는데 이 둘은 모두 PW를 가집니다. 즉, 같은 검증을 해야하므로 각각의 엔터티에 따로 메서드를 만들어줘야 했습니다.

 

- 개선해보기

1. value Object

이메일, 전화번호 등 자체 검증이 필요한 필드를 기본 자료형이 아닌 자체 클래스로 만들면 자유로운 검증과 테스트가 가능합니다.

 

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Email {
    @Column(name = "email", length = 255, nullable = false)
    String value;
    
    private Email(String value) {
        this.value = value;
    }
    
    public static Email from(String value) {
        validateEmail(value);
        return new Email(value);
    }
    
    private static void validateEmail(String value) {
        Pattern pattern = Pattern.compile("^[_a-z0-9-]+(.[_a-z0-9-]+)*@(?:\\w+\\.)+\\w+$");
        Matcher matcher = pattern.matcher(value);
        if (!matcher.matches()) {
            throw new IllegalArgumentException(value);
        }
    }
}

Student 엔터티에 있던 검증 메서드를 Email 클래스로 옮기고 정적 팩토리 메서드로 Student 객체를 생성할 때만 생성되도록 했습니다.

 

1) 자체 테스트 가능

class EmailTest {
    @Test
    @DisplayName("잘못된 이메일 형식을 입력하면 예외가 발생한다.")
    void throwException_invalidEmailFormat() {
        assertThatThrownBy(() -> Email.from("abc"))
                .isInstanceOf(IllegalArgumentException.class);
    }
    
    @Test
    @DisplayName("이메일 객체를 생성한다.")
    void construct() {
        assertDoesNotThrow(() -> Email.from("1@naver.com"));
    }
}

이렇게 하면 이메일을 검증하는데 다른 필요없는 데이터가 없어도 됩니다.

 

2) 검증 메서드 중복 제거

@Entity
@Getter
@Table(name = "student")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Student extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "student_id")
    private Long id;
    
    @Column(name = "login_id", length = 255, nullable = false)
    private String loginId;
    
    @Column(name = "password", length = 255, nullable = false)
    private String password;
    
    @Column(name = "birth", nullable = false)
    private LocalDate birth;
    
    @Column(name = "department", length = 255, nullable = false)
    private String department;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "grade", nullable = false)
    private Grade grade;
    
    @Embedded
    private PhoneNumber phoneNumber;
    @Enumerated(EnumType.STRING)
    @Column(name = "sex", nullable = false)
    private Sex sex;
    
    @Column(name = "name", length = 255, nullable = false)
    private String name;
    
    @Embedded
    private Email email;
    
    @Column(name = "student_number", length = 255, nullable = false)
    private String studentNumber;
    
    public Student(String loginId, String password, LocalDate birth, String department, Grade grade, PhoneNumber phoneNumber, Sex sex, String name, Email email, String studentNumber) {
        this.loginId = loginId;
        this.password = password;
        this.birth = birth;
        this.department = department;
        this.grade = grade;
        this.phoneNumber = phoneNumber;
        this.sex = sex;
        this.name = name;
        this.email = email;
        this.studentNumber = studentNumber;
    }
}

자체 클래스로 분리하지 않았을 때 만약 관리자 엔터티에도 이메일 필드가 있었다면 중복된 검증 메서드가 여기저기 흩어져있었을 것입니다. 이를 자체 클래스에 두어 이메일이라는 역할에만 집중할 수 있게 합니다.

 

2. PW Custom Validator

PW는 관리자와 학생이 중복으로 가지고 있는 필드로 완벽히 동일한 모양의 검증 메서드를 복사 붙여넣기로 가지고 있습니다. 이를 없애기 위해 자바 어노테이션으로 만들어 공통 Validator를 사용해봅니다.

 

1) 어노테이션 정의

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidPasswordValidator.class)
public @interface ValidPassword {
    String message() default "2자 이상의 16자 이하의 숫자, 영문자, 특수문자를 포함한 비밀번호를 입력해주세요.";
    
    Class<?>[] groups() default {};
    
    Class<? extends Payload>[] payload() default {};
}

PW 빈 검증 어노테이션을 만들었습니다. 저는 내부에서 isValid를 통해 검증 메세지를 수정하지 않을 것이기에 기본 메세지를 저장합니다.

 

2) Validator

public class ValidPasswordValidator implements ConstraintValidator<ValidPassword, String> {
    private static final int MIN_SIZE = 2;
    private static final int MAX_SIZE = 16;
    public static final String PASSWORD_REGEX = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&.])[A-Za-z[0-9]$@$!%*#?&.]{" + MIN_SIZE + "," + MAX_SIZE + "}$";
    private static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX);

    @Override
    public void initialize(ValidPassword constraint) {
    }
    
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        Matcher matcher = PASSWORD_PATTERN.matcher(password);
        return matcher.matches();
    }
}

검증기의 isvalid에서 원하는 패턴 매치 검증을 수행합니다.

 

-> 빈 검증 테스트

@ParameterizedTest
@ValueSource(strings = {"short", "password123", "AllLowercaseLetters", "alluppercaseletters", "1234567890", "!@#$%^&*()"})
@DisplayName("형식에 맞지 않은 비밀번호가 입력되면 검증을 통과하지 못한다.")
void invalidPassword(String password) {
    assertThat(validator.isValid(password, context))
            .isFalse();
}

@ParameterizedTest
@ValueSource(strings = {"Abcd123!@#", "P@ssw0rd"})
@DisplayName("형식에 맞는 비밀번호가 입력되면 검증을 통과한다.")
void validPassword(String password) {
    assertThat(validator.isValid(password, context))
            .isTrue();
}

Mockito를 사용하여 검증에 통과하는지 불통과하는지 테스트합니다.

 

-> 직접 어노테이션을 붙여서 사용해보기

@EventListener(ApplicationReadyEvent.class)
public void initData() {
    Student student = new Student(
            "jack",
            "123",
            LocalDate.of(2023, 07, 18),
            "컴퓨터공학부",
            Grade.FOURTH,
            PhoneNumber.from("010-1111-1111"),
            Sex.FEMAIL,
            "한상범",
            Email.from("1@naver.com"),
            "20181111"
    );
    studentRepository.save(student);
}

password 필드에 커스텀 어노테이션을 붙이고 실행해보면 

 

작성한 검증이 실행되면서 검증 메서드가 기록되는 것을 확인할 수 있었습니다.

 

수정 후 올바른 형식의 비밀번호를 입력하면 DB에 제대로 저장이 됩니다.

 

3. Pattern 빈 검증

역시나 제가 원하는 모든 것은 이미 스프링에 개발이 되어 있는 것 같습니다. 제 커스텀 어노테이션과 같은 기능을 하는 Pattern 빈 검증이 있으며 regexp에 원하는 정규표현식을 넣어주면 동일하게 동작합니다.

 

참고

스프링 커스텀 검증기

Comments