업데이트:


서비스 시스템에서 네트워크 이슈나 예외 발생으로 특정 작업을 실패했을 때,

재시도를 해야 하는 경우가 종종 있습니다.

Spring에서는 이와 같은 재시도를 위해 다양한 인터페이스를 제공하는데요,

Spring Retry에 대해 알아보겠습니다.

예제 코드는 여기에 있습니다.


RetryCallback

public interface RetryCallback<T, E extends Throwable> {

    T doWithRetry(RetryContext context) throws E;
}

위와 같이, RetryCallback 이라는 인터페이스를 제공합니다.

이 인터페이스를 구현 할 때, doWithRetry 메서드에 재시도할 비즈니스 로직을 작성하면 됩니다.

RetryCallback 함수를 넘기면, 성공하거나 최종 실패 조건에 다다르기 전까지 RetryCallback 함수를 계속 반복해서 실행합니다.


Callback 이란?

Callback 함수는

  • 다른 함수의 매개변수로 넘겨주는 함수 입니다.
  • Callback 메서드를 매개변수로 받은 다른 함수는, 자신이 원하는 시점에 Callback 메서드를 알아서 실행합니다.

저는 “Call at the back.” 이라는 말로 이해하고 있습니다.

“Callback 함수를 매개변수로 넘겨줄 테니까, 너는 뒤(back)에서 알아서 실행해.” 라고요.

그래서 이름이 Callback 함수이지 않을까 추측해봅니다. :)

RetryCallback 인터페이스는 다음과 같습니다.

RetryCallback 인터페이스의 구현체에 내가 실행하고 싶은 비즈니스 로직을 구현하고, RetryCallback 타입의 다형성을 활용해 다른 함수의 매개변수로 넘겨줍니다.

그러면 RetryCallback 인터페이스의 구현체를 매개변수로 받은 다른 함수는 “알아서” RetryCallback 인터페이스 구현체의 메서드를 실행합니다.

메서드가 성공하지 못하면, 성공할 때 까지 계속 재시도를 합니다.

(물론 설정을 통해서 재시도 간격, 최종적으로 실패를 판단하는 조건 등을 지정할 수 있습니다. 이는 이후 내용에서 자세하게 살펴봅니다.)

즉, 여기서 RetryCallback 인터페이스는 이름 그대로 Callback 구현체를 위한 인터페이스인 것이죠.


Spring 의존성 추가

Spring Retry 를 사용하기 위해서는 의존성을 추가해야 합니다.

build.gradle 파일에 아래와 같이 추가합니다.

dependencies {
    ...
    implementation 'org.springframework.retry:spring-retry:1.3.3'	// 추가
    ...
}


최종 실패 판단 조건

위에서 RetryCallback 메서드가 성공하지 못하면, 성공할 때 까지 재시도를 한다고 했는데요.

무한히 재시도 할 수는 없는 노릇입니다. 그러면 해당 쓰레드는 영원히 무한루프를 도니까요.

그래서 개발자는 “이 조건까지 시도했는데도 실패하면, 더이상 시도해봤자 의미가 없다.”라는 최종 실패 조건 을 설정으로 지정해 주어야 합니다.

그러면 실패하는 RetryCallback 메서드는 Spring Retry 에 의해 성공할 때 까지 계속 반복 실행되다가, 최종 실패 조건에 다다르면 재시도를 멈춥니다.

어떤 최종 실패 판단 조건들이 있는지 알아보겠습니다.


최대 시도 시간 제한 (Timeout)

첫 번째 최종 실패 조건으로 최대 시도 시간 제한 (Timeout)이 있습니다.

예를들어 메세지를 전송하다가 실패했을 때 재시도를 하는데, 총 시도 시간이 5초를 넘어가면 최종적으로 실패했다고 판단해야 하는 정책이 있다고 가정해 보겠습니다.

이 정책에 맞게 RetryTemplate 을 작성해 보겠습니다.

Spring Boot 프로젝트를 생성하고, 위에서 설명한 spring-retry 의존성을 추가합니다.

그리고 아래와 같이 코드를 작성했습니다.

@Slf4j
@Service
public class SendMessageRetryWithTimoutPolicySuccessService {

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        TimeoutRetryPolicy timeoutRetryPolicy = new TimeoutRetryPolicy();
        timeoutRetryPolicy.setTimeout(5000L); // 5초 timeout 설정

