Kotlin Flow의
retryWhen으로 시도 횟수가 보장되는 재시도를 만드는 방법을 설명한다.flow { } → retryWhen { } → single()로 단발 suspend 호출을 감싸고,Result.fold로 성공과 실패를 가르는 패턴이다. Flow를 처음 접하는 개발자가 이 패턴을 읽거나 직접 짤 때 필요한 배경 지식과, 시나리오별 동작을 도식으로 정리했다.
기준: Kotlin 2.0.0, kotlinx.coroutines 1.8.1 (2026-09 확인). 본문에 인용한 라이브러리 코드는 1.8.1 소스에서 옮겼고, 예제는 이 버전에서 컴파일해 시나리오 10개를 돌려 확인했다.
외부 HTTP API를 부르는 suspend 함수가 있다. 일시적인 실패(503, 연결 끊김)면 다시 시도하고 싶다. 조건은 셋이다.
Retry-After 헤더만큼 기다린다while (true) 안에 탈출 조건을 여러 개 두는 방법도 있다. 하지만 탈출 조건 하나만 잘못 지워도 끝나지 않는 루프가 된다. 아래 패턴은 종료 조건을 시도 횟수 비교 한 곳으로 모은다.
날씨 예보 API를 부르는 서비스라고 하자. 실제 HTTP 클라이언트 대신 인터페이스로 둔다.
data class Forecast(val city: String, val summary: String)
// 4xx·5xx 응답을 받으면 클라이언트가 던지는 예외
class HttpStatusException(val status: Int, val retryAfterSeconds: Long?) : RuntimeException("HTTP $status")
interface ForecastApi {
// 응답을 아예 받지 못하면 IOException을 던진다
suspend fun fetch(requestId: String, city: String): Forecast
}
data class RetryPolicy(val maxAttempts: Int) {
companion object {
val NO_RETRY = RetryPolicy(maxAttempts = 1)
}
}
sealed interface FetchResult {
val attempts: Int
data class Success(override val attempts: Int, val forecast: Forecast) : FetchResult
data class Failure(override val attempts: Int, val status: Int) : FetchResult
}
재시도할지와 대기 시간은 응답 상태로 정한다.
private const val MAX_RETRY_AFTER_SECONDS = 25L
// 재시도해도 되면 기다릴 초를, 아니면 null을 준다
fun retryDelaySeconds(e: HttpStatusException): Long? {
return when (e.status) {
503 -> (e.retryAfterSeconds ?: 0L).coerceAtMost(MAX_RETRY_AFTER_SECONDS)
502 -> 0L
else -> null
}
}
핵심 함수다.
class ForecastService(private val api: ForecastApi) {
suspend fun fetchForecast(city: String, policy: RetryPolicy): FetchResult {
var attempts = 0
return runCatchingCancellable {
flow {
attempts++
emit(api.fetch(requestId = "forecast:$city:$attempts", city = city))
}.retryWhen { cause, retriedCount ->
if (retriedCount + 1 >= policy.maxAttempts) {
return@retryWhen false
}
val delaySeconds = when (cause) {
is IOException -> 0L
is HttpStatusException -> retryDelaySeconds(cause)
else -> null
} ?: return@retryWhen false
delay(delaySeconds.seconds)
true
}.single()
}.fold(
onSuccess = { forecast -> FetchResult.Success(attempts = attempts, forecast = forecast) },
onFailure = { e ->
if (e !is HttpStatusException) {
throw e
}
FetchResult.Failure(attempts = attempts, status = e.status)
},
)
}
}
runCatchingCancellable은 표준 라이브러리 함수가 아니다. 표준 runCatching이 CancellationException까지 삼키는 문제(kotlinx.coroutines#1814)를 피하려고 흔히 직접 정의하는 헬퍼다.
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}
핵심은 두 가지다.
retryWhen이다. 판단 함수가 true를 돌려주면 retryWhen이 위쪽 flow를 처음부터 다시 수집한다. 그래서 flow { } 블록이 한 번 더 돈다.retriedCount + 1 >= maxAttempts이면 판단 함수가 무조건 false를 돌려준다. 그래서 호출 횟수는 maxAttempts를 넘지 않는다.flow { } 빌더는 블록을 바로 실행하지 않는다. 누군가 수집(collect)할 때 실행하고, 다시 수집하면 블록을 처음부터 다시 실행한다. 이런 flow를 cold flow라고 부른다.
val numbers = flow {
println("블록 시작")
emit(1)
}
numbers.collect { println(it) } // "블록 시작", 1
numbers.collect { println(it) } // "블록 시작", 1 ← 블록이 다시 돈다
이 성질이 재시도의 바탕이다. retryWhen은 실패한 호출을 되감지 않는다. 위쪽 flow를 새로 수집할 뿐이다. 새로 수집하면 블록이 처음부터 다시 돌고, 그 안의 api.fetch가 다시 호출된다. 예제의 attempts가 오르는 이유도 같다. retryWhen이 카운트를 올리는 것이 아니라, 블록이 다시 돌면서 attempts++가 다시 실행된다.
연산자는 중간 연산자와 종단 연산자로 나뉜다. retryWhen 같은 중간 연산자는 새 flow를 만들어 돌려줄 뿐 아무것도 실행하지 않는다. single()·collect() 같은 종단 연산자를 불러야 체인 전체가 움직인다. 예제에서 실제로 호출을 시작시키는 것은 체인 끝의 .single()이다.
kotlinx.coroutines 1.8.1의 구현은 짧다. 원문 그대로 옮긴다.
public fun <T> Flow<T>.retryWhen(predicate: suspend FlowCollector<T>.(cause: Throwable, attempt: Long) -> Boolean): Flow<T> =
flow {
var attempt = 0L
var shallRetry: Boolean
do {
shallRetry = false
val cause = catchImpl(this)
if (cause != null) {
if (predicate(cause, attempt)) {
shallRetry = true
attempt++
} else {
throw cause
}
}
} while (shallRetry)
}
동작은 다섯 가지로 정리된다.
predicate(cause, attempt)를 부르고, true면 attempt를 1 올린 뒤 다시 수집함predicate가 false면 원래 예외 cause를 감싸지 않고 그대로 던짐attempt는 0부터 셈(첫 실패 때 0)predicate는 suspend 함수라 안에서 delay로 기다린 뒤 true를 돌려주면 "대기 후 재시도"가 됨retryWhen이 잡지 않는 예외가 두 종류 있다. 내부의 catchImpl이 이 둘을 그대로 던진다.
single()이나 수집 쪽 코드가 던진 예외CancellationException그래서 호출한 코루틴이 취소되면 재시도로 붙잡지 않고 바로 취소된다. 예제의 runCatchingCancellable도 취소 예외를 다시 던지므로, 취소가 Failure 결과로 바뀌어 삼켜지는 일은 없다.
retry(retries)는 retryWhen을 얇게 감싼 것이다. 1.8.1 구현은 retryWhen { cause, attempt -> attempt < retries && predicate(cause) } 한 줄이다. 예제는 예외 종류와 응답마다 대기 시간이 달라서 retryWhen을 직접 쓴다.
retryWhen의attempt와retry(retries)의retries는 재시도 횟수다. 전체 시도 횟수는 여기에 1을 더한 값이다. 예제가retriedCount + 1과maxAttempts를 비교하는 이유다.
single()은 종단 연산자다. flow를 수집해 값 하나를 돌려준다. KDoc에 따르면 빈 flow면 NoSuchElementException을, 원소가 둘 이상이면 IllegalArgumentException을 던진다.
예제의 flow { } 블록은 한 번 돌 때 emit을 최대 한 번 한다. 실패한 시도는 emit 전에 예외가 나서 값을 내보내지 않는다. 그래서 single()이 받는 값은 성공한 마지막 시도의 응답 하나다. 위쪽이 끝내 예외로 끝나면 single()도 그 예외를 그대로 던진다.
Result.fold(onSuccess, onFailure)는 성공이면 onSuccess를, 실패면 onFailure를 불러 그 반환값을 돌려준다. 두 람다 모두 inline이라 onFailure 안에서 throw하면 함수 바깥으로 예외가 그대로 나간다.
예제는 이 성질로 예외를 두 부류로 나눈다.
| 마지막까지 실패한 예외 | 뜻 | 결과 |
|---|---|---|
HttpStatusException |
서버가 4xx·5xx로 응답함 | Failure(status)로 반환 |
IOException |
응답을 받지 못함 | 예외 전파 |
| 그 밖의 예외 | 예상하지 못한 실패 | 예외 전파 |
CancellationException |
코루틴 취소 | runCatchingCancellable이 다시 던져 취소가 그대로 진행 |
응답은 받았지만 실패한 경우는 호출자가 결과로 받아 분기하고, 응답 자체가 없는 경우는 예외로 알린다.
판단 함수에 넘어오는 retriedCount는 0부터 센다. attempts는 flow { } 블록이 돌 때마다 1씩 오른다. 판단 함수가 불리는 시점에는 항상 attempts = retriedCount + 1이다. maxAttempts = 2일 때를 따라가면 다음과 같다.
| 시점 | attempts |
requestId 끝 | retriedCount |
retriedCount + 1 >= 2 |
결과 |
|---|---|---|---|---|---|
| 1차 호출 실패 | 1 | :1 |
0 | 거짓 | 재시도 대상이면 다시 수집 |
| 2차 호출 실패 | 2 | :2 |
1 | 참 | false: 예외를 그대로 던짐 |
3차 호출은 일어나지 않는다. 종료가 루프 안의 탈출 조건이 아니라 시도 횟수 비교로 정해진다.
두 요청의 requestId는 :1, :2로 다르다. 서버 로그에서 같은 호출의 재시도를 이 접미사로 구분할 수 있다.
응답이 있는 실패는 마지막까지 실패해도 예외가 아니라 Failure 결과로 돌아온다. IOException이 두 번 나면 같은 경로로 가다가 fold에서 갈려 예외가 전파된다.
JUnit 5·AssertJ로 쓴 테스트다. runTest는 가상 시간을 써서 delay(7초)를 실제로 기다리지 않는다. testTimeSource로 흐른 가상 시간을 재면, 대기 로직이 빠졌을 때 테스트가 실패한다.
class ForecastServiceTest {
// 호출마다 준비된 동작을 차례로 실행하는 가짜 API
private class ScriptedApi(private val steps: List<() -> Forecast>) : ForecastApi {
private var calls = 0
override suspend fun fetch(requestId: String, city: String): Forecast {
return steps[calls++]()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `503이면 Retry-After만큼 기다린 뒤 재시도해 성공한다`() = runTest {
// given: 1차 503(Retry-After 7), 2차 성공
val forecast = Forecast("Seoul", "맑음")
val api = ScriptedApi(listOf({ throw HttpStatusException(503, retryAfterSeconds = 7) }, { forecast }))
val startMark = testTimeSource.markNow()
// when
val result = ForecastService(api).fetchForecast("Seoul", RetryPolicy(maxAttempts = 2))
// then
assertThat(startMark.elapsedNow()).isEqualTo(7.seconds)
assertThat(result).isEqualTo(FetchResult.Success(attempts = 2, forecast = forecast))
}
}
testTimeSource와testScheduler.currentTime은 1.8.1에서@ExperimentalCoroutinesApi다. opt-in이 없으면 컴파일 경고가 난다. 가상 시간을 읽는 테스트에만 붙인다.
attempts는 flow 블록이 다시 돌 때 오름. retryWhen이 올리는 값이 아님false를 돌려주면 원래 예외가 그대로 나감. 래핑되지 않아서 fold에서 is 검사로 타입을 가를 수 있음delay임. 판단 함수가 suspend라서 가능함retryWhen이 잡지 않고, runCatchingCancellable도 다시 던짐emit하면 single()이 IllegalArgumentException을 던짐. flow 블록을 고칠 때 emit을 한 번만 하는지 확인해야 함attempts는 호출 하나 안에서만 씀. 체인이 순차로 돌기 때문에 동시 접근이 없음. 여러 호출이 같은 변수를 공유하게 바꾸면 안 됨retry(n) { cause -> ... }: 대기 시간이 고정이고 판단이 예외 종류만 보면 이쪽이 짧다. 판단 람다 안에서 delay도 쓸 수 있다for (attempt in 1..maxAttempts) 루프: Flow를 모르는 팀원에게는 더 익숙하다. 다만 루프 밖에서 "마지막 실패"를 돌려줄 변수를 따로 들고 있어야 한다flow + retryWhen + single): 종료 조건·재시도 판단·대기가 판단 함수 한 곳에 모인다