Spring boot

스프링 핵심원리 12 [커스텀 AOP 어노테이션 만들기]

코앤미 2023. 6. 19. 09:50

어노테이션 기반 개발의 예제를 몇가지 만들어보자.

 

@Trace: 로그 출력

 

@Retry: 예외 발생해도 일정 횟수 재시도.

 

과 같은 유용한 AOP를 만들어보자.

 

우선, AOP를 적용할 예제를 만들어보자.

 

Repository

5번에 1번은 실패하는 Repository를 구성한다.

@Repository
public class ExamRepository {

    private static int seq = 0;

    /**
     * 5번에 1번 실패하는 요청
     */
    public String save(String itemId) {
        seq++;
        if (seq % 5 == 0) {
            throw new IllegalStateException("예외 발생");
        }
        return "ok";
    }
}

 

Service

단순히 Repository를 호출하는 서비스

@Service
@RequiredArgsConstructor
public class ExamService {

    private final ExamRepository examRepository;

    @Trace
    public void request(String itemId) {
        examRepository.save(itemId);
    }
}

 

 

Test Code

@Slf4j
//@Import(TraceAspect.class)
@Import({TraceAspect.class, RetryAspect.class})
@SpringBootTest
public class ExamTest {

    @Autowired
    ExamService examService;

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

예제를 구성했으니, 이제 어노테이션 기반 로직을 만들기 위해 Meta Annotation에 대해 알아보자.

 

 

Meta Annotation

다른 어노테이션에서도 사용되는 어노테이션을 말한다.

주로 커스텀 어노테이션을 만들 때 사용된다. 

@Controller, @Service, @Repository등은 컴포넌트 스캔을 위해 @Component 를 내포하고 있는데, 여기서 @Component를

meta annotation이라고 한다.

 

ex

@Repository 어노테이션의 내부

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Repository {
	/**
	 * The value may indicate a suggestion for a logical component name,
	 * to be turned into a Spring bean in case of an autodetected component.
	 * @return the suggested component name, if any (or empty String otherwise)
	 */
	@AliasFor(annotation = Component.class)
	String value() default "";

}

 

 

@Target

자바 컴파일러에게 해당 어노테이션이 어디에 적용될지 알려준다.

위의 예시에서 ElementType.TYPE 은, 해당 어노테이션이 타입 선언에 사용된다는 의미이다.

ElementType.PACKAGE : 패키지 선언
ElementType.TYPE : 타입 선언
ElementType.ANNOTATION_TYPE : 어노테이션 타입 선언
ElementType.CONSTRUCTOR : 생성자 선언
ElementType.FIELD : 멤버 변수 선언
ElementType.LOCAL_VARIABLE : 지역 변수 선언
ElementType.METHOD : 메서드 선언
ElementType.PARAMETER : 전달인자 선언
ElementType.TYPE_PARAMETER : 전달인자 타입 선언
ElementType.TYPE_USE : 타입 선언

 

@Retention

해당 어노테이션의 적용, 유지 범위를 나타낸다.

RetentionPolicy.RUNTIME
RetentionPolicy.CLASS
RetentionPolicy.SOURCE

RUNTIME: 컴파일 이후에도 JVM에 의해서 참조가 가능하다. 주로 리플랙션, 로깅에 많이 사용된다.

 

CLASS: 컴파일러가 클래스를 참조할 때까지 유효하다.

 

SOURCE: 컴파일 전까지만 유효하다.

 

 

이제 @Trace를 통해 로그를 출력하도록 커스텀 어노테이션을 만들어보자.

 

 

@Trace(로그 추적을 수행하는 어노테이션 만들기)

 

Trace 메타 어노테이션

@Target(ElementType.METHOD) //적용 대상은 메소드만
@Retention(RetentionPolicy.RUNTIME) //어노테이션 정보가 런타임에 읽고 처리될 수 있음을 의미한다.
public @interface Trace {
}

 

Aspect

로그를 적용하기 위한 어드바이저를 담은 Aspect를 만든다.

@Slf4j
@Aspect
public class TraceAspect {

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

포인트 컷에는 방금 만든 @Trace을 등록하여 해당 어노테이션에 대해 doTrace에 정의된 Advice를 적용하도록 한다.

@Before을 통해 메소드 실행전에, 넘어오는 파라미터 정보와 시그니처(메소드 정보)를 출력해보자.

 

**주의

PorceedingJoinPoint가 아닌 JoinPoint를 파라미터로 전달 받는다. 

 

이제 방금 예제의 Repository의 save, Service의 request에 @Trace를 붙이고