        retryTemplate.setRetryPolicy(timeoutRetryPolicy); // RetryTemplate에 TimeoutRetryPolicy 세팅

        LocalDateTime startTime = LocalDateTime.now(); // 실행 시작 시간

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {
                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithTimoutPolicySuccessService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithTimoutPolicySuccessService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            long executionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS);
            log.info("[SendMessageRetryWithTimoutPolicySuccessService][run][SendMessage] 메세지 전송 총 실행 시간={}ms", executionDuration);
        }
    }
}

그리고 Spring Boot 애플리케이션을 실행시키면,

image

위와 같이 메세지 전송에 성공한 것을 확인할 수 있습니다.

그러면 이제, 메세지 전송이 항상 실패하도록 코드 사이에 예외 발생 로직을 추가하겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithTimoutPolicyFailedService {

    private static final boolean OCCUR_EXCEPTION = true;

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        TimeoutRetryPolicy timeoutRetryPolicy = new TimeoutRetryPolicy();
        timeoutRetryPolicy.setTimeout(5000L); // 5초 timeout 설정

        retryTemplate.setRetryPolicy(timeoutRetryPolicy); // RetryTemplate에 TimeoutRetryPolicy 세팅

        LocalDateTime startTime = LocalDateTime.now(); // 실행 시작 시간

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithTimoutPolicyFailedService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithTimoutPolicyFailedService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            long executionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS);
            log.info("[SendMessageRetryWithTimoutPolicyFailedService][run][SendMessage] 메세지 전송 총 실행 시간={}ms", executionDuration);
        }
    }
}

위와 같이 로직을 추가하고 다시 실행시킨 뒤에 5초를 기다리면,

image

위와 같은 로그가 나옵니다.

5초동안 계속 재시도를 했는데 계속 실패하니, 5초 뒤에 재시도를 중단한 모습입니다.


최대 시도 횟수 제한

최대 시도 시간 제한 말고도 최대 시도 횟수 제한을 최종 실패 정책으로 설정할 수 있습니다.

코드로 살펴보겠습니다.

