자동 재시도를 위한 스프링의 @Retryable 어노테이션 사용하기

자동 재시도를 위한 스프링의 @Retryable 어노테이션 사용하기

원문: Alexander Obregon, "Using Spring's @retryable Annotation for Automatic Retries"

개요

소프트웨어 세계는 네트워크 지연 시간에서부터 제삼자 서비스 다운타임에 이르기까지 다양한 변수로 인해 예측이 매우 어렵습니다. 따라서 견고한 애플리케이션을 개발하는 데 있어 내결함성과 복원력을 보장하는 것은 중요합니다. 스프링 프레임워크의 @Retryable 어노테이션은 일시적 문제로 인해 실패할 수 있는 메서드에 대해 자동 재시도를 우아하게 제공하는 방법입니다. 이 글은 스프링 기반 애플리케이션에서 실패를 우아하게 처리할 수 있게 해주는 @Retryable 어노테이션의 사용법을 탐구하는 것을 목표로 합니다.

스프링의 @Retryable 소개

오늘날 서로 연결된 세계에서 애플리케이션들은 종종 외부 서비스, 데이터베이스 및 기타 리소스와 상호 작용해야 합니다. 이 과정에서 일시적 오류, 네트워크 지연, 타임아웃 또는 제삼자 서비스의 다운타임과 같은 문제를 만나게 되며 특정 작업의 실행이 불확실해집니다. 애플리케이션에 이러한 실패 시나리오에 취약한 중요 코드 섹션이 있다면 적어도 일시적 문제에 대해 복원력이 있어 자가 복구가 가능하도록 하고 싶을 것입니다. 이때 스프링의 @Retryable 어노테이션이 사용되며 애플리케이션에 내결함성의 레이어를 추가합니다.

작업 재시도의 필요성

원격 API에서 데이터를 가져오는 서비스를 상상해 보세요. 이상적인 상황에서는 HTTP 요청을 하면 데이터가 반환됩니다. 하지만 실제 세계에서는 문제가 발생합니다. 원격 서버가 과부하 상태일 수 있고, 자체 서비스가 네트워크 지연을 경험하거나, 다른 여러 일시적 문제들이 발생할 것입니다. 애플리케이션이 이러한 시나리오를 제대로 처리하지 못한다면 작업은 실패하고, 사용자들은 화가 나고, 비즈니스는 비즈니스대로 손해를 볼 것입니다.

다시 시도하는 것은 분명한 해결책으로 보일 수 있지만 애플리케이션 전반에 걸쳐 수동으로 구현하면 비대해지고 유지 보수하기 힘든 코드가 될 수 있습니다. 다음은 기본적인 예시입니다.

public class ManualRetryService {

    public String fetchDataFromRemote() {
        int attempts = 0;
        while(attempts < 3) {
            try {
                // Make the API call
                return "Success";
            } catch (Exception e) {
                attempts++;
            }
        }
        throw new MyCustomException("Failed after 3 attempts");
    }
}

이 예시에서는 while 루프를 사용해 수동으로 재시도 로직을 구현하고 있으며 이는 코드에 복잡성을 추가합니다. 재시도 간격을 다양하게 하거나 잡아야 할 다른 종류의 예외 등을 추가하면 관리하기 점점 더 어려워질 것입니다.

@Retryable이 프로세스를 단순화하는 방법

스프링 프레임워크는 @Retryable 어노테이션으로 이를 단순화합니다. 이 어노테이션을 통해 스프링은 컴포넌트에 직접 재시도 로직을 추가하여 필요 없는 보일러플레이트 코드를 제거하는 선언적 방법을 제공합니다. 다음은 이전 예시에서 @Retryable을 사용한 모습입니다.

import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class MyService {

   @Retryable(MyCustomException.class)
   public String fetchDataFromRemote() {
       // Make the API call that might fail
       return "Success";
   }
}

이 코드를 사용하면 스프링은 MyCustomException이 발생하면 자동으로 fetchDataFromRemote 메서드를 재시도합니다. 메서드는 훨씬 깔끔해지며 고급 재시도 옵션으로 쉽게 확장할 수 있습니다.

내부에서 일어나는 일

메서드에 @Retryable를 어노테이션으로 달면 스프링은 어노테이션이 달린 메서드 주변에 프록시를 생성합니다. 이를 통해 프레임워크는 메서드에 대한 호출을 가로채고 투명하게 재시도 로직을 추가할 수 있습니다. @Transactional과 같이 프록시를 사용하는 스프링의 다른 기능과 유사합니다.

