[Advanced-12] 스프링 AOP - 실전 예제

2026. 1. 2. 23:57·Spring/Core

1. 예제 프로젝트 구성

1.1. 실전 예제 목표

이번 장에서는 지금까지 학습한 스프링 AOP 개념을 활용하여 실무에서 유용하게 사용할 수 있는 두 가지 AOP를 직접 구현해본다:

  1. 로그 출력 AOP: @Trace 애노테이션을 사용하여 메서드 호출 정보를 로그로 출력
  2. 재시도 AOP: @Retry 애노테이션을 사용하여 예외 발생 시 자동으로 재시도

1.2. 기본 예제 프로젝트 구성

1.2.1. ExamRepository - 저장소 클래스

간헐적으로 실패하는 저장소를 시뮬레이션하는 클래스이다.

package hello.aop.exam;

import org.springframework.stereotype.Repository;

@Repository
public class ExamRepository {

    // 테스트를 위한 시퀀스 카운터
    private static int seq = 0;

    /**
     * 5번에 1번 실패하는 저장 메서드
     * @param itemId 저장할 아이템 ID
     * @return 저장 결과
     * @throws IllegalStateException 5번째 호출 시 예외 발생
     */
    public String save(String itemId) {
        seq++;  // 호출 횟수 증가

        // 5번에 1번 실패하도록 구현
        if (seq % 5 == 0) {
            throw new IllegalStateException("예외 발생");
        }

        return "ok";
    }
}

동작 원리:

  • 정적 변수 seq를 사용하여 호출 횟수를 카운트
  • 5번에 한 번씩(seq % 5 == 0) IllegalStateException 발생
  • 이렇게 간헐적으로 실패하는 시나리오는 재시도 AOP의 필요성을 보여줌

1.2.2. ExamService - 서비스 클래스

비즈니스 로직을 처리하는 서비스 클래스이다.

package hello.aop.exam;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor  // Lombok: final 필드 생성자 자동 생성
public class ExamService {

    private final ExamRepository examRepository;

    /**
     * 아이템 저장 요청 처리
     * @param itemId 저장할 아이템 ID
     */
    public void request(String itemId) {
        examRepository.save(itemId);
    }
}

설명:

  • @RequiredArgsConstructor: Lombok 애노테이션으로 final 필드에 대한 생성자 자동 생성
  • ExamRepository를 의존성으로 주입받아 사용
  • 단순히 저장소의 save() 메서드를 호출하는 역할

1.2.3. ExamTest - 테스트 클래스

예제를 테스트하는 클래스이다.

package hello.aop.exam;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class ExamTest {

    @Autowired
    ExamService examService;

    @Test
    void test() {
        // 5번 반복 실행
        for (int i = 0; i < 5; i++) {
            examService.request("data" + i);
        }
    }
}

초기 실행 결과 (AOP 적용 전):

// 1-4번: 정상 실행
// 5번째 실행 시 예외 발생
java.lang.IllegalStateException: 예외 발생
	at hello.aop.exam.ExamRepository.save(ExamRepository.java:15)
	...

문제점:

  • 5번째 호출에서 항상 예외 발생
  • 애플리케이션 비정상 종료
  • 재시도 기능이 없어 실패한 작업 복구 불가

2. 로그 출력 AOP 구현

2.1. @Trace 애노테이션 정의

메서드에 로그 출력을 적용하기 위한 커스텀 애노테이션을 정의한다.

package hello.aop.exam.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)  // 메서드에만 적용 가능
@Retention(RetentionPolicy.RUNTIME)  // 런타임까지 유지
public @interface Trace {
    // 추가 속성 없이 마커 애노테이션으로 사용
}

애노테이션 설정 설명:

  • @Target(ElementType.METHOD): 이 애노테이션은 메서드에만 적용 가능
  • @Retention(RetentionPolicy.RUNTIME): 런타임까지 애노테이션 정보 유지 (AOP에서 접근 가능)
  • 마커 애노테이션(Marker Annotation): 속성이 없는 단순 표식 애노테이션

2.2. TraceAspect 구현

@Trace 애노테이션이 적용된 메서드의 호출 정보를 로그로 출력하는 Aspect를 구현한다.