먼저, 한 번에 성공하는 케이스를 보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithMaxAttemptsPolicySuccessService {

    private static int ATTEMPTS_COUNT = 0; // 비즈니스 로직 실행 횟수 측정용 변수 추가

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy();
        simpleRetryPolicy.setMaxAttempts(5); // 최대 시도 횟수 5회로 설정

        retryTemplate.setRetryPolicy(simpleRetryPolicy); // RetryTemplate에 SimpleRetryPolicy 세팅

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {

                    ATTEMPTS_COUNT += 1;

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithMaxAttemptsPolicySuccessService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithMaxAttemptsPolicySuccessService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithMaxAttemptsPolicySuccessService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위의 코드를 실행시켜보면,

image

1회만에 성공한 것을 확인할 수 있습니다.

그럼 이제 계속 실패하는 케이스를 살펴보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithMaxAttemptsPolicyFailedService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy();
        simpleRetryPolicy.setMaxAttempts(5); // 최대 시도 횟수 5회로 설정

        retryTemplate.setRetryPolicy(simpleRetryPolicy); // RetryTemplate에 SimpleRetryPolicy 세팅

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {
                    ATTEMPTS_COUNT += 1;

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위의 코드를 실행시켜보면,

image

위와 같이 총 5회를 시도했고, 5회 째의 시도 마저도 실패하니 재시도를 중단한 것을 확인할 수 있습니다.

만약 3회째에 성공하도록 하면, 어떻게 될까요?

@Slf4j
@Service
public class SendMessageRetryWithMaxAttemptsPolicyFailedService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy();
        simpleRetryPolicy.setMaxAttempts(5); // 최대 시도 횟수 5회로 설정

        retryTemplate.setRetryPolicy(simpleRetryPolicy); // RetryTemplate에 SimpleRetryPolicy 세팅

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {
                    ATTEMPTS_COUNT += 1;

                    if (ATTEMPTS_COUNT < 3 && OCCUR_EXCEPTION) { // 예외 발생 조건 수정
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithMaxAttemptsPolicyFailedService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위와 같이 1회, 2회는 예외를 발생시키고, 3회부터는 예외를 발생시키지 않도록 예외 발생 조건을 수정했습니다.

이제 다시 애플리케이션을 실행시켜보면,

image

위와 같이 3회째 시도에서 성공한 것을 확인할 수 있습니다!


예외 조건

기본적으로 Spring Retry 에서는 어떤 종류라도 예외가 발생하면 무조건 재시도를 합니다.

하지만, 특정 예외에서는 재시도를 하고싶지 않을 수도 있습니다.

코드로 살펴보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithFatalExceptionPolicyService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = RetryTemplate.builder()
                .maxAttempts(5)
                .retryOn(IllegalArgumentException.class) // IllegalArgumentException 발생시에는 재시도
                .build();

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {
                    ATTEMPTS_COUNT += 1;

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

RetryTemplate을 위와 같이 builder를 사용해서 생성할 수도 있습니다.

여기에서는

  • 최대 시도 횟수 : 5회
  • IllegalArgumentException가 발생하면 재시도

의 조건을 세팅했습니다.

위의 코드를 실행시켜보면,

image

재시도 조건인 IllegalArgumentException를 발생시켰기 때문에, 최대 시도 횟수인 5회까지 재시도 한것을 확인할 수 있습니다.

그러면 다른 예외인, IllegalStateException을 발생시키면 어떨까요?

@Slf4j
@Service
public class SendMessageRetryWithFatalExceptionPolicyService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = RetryTemplate.builder()
                .maxAttempts(5)
                .retryOn(IllegalArgumentException.class) // IllegalArgumentException 발생시에는 재시도
                .build();

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                @Override
                public String doWithRetry(RetryContext context) throws Throwable {
                    ATTEMPTS_COUNT += 1;

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalStateException(); // 발생 예외 수정
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            });

            log.info("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithFatalExceptionPolicyService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위처럼 발생 예외를 IllegalStateException로 수정하고 다시 실행시켜보면,

image

위처럼 예외는 발생했지만, 재시도 하지 않아 메세지 전송 총 시도 횟수가 1회인 것을 확인할 수 있습니다.


RecoveryCallback

그러면, 최종 실패 조건에 다다랐을 때는 어떻게 해야 할까요?

최종 실패 조건에 다다랐을 때 RecoveryCallback을 사용해 특정 비즈니스 로직을 실행시킬 수 있습니다.

@Slf4j
@Service
public class SendMessageRetryWithRecoveryCallbackService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy();
        simpleRetryPolicy.setMaxAttempts(5); // 최대 시도 횟수 5회로 설정

        retryTemplate.setRetryPolicy(simpleRetryPolicy); // RetryTemplate에 SimpleRetryPolicy 세팅

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                public String doWithRetry(RetryContext context) {
                    ATTEMPTS_COUNT += 1;

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            }, new RecoveryCallback<String>() {
                @Override
                public String recover(RetryContext context) throws Exception {
                    log.warn("[SendMessageRetryWithRecoveryCallbackService][run][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
                    return "메세지 전송 복구 로직이 실행되었습니다.";
                }
            });

            log.info("[SendMessageRetryWithRecoveryCallbackService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithRecoveryCallbackService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithRecoveryCallbackService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위처럼, RecoveryCallback 인스턴스를 통해 최종 실패 조건에 다다랐을 때, WARN 로그를 찍도록 수정했습니다.

그리고 위의 코드를 실행시켜보겠습니다.

스크린샷 2022-10-24 오전 12 34 17

그러면 위처럼 5회까지 재시도를 한 뒤에 다시 실패했을 때, 예외를 발생시키지 않고 RecoveryCallbackrecover 메서드를 실행하는것을 볼 수 있습니다.


RetryContext

Retry를 하면서 함수의 매개변수로 받게되는 RetryContext를 통해, Retry를 진행하면서 Context를 공유할 수 있습니다.

위의 예제들에서는 총 재시도 횟수를 카운트하기 위해 ATTEMPTS_COUNT를 사용했지만, 이번에는 RetryContext를 통해 재시도 횟수를 카운트 해 보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithRetryContextService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = new RetryTemplate();

        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy();
        simpleRetryPolicy.setMaxAttempts(5); // 최대 시도 횟수 5회로 설정

        retryTemplate.setRetryPolicy(simpleRetryPolicy); // RetryTemplate에 SimpleRetryPolicy 세팅

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                public String doWithRetry(RetryContext context) {

                    // RetryContext로 Context 공유
                    Object attemptsCountContext = context.getAttribute("attemptsCount");
                    if (attemptsCountContext == null) {
                        context.setAttribute("attemptsCount", 1);
                    } else {
                        int attemptsCount = (int) attemptsCountContext;
                        context.setAttribute("attemptsCount", attemptsCount + 1);
                    }

                    ATTEMPTS_COUNT += 1;

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            }, new RecoveryCallback<String>() {
                @Override
                public String recover(RetryContext context) throws Exception {
                    log.warn("[SendMessageRetryWithRecoveryCallbackWithRetryContextService][run][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);

                    return "메세지 전송 복구 로직이 실행되었습니다.";
                }
            });

            log.info("[SendMessageRetryWithRecoveryCallbackWithRetryContextService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithRecoveryCallbackWithRetryContextService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            log.info("[SendMessageRetryWithRecoveryCallbackWithRetryContextService][run][SendMessage] 메세지 전송 총 시도 횟수={}회", ATTEMPTS_COUNT);
        }
    }
}

위의 코드를 실행해보면,

image

ATTEMPTS_COUNT로 직접 카운트한 총 시도 횟수와 RetryContext로 공유한 총 시도 횟수가 동일한 것을 확인할 수 있습니다.

사실, RetryContextgetRetryCount메서드가 있어서,

@Override
public String recover(RetryContext context) throws Exception {
    log.warn("[SendMessageRetryWithMaxAttemptsPolicyFailedRecoveredService][run][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회",
            context.getRetryCount());

    return "메세지 전송 복구 로직이 실행되었습니다.";
}

위와 같이 간단하게 호출할 수 있습니다.

RetryContextMap을 활용해 Retry 동안의 컨텍스트를 공유할 수 있도록 합니다.


Backoff

지금까지의 재시도는 비즈니스 로직에서 예외가 발생하자 마자 즉시 재시도 되었습니다.

하지만, 재시도 전에 잠깐 기다리는 시간을 Backoff 정책을 통해 지정할 수 있습니다.

먼저, 계속 실패하는 비즈니스 로직을 최대 5회 시도했을 때의 총 시간을 측정해 보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithBackoffPolicyService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = RetryTemplate.builder()
                .maxAttempts(5) // 최대 시도 횟수 5회로 설정
                .build();

        LocalDateTime startTime = LocalDateTime.now(); // 실행 시작 시간

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                public String doWithRetry(RetryContext context) {

                    ATTEMPTS_COUNT += 1;

                    long currentExecutionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS); // 처음부터 지금까지 걸린 시간 측정
                    log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 처음부터 지금까지 걸린 시간={}ms", currentExecutionDuration);

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            }, new RecoveryCallback<String>() {
                @Override
                public String recover(RetryContext context) throws Exception {
                    log.warn("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회",
                            context.getRetryCount());

                    return "메세지 전송 복구 로직이 실행되었습니다.";
                }
            });

            log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            long executionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS);
            log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 메세지 전송 총 시도 횟수={}회, 총 실행 시간={}ms", ATTEMPTS_COUNT, executionDuration);
        }
    }
}