수동 재시도 대신 @Retryable을 선택하는 이유

  1. 코드 청결성: 비즈니스 로직이 내결함성 로직과 분리됩니다.

  2. 유지 보수성: 비즈니스 코드를 건드리지 않고 재시도 구성을 확장하거나 수정하기가 더 쉽습니다.

  3. 가독성: 어노테이션은 개발자의 의도를 명확하게 하여 메서드의 예상 동작을 이해하기 쉽게 합니다.

@Retryable 어노테이션을 사용함으로써 복잡한 코드베이스 없이도 메서드에 강력하고 유연한 재시도 로직을 추가할 수 있습니다. 이를 통해 비즈니스 로직에 집중할 수 있으며 프레임워크가 내결함성을 처리하여 애플리케이션을 복원력 있고 유지보수가 가능하게 만듭니다.

@Retryable 구성하기

@Retryable 어노테이션은 모든 상황에 맞는 해결책이 아니라 다양한 시나리오에 적응할 수 있는 매우 맞춤화된 기능입니다. 유연성은 재시도가 어떻게 관리되는지 세밀하게 조정할 수 있게 하는 풍부한 설정 매개변수 세트를 통해 달성됩니다. 단순하거나 복잡한 실패 시나리오를 다루고 있든 @Retryable 어노테이션은 우리의 요구사항을 잘 충족합니다.

예외 유형 지정하기

메서드는 여러 종류의 예외를 발생시킬 수 있지만 모든 예외에 대해 작업을 재시도하고 싶지 않을 수 있습니다. 예를 들어 NullPointerException으로 인해 실패한 작업을 재시도하는 것은 아마도 무의미할 것입니다. 왜냐하면 예외가 아마도 프로그래밍 오류에 의해 발생하기 때문입니다. 반면에 실패한 네트워크 작업을 재시도하는 것은 의미가 있습니다.

@Retryable을 사용하면 value 속성을 사용하여 재시도를 트리거해야 하는 예외 유형을 지정할 수 있습니다. 이것은 스프링에게 특정 예외가 발생했을 때만 메서드를 재시도하도록 지시하는 방법입니다.

@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String fetchRemoteData() {
   // Network call that might fail
   return "Data";
}

최대 시도 횟수 구성하기

기본적으로 @Retryable 어노테이션은 실패한 작업을 최대 세 번까지 시도한 후 포기합니다. 하지만 maxAttempts 속성을 설정하면 이 동작을 쉽게 사용자 정의할 수 있습니다.

@Retryable(value = MyNetworkException.class, maxAttempts = 5)
public String fetchRemoteData() {
   // Network call that might fail
   return "Data";
}

재시도 간의 지연

종종 재시도 시도 사이에 지연을 도입하는 것이 유용합니다. 이는 외부 서비스가 일시적으로 과부하 상태일 때 도움이 될 수 있습니다. 스프링은 backoff 속성을 통해 이를 구성할 수 있게 해 주며 @Backoff 어노테이션을 받습니다. 여기 재시도 시도 사이에 2초 지연을 지정하는 예시가 있습니다.

@Retryable(value = MyNetworkException.class, backoff = @Backoff(delay = 2000))
public String fetchRemoteData() {
   // Network call that might fail
   return "Data";
}

지수 백오프

일부 경우에는 각 재시도 시도 사이의 지연을 증가시키는 지수 백오프 전략을 사용하고 싶을 수 있습니다. 이는 회복하거나 스케일 업할 시간이 필요한 서비스를 다룰 때 도움이 될 수 있습니다. @Backoff 어노테이션은 이를 위한 multiplier 속성을 지원합니다.

@Retryable(value = MyNetworkException.class, backoff = @Backoff(delay = 1000, multiplier = 2))
public String fetchRemoteData() {
    // 실패할 수 있는 네트워크 호출
    return "Data";
}

여러 매개변수 결합하기

@Retryable의 진정한 힘은 이러한 속성을 결합하기 시작할 때 나타납니다. 다음은 세밀하게 조정된 재시도 정책을 위해 여러 속성을 설정하는 예시입니다.

@Retryable(value = { MyNetworkException.class, TimeoutException.class },
          maxAttempts = 5,
          backoff = @Backoff(delay = 1000, multiplier = 2))
