개요
데이터를 생성 또는 수정하는 REST API를 만들다보면 종종 클라이언트가 보낸 값을 검사하는 과정이 필요해진다. 유효한 이메일 형식인지 확인하거나, 입력값의 null 여부를 확인하는 등의 과정이다. 보통은 프론트엔드에서 화면 UI를 구성하기 위해 한 번 유효성 체크를 해주지만, 서버는 클라이언트가 아닌 다른 곳에서도 요청을 받을 수 있고, 클라단의 데이터 위변조 가능성도 무시할 수 없기에 서버 측에서도 한 번 더 유효성 검사를 해주는게 좋다.
spring boot는 이런 개발자의 수고를 덜어주기 위해, 많이 사용되는 유효성 검사들을 'spring-boot-starter-validation' 라이브러리를 통해 제공한다. 하지만 프로젝트 요구사항은 항상 제각각이므로 위 라이브러리로 필드 입력 값을 검증하기엔 한계가 있다.
동일한 검증 로직을 매 비즈니스 로직마다 반복적으로 끼워넣지 말고, ConstraintValidator를 이용해 Interceptor 단계에서 클라이언트의 송신 값을 검증하는 custom validator annotation을 만들어 DRY 원칙을 지켜보자.
ConstraintValidator
ConstraintValidator은 jakarta.validation (경우에 따라 javax.validation. 저는 spring boot 3이라서 jakarta입니다)에서 제공하는 유효성 검증 인터페이스이다. ConstraintValidator는 Controller 진입 전, Interceptor에서 동작한다고 하니 참고하자.
주석에 쓰여진대로, 주어진 객체 T에 대해 제약 조건 A의 유효성을 검증하며 검증 비즈니스 로직은 isValid에 작성하면 된다. 유효성을 검증하는 로직을 개발자가 직접 작성할 수 있으므로, 위에서 언급했던 디테일한 입력값 검증을 수행할 수 있다.
비즈니스 로직을 isValid에 한 번 작성한 후, 필요할 때마다 ConstraintValidator를 이용해 꺼내 쓰는 방식이므로 똑같은 코드를 여러 번 반복해서 작성할 필요가 없으며, 개발자가 여러 명일 경우에 코드 통일성을 유지할 수 있어 개발 효율성을 높일 수 있다. 객체 지향 관점에서 바라봤을 때, 결합도를 낮추고 응집력을 높일 수 있다는 점에서 기능 변경 시의 유지 관리 포인트를 낮출 수 있게 된다.
(이메일 검증 로직을 누구는 정규식을 이용해서 구현하고, 누구는 문자열을 이용해 구현하면 보는 입장에선 다른 기능이라고 생각할 수 있으니까!)
ConstraintValidator에서 유효성 검증에 실패하는 경우, 예외 처리도 비교적 간편하다. 발생하는 Exception을 크게 두 가지로 구분할 수 있는데, 다음 Exception을 상황에 맞춰 ControllerAdvice에서 처리해주면 된다.
ConstraintViolationException | MethodArgumentNotValidException |
메서드 파라미터, 또는 메서드 리턴 값에 문제가 있을 경우 | - @RequestBody 내부에서 처리에 실패한 경우 - @Validated, @Valid에서 처리되지 못 한 경우 |
Spring Boot가 HTTP 상태코드 500 으로 처리한다. Bean Validation API를 활용하여 검사하는 경우에 발생한다. | Spring Boot가 HTTP 상태코드 400 으로 처리한다. Spring Framework의 유효성 검증 기능을 활용하여 검사하는 경우에 발생한다. |
만약, 400 bad request나 500 internal server error가 아닌 세분화된 custom exception을 사용하고 싶다면 IsValid 메서드 내부에서 직접 커스텀 exception을 throw 해주면 된다.
public class EmailValidator implements ConstraintValidator<EmailCheck, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (/* 유효성 검증 실패 */) {
throw new EmailFormatInvalid("input email format invalid exception");
}
return true;
}
}
throw 한 커스텀 exception을 ControllerAdvice에서 처리하도록 만들어주면, 서버에서 많이들 사용하는 커스텀 상태 코드, 상태 메세지를 리턴하는 것도 가능하다.
실습 : Emoji Validator 만들기
마침 진행 중인 프로젝트에서 이모지를 입력받아야 하는 요구사항이 있다. 사용자는 자신의 프로필을 대체할 수 있는 이모지를 필수로 가져야 하며, 처음 프로필을 등록하는 POST API와 프로필을 수정하는 PATCH API 둘에서 이모지를 서버에 제출한다.
@Pattern을 이용해 정규식으로도 체크할 수 있을 것 같지만, 이모지를 나타내는 정규식이 매우 복잡하고, 읽기도 어려우며, 하이픈 (-)도 이모지로 취급하는 이슈가 있고, 요구사항을 전부 충족할 수 없기에 ConstraintValidator을 이용해 직접 validator를 만들거다.
(참고로 이모지 판단 정규식은 -> "[\\u1F600-\\u1F64F\\u2702-\\u27B0\\uD83C-\\uD83E][\\uDC00-\\uDFFF]?" 이라고 한다.)
1️⃣ ConstraintValidator를 이용해 Custom Validator 만들기
public class EmojiValidator implements ConstraintValidator<EmojiCheck, String> {
@Override
public boolean isValid(String emoji, ConstraintValidatorContext context) {
if (checkIsEmpty(emoji)) {
return false;
}
if (checkCodePointCount(emoji)) {
return false;
}
int codePoint = emoji.codePointAt(0);
return isEmoji(codePoint);
}
private static boolean checkIsEmpty(String value) {
return value == null || value.length() == 0;
}
private static boolean checkCodePointCount(String value) {
int codePointCnt = value.codePointCount(0, value.length());
return codePointCnt > 1;
}
private static boolean isEmoji(int codePoint) {
return (codePoint == 0x0)
|| (codePoint == 0x9)
|| (codePoint == 0xA)
|| (codePoint == 0xD)
|| ((codePoint >= 0x20) && (codePoint <= 0xD7FF))
|| ((codePoint >= 0xE000) && (codePoint <= 0xFFFD))
|| ((codePoint >= 0x10000) && (codePoint <= 0x10FFFF));
}
}
ConstraintValidator를 상속받은 후, isValid 메서드를 오버라이드 해 원하는 비즈니스 로직을 작성해주면 된다. 유효성 검증에 성공할 경우 true를, 실패할 경우 false를 리턴하는 식으로 코드를 작성해주면 완성!
나는 입력값이 이모지인지 아닌지, 이모지가 하나만 입력된건지 아닌지를 체크하게 작성했다. GPT의 도움을 받았다 😇 codePoint와 헥사코드 범위 등, Java에서 이모지를 다루는 방식은 별도의 블로그 포스팅으로 작성할 예정이다.
2️⃣ Custom Validator을 이용한 Annotation 만들기
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmojiValidator.class)
public @interface EmojiCheck {
String message() default "이모지가 아닙니다.";
Class[] groups() default {};
Class[] payload() default {};
}
1. @Target(ElementType.FIELD)
어노테이션을 붙일 수 있는 대상을 지정한다.
매개변수 ElementType으로는 다음과 같은 옵션을 할당할 수 있다.
- CONSTRUCTOR, METHOD, FIELD, PACKAGE, MODULE, PARAMETER : 이름 그대로!
- TYPE : 클래스, 인터페이스, ENUM에 어노테이션을 붙일 수 있다는 의미
- TYPE_PARAMETER : Parameter의 타입을 선언할 때 어노테이션을 붙일 수 있다
- ANNOTATION_TYPE : 어노테이션의 타입을 선언할 때 어노테이션을 붙일 수 있다 (어떤 상황이지..?)
- LOCAL_VARIABLE : 지역 변수를 선언할 때 어노테이션을 붙일 수 있다
나는 @RequestBody로 지정된 클래스의 필드에 어노테이션을 사용해 줄 것이기에, FIELD 옵션을 사용했다.
2. @Retention(RetentionPolicy.RUNTIME)
어노테이션이 실제로 적용되고 유지되는 범위를 지정한다.
매개변수 RetentionPolicy로는 다음과 같은 옵션을 할당할 수 있다.
- RUNTIME : 런타임 중 ( = 실행 중)
- CLASS : 컴파일러가 클래스를 참조할 때
- SOURCE : 컴파일 전까지. 즉 컴파일이 완료된 후에는 어노테이션이 유지되지 않는다.
3. @Constraint(validatedBy = EmojiValidator.class)
hibernate에서 제공하는 어노테이션으로, 유효성 검증 로직을 지정한다.
validatedBy에 생성한 Custom Validator class를 입력해주면 된다.
3️⃣ RequestBody 객체의 필드에 적용하기
public record MemberProfileRequestDTO(
@NotEmpty(message = "유저의 닉네임을 입력해주세요.")
String name,
@EmojiCheck
@NotEmpty(message = "유저를 나타낼 emoji를 입력해주세요.")
String emoji
){
}
2에서 작성한 annotation을 원하는 필드에 적용해주면 끝! @Valid는 컨트롤러단에서 메서드 파라미터 부분에 선언해주었다.
이제 MemberProfileRequestDTO의 emoji 필드 값이 이모지가 아니거나, 값이 여러개가 입력된 경우 유효성 검증에 실패한 것으로 간주해 API가 400 Bad Request를 반환할 것이다.
마무리
오늘은 ConstraintValidator을 활용해 나만의 유효성 검증 어노테이션을 만들고 적용해보았다. 반복되는 작업을 자동화할 수 있어 잘 활용한다면 개발 효율성을 높일 수 있을 것 같다 👍 더 잘 응용할 수 있는 방법도 고민해봐야겠다.
레퍼런스
- Java Bean Validation : https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html
- Custom Validate 어노테이션 만들기 : https://cheese10yun.github.io/spring-custom-valdate-annotation/
- Annotation 옵션 관련 : https://sanghye.tistory.com/39
'Develop > Spring' 카테고리의 다른 글
[Spring Boot] 자바 스프링에서 처리율 제한 기능을 구현하는 4가지 방법 (2) | 2023.06.21 |
---|---|
[Spring Boot] Jsoup으로 OG태그 메타 데이터 크롤링하기 (1) | 2023.06.14 |
[Spring Boot] count를 구현하는 5가지 방법 (2) | 2023.05.24 |
[Spring Boot] 테스트 컨테이너로 테스트하기 (3) | 2023.04.17 |
[Spring Boot 3] SpringDoc과 Swagger를 이용해 API 문서화 자동화하기 (4) | 2023.03.21 |