이 문서는 Spring Retry 시리즈 중 1편이다.

1편: @Retryable과 백오프 전략 · 2편

관련 문서 : 이벤트와 트랜잭션 설계

왜 재시도가 필요한가

외부 시스템은 언제든 일시적으로 실패할 수 있다. S3에 파일을 올리는 중 네트워크가 끊기거나, 외부 API 서버가 순간적으로 과부하에 빠지거나. 이런 실패는 몇 초 후 다시 시도하면 대부분 성공한다. 문제는 이걸 코드로 직접 처리하려 할 때 생긴다.

public void uploadFile(UUID fileId, byte[] data) {
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            s3Client.putObject(fileId, data);
            return;
        } catch (Exception e) {
            if (i == maxRetries - 1) throw e;
            Thread.sleep(1000 * (i + 1));
        }
    }
}

동작은 하지만 문제가 많다.

  • 재시도 로직이 비즈니스 로직을 오염시킨다. 파일 업로드라는 핵심 동작이 for문, try-catch, sleep 사이에 파묻힌다.
  • 설정이 하드코딩된다. 재시도 횟수, 대기 시간을 바꾸려면 코드를 수정해야 한다.
  • 메서드마다 반복해야 한다. 이메일 전송, 외부 API 호출 등 재시도가 필요한 곳마다 동일한 패턴을 복붙하게 된다.

수동 재시도의 구조적 문제를 다이어그램으로 보면 더 명확하다.

flowchart TD subgraph Manual ["수동 재시도"] direction TB A["uploadFile()"] --> B["for 루프"] B --> C["try-catch"] C --> D["Thread.sleep()"] D --> E["예외 분기 처리"] end Manual ~~~ Auto subgraph Auto ["Spring Retry"] direction TB F["uploadFile()"] --> G["s3Client.putObject()"] end style A fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style B fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style C fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style D fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style E fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style F fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style G fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Manual fill:#fff0f0,stroke:#F44336 style Auto fill:#f0f8f0,stroke:#4CAF50

왼쪽의 분홍 영역이 수동 재시도다. 핵심 로직 한 줄을 위해 4개의 보일러플레이트가 따라붙는다. 오른쪽 초록 영역이 Spring Retry를 적용한 모습이다. 재시도 관심사가 완전히 분리되어 메서드에는 비즈니스 로직만 남는다.

Spring Retry는 이 반복 패턴을 어노테이션 한 줄로 해결한다. 재시도 횟수, 대기 전략, 예외 필터링을 선언적으로 설정하고, 비즈니스 코드에서 완전히 분리할 수 있다.

Spring Retry 설정

두 가지가 필요하다. 의존성 추가와 활성화 어노테이션이다.

implementation 'org.springframework.retry:spring-retry'

의존성을 추가했으면 설정 클래스에 활성화 어노테이션을 붙인다.

@Configuration
@EnableRetry
public class RetryConfig {
}

@EnableRetry가 없으면 @Retryable을 붙여도 재시도가 동작하지 않는다. @EnableAsync와 동일한 패턴이다. Spring Retry도 AOP 프록시 기반으로 동작하기 때문에, 프록시 생성을 활성화하는 스위치가 반드시 있어야 한다.

@Retryable 기본 사용법

가장 간단한 형태부터 보자.

@Retryable
public void uploadFile(UUID fileId, byte[] data) {
    s3Client.putObject(fileId, data);
}

이것만으로 자동 재시도가 동작한다. 기본 설정은 다음과 같다.

  • 최대 시도 횟수 : 3회 (첫 시도 + 재시도 2회)
  • 재시도 간격 : 1초 (고정)
  • 대상 예외 : 모든 예외

예외가 발생하면 1초 후 다시 호출하고, 또 실패하면 1초 후 한 번 더 시도한다. 3번째도 실패하면 마지막 예외를 그대로 던진다.

기본 재시도가 어떻게 진행되는지 흐름을 보자.

sequenceDiagram autonumber participant Caller as 호출자 participant Proxy as Spring Retry 프록시 participant Method as uploadFile() Caller->>Proxy: uploadFile(fileId, data) rect rgb(255, 230, 230) Note over Proxy, Method: 시도 1 Proxy->>Method: 실행 Method-->>Proxy: S3Exception end Note over Proxy: 1초 대기 rect rgb(255, 243, 224) Note over Proxy, Method: 시도 2 Proxy->>Method: 실행 Method-->>Proxy: S3Exception end Note over Proxy: 1초 대기 rect rgb(232, 248, 232) Note over Proxy, Method: 시도 3 Proxy->>Method: 실행 Method-->>Proxy: 성공 end Proxy-->>Caller: 정상 리턴

