1편에서 @Retryable의 기본 사용법과 백오프 전략을 다뤘다. 하지만 "어디에 붙일 것인가"라는 질문에는 답하지 않았다. 이번 편에서는 재시도 책임의 위치를 설계하고, @Recover, @Async, @TransactionalEventListener와 함께 실전 패턴을 조립하는 방법을 다룬다.
@Recover의 동작 원리
@Retryable 메서드가 maxAttempts만큼 시도한 뒤에도 실패하면, 마지막 예외가 그대로 던져진다. @Recover는 이 최종 실패 시점에 개입하여 대안 동작을 실행하는 어노테이션이다.
@Retryable(retryFor = S3Exception.class, maxAttempts = 3)
public void uploadFile(String key, byte[] data) {
s3Client.putObject(key, data);
}
@Recover
public void recover(S3Exception e, String key, byte[] data) {
log.error("S3 업로드 최종 실패: key={}", key, e);
}
@Recover가 호출되면 예외가 더 이상 전파되지 않는다. 호출자 입장에서는 정상 리턴으로 보인다. 반대로, @Recover가 없으면 마지막 시도의 예외가 호출자에게 그대로 전달된다.
재시도부터 복구까지의 전체 흐름을 보면 각 단계의 역할이 명확해진다.
주황색 영역이 재시도 구간이다. 3번 모두 실패하면 초록색 영역의 @Recover로 넘어간다. @Recover가 정상적으로 끝나면, 호출자는 예외를 모른 채 정상 리턴을 받는다.
시그니처 매칭 규칙
@Recover 메서드의 시그니처는 세 가지 조건을 모두 만족해야 한다.
- 첫 번째 파라미터 : 발생한 예외 타입.
@Retryable의retryFor와 일치하거나 그 상위 타입이어야 한다 - 나머지 파라미터 :
@Retryable메서드의 파라미터와 동일한 타입, 동일한 순서 - 반환 타입 :
@Retryable메서드와 동일 (void면 void, String이면 String)
하나라도 맞지 않으면 Spring은 @Recover를 매칭하지 못한다. 이때 컴파일 에러도, 런타임 경고도 없다. 그냥 조용히 무시되고 예외가 그대로 던져진다. 디버깅이 매우 어려워지므로 시그니처를 반드시 확인해야 한다.
maxAttempts 초과"] --> B{"@Recover
매칭 성공?"} B -->|"시그니처 일치"| C["@Recover 실행"] C --> D["정상 리턴"] B -->|"불일치 또는 없음"| E["예외 그대로 전파"] style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style B fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style E fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000
@Recover 매칭 실패는 초록 경로가 아닌 빨간 경로를 탄다. 빨간 경로로 빠지면 재시도가 의미 없어진다. 재시도를 3번 했지만 결국 예외가 그대로 올라가기 때문이다.
재시도 책임은 누구의 것인가
@Retryable을 어디에 붙여야 할까? S3에 파일을 올리는 S3Service에 붙여야 할까, 아니면 이벤트를 수신하는 S3EventListener에 붙여야 할까?
S3Service에 붙이면 생기는 문제
직관적으로는 S3Service에 붙이는 게 자연스러워 보인다. "S3 업로드는 실패할 수 있으니까 S3Service가 재시도하면 되지 않나?" 라는 생각이다.
// S3Service에 @Retryable을 붙인 경우
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public void put(String fileName, String contentType, byte[] bytes) {
s3Client.putObject(fileName, contentType, bytes);
}
문제는 모든 호출자가 같은 재시도 정책에 묶인다는 것이다. S3Service를 호출하는 곳이 하나가 아니기 때문이다.
3회 재시도 필요"] B["배치 업로드
재시도 불필요"] C["동기 API
즉시 실패 필요"] end S["S3Service
@Retryable(maxAttempts=3)"] A --> S B --> S C --> S style A fill:#E8F8E8,stroke:#4CAF50,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 S fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Callers fill:#f8f8f8,stroke:#999
이벤트 리스너는 비동기라서 3회 재시도해도 괜찮다. 하지만 배치 업로드는 1000개 파일을 올리는데 실패한 파일마다 재시도하면 시간이 3배로 늘어난다. 동기 API는 사용자가 응답을 기다리고 있으니 재시도 대기 시간이 곧 응답 지연이 된다.
S3Service의 책임은 "파일을 업로드하고, 실패하면 예외를 던지는 것"이다. 재시도 여부는 S3Service가 결정할 문제가 아니다. 그건 호출하는 쪽의 맥락에 따라 달라지는 정책이다.
리스너에 붙이는 것이 맞다
재시도 정책은 호출 맥락을 아는 쪽이 결정해야 한다. "이 실패가 치명적인가?", "재시도할 시간적 여유가 있는가?", "최종 실패 시 어떻게 할 것인가?" — 이 판단은 호출자만 할 수 있다.
@Component
@RequiredArgsConstructor
public class S3EventListener {
private final S3Service s3Service;
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleS3PutEvent(S3PutEvent event) {
s3Service.put(event.fileName(), event.contentType(), event.bytes());
}
@Recover
public void recover(Exception e, S3PutEvent event) {
log.error("S3 업로드 최종 실패: key={}", event.fileName(), e);
}
}
이렇게 하면 S3Service는 순수한 인프라 어댑터로 남고, 재시도 정책은 각 호출자가 자신의 맥락에 맞게 독립적으로 결정한다.
@Retryable(3회)"] B["배치 업로드
재시도 없음"] C["동기 API
재시도 없음"] end S["S3Service
순수 인프라 어댑터"] A --> S B --> S C --> S style A fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style B fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style S fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style Callers fill:#f8f8f8,stroke:#999
이전 다이어그램에서는 빨간색이 2개였지만, 지금은 모두 초록색이다. 각 호출자가 자기 맥락에 맞는 정책을 쓰기 때문이다.
Service는 "무엇을 하는가", Listener는 "실패하면 어떻게 할 것인가" 를 결정한다. 재시도는 "어떻게 할 것인가"의 영역이므로 호출자(Listener)의 책임이다.
@Retryable과 @Async와 @TransactionalEventListener 조합
실전에서는 세 어노테이션이 한 세트로 움직인다. 이벤트와 트랜잭션 설계 1편과 이벤트와 트랜잭션 설계 2편에서 다뤘듯이, 부수효과는 트랜잭션 커밋 후 비동기로 처리하는 것이 안전하다. 여기에 재시도까지 더하면 견고한 파이프라인이 완성된다.
@Async("s3UploadTaskExecutor")
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleS3PutEvent(S3PutEvent event) {
s3Service.put(event.fileName(), event.contentType(), event.bytes());
}
@Recover
public void recover(Exception e, S3PutEvent event) {
log.error("S3 업로드 최종 실패: key={}", event.fileName(), e);
}
각 어노테이션이 하나씩 레이어를 담당한다. 이 조합이 실행되는 순서를 살펴보자.
파란 영역에서 트랜잭션이 커밋된 후 이벤트가 전달되고, 초록 영역에서 별도 스레드로 넘어간다. 주황 영역에서 재시도가 진행된다. 이 순서 덕분에 트랜잭션 롤백과 재시도가 서로 간섭하지 않는다.
이 조합이 동작하는 이유
세 어노테이션이 모두 프록시 기반이라는 점이 핵심이다. S3EventListener가 @Component로 등록되어 있으므로 Spring이 프록시를 생성한다. 외부에서 handleS3PutEvent()를 호출하면 프록시를 통과하고, 프록시가 @Async → @Retryable → @TransactionalEventListener 순서로 처리한다.
@TransactionalEventListener에서 설명했듯이, AFTER_COMMIT은 트랜잭션 커밋 이후에만 리스너를 실행한다. @Async가 별도 스레드에서 실행하므로 원래 트랜잭션 스레드를 블로킹하지 않는다. @Retryable이 실패 시 자동으로 재시도한다.
내부 호출 함정
프록시 기반이기 때문에 같은 클래스 내부에서 호출하면 동작하지 않는다. 이건 @Async, @Transactional과 동일한 제약이다.
// 동작하지 않는 코드
@Component
public class S3EventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleS3PutEvent(S3PutEvent event) {
this.retryableUpload(event); // 프록시를 우회 — @Retryable 무시
}
@Retryable(maxAttempts = 3)
public void retryableUpload(S3PutEvent event) {
s3Service.put(event.fileName(), event.contentType(), event.bytes());
}
}
this.retryableUpload()는 프록시가 아닌 실제 객체의 메서드를 호출한다. Spring Retry 프록시를 건너뛰므로 재시도가 전혀 일어나지 않는다.
위쪽 초록 경로처럼 Spring이 프록시를 통해 호출해야 @Retryable이 동작한다. 아래쪽 빨간 경로는 this로 직접 호출하므로 프록시를 건너뛴다.
해결 방법은 두 가지다.
- 리스너 메서드 자체에
@Retryable붙이기 : 내부 호출이 없으므로 프록시가 정상 작동한다. 앞서 본 코드가 이 방식이다 - 재시도 로직을 별도 빈으로 분리하기 :
RetryableS3Uploader같은 빈을 만들고 리스너가 주입받아 호출한다
첫 번째 방법이 간결하고 직관적이다. 별도 빈을 만들면 클래스가 하나 더 생기고, 재시도 정책이 리스너에서 분리되어 흐름을 파악하기 어려워진다.
프로젝트 적용 예시
실제 WeatherFit 프로젝트의 S3EventListener에 이 패턴을 적용한 전체 코드다.
@Component
@RequiredArgsConstructor
@Slf4j
public class S3EventListener {
private final S3Service s3Service;
@Async("s3UploadTaskExecutor")
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleS3PutEvent(S3PutEvent event) {
s3Service.put(event.fileName(), event.contentType(), event.bytes());
}
@Recover
public void recover(Exception e, S3PutEvent event) {
log.error("S3 업로드 최종 실패: key={}", event.fileName(), e);
}
}
각 어노테이션이 왜 이 자리에 있는지 정리하면 다음과 같다.
@Component: Spring 빈으로 등록되어 프록시가 생성된다.@Async,@Retryable모두 프록시 기반이므로 필수다@Async("s3UploadTaskExecutor"): S3 업로드 전용 스레드 풀에서 실행한다. 메인 스레드를 블로킹하지 않는다@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)): 실패 시 1초 → 2초 간격으로 최대 3회 시도한다@TransactionalEventListener(phase = AFTER_COMMIT): 트랜잭션이 커밋된 후에만 실행한다. 롤백되면 S3 업로드 자체가 일어나지 않는다@Recover: 3회 모두 실패하면 로그를 남긴다. 예외가 더 이상 전파되지 않으므로 비동기 스레드가 조용히 종료된다
이 구조의 실행 타임라인을 시간 축으로 정리하면 전체 그림이 한눈에 들어온다.
파란 영역에서 사용자는 즉시 응답을 받는다. S3 업로드는 초록 영역에서 완전히 별개로 진행된다. 재시도가 3번 다 실패해도 사용자 응답에는 영향이 없다.
면접 Q&A
안 되는 건 아니다. 하지만 재시도 정책은 호출 맥락에 따라 달라져야 한다. 배치에서는 재시도가 불필요하고, 동기 API에서는 재시도 대기가 응답 지연이 된다. Service에 고정하면 모든 호출자가 같은 정책에 묶이는 문제가 생긴다. 재시도는 "무엇을 하느냐"가 아니라 "실패했을 때 어떻게 할 것이냐"의 문제이므로, 호출자가 결정하는 것이 맞다.
[!QUESTION] @Retryable과 @Transactional을 함께 쓰면 어떻게 되나요?
주의가 필요하다. @Transactional 메서드 안에서 예외가 터지면 트랜잭션이 롤백 마킹된다. 이 상태에서 재시도해봐야 같은 트랜잭션이 이미 롤백 마킹되어 있으므로 의미가 없다. 재시도할 때마다 새로운 트랜잭션이 필요하다면 @Retryable을 @Transactional 바깥에 두거나, Propagation.REQUIRES_NEW를 사용해야 한다.
[!QUESTION] @Recover가 매칭 안 되면 어떤 일이 일어나나요?
컴파일 에러도, 런타임 경고도 없다. 조용히 무시되고 마지막 시도의 예외가 그대로 호출자에게 던져진다. 시그니처 불일치(파라미터 타입, 순서, 반환 타입)가 가장 흔한 원인이다. @Recover를 작성한 뒤에는 반드시 일부러 실패시켜서 실제로 호출되는지 테스트해야 한다.
[!QUESTION] 비동기 리스너에서 @Retryable이 동작하는 이유는 뭔가요?
리스너가 @Component로 등록된 별도 빈이기 때문이다. Spring은 빈마다 프록시를 생성하고, 외부에서 메서드를 호출할 때 프록시를 경유한다. 이벤트 발행 → 리스너 호출은 Spring이 프록시를 통해 하므로 @Retryable이 정상 동작한다. 같은 클래스 내부에서 this.method()로 호출하면 프록시를 건너뛰어 동작하지 않는다.
[!QUESTION] @Async + @Retryable에서 재시도 중 스레드는 점유되나요?
그렇다. @Async로 할당된 스레드 하나가 재시도가 끝날 때까지 점유된다. @Backoff(delay = 1000, multiplier = 2)에서 3회 시도하면 최대 1초 + 2초 = 3초 동안 스레드를 잡고 있는 셈이다. 스레드 풀 크기와 재시도 정책을 함께 고려해야 한다.
자주 하는 실수
@Recover 메서드의 파라미터 타입이나 순서가 @Retryable 메서드와 다르면, Spring이 매칭에 실패하고 조용히 무시한다. 컴파일 에러가 나지 않으므로 운영 환경에서 발견되는 경우가 많다. @Recover를 추가한 뒤에는 의도적으로 실패시키는 테스트를 반드시 작성해야 한다.
[!DANGER] @Retryable을 내부 호출로 사용
같은 클래스에서 this.retryableMethod()를 호출하면 프록시를 우회하여 재시도가 전혀 일어나지 않는다. 리스너 메서드 자체에 @Retryable을 붙이거나, 재시도 대상 메서드를 별도 빈으로 분리해야 한다.
[!DANGER] 모든 예외에 재시도
retryFor를 지정하지 않으면 모든 예외에 재시도한다. IllegalArgumentException처럼 재시도해도 결과가 바뀌지 않는 예외까지 3번씩 시도하면 시간 낭비이고 외부 서비스에 불필요한 부하를 준다. 일시적 실패(네트워크, 타임아웃)만 재시도 대상으로 지정해야 한다.
[!DANGER] @Retryable과 @Transactional의 순서 미고려
@Retryable 메서드 안에서 @Transactional이 동작하면, 재시도할 때마다 같은 롤백된 트랜잭션을 재사용하려고 시도한다. 재시도마다 새 트랜잭션이 필요하다면 @Retryable을 트랜잭션 바깥에 배치하거나 REQUIRES_NEW를 명시해야 한다.
[!DANGER] @Recover에서 예외를 다시 던지는 것
@Recover의 역할은 최종 실패를 우아하게 처리하는 것이다. 여기서 다시 예외를 던지면 @Recover를 쓰는 의미가 없어진다. 로그를 남기거나, 상태를 업데이트하거나, 관리자에게 알림을 보내는 등 복구 가능한 동작을 수행해야 한다.