package hello.aop.exam.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Slf4j  // SLF4J 로거 자동 생성
@Aspect  // 이 클래스가 Aspect임을 선언
public class TraceAspect {

    /**
     * @Trace 애노테이션이 있는 메서드 실행 전에 호출
     * @param joinPoint 조인 포인트 정보
     */
    @Before("@annotation(hello.aop.exam.annotation.Trace)")
    public void doTrace(JoinPoint joinPoint) {
        // 메서드 인자 정보 가져오기
        Object[] args = joinPoint.getArgs();

        // 로그 출력: 메서드 시그니처와 인자 값
        log.info("[trace] {} args={}", joinPoint.getSignature(), args);
    }
}

코드 분석:

  1. @Before 어드바이스:
    • 메서드 실행 전에 실행되는 어드바이스
    • @annotation(...) 포인트컷: 지정된 애노테이션이 있는 메서드만 대상
  2. JoinPoint 파라미터:
    • joinPoint.getSignature(): 메서드 시그니처 정보 (메서드 이름, 반환 타입, 선언 클래스 등)
    • joinPoint.getArgs(): 메서드에 전달된 인자 배열
  3. 로깅 포맷:
    • [trace]: 로그 구분자
    • 메서드 시그니처: 어떤 메서드가 호출되었는지 확인
    • 인자 값: 어떤 값으로 메서드가 호출되었는지 확인

2.3. @Trace 애노테이션 적용

2.3.1. ExamService에 @Trace 적용

package hello.aop.exam;

import hello.aop.exam.annotation.Trace;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ExamService {

    private final ExamRepository examRepository;

    @Trace  // 로그 출력 AOP 적용
    public void request(String itemId) {
        examRepository.save(itemId);
    }
}

2.3.2. ExamRepository에 @Trace 적용

package hello.aop.exam;

import hello.aop.exam.annotation.Trace;
import org.springframework.stereotype.Repository;

@Repository
public class ExamRepository {

    private static int seq = 0;

    @Trace  // 로그 출력 AOP 적용
    public String save(String itemId) {
        seq++;
        if (seq % 5 == 0) {
            throw new IllegalStateException("예외 발생");
        }
        return "ok";
    }
}

2.4. TraceAspect 등록 및 테스트

2.4.1. 테스트 클래스에 Aspect 등록

package hello.aop.exam;

import hello.aop.exam.aop.TraceAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Import(TraceAspect.class)  // TraceAspect를 스프링 빈으로 등록
@SpringBootTest
public class ExamTest {

    @Autowired
    ExamService examService;

    @Test
    void test() {
        for (int i = 0; i < 5; i++) {
            examService.request("data" + i);
        }
    }
}

@Import의 역할:

  • TraceAspect 클래스를 스프링 빈으로 등록
  • @Aspect 클래스는 컴포넌트 스캔 대상이 아니므로 명시적 등록 필요
  • 테스트 환경에서 쉽게 Aspect를 추가/제거할 수 있음

2.4.2. 실행 결과

[trace] void hello.aop.exam.ExamService.request(String) args=[data0]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data0]
[trace] void hello.aop.exam.ExamService.request(String) args=[data1]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data1]
[trace] void hello.aop.exam.ExamService.request(String) args=[data2]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data2]
[trace] void hello.aop.exam.ExamService.request(String) args=[data3]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data3]
[trace] void hello.aop.exam.ExamService.request(String) args=[data4]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data4]

로그 분석:

  1. 메서드 호출 순서를 명확히 확인 가능
  2. 각 메서드에 전달된 인자 값 확인 가능
  3. 예외 발생 지점(5번째 save() 호출)도 로그로 기록됨
  4. 디버깅과 모니터링에 매우 유용한 정보 제공

2.5. 로그 출력 AOP의 실무 활용

2.5.1. 다양한 로깅 레벨 지원

@Slf4j
@Aspect
public class TraceAspect {

    @Before("@annotation(hello.aop.exam.annotation.Trace)")
    public void doTrace(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();

        // DEBUG 레벨: 상세 정보 (성능 영향 고려)
        log.debug("[trace] {} args={}", joinPoint.getSignature(), args);

        // INFO 레벨: 기본 정보
        log.info("[trace] {}", joinPoint.getSignature());

        // 성능 측정 시작
        long startTime = System.currentTimeMillis();
    }