  @Import(TraceAspect.class)

 

등을 통해 aspect를 스프링 빈으로 등록하면(내부 모든 어드바이저를 스프링 빈에 등록) 아래와 같은 결과가 나온다.

 

 

@Retry(재실행 어노테이션 만들기)

외부 호출 API(ex: google 로그인 API) 등등은 간혹, 호출된 API쪽으 문제로 실패할 경우가 있다. 따라서, 외부 API를 호출하는 부분은 

단순히 재실행하는 것 만으로도 성공하는 경우가 있다. 이럴 때 재실행 어노테이션을 사용하면 유용할 수 있다. 

 

@Retry 메타 어노테이션 생성

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry { //외부 호출 API같은 경우, 호출쪽에서 문제일 경우가 간혹 있다. 이경우, 단순 재조회로 해결가능.
    int value() default 3; //기본 3회
}

 

Aspect

마찬가지로 aspect를 구성해주자. 

@Slf4j
@Aspect
public class RetryAspect {

    @Around("@annotation(retry)") //아래의 "Retry retry"를 통한 파라미터 전달로 인해 포인트컷에 자동으로 어노테이션 타입이 "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문에서 발생하는 예외를 담아둔다.

        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try {
                log.info("[retry] try count={}/{}", retryCount, maxRetry);
                return joinPoint.proceed();
            } catch (Exception e) {
                exceptionHolder = e; //exceptionHolder에 예외 담기 -> 몇번의 재시도 동안, 예외가 발생해도 계속 다시 시도하게
            }
        }
        throw exceptionHolder;
    }
}

@annotation을 통한 파라미터 전달 방식을 사용했기에 포인트 컷에서 자동으로 Retry타입만 통과되도록 해주기에 @annotation(xxx) 에서 xxx에 구체적인 패키지 경로까지 담지 않아도 되서 가독성과 편리성을 좋게 했다.

이후 retry.value()를 통해 앞에서 @interface Retry에 구성한 value(기본값은 3)를 받아오고, 그 횟수만큼 수행되도록 한다.

만약 지정된 3회 모두 예외가 발생 시, 마지막으로 발생한 예외를 throw하게 된다. 

 

 

이제 Repository영역에 구성한 Annotation들을 적용해보자.

 

Repository

@Repository
public class ExamRepository {

    private static int seq = 0;

    /**
     * 5번에 1번 실패하는 요청
     */
    @Trace
    @Retry(value = 4) //retry 횟수(value)를 기본값인 3에서 4로 변경
    public String save(String itemId) {
        seq++;
        if (seq % 5 == 0) {
            throw new IllegalStateException("예외 발생");
        }
        return "ok";
    }
}

 

이제

@Import({TraceAspect.class, RetryAspect.class})

 

등을 톨해 Retry Aspect까지 빈으로 등록한 뒤 실행해보자.

 

 

구성한 Repository는 5회째에서 문제가 발생하게 된다. 만약 문제가 발생하면, 재시도를 수행하고, 그 결과 다음 시도에 성공하게 된다.

 

참고

이외에, 커스텀 어노테이션 말고 스프링이 제공하는 유용한 AOP 어노테이션들도 존재하는데, 대표적으로 @Transactional가 있다.

실무에서 만드는 방법

 

로그 출력 AOP: 현장마다 다르지만, 

 

특정 시간 이상 실행되거나, 에러가 터지면 로그로 남기기

(1초걸리면 info로 남기고, 5초넘으면 log.error로 남기고...)

 

 

Retry: 어노테이션을 사용하는 메소드가 어노테이션의 파라미터로 재시도 횟수를 전달하면

그에 맞게 Advice가 재시도를 수행하도록 매개변수 전달을 사용했다.