배경
프로젝트를 진행하다 보면 여러 가지 유효성 검사를 해야 한다. 가령 회원가입을 한다고 한다면 email과 password의 유효성 검사를 해주어야 한다. email은 Java에 내장되어 있는 이메일 유효성 검사용 @Email 애너테이션이 있지만 password는 직접 정규표현식을 짜서 유효성 검사를 해주어야 한다.
만약 password 유효성 검사를 다른 곳에서도 써야 한다면 기다란 정규표현식을 반복해서 작성해야한다. 이럴 때 password 유효성 검사를 담긴 애너테이션을 만들면 재사용성과 생산성이 높아질 것이다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public static class SignUp {
@NotBlank
@Email
private String email;
@Pattern(message = "로컬 최대 64자, 로컬에서 밑줄(_) 하이픈(-) 점(.) 허용, 로컬 시작과 끝에 점(.) 사용 불가능, " +
"로컬 점(.) 연속 두 개 사용 불가능",
regex = "^(?=.{1,64}@)[A-Za-z0-9-]+(.[A-Za-z0-9-]+)@[^-][A-Za-z0-9-]+(.[A-Za-z0-9-]+)(.[A-Za-z]{2,})$")
private String password;
}
Spring에서는 커스텀 애너테이션을 위한 기능을 제공해주고 있다. 이를 이용해 패스워드 유효성 검사 애너테이션을 만들어보자
Java 애너테이션 종류
Java에는 크게 2 종류의 애너테이션이 존재한다.
built-in 애너테이션
내장 애너테이션을 뜻한다. 대표적인 내장 애너테이션은 다음과 같다.
- @Override : 메서드가 오버라이드 되었음을 표시한다.
- @Deprecated: 더 이상 사용되지 않는 클래스, 메서드, 필드 등을 표시한다.
- @SuppressWarnings: 경고 메시지를 무시하도록 지시한다.
- @FunctionalInterface: 함수형 인터페이스임을 명시한다.
meta 애너테이션
meta 애너테이션은 애너테이션을 정의할 때 사용된다. 애너테이션의 속성을 지정하거나 애너테이션을 사용할 수 있는 대상을 제한하는 등의 역할을 한다. 대표적인 meta 애너테이션은 다음과 같다.
- @Target: 애노테이션이 적용될 위치를 지정한다.
- @Retention: 애노테이션의 유지 정책(유효 기간)을 지정한다.
- @Documented: 애노테이션 정보가 javadoc으로 작성된 문서에 포함한다.
- @Inherited: 애노테이션이 상속되도록 지정한다.
meta 애너테이션으로 커스텀 애너테이션을 만들어보자
구현
Password 유효성 검사 조건 및 정규 표현식
패스워드의 유효성 검사 조건과 정규 표현식은 다음과 같다.
- 최소 8자 및 최대 20자, 대문자 하나 이상, 소문자 하나 이상, 숫자 하나 및 특수 문자 하나 이상
- *^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d)(?=.*[@$!%*?&])[A-Za-z\\\\d@$!%*?&]{8,20}$*
Password 애너테이션. Bean Validation Annotation을 만들 때 message, groups, payload를 작성해야 한다.
@Documented
@Constraint(validatedBy = PasswordValidator.class)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
public @interface Password {
String message() default "최소 8자 및 최대 20자, 대문자 하나 이상, " +
"소문자 하나 이상, 숫자 하나 및 특수 문자 하나 이상";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- @Documented : 애노테이션 정보가 javadoc으로 작성된 문서에 포함한다.
- @Constraint : 유효성 검사를 위한 클래스 PasswordValidator 와 함께 사용됨을 나타낸다.
- @Target : 해당 애너테이션이 적용될 위치를 지정한다.
- @Retention : 해당 애너테이션이 언제까지 유지될지를 정한다. RUNTIME 값을 지정했기 때문에 이 애너테이션은 런타임 환경에서도 유지된다.
- message : 제약 조건을 위반한 경우 출력할 메세지
- groups : 유효성 검사 그룹을 위한 속성
- payload : 사용되지는 않지만 지정해서 사용할 수 있다.
PasswordValidator
@Password 애너테이션을 구현한 커스텀 Validator 클래스
public class PasswordValidator implements ConstraintValidator<Password, String> {
private static final String EMAIL_PATTERN =
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\\\d)(?=.*[@$!%*?&])[A-Za-z\\\\d@$!%*?&]{8,20}$";
private Pattern pattern;
@Override
public void initialize(Password constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
pattern = Pattern.compile(EMAIL_PATTERN);
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) {
return false;
}
Matcher matcher = pattern.matcher(email);
return matcher.matches();
}
}
- ConstraintValidator<Password, String> 인터페이스를 구현하는 클래스이다. 이 인터페이스는 비밀번호 유효성 검사를 수행하기 위해 사용된다. 첫 번째 파라미터는 사용할 애너테이션이며, 비밀번호가 String 타입으로 전달되기 때문에 두 번째 파라미터로 String이 들어간다.
- initialize(): 애너테이션에서 지정된 구성 요소를 초기화한다.
- isValid(): 주어진 문자열이 비밀번호의 유효성 검사를 통과하는지 여부를 반환한다.
테스트
그럼 아래와 같이 @Password 애너테이션을 적용하고 테스트해보자
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public static class SignUp {
@NotBlank
@Email
private String email;
@Password
private String password;
}
@WebMvcTest(
controllers = MemberController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfigurer.class)
}
)
class MemberControllerTest {
private final String BASE_URL = "/members";
...
@Test
@DisplayName("비밀번호 유효성 검사: 대문자를 하나 이상 포함하지 않으면 예외 발생")
@WithMockCustomUser
void memberControllerTest9() throws Exception {
// given
String failedPassword = "password1!";
MemberDto.SignUp signUpDto = StubData.MockMember.getFailedSignUpDtoByPassword(failedPassword);
MemberDto.Response response = StubData.MockMember.getResponseDto();
given(memberService.signUp(any(MemberDto.SignUp.class))).willReturn(response);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/signup")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(signUpDto);
ResultActions actions = ResultActionsUtils.postRequestWithContent(mvc, uri, json);
// then
actions
.andExpect(status().is4xxClientError());
}
@Test
@DisplayName("비밀번호 유효성 검사: 소문자를 하나 이상 포함하지 않으면 예외 발생")
@WithMockCustomUser
void memberControllerTest10() throws Exception {
// given
String failedPassword = "PASSWORD1!";
MemberDto.SignUp signUpDto = StubData.MockMember.getFailedSignUpDtoByPassword(failedPassword);
MemberDto.Response response = StubData.MockMember.getResponseDto();
given(memberService.signUp(any(MemberDto.SignUp.class))).willReturn(response);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/signup")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(signUpDto);
ResultActions actions = ResultActionsUtils.postRequestWithContent(mvc, uri, json);
// then
actions
.andExpect(status().is4xxClientError());
}
@Test
@DisplayName("비밀번호 유효성 검사: 숫자를 하나 이상 포함하지 않으면 예외 발생")
@WithMockCustomUser
void memberControllerTest11() throws Exception {
// given
String failedPassword = "Password!";
MemberDto.SignUp signUpDto = StubData.MockMember.getFailedSignUpDtoByPassword(failedPassword);
MemberDto.Response response = StubData.MockMember.getResponseDto();
given(memberService.signUp(any(MemberDto.SignUp.class))).willReturn(response);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/signup")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(signUpDto);
ResultActions actions = ResultActionsUtils.postRequestWithContent(mvc, uri, json);
// then
actions
.andExpect(status().is4xxClientError());
}
@Test
@DisplayName("비밀번호 유효성 검사: 특수 문자를 하나 이상 포함하지 않으면 예외 발생")
@WithMockCustomUser
void memberControllerTest12() throws Exception {
// given
String failedPassword = "Password1";
MemberDto.SignUp signUpDto = StubData.MockMember.getFailedSignUpDtoByPassword(failedPassword);
MemberDto.Response response = StubData.MockMember.getResponseDto();
given(memberService.signUp(any(MemberDto.SignUp.class))).willReturn(response);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/signup")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(signUpDto);
ResultActions actions = ResultActionsUtils.postRequestWithContent(mvc, uri, json);
// then
actions
.andExpect(status().is4xxClientError());
}
}
'Java' 카테고리의 다른 글
Java - File로 파일 목록 이름 조회하기 (1) | 2024.01.25 |
---|---|
Java - @JasonCreator로 DTO에서 유연하게 Enum Type 받기 (0) | 2023.04.26 |
Java - 제네릭(Generic)과 함께하는 리팩토링 (0) | 2023.04.17 |
Java - @NotNull, @NotEmptty, @Notblank 차이점 알고 쓰시나요? (0) | 2023.04.04 |
Java - AES-128 양방향 암호화하기 (0) | 2023.03.29 |