각 재시도마다 맨 처음 실행 시간부터 재시도 까지 걸린 시간을 ms 단위로 측정해 로그로 남기도록 코드를 추가했습니다.

이를 실행시켜보면,

스크린샷 2022-10-24 오전 12 39 07

각 재시도 사이의 간격에 텀 없이 바로바로 재시도 되는것을 확인할 수 있습니다.

그러면, Backoff 정책을 3초로 설정해 보겠습니다.

@Slf4j
@Service
public class SendMessageRetryWithBackoffPolicyService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;

    public void run() {
        RetryTemplate retryTemplate = RetryTemplate.builder()
                .maxAttempts(5) // 최대 시도 횟수 5회로 설정
                .fixedBackoff(3000) // 재시도 간격 3초로 설정
                .build();

        LocalDateTime startTime = LocalDateTime.now(); // 실행 시작 시간

        try {
            String result = retryTemplate.execute(new RetryCallback<String, Throwable>() {
                public String doWithRetry(RetryContext context) {

                    ATTEMPTS_COUNT += 1;

                    long currentExecutionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS); // 처음부터 지금까지 걸린 시간 측정
                    log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 처음부터 지금까지 걸린 시간={}ms", currentExecutionDuration);

                    if (OCCUR_EXCEPTION) {
                        throw new IllegalArgumentException();
                    }

                    return "메세지 전송에 성공했습니다!";
                }
            }, new RecoveryCallback<String>() {
                @Override
                public String recover(RetryContext context) throws Exception {
                    log.warn("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회",
                            context.getRetryCount());

                    return "메세지 전송 복구 로직이 실행되었습니다.";
                }
            });

            log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] result={}", result);
        } catch (Throwable e) {
            log.error("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] failed. {}", e.getMessage(), e);
        } finally {
            long executionDuration = startTime.until(LocalDateTime.now(), ChronoUnit.MILLIS);
            log.info("[SendMessageRetryWithBackoffPolicyService][run][SendMessage] 메세지 전송 총 시도 횟수={}회, 총 실행 시간={}ms", ATTEMPTS_COUNT, executionDuration);
        }
    }
}

