커스텀 어노테이션으로 반복 코드 공통화하는 3단계

실행 시간 측정, 권한 확인, 트랜잭션 락. 메서드마다 핵심 로직은 다른데 그 앞뒤를 감싸는 부가 코드는 늘 똑같습니다. 이 똑같은 부분을 어노테이션 한 줄로 걷어내는 것이 커스텀 어노테이션 공통화입니다. 메서드 본문에는 “무엇을 하는가”만 남기고, “시간을 잰다”거나 “권한을 본다” 같은 부가 관심사는 어노테이션 뒤로 숨기는 것이죠.

이 글은 문법 나열 대신, 실제로 동작하는 어노테이션을 만드는 절차 3단계를 따라가며 정리합니다. 절차를 익히고 나면 어떤 부가 로직이든 같은 방식으로 빼낼 수 있습니다.

1단계 — 어노테이션을 정의한다

먼저 어노테이션 자체를 선언합니다. 어디에 붙고(@Target), 언제까지 살아남는지(@Retention)를 정하는 단계입니다.

@Target(ElementType.METHOD)          // 메서드에만 부착 가능
@Retention(RetentionPolicy.RUNTIME)  // 런타임까지 유지 → 리플렉션으로 읽음
public @interface LogExecutionTime {
}

여기서 @Retention(RUNTIME)이 전부라고 해도 과언이 아닙니다. 런타임까지 살아 있어야 실행 중에 어노테이션을 읽어 동작을 끼워 넣을 수 있습니다. 이 값을 SOURCECLASS로 두면 어노테이션을 붙여도 처리기가 인식하지 못해 아무 일도 일어나지 않습니다. 동작하는 커스텀 어노테이션이 거의 예외 없이 RUNTIME인 이유입니다.

2단계 — 메서드에 부착한다

공통화하고 싶은 메서드에 한 줄만 붙이면 됩니다.

@LogExecutionTime
public Order findOrder(Long orderId) {
    return orderRepository.findById(orderId).orElseThrow();
}

어노테이션은 이 단계까지는 그저 표식일 뿐입니다. 아직 아무 동작도 하지 않습니다. 이 표식을 읽어 실제 로직을 실행해 주는 처리기가 다음 단계의 핵심입니다.

3단계 — 처리기를 연결한다

처리 방식은 용도에 따라 셋으로 갈립니다.

처리 방식동작 시점어울리는 용도
AOP (@Aspect)메서드 호출 전후로깅, 실행시간, 락, 권한
ArgumentResolver컨트롤러 파라미터 주입인증 사용자, 커스텀 요청 값
ConstraintValidator검증 시점커스텀 입력값 유효성 검사

이 중 압도적으로 자주 쓰는 것이 AOP입니다. 메서드 호출을 가로채 앞뒤에 공통 코드를 끼워 넣는 방식이라, “메서드를 감싸는 부가 로직”이라는 목적과 정확히 맞습니다. 스프링의 @Transactional이나 @Cacheable도 내부적으로 이 AOP 방식으로 동작하니, 우리가 만드는 어노테이션은 스프링이 이미 쓰는 패턴을 그대로 따라가는 셈입니다.

이제 절차를 실제 예제 셋에 적용해 보겠습니다.

적용 예제 — 로깅, 분산 락, 인증 주입

실행 시간 로깅(@LogExecutionTime) 은 AOP @Around로 호출을 통째로 감쌉니다.

@Aspect
@Component
@Slf4j
public class LogExecutionTimeAspect {
    @Around("@annotation(LogExecutionTime)")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();            // 실제 메서드 실행
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            log.info("{} 실행 시간: {}ms", joinPoint.getSignature(), elapsed);
        }
    }
}

finally에 측정을 둔 덕분에 메서드가 예외로 끝나도 시간은 반드시 기록됩니다.

분산 락(@DistributedLock) 은 한 단계 어렵지만 실무 가치가 큽니다. 어노테이션에 key, waitTime 같은 속성을 정의해 메서드마다 다른 값을 넘길 수 있다는 점이 포인트입니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();
    long waitTime() default 5L;
}
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint,
                   DistributedLock distributedLock) throws Throwable {
    RLock lock = redissonClient.getLock(distributedLock.key());
    boolean acquired = lock.tryLock(distributedLock.waitTime(), 3, TimeUnit.SECONDS);
    if (!acquired) throw new IllegalStateException("락 획득 실패: " + distributedLock.key());
    try {
        return joinPoint.proceed();
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();   // 해제 누락 방지
    }
}

인증 사용자 주입(@CurrentUser) 만은 AOP가 아니라 ArgumentResolver로 처리합니다. AOP가 “메서드를 감싸는” 용도라면, ArgumentResolver는 “파라미터를 채워 주는” 용도입니다.

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mav,
                              NativeWebRequest webRequest, WebDataBinderFactory binder) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    return ((CustomUserDetails) auth.getPrincipal()).getMember();
}

이제 컨트롤러는 create(@CurrentUser Member member)처럼 파라미터에 어노테이션만 붙이면 로그인 사용자가 자동으로 주입됩니다.

무엇을 빼고, 무엇을 남길 것인가

세 예제의 공통점은 핵심 로직과 분리 가능한 부가 관심사이고 여러 곳에서 반복된다는 점입니다. 이 두 조건이 판단 기준입니다. 반대로 “주문 금액을 계산한다” 같은 핵심 비즈니스 로직을 어노테이션 뒤에 숨기면, 코드만 봐서는 무슨 일이 벌어지는지 추적할 수 없게 됩니다.

남용도 경계해야 합니다. AOP는 호출 코드 어디에도 드러나지 않아, 어노테이션 하나가 무슨 일을 하는지 모르는 동료는 동작을 추적하지 못합니다. 그래서 같은 부가 로직이 세 곳 이상 반복되고 핵심과 명확히 분리될 때만 빼냅니다. 두 곳뿐이라면 그냥 메서드로 추출하는 편이 낫습니다.

마지막으로 가장 흔한 함정 하나. AOP는 같은 클래스 안에서 메서드를 직접 호출하면(self-invocation) 프록시를 타지 않아 어노테이션이 조용히 무시됩니다. 분명 코드는 돌았는데 동작하지 않는다면, ① @Retention(RUNTIME)이 빠지지 않았는지, ② 처리기가 빈으로 등록됐는지, ③ 내부 호출로 프록시를 우회하고 있지 않은지 이 셋부터 점검하시기 바랍니다. 대부분의 “안 되는” 사례가 여기에 걸립니다.

정의 → 부착 → 처리. 이 3단계만 손에 익으면, 다음에 같은 부가 코드를 세 번째 복붙하려는 순간 어노테이션으로 빼야 할 신호임을 알아챌 수 있습니다.

참고: Spring 공식 문서 — AOP, Java 공식 튜토리얼 — Annotations

댓글 남기기