3번에서 초록 영역으로 전환된 것을 보자. 시도 1, 2가 실패해도 시도 3에서 성공하면 호출자에게는 정상 리턴이 반환된다. 호출자는 재시도가 일어났는지조차 모른다.

세부 옵션 설정

실무에서는 기본값으로 부족할 때가 많다. @Retryable의 옵션으로 세밀하게 제어할 수 있다.

@Retryable(
    retryFor = {S3Exception.class, IOException.class},
    noRetryFor = {IllegalArgumentException.class},
    maxAttempts = 4,
    backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000)
)
public void uploadFile(UUID fileId, byte[] data) {
    s3Client.putObject(fileId, data);
}

각 옵션의 의미는 다음과 같다.

옵션의미위 설정
retryFor이 예외가 발생할 때만 재시도S3Exception, IOException
noRetryFor이 예외는 재시도하지 않고 즉시 실패IllegalArgumentException
maxAttempts최대 시도 횟수 (첫 시도 포함)4회
backoff.delay첫 재시도 대기 시간 (ms)1초
backoff.multiplier재시도마다 대기 시간에 곱하는 배율2배
backoff.maxDelay대기 시간의 상한10초

위 설정에서 재시도 타이밍은 이렇게 진행된다.

시도 1 → 실패 → 1초 대기
시도 2 → 실패 → 2초 대기  (1000 × 2.0)
시도 3 → 실패 → 4초 대기  (2000 × 2.0)
시도 4 → 실패 → 예외 발생 (maxAttempts 도달)

maxAttempts첫 시도를 포함한 전체 횟수다. maxAttempts = 4면 재시도는 3번이다. 이 차이를 혼동하면 예상보다 한 번 적게 또는 많이 시도하게 된다.

Backoff 전략

재시도 간격을 어떻게 설정하느냐에 따라 시스템의 안정성이 달라진다. 세 가지 전략을 비교해보자.

고정 간격

@Backoff(delay = 2000)

매번 동일하게 2초를 대기한다. 구현이 단순하고 동작을 예측하기 쉽다. 하지만 여러 서버가 동시에 실패하면, 모두 같은 타이밍에 재시도 요청을 보내게 된다. 이것이 thundering herd 문제다. 외부 서비스가 과부하로 실패한 상황에서, 동시 재시도가 과부하를 더 악화시키는 악순환이 생긴다.

지수 백오프

@Backoff(delay = 1000, multiplier = 2.0)

1초 → 2초 → 4초 → 8초로 간격이 점점 늘어난다. 외부 서비스가 과부하일 때 회복할 시간을 벌어주는 효과가 있다. 가장 널리 쓰이는 전략이다.

다만 maxDelay를 걸지 않으면 간격이 기하급수적으로 커질 수 있다. 10번째 재시도 간격이 512초(8분 이상)가 되면 사실상 의미 없는 대기다. 반드시 maxDelay로 상한을 설정한다.

지수 백오프 + 랜덤 (Jitter)

@Backoff(delay = 1000, multiplier = 2.0, random = true)

지수 백오프에 랜덤 요소를 추가한다. random = true를 넣으면 delaydelay * multiplier 사이에서 랜덤한 값을 선택한다. 여러 서버의 재시도 타이밍이 분산되어 thundering herd를 방지한다.

세 전략이 실제로 어떤 차이를 만드는지 보자. 서버 3대가 동시에 실패했을 때의 재시도 타이밍이다.

sequenceDiagram autonumber participant SA as 서버 A participant SB as 서버 B participant SC as 서버 C participant API as 외부 API Note over SA, API: 고정 간격 — 모두 같은 타이밍 rect rgb(255, 230, 230) SA->>API: 재시도 (2초) SB->>API: 재시도 (2초) SC->>API: 재시도 (2초) Note right of API: 동시 3건 → 과부하 end Note over SA, API: 지수 백오프 + Jitter — 타이밍 분산 rect rgb(232, 248, 232) SA->>API: 재시도 (1.3초) Note right of API: 1건 처리 SB->>API: 재시도 (1.8초) Note right of API: 1건 처리 SC->>API: 재시도 (2.4초) Note right of API: 1건 처리 end

