개발자로 후회없는 삶 살기
spring PART.Value Object와 Custom Validator를 이용한 검증 개선 본문
서론
초기 엔터티 구축 과정에서 발생한 중복 검증을 개선한 방법에 대해 설명합니다.
본론
- 초기 엔터티 구축
첫 번째 기능에 필요한 초기 엔터티를 구축합니다.
@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 {};
int min();
int max();
String regexp();
}
PW 빈 검증 어노테이션을 만들었습니다. 저는 내부에서 isValid를 통해 검증 메세지를 수정하지 않을 것이기에 기본 메세지를 저장합니다. 검증 조건으로 사용할 변수들을 필드로 가집니다.
- @Target : 커스텀 어노테이션을 붙일 대상 타입. 대부분 DTO의 FIELD
- @Retention : 어노테이션은 기본적으로 주석 취급을 받기 때문에 메모리에 로드했을 때 유지되지 않음으로 유지 범위를 지정
- @Constraint : 커스텀 어노테이션으로 검증할 검증 구현체 class 타입
- message : 검증 TRUE 시 제공할 기본 메시지 필수값
- groups, payload도 필수 값이며, 선언하지 않으면 런타임 에러가 발생한다.
2) Validator
public class ValidPasswordValidator implements ConstraintValidator<ValidPassword, String> {
private final String PASSWORD_REGEXP;
private final Pattern PASSWORD_PATTERN;
@Override
public void initialize(ValidPassword constraint) {
PASSWORD_REGEXP = constraint.regexp();
PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX);
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
Matcher matcher = PASSWORD_PATTERN.matcher(password);
return matcher.matches();
}
}
- initialize : 검증 구현체 필드 초기화 메서드
- isValid : 반환이 True이면 빈검증 성공 / False이면 빈검증 걸림.
커스텀 어노테이션으로 검증할 구현체는 ConstraintValidator 인터페이스를 구현해야 합니다. 어노테이션에 필드가 있고 기본 값이 없는 경우 사용하는 곳에서 반드시 값을 입력해야 하는데 어노테이션을 사용하는 순간 그 값이 필드에 초기화 되고, init() 파라미터로 들어오는 어노테이션의 필드를 호출하여 검증기 필드를 초기화한다.
-> 빈 검증 테스트
@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에 원하는 정규표현식을 넣어주면 동일하게 동작합니다.
참고
'[백엔드] > [이슈해결]' 카테고리의 다른 글
| 자바 PART.무한의 값 처리 BigDecimal (0) | 2023.11.27 |
|---|---|
| spring PART.postman으로 login 테스트 할 때 받아온 토큰을 요청 헤더에 자동으로 넣는 방법 (0) | 2023.08.18 |
| [Java] Java의 immutable (0) | 2023.07.07 |
| spring PART.JPA 사용하지 않고 enum 타입 DB에 저장하기 (0) | 2023.06.14 |
| [spring] equals()와 hashCode()를 재정의 해야하는 이유 (0) | 2023.05.03 |