개발자로 후회없는 삶 살기
캡스톤 디자인 PART.10, 11주차(구현 : 초기 엔터티 개선, 로그인, 회원가입 기능) 본문
서론
캡스톤을 진행하며 발생했던 이슈와 개선 사항을 정리합니다.
본론
- 초기 엔터티 구축
첫 번째 기능에 필요한 초기 엔터티를 구축합니다.
@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에 원하는 정규표현식을 넣어주면 동일하게 동작합니다.
- 컨드롤 어드바이스
커스텀 컨드롤 어드바이스를 만들어보고 다양한 파라미터를 출력해보겠습니다.
-> 커스텀 예외
public class CspopException extends RuntimeException{
private final String showMessage;
private final HttpStatus httpStatus;
private final String errorCode;
public CspopException(final String message,
final String showMessage,
final HttpStatus httpStatus,
final String errorCode) {
super(message);
this.showMessage = showMessage;
this.httpStatus = httpStatus;
this.errorCode = errorCode;
}
}
메인이 되는 시스템 커스텀 예외를 만듭니다. 생성자로 콘솔에 보여질 예외 메세지, 예외 메세지, HTTP 오류 상태(4xx, 5xx), 어플리케이션 에러 코드(팀 정책)를 가지도록 했습니다.
public class InvalidEmailFormatException extends CspopException{
public InvalidEmailFormatException(final String email) {
super(
String.format("올바르지 않은 이메일 형식입니다. email={%s}", email),
"올바르지 않은 이메일 형식입니다.",
HttpStatus.BAD_REQUEST,
"1"
);
}
}
시스템 커스텀 예외의 하위 예외는 부모의 생성자로 필요한 4개의 인자 중 첫 번째로 보여질 예외 메세지를 구분할 수 있는 email이나 id 등을 받고 부모 예외를 호출합니다.
-> 빈 검증 어드바이스 코드 작성
List<FieldError> fieldErrors = bindingResult.getFieldErrors(); // 필드 에러 전체
FieldError mainError = fieldErrors.get(0); // [] 벗기기
String[] errorInfo = Objects.requireNonNull(mainError.getDefaultMessage())
.split(":"); // 에러 메세지 추출
log.error("fieldErrors : {}", fieldErrors);
log.error("mainError : {}", mainError);
log.error("errorInfo : {}", errorInfo);
log.error("HandledException: {} {} statusCode={} errMessage={}\n",
request.getMethod(),
request.getRequestURI(),
HttpStatus.BAD_REQUEST.value(),
errorInfo[0]
);
인자로 bindingResult, HttpRequest, 빈 검증 예외를 받고 이것들이 무엇을 의미하나 확인해보겠습니다.
bindingResult로 필드 에러를 가져올 수 있는 데 리스트로 넘어와서 get으로 괄호를 벗길 수 있고 필드 에러에는 또 여러가지 필드가 있는 데 getCodes, getDefaultMessage 등으로 추출할 수 있습니다. 결과적으로 콘솔에는 예쁜 에러 메세지가 나옵니다.
클라이언트에 반환하는 메세지는 어플리케이션 에러 코드(팀 정책)와 예외 메세지로 하였습니다. 빈 검증은 모두 에러 코드 0번으로 정책을 잡았습니다.
참고
'[대외활동] > [캡스톤 디자인]' 카테고리의 다른 글
캡스톤 디자인 PART.스프링 & 리액트 프로젝트 연동 (0) | 2023.07.22 |
---|---|
캡스톤 디자인 PART.DB 모델링 변경사항 (0) | 2023.07.11 |
캡스톤 디자인 PART.8, 9주차(챗봇 DB 설계, MySQL Workbench) (0) | 2023.04.07 |
캡스톤 디자인 PART.1, 2주차(요구분석, 프로토타입 작성) (0) | 2023.02.17 |
캡스톤 디자인 PART.1주차(프로젝트 기획, 설계 단계) (0) | 2023.02.13 |