분홍 영역에서는 세 서버가 정확히 같은 시점에 요청을 보내서 외부 API에 부하가 몰린다. 초록 영역에서는 Jitter 덕분에 요청이 분산되어 외부 API가 순차적으로 처리할 수 있다.

어떤 전략을 쓸까?

지수 백오프 + maxDelay가 가장 무난하다. 초반에는 빠르게 재시도하고, 외부 서비스가 계속 안 되면 간격을 넓혀서 부하를 줄인다. 여러 인스턴스가 동시에 재시도할 가능성이 높다면 random = true를 추가한다.

retryFor와 noRetryFor 설계

모든 예외에 재시도하면 안 된다. 일시적 실패만 재시도해야 한다. 잘못된 파라미터로 실패한 걸 3번 재시도해봐야 3번 다 실패한다. 시간만 낭비하고 불필요한 부하만 준다.

핵심 기준은 "다시 시도하면 성공할 가능성이 있는가"다.

재시도해야 하는 예외 (일시적)재시도하면 안 되는 예외 (영구적)
네트워크 타임아웃잘못된 파라미터 (IllegalArgumentException)
S3 일시 장애 (5xx)파일 형식 오류
DB 커넥션 풀 고갈인증 실패 (403)
외부 API 일시 응답 없음비즈니스 로직 위반

이 구분을 다이어그램으로 정리하면 설계 의도가 더 분명해진다.

flowchart TD EX(["예외 발생"]) --> Q{"다시 시도하면
성공 가능?"} Q -->|"가능"| TEMP["일시적 실패"] Q -->|"불가능"| PERM["영구적 실패"] TEMP --> RETRY["retryFor에 등록"] PERM --> NORETRY["noRetryFor에 등록"] style EX fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Q fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style TEMP fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style PERM fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style RETRY fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style NORETRY fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000

주황 영역의 판단 기준이 전부다. "다시 시도하면 성공할 수 있는가?" 이 질문에 Yes면 retryFor, No면 noRetryFor에 넣는다.

설정 코드로 표현하면 다음과 같다.

@Retryable(
    retryFor = {SdkClientException.class},
    noRetryFor = {IllegalArgumentException.class}
)

retryFornoRetryFor 둘 다 지정하면, noRetryFor가 우선이다. SdkClientException의 하위 클래스가 noRetryFor에도 걸리면 재시도하지 않는다. 이 우선순위를 모르면 "분명 retryFor에 넣었는데 왜 재시도가 안 되지?"라는 상황을 겪게 된다.

자주 하는 실수

@EnableRetry 누락

@Retryable을 붙였는데 재시도가 안 되는 가장 흔한 원인이다. @EnableRetry 설정 클래스가 없으면 프록시가 생성되지 않아 어노테이션이 완전히 무시된다. 컴파일 에러도, 런타임 경고도 없이 조용히 무시되기 때문에 디버깅이 어렵다. 재시도가 동작하지 않을 때 가장 먼저 확인할 항목이다.

[!DANGER] 같은 클래스 내부에서 호출

@Retryable은 프록시 기반으로 동작한다. 같은 클래스 내부에서 this.uploadFile()을 호출하면 프록시를 거치지 않기 때문에 재시도가 동작하지 않는다. @Async, @Transactional동일한 제약이다. 반드시 외부 빈에서 호출해야 한다.

[!DANGER] maxAttempts와 재시도 횟수 혼동

maxAttempts = 3은 "3번 재시도"가 아니라 총 3번 시도다. 첫 시도 1회 + 재시도 2회다. "3번 재시도하고 싶다"면 maxAttempts = 4로 설정해야 한다. 이 차이를 혼동하면 예상보다 한 번 적게 시도하게 된다.

[!DANGER] 영구적 실패에 재시도

retryFor를 지정하지 않으면 모든 예외에 대해 재시도한다. IllegalArgumentException처럼 다시 시도해도 절대 성공하지 않는 예외까지 재시도하면 의미 없는 대기만 발생하고, 외부 서비스에 불필요한 부하를 준다. 반드시 retryFor재시도할 예외를 명시하거나, noRetryFor로 제외할 예외를 지정한다.