    @AfterReturning("@annotation(hello.aop.exam.annotation.Trace)")
    public void doTraceAfter(JoinPoint joinPoint) {
        // 성능 측정 종료 및 로깅
        log.info("[trace] {} completed", joinPoint.getSignature());
    }

    @AfterThrowing(value = "@annotation(hello.aop.exam.annotation.Trace)",
                   throwing = "ex")
    public void doTraceException(JoinPoint joinPoint, Exception ex) {
        // 예외 발생 시 로깅
        log.error("[trace] {} exception={}", joinPoint.getSignature(), ex.getMessage());
    }
}

2.5.2. 조건부 로깅

@Slf4j
@Aspect
public class ConditionalTraceAspect {

    @Around("@annotation(trace)")
    public Object doConditionalTrace(ProceedingJoinPoint joinPoint, Trace trace)
            throws Throwable {

        // 특정 조건에서만 로깅 (예: 특정 파라미터 값일 때)
        Object[] args = joinPoint.getArgs();
        if (shouldLog(args)) {
            log.info("[trace] {} args={}", joinPoint.getSignature(), args);
        }

        return joinPoint.proceed();
    }

    private boolean shouldLog(Object[] args) {
        // 로깅 조건 로직 구현
        // 예: 특정 값이 포함된 경우, 특정 시간대인 경우 등
        return true;
    }
}

3. 재시도 AOP 구현

3.1. @Retry 애노테이션 정의

재시도 횟수를 설정할 수 있는 커스텀 애노테이션을 정의한다.

package hello.aop.exam.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)  // 메서드에만 적용 가능
@Retention(RetentionPolicy.RUNTIME)  // 런타임까지 유지
public @interface Retry {

    /**
     * 재시도 횟수 (기본값: 3)
     * @return 최대 재시도 횟수
     */
    int value() default 3;
}

애노테이션 특징:

  • value() 메서드: 재시도 횟수를 설정하는 속성
  • default 3: 기본값으로 3회 재시도
  • 애노테이션 값을 AOP에서 읽어 동적으로 재시도 횟수 제어 가능

3.2. RetryAspect 구현

@Retry 애노테이션이 적용된 메서드에서 예외 발생 시 지정된 횟수만큼 재시도하는 Aspect를 구현한다.

package hello.aop.exam.aop;

import hello.aop.exam.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class RetryAspect {

    /**
     * @Retry 애노테이션이 있는 메서드에 재시도 로직 적용
     * @param joinPoint 조인 포인트
     * @param retry @Retry 애노테이션 인스턴스
     * @return 메서드 실행 결과
     * @throws Throwable 모든 재시도 실패 시 최종 예외
     */
    @Around("@annotation(retry)")
    public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry)
            throws Throwable {

        log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);

        // 애노테이션에서 재시도 횟수 가져오기
        int maxRetry = retry.value();
        Exception exceptionHolder = null;

        // 지정된 횟수만큼 재시도
        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try {
                log.info("[retry] try count={}/{}", retryCount, maxRetry);

                // 원본 메서드 실행
                return joinPoint.proceed();

            } catch (Exception e) {
                // 예외 발생 시 기록
                log.info("[retry] exception={}", e.getMessage());
                exceptionHolder = e;

                // 마지막 시도가 아니면 계속 진행
                if (retryCount < maxRetry) {
                    // 재시도 전 잠시 대기 (선택적)
                    // Thread.sleep(100);
                }
            }
        }

        // 모든 재시도 실패 시 최종 예외 발생
        throw exceptionHolder;
    }
}

코드 분석:

3.2.1. @Around 어드바이스

  • @Around("@annotation(retry)"): @Retry 애노테이션이 있는 메서드 대상
  • Retry retry: 애노테이션 인스턴스를 파라미터로 받음 (매개변수 바인딩)

