개발자로 후회없는 삶 살기
spring PART.Value Object와 Custom Validator를 이용한 검증 개선 본문
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에 원하는 정규표현식을 넣어주면 동일하게 동작합니다.
참고
'[백엔드] > [spring+JPA | 이슈해결]' 카테고리의 다른 글
자바 PART.무한의 값 처리 BigDecimal (0) | 2023.11.27 |
---|---|
spring PART.postman으로 login 테스트 할 때 받아온 토큰을 요청 헤더에 자동으로 넣는 방법 (0) | 2023.08.18 |
[Java] Java의 immutable (0) | 2023.07.07 |
(작성중) spring PART.로컬 호스트에서 spring 서버와 flask 서버 통신하기 (0) | 2023.06.16 |
spring PART.JPA 사용하지 않고 enum 타입 DB에 저장하기 (0) | 2023.06.14 |