public String fetchRemoteData() {
    // 실패할 수 있는 네트워크 호출
return "Data";
}

이 예시에서는 MyNetworkExceptionTimeoutException에 대해서만 최대 5회까지 재시도하며 처음에는 1000밀리 초의 지연으로 시작하여 이후 재시도마다 지연을 두 배로 늘립니다.

조건부 만들기

런타임 조건이나 발생한 예외에 기반하여 동적으로 재시도를 진행할지 여부를 제어하고 싶은 시나리오가 있을 수 있습니다. 이를 위해 SpEL 표현식을 받아들이는 condition 속성을 사용할 수 있습니다.

@Retryable(value = MyNetworkException.class, condition = "#{#root.args[0] != 'no-retry'}")
public String fetchRemoteData(String controlFlag) {
    // 실패할 수 있는 네트워크 호출
    return "Data";
}

이 예시에서는 controlFlag 인수가 'no-retry'인 경우 재시도가 진행되지 않습니다.

이러한 다양한 속성을 활용하면 비즈니스 로직을 내결함성 코드로 복잡하게 만들지 않고도 특정 프로젝트 요구사항에 맞는 고도로 정교한 재시도 메커니즘을 만들 수 있습니다.

매개변수 이해하기

@Retryable 어노테이션은 재시도 로직을 맞춤 설정할 수 있는 다양한 매개변수를 제공합니다. 이 매개변수들은 바로 사용할 수 있는 강력한 재시도 메커니즘을 제공하기 위해 조화롭게 작동합니다. 고정 간격으로 단순 재시도를 하든, 여러 조건을 기반으로 재시도를 하든, 이 매개변수들을 이해하는 것은 그것을 수월하게 구현하는 데 도움이 될 것입니다.

value

value 매개변수는 재시도를 트리거해야 하는 예외를 지정합니다. Throwable 클래스의 배열을 값으로 받습니다. 기본적으로 Throwable을 확장하는 모든 예외에 대해 재시도하도록 설정됩니다.

@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String execute() {
 // 코드
}

include

value와 유사하게 include 매개변수는 재시도를 트리거해야 하는 예외를 지정합니다. 차이점은 includevalue에 의해 이미 정의된 예외들에 추가로 지정할 수 있다는 것입니다.

@Retryable(value = MyNetworkException.class, include = TimeoutException.class)
public String execute() {
 // 코드
}

exclude

반대로 exclude 매개변수는 재시도를 트리거해서는 안 되는 예외를 정의합니다. 이는 넓게 예외를 잡되 특정한 것들을 제외하고 싶을 때 유용합니다.

@Retryable(value = Exception.class, exclude = IllegalArgumentException.class)
public String execute() {
 // 코드
}

maxAttempts

maxAttempts 매개변수는 어노테이션된 메서드에 대한 최대 시도 횟수를 지정합니다. 기본값은 3입니다.

@Retryable(maxAttempts = 5)
public String execute() {
 // 코드
}

backoff

backoff 매개변수는 재시도 시도 사이에 지연을 구현하는 데 사용합니다. 이는 @Backoff 어노테이션을 받아 밀리초 단위로 지연을 지정하고 지수 백오프에 대한 선택적 배수를 지정할 수 있습니다.

@Retryable(backoff = @Backoff(delay = 2000, multiplier = 2))
public String execute() {
 // 코드
}

condition

condition 매개변수는 boolean으로 평가되는 SpEL(스프링 표현 언어, Spring Expression Language) 표현식을 지정할 수 있습니다. 이 표현식이 true로 평가될 때만 재시도 로직을 활성화합니다.

@Retryable(condition = "#{#arg > 100}")
public String execute(int arg) {
 // 코드
}

stateful

stateful 매개변수는 재시도가 상태를 가지고 있는지 또는 상태가 없는지를 지정합니다. 상태를 가진 재시도에서는 첫 실패 시도의 상태가 기억되고 그에 기반하여 후속 재시도가 이루어집니다. 반면 상태가 없는 재시도는 서로 독립적입니다.

@Retryable(stateful = true)
public String execute() {
 // 코드
}

listeners

listeners 매개변수는 각 재시도 시도에 대해 알림을 받을 빈(bean)을 지정할 수 있게 합니다. 이 빈은 RetryListener 인터페이스를 구현해야 합니다. 이는 로깅, 메트릭스 또는 기타 부작용에 유용할 수 있습니다.