3.2.2. 재시도 로직

  1. 최대 재시도 횟수 설정:
    int maxRetry = retry.value();  // 애노테이션의 value 값 가져오기
    
  2. 재시도 루프:
    for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
        try {
            // 메서드 실행
            return joinPoint.proceed();
        } catch (Exception e) {
            // 예외 처리
            exceptionHolder = e;
        }
    }
    
  3. 예외 보관:
    Exception exceptionHolder = null;
    // ...
    exceptionHolder = e;
    
    마지막에 발생한 예외를 보관하여 모든 재시도 실패 시 던짐

3.2.3. 로깅

  • 각 재시도 시도마다 로그 기록
  • 예외 발생 시 예외 메시지 기록
  • 디버깅과 모니터링에 유용

3.3. @Retry 애노테이션 적용

3.3.1. ExamRepository에 @Retry 적용

package hello.aop.exam;

import hello.aop.exam.annotation.Retry;
import hello.aop.exam.annotation.Trace;
import org.springframework.stereotype.Repository;

@Repository
public class ExamRepository {

    private static int seq = 0;

    @Trace  // 로그 출력 적용
    @Retry(value = 4)  // 4번 재시도 적용
    public String save(String itemId) {
        seq++;
        if (seq % 5 == 0) {
            throw new IllegalStateException("예외 발생");
        }
        return "ok";
    }
}

설정 설명:

  • @Retry(value = 4): 최대 4번 재시도
  • @Trace와 함께 사용 가능 (다중 AOP 적용)
  • 5번에 한 번 실패하는 로직 + 4번 재시도 = 총 5번 시도 가능

3.3.2. 중첩 애노테이션 처리

스프링 AOP는 여러 애노테이션이 동시에 적용된 경우 자동으로 체인 형태로 어드바이스를 실행한다. 실행 순서는 @Order 애노테이션으로 제어할 수 있다.

3.4. RetryAspect 등록 및 테스트

3.4.1. 테스트 클래스에 Aspect 등록

package hello.aop.exam;

import hello.aop.exam.aop.RetryAspect;
import hello.aop.exam.aop.TraceAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

// 두 Aspect 모두 등록
@Import({TraceAspect.class, RetryAspect.class})
@SpringBootTest
public class ExamTest {

    @Autowired
    ExamService examService;

    @Test
    void test() {
        for (int i = 0; i < 5; i++) {
            examService.request("data" + i);
        }
    }
}

3.4.2. 실행 결과

[trace] void hello.aop.exam.ExamService.request(String) args=[data0]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data0]

[trace] void hello.aop.exam.ExamService.request(String) args=[data1]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data1]

[trace] void hello.aop.exam.ExamService.request(String) args=[data2]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data2]

[trace] void hello.aop.exam.ExamService.request(String) args=[data3]
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data3]

[trace] void hello.aop.exam.ExamService.request(String) args=[data4]
[retry] String hello.aop.exam.ExamRepository.save(String) retry=@hello.aop.exam.annotation.Retry(value=4)
[retry] try count=1/4
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data4]
[retry] exception=예외 발생
[retry] try count=2/4
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data4]
[retry] exception=예외 발생
[retry] try count=3/4
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data4]
[retry] exception=예외 발생
[retry] try count=4/4
[trace] String hello.aop.exam.ExamRepository.save(String) args=[data4]

결과 분석:

  1. 1-4번째 호출: 정상 실행, 로그만 출력
  2. 5번째 호출: 재시도 로직 발동
    • 첫 시도 실패 → 재시도 시작
    • 총 4번 재시도 설정
    • 각 재시도마다 @Trace도 적용됨
    • 5번째 시도(원본 1번 + 재시도 4번) 성공
  3. AOP 체인 실행 순서:
    요청 → TraceAspect → RetryAspect → 실제 메서드
    

3.5. 고급 재시도 AOP 구현

3.5.1. 지수 백오프(Exponential Backoff) 추가

@Slf4j
@Aspect
public class RetryWithBackoffAspect {

