관련 문서 : @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를 넣으면 delay와 delay * 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 메서드의 시그니처 규칙은 다음과 같다.
- 첫 번째 파라미터 : 발생한 예외 타입.
@Retryable의retryFor와 일치해야 한다. - 나머지 파라미터 :
@Retryable메서드의 파라미터와 동일한 타입, 동일한 순서여야 한다. - 반환 타입 :
@Retryable메서드와 동일해야 한다 (void면 void).
파라미터 타입이나 순서가 다르면 Spring이 @Recover를 매칭하지 못하고, 마지막 예외가 그대로 던져진다. 컴파일 에러 없이 조용히 무시되므로 디버깅이 어렵다. 시그니처를 정확히 맞추자.
@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());
}
실행 흐름은 이렇다.
- 트랜잭션 커밋 후 → 이벤트 전달
- 별도 스레드에서 리스너 실행 (비동기)
- 실패하면 자동 재시도 (최대 3회)
- 전부 실패하면
@Recover에서 상태 업데이트 + 관리자 알림
@Async와 마찬가지로 @Retryable도 프록시 기반이다. 같은 클래스에서 this.saveBinaryContent()를 호출하면 재시도가 동작하지 않는다. 반드시 별도 빈의 메서드로 분리하거나, 리스너 메서드 자체에 @Retryable을 붙여야 한다.
retryFor와 noRetryFor 설계
모든 예외에 재시도하면 안 된다. 일시적 실패만 재시도해야 한다.
| 재시도해야 하는 예외 | 재시도하면 안 되는 예외 |
|---|---|
| 네트워크 타임아웃 | 잘못된 파라미터 (IllegalArgumentException) |
| S3 일시 장애 (5xx) | 파일 형식 오류 |
| DB 커넥션 풀 고갈 | 인증 실패 (403) |
| 외부 API 일시 응답 없음 | 비즈니스 로직 위반 |
잘못된 파라미터로 실패한 걸 3번 재시도해봐야 3번 다 실패한다. 시간만 낭비하고 불필요한 부하만 준다.
@Retryable(
retryFor = {SdkClientException.class},
noRetryFor = {IllegalArgumentException.class}
)
retryFor와 noRetryFor 둘 다 지정하면, noRetryFor가 우선이다. SdkClientException의 하위 클래스가 noRetryFor에도 걸리면 재시도하지 않는다.