@Retryable(listeners = "myRetryListenerBean")
public String execute() {
 // 코드
}

모든 것을 함께 두기

이 매개변수들을 조합하여 복잡한 재시도 메커니즘을 생성할 때 진정한 마법이 일어납니다. 다음은 예시입니다.

@Retryable(value = { MyNetworkException.class, TimeoutException.class }, 
 maxAttempts = 5, 
 backoff = @Backoff(delay = 2000, multiplier = 2), 
 condition = "#{#arg != 'no-retry'}")
public String execute(String arg) {
 // 코드
}

이 예시에서는 MyNetworkException이나 TimeoutException이 발생한 경우에만 최대 5회까지 메서드를 재시도합니다. 재시도는 처음에는 2000밀리 초의 지연으로 시작하여 각 시도 후 지연 시간을 두 배로 하고 arg 인수가 'no-retry'가 아닐 경우에만 진행됩니다.

이 매개변수들과 그 상호작용을 깊이 이해함으로써 @Retryable을 가능한 가장 효과적인 방법으로 사용할 준비가 되어 있으며 애플리케이션이 가능한 한 복원력 있고 내결함성 있게 될 것임을 보장할 수 있습니다.

@Retryable @Recover 결합하기

@Retryable 어노테이션을 사용할 때 모든 재시도 시도가 실패했을 경우에 무엇이 일어나야 하는지 고려하는 것이 중요합니다. 재시도는 성공적인 작업의 가능성을 높일 수 있지만 그것을 보장할 수는 없습니다. 바로 그때 @Recover 어노테이션이 등장합니다.

@Recover의 역할

@Recover 어노테이션은 @Retryable에 의해 구성된 모든 재시도 시도가 소진되었을 때 호출될 대체 메서드를 정의할 수 있게 합니다. 대체 메서드는 오류 메시지를 보내거나, 백업 서비스에 연결을 시도하거나, 실패를 반영하여 애플리케이션 상태를 업데이트하는 등의 대체 로직을 실행하는 것을 의미합니다.

사용 방법을 설명하는 간단한 예시입니다.

import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class MyService {

 @Retryable(MyNetworkException.class)
 public String fetchDataFromRemote() {
 // 실패할 수 있는 네트워크 호출
 return "Data";
 }

 @Recover
 public String recover(MyNetworkException e) {
 // 대체 로직
 return "Default Data";
 }
}

이 예시에서 fetchDataFromRemote 메서드가 MyNetworkException을 던지고 모든 재시도 시도가 소진되면 recover 메서드가 호출되어 "Default Data"를 대체 데이터로 반환합니다.

예외 유형 매칭하기

@Recover 메서드의 매개변수 목록은 @Retryable 메서드의 것과 일치해야 하지만 복구하고자 하는 예외 유형에 대한 추가 첫 번째 매개변수가 있어야 합니다.

예를 들어 @Retryable 메서드가 다음과 같이 두 매개변수를 가지고 있다면

@Retryable(MyNetworkException.class)
public String fetchData(String param1, int param2) {
 // 네트워크 호출
}

그러면 @Recover 메소드 시그니처는 다음과 같을 수 있습니다.

@Recover
public String recover(MyNetworkException e, String param1, int param2) {
 // 대체 로직
}

다중 복구 경로

다른 유형의 예외에 대해 여러 @Recover 메서드를 정의할 수 있습니다. 이 방법으로 모든 재시도가 실패한 원인이 된 예외 유형에 따라 다른 복구 로직을 실행할 수 있습니다. 설정 방법은 다음과 같습니다.

@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String fetchDataFromRemote() {
 // 네트워크 호출
}

@Recover
public String recover(MyNetworkException e) {
 return "MyNetworkException에 대한 기본 데이터";
}

@Recover
public String recover(TimeoutException e) {
 return "TimeoutException에 대한 기본 데이터";
}

이 예제에서는 MyNetworkExceptionTimeoutException에 대한 두 개의 @Recover 메서드가 있습니다. 재시도의 원인으로 여겨지는 예외에 따라 적절한 @Recover 메서드가 호출됩니다.

조건부 만들기

@Retryable과 유사하게 @Recover 메서드에도 SpEL(Spring Expression Language) 표현식을 사용하는 condition 매개변수를 추가할 수 있습니다. 이를 통해 동적 조건에 기반하여 심지어 대체 행동조차도 세밀하게 조정할 수 있습니다.

