이 문서는 단일 가이드 문서다.

관련 문서 : @Async와 비동기 처리 · @TransactionalEventListener

왜 재시도가 필요한가

외부 시스템(S3, 이메일 서버, 외부 API)은 언제든 일시적으로 실패할 수 있다. 네트워크가 잠깐 끊기거나, 서버가 순간적으로 과부하에 빠지거나. 이런 실패는 1~2초 후에 다시 시도하면 대부분 성공한다.

특히 비동기 처리에서 이 문제가 심각해진다. @Async와 비동기 처리에서 봤듯이, 비동기 메서드의 예외는 호출자에게 전파되지 않는다. 사용자는 요청이 성공한 줄 알고 있는데, 백그라운드에서 파일 업로드가 조용히 실패해버리면 데이터 불일치가 생긴다.

수동으로 try-catch와 반복문을 작성할 수도 있다.

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));
        }
    }
}

동작은 하지만 문제가 많다. 재시도 횟수, 대기 시간, 예외 종류별 처리를 메서드마다 반복해야 한다. 비즈니스 로직과 재시도 로직이 섞여서 코드가 읽기 어려워진다.

Spring Retry는 이 반복 패턴을 어노테이션 한 줄로 해결한다.

Spring Retry 설정

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

implementation 'org.springframework.retry:spring-retry'
@Configuration
@EnableRetry
public class RetryConfig {
}

@EnableRetry가 없으면 @Retryable을 붙여도 재시도가 동작하지 않는다. @EnableAsync와 같은 패턴이다.

@Retryable 기본 사용법

가장 간단한 형태는 이렇다.

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

기본 설정으로 최대 3회 시도(첫 시도 + 재시도 2회), 재시도 간격 1초다. 예외가 발생하면 1초 후 다시 호출하고, 또 실패하면 1초 후 한 번 더 시도한다. 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 도달)

Backoff 전략

재시도 간격을 어떻게 설정하느냐가 중요하다. 크게 세 가지 전략이 있다.

고정 간격 (Fixed)

@Backoff(delay = 2000)

매번 2초 대기. 간단하지만, 여러 서버가 동시에 같은 타이밍에 재시도해서 외부 서비스에 부하가 몰리는 thundering herd 문제가 생길 수 있다.

지수 백오프 (Exponential)

@Backoff(delay = 1000, multiplier = 2.0)

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

지수 백오프 + 랜덤 (Jitter)

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

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

어떤 전략을 쓸까?

지수 백오프 + maxDelay가 가장 무난하다. 초반에는 빠르게 재시도하고, 외부 서비스가 계속 안 되면 간격을 넓혀서 부하를 줄인다. maxDelay로 상한을 걸어서 대기가 너무 길어지는 것을 방지한다.

@Recover — 최종 실패 처리

maxAttempts만큼 시도해도 전부 실패하면 어떻게 될까? 기본적으로 마지막 예외가 그대로 던져진다. @Recover를 사용하면 최종 실패 시 대안 동작을 정의할 수 있다.

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

@Recover
public void recoverUpload(S3Exception e, UUID fileId, byte[] data) {
    log.error("파일 업로드 최종 실패 - fileId: {}, error: {}", fileId, e.getMessage());
    notificationService.createNotification(
        adminUser, "파일 업로드 실패", "fileId: " + fileId
    );
}

@Recover 메서드의 시그니처 규칙은 다음과 같다.

  • 첫 번째 파라미터 : 발생한 예외 타입. @RetryableretryFor와 일치해야 한다.
  • 나머지 파라미터 : @Retryable 메서드의 파라미터와 동일한 타입, 동일한 순서여야 한다.
  • 반환 타입 : @Retryable 메서드와 동일해야 한다 (void면 void).
@Recover 시그니처 불일치

파라미터 타입이나 순서가 다르면 Spring이 @Recover를 매칭하지 못하고, 마지막 예외가 그대로 던져진다. 컴파일 에러 없이 조용히 무시되므로 디버깅이 어렵다. 시그니처를 정확히 맞추자.

sequenceDiagram participant Caller as 호출자 participant Retry as Spring Retry participant Method as uploadFile() participant Recover as @Recover Caller->>Retry: uploadFile(fileId, data) Retry->>Method: 시도 1 Method-->>Retry: S3Exception ❌ Note over Retry: 1초 대기 Retry->>Method: 시도 2 Method-->>Retry: S3Exception ❌ Note over Retry: 2초 대기 Retry->>Method: 시도 3 Method-->>Retry: S3Exception ❌ Note over Retry: maxAttempts 도달 Retry->>Recover: recoverUpload(e, fileId, data) Note over Recover: 관리자 알림 생성 Recover-->>Caller: 정상 리턴 (예외 없음)

@Retryable과 @Async 함께 쓰기

@Retryable@Async와 자연스럽게 결합된다. 비동기 실행 + 자동 재시도 + 최종 실패 처리라는 견고한 파이프라인이 만들어진다.

@Async("eventTaskExecutor")
@TransactionalEventListener
public void onBinaryContentCreated(BinaryContentCreatedEvent event) {
    saveBinaryContent(event);
}

@Retryable(
    retryFor = S3Exception.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public void saveBinaryContent(BinaryContentCreatedEvent event) {
    binaryContentStorage.put(event.getFileId(), event.getBytes());
}

@Recover
public void recoverSave(S3Exception e, BinaryContentCreatedEvent event) {
    binaryContentService.updateStatus(event.getFileId(), Status.FAIL);
    notificationService.notifyAdmin("업로드 실패: " + event.getFileId());
}

실행 흐름은 이렇다.

  1. 트랜잭션 커밋 후 → 이벤트 전달
  2. 별도 스레드에서 리스너 실행 (비동기)
  3. 실패하면 자동 재시도 (최대 3회)
  4. 전부 실패하면 @Recover에서 상태 업데이트 + 관리자 알림
@Retryable은 같은 클래스 내부 호출 금지

@Async와 마찬가지로 @Retryable도 프록시 기반이다. 같은 클래스에서 this.saveBinaryContent()를 호출하면 재시도가 동작하지 않는다. 반드시 별도 빈의 메서드로 분리하거나, 리스너 메서드 자체에 @Retryable을 붙여야 한다.

retryFor와 noRetryFor 설계

모든 예외에 재시도하면 안 된다. 일시적 실패만 재시도해야 한다.

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

잘못된 파라미터로 실패한 걸 3번 재시도해봐야 3번 다 실패한다. 시간만 낭비하고 불필요한 부하만 준다.

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

retryFornoRetryFor 둘 다 지정하면, noRetryFor가 우선이다. SdkClientException의 하위 클래스가 noRetryFor에도 걸리면 재시도하지 않는다.