(소스 코드) github : (빌드해서 라이브러리로 사용하거나 기능을 추가해서 사용하셔도 됩니다)
우선.. spring-retry라는 라이브러리를 설명하는 글은 아니므로, 만약 라이브러리 사용법을 알고싶으면... 뒤로가기를 눌러주세요.
메서드를 실패하면 재 실행하도록 하는 @Retryable 어노테이션을 만들자!
@Retryable 어노테이션을 쓰는 이유는 다음과 같습니다.
1. api 호출 시 장애가 발생할 가능성이 있는 경우
2. 네트워크 상태가 불안정한 경우
3. 무거운 계산을 수행하는 메서드에서 리소스 부족 등으로 실패할 가능성이 있는 경우
회사에서는 다른 부서의 API를 수백번 호출하는 배치 작업을 개발하기로 했고,
배치 안정성을 높이기 위해 '실패 시 재실행' 어노테이션을 추가하려 합니다.
만약 온라인 환경이었다면 spring-retry 라이브러리를 사용할 수 있었겠지만,
회사 노트북은 폐쇄망으로 되어있어 직접 @Retryable 어노테이션을 구현하게 됬습니다.
우선 @Retryable 어노테이션에 필요한 기능들을 알아보시죠.
1. 재시도 횟수를 지정할 수 있어야 한다.
2. 예외 발생 시 재시도 여부를 선택할 수 있어야 한다.
3. 재시도 전 딜레이를 지정할 수 있어야 한다.
4. 모든 재시도가 실패한 경우 Recover 기능을 수행할 수 있어야 한다.
위 4개의 기능들을 추가하여 코드를 작성하였습니다.
개발자는 코드가 중요하니 코드부터 보시죠. 모든 기능이 들어가있으며,
코드는 총 2개의 어노테이션과 1개의 aop Aspect로 제작했습니다.
AOP를 사용하기 위해서는 'org.springframework.boot:spring-boot-starter-aop' 라이브러리를 추가해주세요.
1. Aspect(구현부)
@Slf4j
@Aspect
@Component
public class RetryableAspect {
@Around("@annotation(retry.Retryable)")
public Object retryMethod(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method reatryableMethod = signature.getMethod();
Retryable annotation = reatryableMethod.getAnnotation(Retryable.class);
int retryCount = 0;
Object proceed;
Exception lazyException = null;
while (retryCount <= annotation.maxAttempts()) {
try {
if (retryCount != 0) {
log.warn("Retry method...");
Thread.sleep(annotation.backoff());
}
// Proceed target method
proceed = joinPoint.proceed();
// Success case
return proceed;
} catch (Exception e) {
lazyException = e;
boolean a = isAssignable(e, annotation.include());
boolean b = isAssignable(e, annotation.exclude());
if (a && !b) {
if (annotation.printStackTrace()) {
e.printStackTrace();
}
retryCount++;
} else {
throw e;
}
}
}
// @retry.Retryable fail case
Object target = joinPoint.getTarget();
// Find @retry.Recover method (@retry.Retryable return type == @retry.Recover return type)
Method recoverMethod = findRecoverMethodWithSameReturnType(target, reatryableMethod.getReturnType());
if (recoverMethod != null) {
log.info("retry.Recover start");
// The exception occurring here is not retried.
return recoverMethod.invoke(target);
} else {
throw lazyException;
}
}
private boolean isAssignable(Exception target, Class<? extends Exception>[] compareWiths) {
for (Class<? extends Exception> ex : compareWiths) {
if (ex.isAssignableFrom(target.getClass())) {
return true;
}
}
return false;
}
public Method findRecoverMethodWithSameReturnType(Object target, Class<?> retryableMethodReturnType) {
Method[] methods = ReflectionUtils.getAllDeclaredMethods(target.getClass());
// Find @retry.Recover annotation
for (Method method : methods) {
if (method.isAnnotationPresent(Recover.class) &&
method.getReturnType().equals(retryableMethodReturnType)) {
// If the return types are the same, then execute the recover logic.
// Choose the first recover method
return method;
}
}
return null;
}
}
2. @Retryable
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
@Min(1)
@Max(50)
int maxAttempts() default 3;
Class<? extends Exception>[] include() default {Exception.class};
Class<? extends Exception>[] exclude() default {};
@Min(0)
@Max(600000)
int backoff() default 0;
boolean printStackTrace() default false;
}
3. @Recover
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Recover {
}
- 사용 방법(예시)
@GetMapping("/")
@Retryable(maxAttempts = 2, include = IllegalArgumentException.class, backoff = 1000)
public String ExTest() {
throw new IllegalArgumentException("");
}
@Recover
public String recoverTest() {
log.info("Recover Success");
return "recover success";
}
위의 코드가 전부입니다. 그럼 중요한 부분만 뜯어서 간단하게 보시죠.
while (retryCount <= annotation.maxAttempts()) {
try {
if (retryCount != 0) {
log.warn("Retry method...");
Thread.sleep(annotation.backoff());
}
// Proceed target method
proceed = joinPoint.proceed();
// Success case
return proceed;
} catch (Exception e) {
lazyException = e;
boolean a = isAssignable(e, annotation.include());
boolean b = isAssignable(e, annotation.exclude());
if (a && !b) {
if (annotation.printStackTrace()) {
e.printStackTrace();
}
retryCount++;
} else {
throw e;
}
}
}
1. 로직을 성공하면 바로 리턴
2. 발생한 예외가 annotation.exclude()의 하위 클래스가 아닌지 판단
- assign 가능하다 : 예외를 던짐
- assign 불가능하다 : include()의 하위 클래스인지 판단
3. 실패하면 발생한 예외가 annotaion.include()의 하위 클래스인지 판단
- assign 가능하다 : 최대 maxAttempts 까지 재시도
- assign 불가능하다 : 예외를 던짐
- 여기서 assign이란. 아래와 같이 하위 클래스인지 아닌지를 판단하는 것 입니다.
> RuntimeException은 Exception의 하위 클래스 이므로 assign(할당)할 수 있다.
> Exception은 RuntimeException의 하위 클래스가 아니므로 assign(할당)할 수 없다.
> RuntimeException은 RuntimeException의 동일한 클래스 이므로 assign(할당)할 수 있다.
4. 만약 maxAttempts를 모두 사용한다면 Recover 모드로 들어간다.
Recover 모드도 중요한 부분만 보자.
// Find @Recover method (@Retryable return type == @Recover return type)
Method recoverMethod = findRecoverMethodWithSameReturnType(target, reatryableMethod.getReturnType());
if (recoverMethod != null) {
log.info("Recover start");
// The exception occurring here is not retried.
return recoverMethod.invoke(target);
} else {
throw lazyException;
}
1. @Recover 메서드가 @Retryable이 있는 클래스에 있는지 확인
2. 만약 있다면, @Retryable 메서드의 returnType과 @Recover 메서드의 returnType이 동일한지 파악
3. 동일하다면, invoke(target)으로 @Recover메서드를 실행. 결과 return
- @Recover로 실행된 메서드가 예외를 발생하면 상위 메서드로 throw 합니다.
- @Recover는 실패해도 재시도 하지 않습니다.
(재시도 로직을 실행 했음에도 실패하여 @Recover 로직까지 왔으므로 해당 로직은 재시도 하지 않는 것이 더 적절해 보입니다.)
회사 PC에서 API 시스템 점검에 요긴하게 사용하고 있고(로컬 PC 폐쇄망이 자주 끊기기 때문에)
새로운 개발건에도 도입하여 사용하고 있습니다.
'Java & Kotlin' 카테고리의 다른 글
자바의 Garbage Collection 이해와 성능 최적화 방법 (0) | 2023.07.30 |
---|---|
Lock과 Double Check Lock: 동시성 문제 해결을 위한 효율적 패턴 (0) | 2023.07.11 |
Java Functional Interface로 API 테스트 모듈 간결하게 만들기: 적용 사례와 코드 개선 (0) | 2023.07.11 |
Stack Overflow Error 발생 원인과 해결 방법: 재귀 호출 피하기 (0) | 2023.05.15 |
Java에서 제공해주는 Functional Interface (0) | 2023.04.30 |