    @Around("@annotation(retry)")
    public Object doRetryWithBackoff(ProceedingJoinPoint joinPoint, Retry retry)
            throws Throwable {

        int maxRetry = retry.value();
        long baseDelay = 100;  // 기본 대기 시간 (밀리초)
        Exception lastException = null;

        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try {
                log.info("[retry] attempt {}/{}", retryCount, maxRetry);
                return joinPoint.proceed();

            } catch (Exception e) {
                lastException = e;
                log.warn("[retry] attempt {}/{} failed: {}",
                        retryCount, maxRetry, e.getMessage());

                if (retryCount < maxRetry) {
                    // 지수 백오프: 100ms, 200ms, 400ms, 800ms...
                    long delay = (long) (baseDelay * Math.pow(2, retryCount - 1));
                    log.info("[retry] waiting {}ms before next attempt", delay);

                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw ie;
                    }
                }
            }
        }

        throw lastException;
    }
}

지수 백오프 장점:

  • 서버 부하 분산
  • 네트워크 복구 시간 확보
  • 재시도 폭주(Retry Storm) 방지

3.5.2. 예외 타입별 재시도 처리

@Slf4j
@Aspect
public class SmartRetryAspect {

    @Around("@annotation(retry)")
    public Object doSmartRetry(ProceedingJoinPoint joinPoint, Retry retry)
            throws Throwable {

        int maxRetry = retry.value();
        Exception lastException = null;

        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try {
                return joinPoint.proceed();

            } catch (IllegalStateException e) {
                // 일시적 오류: 재시도
                lastException = e;
                log.info("[retry] temporary error: {}", e.getMessage());

            } catch (IllegalArgumentException e) {
                // 잘못된 인자: 재시도 의미 없음
                log.error("[retry] invalid argument, no retry: {}", e.getMessage());
                throw e;

            } catch (Exception e) {
                // 기타 예외: 로깅 후 재시도
                lastException = e;
                log.warn("[retry] unexpected error: {}", e.getMessage());
            }

            if (retryCount >= maxRetry) {
                break;
            }

            // 대기 후 재시도
            Thread.sleep(100);
        }

        throw lastException;
    }
}

3.5.3. 회로 차단기(Circuit Breaker) 패턴 통합

@Slf4j
@Aspect
public class CircuitBreakerRetryAspect {

    private static final Map<String, CircuitBreaker> circuitBreakers =
            new ConcurrentHashMap<>();

    @Around("@annotation(retry)")
    public Object doCircuitBreakerRetry(ProceedingJoinPoint joinPoint, Retry retry)
            throws Throwable {

        String methodSignature = joinPoint.getSignature().toLongString();
        CircuitBreaker circuitBreaker = circuitBreakers.computeIfAbsent(
            methodSignature,
            key -> new CircuitBreaker()
        );

        // 회로 차단기 상태 확인
        if (!circuitBreaker.allowRequest()) {
            log.warn("[circuit-breaker] circuit is OPEN for {}", methodSignature);
            throw new CircuitBreakerOpenException("Circuit breaker is open");
        }

        try {
            Object result = joinPoint.proceed();
            circuitBreaker.recordSuccess();
            return result;

        } catch (Exception e) {
            circuitBreaker.recordFailure();
            throw e;
        }
    }

    // 간단한 회로 차단기 구현
    static class CircuitBreaker {
        private static final int FAILURE_THRESHOLD = 5;
        private static final long TIMEOUT = 5000;  // 5초

        private int failureCount = 0;
        private long lastFailureTime = 0;
        private State state = State.CLOSED;

        enum State { CLOSED, OPEN, HALF_OPEN }

        boolean allowRequest() {
            if (state == State.OPEN) {
                // 타임아웃 확인
                if (System.currentTimeMillis() - lastFailureTime > TIMEOUT) {
                    state = State.HALF_OPEN;
                    return true;
                }
                return false;
            }
            return true;
        }

        void recordSuccess() {
            if (state == State.HALF_OPEN) {
                state = State.CLOSED;
                failureCount = 0;
            }
        }

        void recordFailure() {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();

            if (failureCount >= FAILURE_THRESHOLD) {
                state = State.OPEN;
            }
        }
    }
}

4. 실전 적용 고려사항

4.1. AOP 실행 순서 제어

여러 Aspect가 동시에 적용될 때 실행 순서가 중요한 경우가 있다.

@Slf4j
@Aspect
@Order(1)  // 낮은 숫자가 먼저 실행
public class TraceAspect {
    // 구현
}