@Recover
public String recover(MyNetworkException e, String param1) {
 if ("special_case".equals(param1)) {
 return "특별 복구 로직";
 }
 return "일반 복구 로직";
}

@Recover사용 시기

@Retryable은 일시적 실패로부터 회복하는 데 도움이 될 수 있지만, 모든 재시도 시도가 실패한 후 "계획 B"를 실행하거나 더 지속적인 문제를 다뤄야 할 때 @Recover가 중요해 집니다.

@Retryable@Recover를 결합함으로써 일시적 및 더 영구적인 문제를 모두 처리할 수 있는 강력하고 자가 복구 가능한 시스템을 구축할 수 있으며, 이를 통해 더 높은 수준의 장애 허용성을 보장하고 전반적인 사용자 경험을 향상할 수 있습니다.

사용 사례

원격 서비스 호출

애플리케이션이 일시적으로 사용할 수 없거나 간헐적인 문제를 겪고 있는 원격 서비스에 의존하는 경우 @Retryable을 사용하면 작업을 성공적으로 완료할 확률을 높일 수 있습니다.

@Retryable(MyNetworkException.class)
public String fetchFromRemoteService() {
 // 외부 API로 HTTP 요청
 return "Data";
}

분산 시스템

마이크로서비스나 분산 아키텍처에서는 네트워크 오류나 일시적인 서비스 불가능성이 흔합니다. @Retryable은 이러한 실패에 대해 시스템이 복원력을 유지하도록 보장할 수 있습니다.

@Retryable(TimeoutException.class)
public void sendMessageToQueue(String message) {
 // 메시지 큐에 메시지 보내기
}

데이터베이스

때때로 데이터베이스 작업은 데드락이나 일시적인 연결 문제로 인해 실패할 수 있습니다. 거래를 재시도하는 것은 이러한 문제를 종종 해결할 수 있습니다.

@Retryable(DatabaseException.class)
public void updateDatabaseRecord() {
 // 데이터베이스 레코드 업데이트
}

파일 작업

파일 작업은 권한 부족이나 디스크 공간 부족과 같은 다양한 이유로 실패할 수 있습니다. 특정 문제를 해결한 후 재시도하는 것이 효과적일 수 있습니다.

@Retryable(IOException.class)
public void writeFile() {
 // 파일에 쓰기
}

복잡한 조건부 재시도

런타임 조건에 기반한 복잡한 재시도 로직을 구현하기 위해 condition 매개변수를 사용할 수 있습니다. 이는 놀라울 정도로 유연하게 만듭니다.

@Retryable(IOException.class)
public void writeFile() {
 // 파일에 쓰기
}

제한사항

성능 오버헤드

각 재시도는 CPU 사이클, 메모리 또는 네트워크 대역폭과 같은 리소스를 소모합니다. 과도한 재시도는 성능 병목현상을 초래할 수 있습니다.

모든 오류에 적합하지 않음

모든 유형의 오류가 재시도 가능한 것은 아닙니다. 예를 들어 "파일을 찾을 수 없음" 예외로 인한 작업 실패를 재시도하는 것은 반복적인 실패를 초래할 가능성이 높습니다.

연쇄 실패

마이크로서비스 아키텍처에서 너무 많은 재시도는 한 실패하는 서비스가 다른 서비스들까지 실패하게 만드는 연쇄 실패를 초래할 수 있습니다.

상태 유지 시스템의 복잡성

상태를 유지하는 시스템에서 상태를 변경하는 실패한 작업은 재시도를 복잡하게 만들 수 있습니다.

오류 처리

@Recover 메서드를 사용하면 오류 처리 로직이 분산되어 대규모 코드베이스에서 관리하기 어려울 수 있습니다.

결론

스프링의 @Retryable@Recover 어노테이션은 애플리케이션에 재시도 로직과 내결함성을 추가하는 우아하고 선언적인 접근 방식을 제공합니다. 다양한 맞춤 설정 옵션이 제공되지만 그 사용 사례와 한계를 염두에 두고 신중하게 사용하는 것이 중요합니다. 그 기능의 깊이를 이해하고 현명하게 적용함으로써 복원이 쉽고 견고한 애플리케이션을 만들 수 있습니다.

  1. 스프링 공식 @Retryable 문서

  2. 스프링 표현 언어(SpEL) 참고자료

  3. 자바 예외 처리 모범 사례