커스텀 어노테이션을 만들어보자!

Eunbi Lee·2025년 11월 23일

SeaVantage

목록 보기
11/18
post-thumbnail

🎨 Background

업무 중, 특정 API 에서 수정해야 하는 한 건이 있었다.

keyword value 에는 소문자가 포함될 수 없다.

또한, 이어진 요구사항은 다음과 같았다.

400 error 와 함께 exception 으로 반환될 것.

따라서, 위 요구 사항은

  • 예외 케이스가 아닌 성립될 수 없는 잘못된 케이스라는 점
  • 400 error 와 함께 예외를 반환하는 간단한 케이스라는 점

이라는 상황에 따라 비즈니스 로직 level 에서 수정을 진행하였다.

하지만, 요구사항은 곧 특정 API 가 아닌 다른 API 로까지 확장되었다.

등록뿐만 아니라, 검증 API 에서도 위 요구사항은 적용되어야 한다.

위 경우, 해당 API 에도 간단하게 몇 줄을 추가함으로써 해결할 수 있었지만..

뭔가 다른 방법을 시도해보고 싶어서 서핑을 시작했다. 🔎

🔎 Custom Annotation

그러던 중, 커스텀 어노테이션을 만들어볼까? 라는 생각이 들었다.

위 요구사항이 복잡하지도 않으니, 적용해보기에 딱 좋은 간단한 예시라고 생각이 들었다.

따라서, 다음과 같이 틀을 잡아보았다.

@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 {};

}

먼저, 커스텀 어노테이션을 선언하는데 있어서 지정해줘야 할 어노테이션은 다음과 같다.

  1. @Constraint
  • Bean Validation API에서 커스텀 제약 조건(Validation)을 정의할 때
    사용
    • 비즈니스 로직과는 별도로 입력값의 유효성 검사를 분리해서 확장할 수 있음
    • 이때, validatedBy 를 통해 적용하고자 하는 클래스를 지정.
  1. @Target
  • 커스텀 어노테이션이 어디에 적용될 수 있는지를 지정

    • ElementType.ANNOTATION_TYPE : 다른 어노테이션 정의 시, 할당하여 여러 어노테이션 조합으로 재활용하거나 확장할 수 있게 만듦
    • ElementType.FIELD : field 에 사용할 수 있도록 지정

    특히, ElementType.ANNOTATION_TYPE 을 활용하여 지금은 특정 도메인에만 귀속된 어노테이션이지만 추후 확장될 수 있도록 고려하였다❗

  1. @Retention
  • 어노테이션의 생명 주기 지정
    • SOURCE : 소스 코드(.java)까지만 남고, 컴파일 시 제거.

      • @Override
    • CLASS : 클래스 파일(.class)까진 남지만, JVM에서 실행할 땐 어노테이션 정보 사용 불가.

    • RUNTIME : JVM 실행 시에도 어노테이션 정보가 남아 리플렉션 등으로 읽어 사용 가능

      • @Component

      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 를 할당하여 유효하지 않은 값임을 반환하도록 구현하였다.

🐤 Actaul Using

해당 어노테이션을 아래와 같이 Request Dto 내부에 어노테이션으로서 지정해서 사용하면 된다.

  • Request (1)
public record CargoValidationRequest(

        ..
        
        @NotBlank
        @UnableLowerCaseOfKeyword
        @JsonProperty("keyword")
        String keyword
) {
}
  • Request (2)
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)

✏️ Review

처음엔 단순히 커스텀 어노테이션을 만들어보자! 라는 간단한 포부였지만, 예상 외로 해당 어노테이션을 붙인 Request Dto 의 다양한 구조에 따라 많은 Excpetion 이 던져짐에 따라 디버깅에 생각보다 시간이 걸렸었다.

그리고 꽤나 디테일한 Response 임에 따라 너무 시간을 많이 썼나.. 싶기도 하지만, 해보고 싶던 내용을 간단한 예시에 적용해볼 수 있었기에 재밌었던 시간이었다 🦔

profile
안녕하세요, 개발자 비비입니다.

0개의 댓글