@Slf4j
@Aspect
@Order(2)  // 높은 숫자가 나중에 실행
public class RetryAspect {
    // 구현
}

권장 실행 순서:

  1. 보안 검증 (가장 먼저)
  2. 트랜잭션 시작
  3. 로깅
  4. 재시도
  5. 캐싱
  6. 트랜잭션 커밋 (가장 나중)

4.2. 성능 고려사항

4.2.1. 불필요한 AOP 비활성화

@Configuration
public class AopConfig {

    @Bean
    @ConditionalOnProperty(name = "app.aop.trace.enabled", havingValue = "true")
    public TraceAspect traceAspect() {
        return new TraceAspect();
    }

    @Bean
    @ConditionalOnProperty(name = "app.aop.retry.enabled", havingValue = "true")
    public RetryAspect retryAspect() {
        return new RetryAspect();
    }
}

application.yml 설정:

app:
  aop:
    trace:
      enabled: true
    retry:
      enabled: false  # 프로덕션에서는 재시도 비활성화

4.2.2. 샘플링 로깅

@Slf4j
@Aspect
public class SamplingTraceAspect {

    private final Random random = new Random();

    @Before("@annotation(hello.aop.exam.annotation.Trace)")
    public void doTrace(JoinPoint joinPoint) {
        // 10% 확률로만 로깅 (성능 향상)
        if (random.nextInt(100) < 10) {
            Object[] args = joinPoint.getArgs();
            log.info("[trace] {} args={}", joinPoint.getSignature(), args);
        }
    }
}

4.3. 예외 처리 전략

4.3.1. 재시도 가능 예외 vs 불가능 예외

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {

    int value() default 3;

    // 재시도할 예외 클래스들 (기본: 모든 예외)
    Class<? extends Throwable>[] retryFor() default {Exception.class};

    // 재시도하지 않을 예외 클래스들
    Class<? extends Throwable>[] noRetryFor() default {};
}

4.3.2. Aspect 구현

@Slf4j
@Aspect
public class ConfigurableRetryAspect {

    @Around("@annotation(retry)")
    public Object doConfigurableRetry(ProceedingJoinPoint joinPoint, Retry retry)
            throws Throwable {

        int maxRetry = retry.value();
        Class<? extends Throwable>[] retryFor = retry.retryFor();
        Class<? extends Throwable>[] noRetryFor = retry.noRetryFor();

        Exception lastException = null;

        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try {
                return joinPoint.proceed();

            } catch (Exception e) {
                // 재시도 여부 판단
                if (shouldRetry(e, retryFor, noRetryFor)) {
                    lastException = e;
                    log.info("[retry] attempt {}/{} failed: {}",
                            retryCount, maxRetry, e.getMessage());

                    if (retryCount < maxRetry) {
                        Thread.sleep(100);
                    }
                } else {
                    // 재시도하지 않는 예외
                    throw e;
                }
            }
        }

        throw lastException;
    }

    private boolean shouldRetry(Exception e,
                               Class<? extends Throwable>[] retryFor,
                               Class<? extends Throwable>[] noRetryFor) {
        // noRetryFor 우선 체크
        for (Class<? extends Throwable> noRetryClass : noRetryFor) {
            if (noRetryClass.isInstance(e)) {
                return false;
            }
        }

        // retryFor 체크
        for (Class<? extends Throwable> retryClass : retryFor) {
            if (retryClass.isInstance(e)) {
                return true;
            }
        }

        return false;
    }
}

5. 정리

5.1. 학습한 주요 개념

  1. 커스텀 애노테이션 생성: @Trace, @Retry 애노테이션 정의
  2. Aspect 구현: @Aspect, @Before, @Around 어드바이스 사용
  3. 포인트컷 표현식: @annotation()을 이용한 애노테이션 기반 매칭
  4. 매개변수 바인딩: 애노테이션 인스턴스를 어드바이스 파라미터로 전달
  5. 재시도 패턴: 예외 발생 시 지정된 횟수만큼 재시도
  6. AOP 체인: 여러 Aspect가 함께 동작하는 방식

5.2. 실무 적용 시 고려사항

