
업무 중, 특정 API 에서 수정해야 하는 한 건이 있었다.
keyword value 에는 소문자가 포함될 수 없다.
또한, 이어진 요구사항은 다음과 같았다.
400 error 와 함께 exception 으로 반환될 것.
따라서, 위 요구 사항은
이라는 상황에 따라 비즈니스 로직 level 에서 수정을 진행하였다.
하지만, 요구사항은 곧 특정 API 가 아닌 다른 API 로까지 확장되었다.
등록뿐만 아니라, 검증 API 에서도 위 요구사항은 적용되어야 한다.
위 경우, 해당 API 에도 간단하게 몇 줄을 추가함으로써 해결할 수 있었지만..
뭔가 다른 방법을 시도해보고 싶어서 서핑을 시작했다. 🔎
그러던 중, 커스텀 어노테이션을 만들어볼까? 라는 생각이 들었다.
위 요구사항이 복잡하지도 않으니, 적용해보기에 딱 좋은 간단한 예시라고 생각이 들었다.
따라서, 다음과 같이 틀을 잡아보았다.
@Constraint(validatedBy = UnableLowerCaseOfKeywordValidator.class)
@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UnableLowerCaseOfKeyword {
String message() default "must not contain lower case characters";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
먼저, 커스텀 어노테이션을 선언하는데 있어서 지정해줘야 할 어노테이션은 다음과 같다.
validatedBy 를 통해 적용하고자 하는 클래스를 지정.커스텀 어노테이션이 어디에 적용될 수 있는지를 지정
ElementType.ANNOTATION_TYPE : 다른 어노테이션 정의 시, 할당하여 여러 어노테이션 조합으로 재활용하거나 확장할 수 있게 만듦ElementType.FIELD : field 에 사용할 수 있도록 지정특히, ElementType.ANNOTATION_TYPE 을 활용하여 지금은 특정 도메인에만 귀속된 어노테이션이지만 추후 확장될 수 있도록 고려하였다❗
SOURCE : 소스 코드(.java)까지만 남고, 컴파일 시 제거.
CLASS : 클래스 파일(.class)까진 남지만, JVM에서 실행할 땐 어노테이션 정보 사용 불가.
RUNTIME : JVM 실행 시에도 어노테이션 정보가 남아 리플렉션 등으로 읽어 사용 가능
Bean Validation (@Constraint), 스프링, JPA 등에서 동적 검증 및 처리를 위해선 보통 Runtime 으로 처리한다고 한다 📝
그리고, 해당 어노테이션을 적용하고자 하는 클래스를 다음과 같이 구현하였다.
ConstraintValidator 는 커스텀 어노테이션 기반 유효성 검사기 표준 인터페이스로서, isValid 메서드를 오버라이딩 하여 구현함으로써 완성할 수 있다.
public class UnableLowerCaseOfKeywordValidator implements ConstraintValidator<UnableLowerCaseOfKeyword, String> {
/*
* (해당 클래스의 역할 및 사용처에 대한 설명 작성)
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true;
}
boolean hasLowerCase = value.chars().anyMatch(Character::isLowerCase);
return !hasLowerCase;
}
}
즉, 주어진 value 가 소문자가 아닐 경우, true 를 할당하여 유효하지 않은 값임을 반환하도록 구현하였다.
해당 어노테이션을 아래와 같이 Request Dto 내부에 어노테이션으로서 지정해서 사용하면 된다.
public record CargoValidationRequest(
..
@NotBlank
@UnableLowerCaseOfKeyword
@JsonProperty("keyword")
String keyword
) {
}
public record CreateMblRequest(
..
@UnableLowerCaseOfKeyword
String mblNo,
@UnableLowerCaseOfKeyword
String bookingNo,
@UnableLowerCaseOfKeyword
String containerNo,
..
그리고, 각 Request 를 Controller 에서 담는 구조별로 (ex. List, simple object, ..) 던진 예외를 GlobalExceptionHanlder 에서 catch 하도록 커스텀해서 구현하면 아래와 같이 response 에서 exception message 를 잘 담을 수 있다.
Response (1)

Response (2)

처음엔 단순히 커스텀 어노테이션을 만들어보자! 라는 간단한 포부였지만, 예상 외로 해당 어노테이션을 붙인 Request Dto 의 다양한 구조에 따라 많은 Excpetion 이 던져짐에 따라 디버깅에 생각보다 시간이 걸렸었다.
그리고 꽤나 디테일한 Response 임에 따라 너무 시간을 많이 썼나.. 싶기도 하지만, 해보고 싶던 내용을 간단한 예시에 적용해볼 수 있었기에 재밌었던 시간이었다 🦔