위처럼 BackOff 정책을 3초로 지정하고 이를 실행시켜보면,

image

위처럼 3초마다 재시도를 하는것을 확인할 수 있습니다!!


Annotation 활용

지금까지 위에서 다루었던 모든 것들을 어노테이션으로 아주 간단하게 작성할 수 있습니다.

image

먼저 위와 같이 @EnableRetry라는 어노테이션을 추가합니다.

@Slf4j
@Service
public class SendMessageRetryWithAnnotationService {

    private static final boolean OCCUR_EXCEPTION = true;
    private static int ATTEMPTS_COUNT = 0;
    private static final LocalDateTime START_TIME = LocalDateTime.now();

    /**
     * 예외 발생시 재시도할 비즈니스로직 입니다.
     * - IllegalArgumentException이 발생했을 때만 재시도
     * - 최대 시도 횟수 : 5회
     * - 재시도 간격 : 3초
     */
    @Retryable(include = IllegalArgumentException.class, maxAttempts = 5, backoff = @Backoff(delay = 3000))
    public String sendMessage() {
        ATTEMPTS_COUNT += 1;

        log.info("[SendMessageRetryWithAnnotationService][sendMessage][SendMessage] 지금까지 시도 횟수={}회, 처음부터 지금까지 걸린 시간={}ms",
                ATTEMPTS_COUNT, START_TIME.until(LocalDateTime.now(), ChronoUnit.MILLIS));

        if (OCCUR_EXCEPTION) {
            throw new IllegalArgumentException();
        }

        return "메세지 전송에 성공했습니다!";
    }

    /**
     * 최종 실패 조건 도달시, 실행될 RecoveryCallback 함수 입니다.
     */
    @Recover
    public String sendMessageRecovery() {
        log.warn("[SendMessageRetryWithAnnotationService][sendMessageRecovery][SendMessage] 메세지 전송에 실패해, 메세지 전송 복구 로직이 실행되었습니다. 메세지 전송 총 시도 횟수={}회, 처음부터 지금까지 걸린 시간={}ms",
                ATTEMPTS_COUNT, START_TIME.until(LocalDateTime.now(), ChronoUnit.MILLIS));

        return "메세지 전송 복구 로직이 실행되었습니다.";
    }
}

위와같이 작성하고, 실행시켜보면

image

지금까지 했던 예제들과 동일한 결과가 출력됨을 확인할 수 있습니다.

이처럼 어노테이션을 활용화면 훨씬 간단하고 깔끔하게 코드를 작성할 수 있습니다.


마치며

지금까지 Spring에서 제공해는 재시도 인터페이스인 Retry 에 대해 살펴보았습니다.

더 많은 설정과 기능들이 있지만, 여기에서는 핵심만 살펴보았습니다.

더 다양한 내용은 아래 참고 에 있는 링크들을 참고하시면 좋을 것 같습니다.

다양한 조건으로 재시도를 해야 할 일이 종종 있는데요, 그럴 때 아주 유용하게 사용할 수 있을 것 같습니다.

감사합니다.


참고


태그:

업데이트:

댓글남기기