항목 고려사항 해결 방안
성능 AOP는 성능 오버헤드 있음 필요시만 적용, 샘플링 로깅
디버깅 실행 흐름 추적 어려움 상세 로깅, 트레이스 ID
순서 여러 Aspect 간 실행 순서 @Order 애노테이션 사용
예외 재시도 무한 루프 가능 최대 재시도 횟수 제한
테스트 AOP 적용 여부 테스트 단위 테스트, 통합 테스트 분리

5.3. 확장 가능한 아이디어

  1. 실행 시간 측정 AOP:
    @Around("@annotation(MeasureTime)")
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long endTime = System.currentTimeMillis();
            log.info("Execution time: {}ms", endTime - startTime);
        }
    }
    
  2. 캐싱 AOP:
    @Around("@annotation(Cacheable)")
    public Object cache(ProceedingJoinPoint joinPoint) throws Throwable {
        String cacheKey = generateCacheKey(joinPoint);
        Object cachedValue = cache.get(cacheKey);
        if (cachedValue != null) {
            return cachedValue;
        }
        Object result = joinPoint.proceed();
        cache.put(cacheKey, result);
        return result;
    }
    
  3. 분산 트레이싱 AOP:
    @Around("execution(* com.example..*(..))")
    public Object distributedTrace(ProceedingJoinPoint joinPoint) throws Throwable {
        String traceId = MDC.get("traceId");
        if (traceId == null) {
            traceId = generateTraceId();
            MDC.put("traceId", traceId);
        }
        // 분산 추적 시스템에 정보 전송
        return joinPoint.proceed();
    }
    

5.4. 스프링의 @Transactional과의 관계

스프링의 @Transactional 애노테이션은 가장 대표적인 AOP 적용 사례이다:

@Service
public class UserService {

    @Transactional
    public void createUser(User user) {
        // 트랜잭션 내에서 실행
        userRepository.save(user);
        sendWelcomeEmail(user);
    }
}

비교:

항목 커스텀 AOP @Transactional
구현 방식 직접 구현 스프링 제공
적용 범위 제한적 광범위
설정 애노테이션 값으로 제어 다양한 속성 지원
테스트 직접 테스트 필요 스프링 테스트 지원

5.5. 마무리

이번 장에서는 스프링 AOP를 활용하여 실무에서 바로 사용할 수 있는 두 가지 유용한 AOP를 구현해보았다:

  1. 로그 출력 AOP(@Trace): 메서드 호출 정보를 자동으로 로깅하여 디버깅과 모니터링을 지원
  2. 재시도 AOP(@Retry): 예외 발생 시 자동으로 재시도하여 시스템의 견고성 향상

이러한 AOP 패턴들은 실제 프로젝트에서 반복적으로 발생하는 횡단 관심사를 효과적으로 처리할 수 있는 강력한 도구이다. 적절히 활용하면 코드의 가독성, 유지보수성, 안정성을 크게 향상시킬 수 있다.

AOP의 진정한 가치는 비즈니스 로직과 횡단 관심사를 명확히 분리함으로써 단일 책임 원칙(Single Responsibility Principle)을 준수하고, 관심사의 분리(Separation of Concerns)를 실현하는 데 있다. 이를 통해 더 깔끔하고 관리하기 쉬운 코드베이스를 구축할 수 있다.

'Spring > Core' 카테고리의 다른 글

[Advanced-13] 스프링 AOP - 실무 주의사항  (0) 2026.01.03
[Advanced-11] 스프링 AOP - 포인트 컷  (0) 2026.01.02
[Advanced-10] 스프링 AOP 구현  (0) 2026.01.02
[Advanced-9] 스프링 AOP 개념  (0) 2026.01.02
[Advanced-8] @Aspect AOP  (0) 2026.01.02
'Spring/Core' 카테고리의 다른 글
  • [Advanced-13] 스프링 AOP - 실무 주의사항
  • [Advanced-11] 스프링 AOP - 포인트 컷
  • [Advanced-10] 스프링 AOP 구현
  • [Advanced-9] 스프링 AOP 개념
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Advanced-12] 스프링 AOP - 실전 예제
상단으로

티스토리툴바