Java Reactive Programming 핵심 정리 시리즈 10편. Reactor Retry 전략 완전 정복 — repeat와 retry의 차이, retryWhen/fixedDelay/backoff/jitter 전략, RetrySpec 고급 설정, 실무 HTTP 재시도 패턴까지 비유와 시험 함정을 곁들여 친절하게 풀어쓴 글.
이 글은 Java Reactive Programming 핵심 정리 시리즈의 열 번째 편입니다. 실무에서 Reactive 파이프라인을 짜다 보면 반드시 마주치는 두 가지 상황이 있어요. "이 작업을 성공하면 N번 더 반복해야 한다"와 "이 작업이 실패하면 몇 번 더 시도해야 한다" — 이게 각각 repeat와 retry의 존재 이유입니다.
이번 편은 Reactor Retry 전략 전체를 다룹니다. 간단한 repeat(N) / retry(N) 기본기부터, 지수 백오프·지터·필터까지 갖춘 retryWhen의 고급 전략까지 한 번에 정리해요.
이 시리즈는 Project Reactor 공식 문서와 Reactive Streams 명세를 포함한 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
IDE에 reactor-core 의존성을 추가하고 예제 코드를 직접 실행해 보면 훨씬 잘 이해됩니다.
왜 Repeat & Retry가 처음엔 헷갈릴까요
이유는 네 가지예요.
첫째, 이름이 비슷해서 언제 어느 쪽을 써야 할지 헷갈립니다. repeat도 다시 실행, retry도 다시 실행 — 둘 다 재실행인데 어떻게 다른지 처음엔 모호하게 느껴져요.
둘째, repeat(N)이 총 N번 실행이 아니라 N번 추가 반복이라는 점을 모릅니다. 직관적으로 "3번만 실행해" 라고 생각하고 repeat(3)을 쓰면 실제로는 4번 실행돼요.
셋째, retryWhen에 전달하는 Retry 객체의 옵션이 너무 많아 보입니다. fixedDelay, backoff, jitter, filter, onRetryExhaustedThrow — 한꺼번에 보면 압도돼요.
넷째, 무한 재시도의 위험성을 인식하지 못합니다. retry()나 repeat()를 인자 없이 쓰면 무한 루프가 돼서 CPU가 100%에 박히는 상황을 만들 수 있어요.
해결법은 한 가지예요. repeat는 "라이브 폴링"이고 retry는 "네트워크 일시 장애 복구" 라는 두 비유를 잡으면 모든 게 자연스럽게 정리됩니다. 정상 완료 후 데이터를 계속 가져오고 싶다면 repeat, 오류가 났을 때 다시 시도하고 싶다면 retry — 트리거가 다를 뿐 구조는 같아요.
repeat — 성공 후 N번 더 반복 (라이브 폴링)
repeat는 onComplete 신호가 왔을 때 소스를 다시 구독합니다. 랜덤 국가 이름을 반환하는 API를 계속 폴링하는 상황을 떠올려 보세요.
// 국가 이름을 반환하는 Mono (구독마다 새 값)
Mono<String> getCountry() {
return Mono.fromSupplier(() -> faker.country().name());
}
// repeat(): 무한 반복 — 반드시 take() 등으로 제한 필요
getCountry()
.repeat()
.subscribe(System.out::println); // 무한 출력!
// repeat(n): n번 추가 반복 = 총 n+1번 실행
getCountry()
.repeat(3) // 추가 3번 = 총 4번
.subscribe(System.out::println);
// takeUntil과 조합: 특정 값이 나올 때까지
getCountry()
.repeat()
.takeUntil(country -> country.equalsIgnoreCase("Canada"))
.subscribe(System.out::println);
// Canada가 나오면 그 값 포함하고 종료
Flux에도 repeat를 붙일 수 있어요. 전체 시퀀스가 반복됩니다.
Flux.just(1, 2, 3)
.repeat(2) // 전체 시퀀스를 2번 더 반복 = 총 9개
.subscribe(System.out::println);
// 출력: 1, 2, 3, 1, 2, 3, 1, 2, 3
더 세밀한 제어가 필요하면 repeatWhen을 씁니다. companion Flux가 완료 신호를 받아서 재구독 여부를 결정하는 구조예요.
// 2초마다 재실행 (라이브 폴링)
getCountry()
.repeatWhen(companion ->
companion.delayElements(Duration.ofSeconds(2))
)
.subscribe(System.out::println);
Thread.sleep(10000); // 2초마다 새 국가 이름 출력
// BooleanSupplier로 조건부 반복
AtomicInteger count = new AtomicInteger(0);
getCountry()
.repeat(() -> count.incrementAndGet() < 5) // 5번 미만이면 반복
.subscribe(System.out::println);
// 총 5번 실행 (최초 1번 + 추가 4번)
repeat 핵심 3가지:
- 트리거: onComplete — 정상 완료 후에만 재구독
- repeat(n) = 추가 n번 = 총 n+1번 실행
- 무한 repeat는 반드시
take()또는takeUntil()로 종료 조건 설정
retry — 실패 시 N번 재시도 (네트워크 일시 장애)
retry는 onError 신호가 왔을 때 소스를 다시 구독합니다. 네트워크가 일시적으로 불안정할 때 자동으로 재시도하는 상황이에요.
// 오류를 시뮬레이션하는 Mono
AtomicInteger attempts = new AtomicInteger(0);
Mono<String> flakyService() {
return Mono.fromSupplier(() -> {
if (attempts.incrementAndGet() < 3) {
throw new RuntimeException("일시적 오류 (시도 " + attempts.get() + ")");
}
return "성공!";
});
}
// retry(n): 최대 n번 재시도 (총 n+1번 시도)
flakyService()
.retry(2) // 최대 2번 재시도 = 총 3번 시도
.subscribe(
result -> System.out.println("결과: " + result),
error -> System.out.println("최종 실패: " + error.getMessage())
);
// 시도1 실패 → 시도2 실패 → 시도3 성공
// 출력: 결과: 성공!
여기서 시험 함정이 하나 있어요. retry()를 인자 없이 쓰면 무한 재시도가 됩니다. 영구적인 오류(예: 잘못된 인증 토큰)에 retry()를 붙이면 CPU 100%로 무한 루프에 빠져요. 항상 최대 횟수를 지정하거나 오류 타입 필터를 설정하세요.
Reactor Retry — retryWhen으로 정교한 전략 구성
단순한 retry(N)으로 부족할 때는 retryWhen(Retry)으로 전략을 조합합니다. Project Reactor의 Retry 클래스가 네 가지 기본 전략을 제공해요.
| 전략 | 메서드 | 재시도 간격 | 서버 부하 |
|---|---|---|---|
| 즉시 재시도 | Retry.max(n) | 없음 | 높음 |
| 고정 지연 | Retry.fixedDelay(n, Duration) | 일정 | 중간 |
| 지수 백오프 | Retry.backoff(n, Duration) | 지수 증가 | 낮음 |
| 무한 재시도 | Retry.indefinitely() | 없음 | 주의 |
// fixedDelay: 1초 간격으로 최대 3번
flakyService()
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)))
.subscribe(
result -> System.out.println("결과: " + result),
error -> System.out.println("실패: " + error.getMessage())
);
// backoff: 지수 백오프 (1초 → 2초 → 4초 → 8초 ...)
flakyService()
.retryWhen(Retry.backoff(5, Duration.ofSeconds(1)))
.subscribe(
result -> System.out.println("결과: " + result),
error -> System.out.println("최종 실패: " + error.getMessage())
);
여기서 시험 함정이 하나 있어요. Retry.backoff는 기본 지터(jitter)가 0.5 (50%)로 설정돼 있습니다. 지터 없이 순수 지수 백오프만 원한다면 .jitter(0.0)을 명시해야 해요. 반대로 다중 클라이언트 환경에서는 지터가 있는 편이 서버 부하 분산에 유리합니다.
RetrySpec 고급 설정 — 실무 체크리스트
flakyService()
.retryWhen(
Retry.backoff(3, Duration.ofSeconds(1))
.maxBackoff(Duration.ofSeconds(10)) // 최대 10초 간격
.jitter(0.5) // 50% 랜덤 분산
.filter(ex -> ex instanceof RuntimeException) // RuntimeException만 재시도
.doBeforeRetry(retrySignal -> {
System.out.println("재시도 " + retrySignal.totalRetries() + "번째");
System.out.println("실패 원인: " + retrySignal.failure().getMessage());
})
.onRetryExhaustedThrow((spec, signal) -> signal.failure()) // 원본 예외 전달
)
.subscribe(
result -> System.out.println("성공: " + result),
error -> System.out.println("최종 실패: " + error.getClass().getSimpleName())
);
여기서 시험 함정이 하나 있어요. retryWhen 재시도가 소진되면 기본적으로 RetryExhaustedException이 발생합니다. 원본 예외(예: RuntimeException)를 전달하고 싶으면 .onRetryExhaustedThrow((spec, signal) -> signal.failure())를 반드시 추가해야 해요.
repeat vs retry 조합 패턴
retry와 repeat를 함께 쓰면 "에러 복구 후 반복"이 가능해요. 실무에서 외부 API를 폴링하면서 서버 오류에는 자동 재시도까지 원할 때 유용합니다.
// 서버 에러(5xx)만 재시도, 정상 응답이면 계속 폴링
public Flux<String> getOrders() {
return httpClient.get("/orders")
.repeat() // 정상 완료 시 계속 폴링
.takeUntil(order -> order.equals("DONE")) // 완료 조건
.retryWhen(
Retry.fixedDelay(20, Duration.ofSeconds(1))
.filter(ex -> ex instanceof ServerError)
.doBeforeRetry(s ->
System.out.println("서버 에러 재시도: " + s.failure().getMessage())
)
);
}
여기서 시험 함정이 하나 있어요. 실패 무한 재시도는 자원 고갈로 이어집니다. filter로 재시도할 예외 타입을 제한하지 않으면, 클라이언트 오류(400) 같이 재시도해도 의미 없는 케이스까지 무한 반복해요. 항상 .filter()로 재시도 대상 예외를 명확히 지정하세요.
자세한 Retry 전략과 Backpressure 연동 패턴은 Project Reactor 공식 문서에서 확인할 수 있습니다.
retryWhen 설정 체크리스트:
.filter()— 재시도할 예외 타입 명시.maxBackoff()— 최대 대기 시간 제한.jitter()— 다중 클라이언트 분산 (기본 0.5).doBeforeRetry()— 재시도 전 로깅.onRetryExhaustedThrow()— 소진 시 원본 예외 전달
핵심 압축 노트 — 시험 직전 20개
여기까지가 Repeat & Retry 편의 핵심입니다. 빠르게 복습할 수 있게 압축 노트로 마무리합니다.
- repeat = onComplete 트리거 — 정상 완료 후 소스 재구독
- retry = onError 트리거 — 에러 발생 후 소스 재구독
repeat(n)= 추가 n번 반복 = 총 n+1번 실행 (n번이 아님!)retry(n)= 최대 n번 재시도 = 총 n+1번 시도repeat()/retry()인자 없이 = 무한 루프 주의takeUntil(pred)조합으로 무한 repeat 종료 조건 설정repeatWhen(companion)— companion Flux 완료 시 repeat 중단Retry.max(n)— 즉시 재시도 (서버 부하 높음)Retry.fixedDelay(n, d)— 고정 간격 재시도Retry.backoff(n, d)— 지수 백오프 (실무 권장)- Reactor Retry backoff 기본 jitter = 0.5 (50% 랜덤 분산)
- jitter는 Thundering Herd(동시 재시도) 방지 목적
.filter(pred)— 재시도 대상 예외 타입 제한 (필수!).doBeforeRetry(signal)— 재시도 전 로깅용 콜백retrySignal.totalRetries()— 현재까지 총 재시도 횟수- retryWhen 소진 기본 예외 = RetryExhaustedException (원본 예외 아님)
.onRetryExhaustedThrow((spec, signal) -> signal.failure())로 원본 예외 전달retry는 소스를 재구독 — 외부 상태는 리셋 안 됨, 내부 상태는 재초기화- retry + repeat 조합 — 에러 복구 후 폴링 패턴
- 영구 오류 + 무한 재시도 = CPU 100% 고갈 — 항상 횟수/타입 제한
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Reactive Programming 입문
- 2편 — Mono 완전 정복
- 3편 — Flux 완전 정복
- 4편 — 연산자 (map·flatMap·filter·reduce 등)
- 5편 — Hot & Cold Publishers
- 6편 — Threading & Schedulers
- 7편 — Backpressure (배압)
- 8편 — Publisher 결합 (zip·merge·concat 등)
- 9편 — Batching·Windowing·Grouping
- 10편 — Repeat & Retry (현재 글)
- 11편 — Sinks
- 12편 — Context
- 13편 — 단위 테스트 (StepVerifier)