Kotlin flow의 예외 처리(catch), 재시도(retry, retryWhen) 살펴보기



개인 광고 영역

kotlin coroutines을 활용한 exception은 매우 쉽다.

fun some() {
  viewModelScope.launch {
    try {
      // call suspend function
    } catch(e: Exception) {}
  }
}

launch 안에 try/catch 묶어야 함이 중요하다. coroutines의 장점 역시 우리가 항상 사용하는 함수만 이해하면 코루틴을 활용할 수 있다는 점이다.

그렇다면 Flow에서의 예외 처리는 어떻게 할 수 있을까?

가장 기본적으론 아래와 같이 할 수 있다.

fun some() {
  viewModelScope.launch {
    try {
      flowXXX
        .collect {}
    } catch(e: Exception) {}
  }
}

flow 같진 않지만 flow로 동작하니 일단 넘어가자.


이 글에서 알아볼 내용

  • flow를 활용한 exception 처리
  • catch 활용
  • retry, retryWhen 활용


먼저 launchIn을 살펴보자

위에서 작성한 코드는 flow에 launch 감싸고 있는데, 좀 더 flow 같이 만들어보자.

fun some() {
  flowXXX
    .onEach {
      // use value
    }
    .launchIn(viewModelScope)
}

launchIn으로 처리함으로써 viewModelScope을 flow 밖에 감싸는 코드가 제거되었다.

launchIn은 어떻게 생겼을까? 정말 별거 없다. 기존에 작성했던 코드 그대로 함수 안으로 들어가 있다.

public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
    collect() // tail-call
}

여기서 중요한 건 suspend 함수가 아니라는 점이다.


Catch 처리는?

그럼 flow에서 발생하는 exception 처리는 어떻게 해야 할까?

catch를 제공한다.

사용 방법은 문서에도 잘 나와있는데 아래와 같다.

fun some() {
  flowXXX
    .onEach {
      // use value
    }
    .catch {
      if (it !is IOException) throw e
      // e is IOException here
    }
    .launchIn(viewModelScope)
}

이 코드는 IOException이 아닌 경우 그대로 throw 시키고, 그렇지 않으면 exception을 처리하는 것이다.


지속 가능한 스트림 활용 - StateFlow, SharedFlow

StateFlow와 SharedFlow는 값을 지속적으로 처리하기 위해 활용하는데, exception이 발생하면 그대로 죽어버린다.

그래서 catch를 활용해 처리할 수 있다.

그런데 catch를 적용하더라도 stream의 상태는 complete로 변경되어, 더 이상 스트림 활용이 불가능해진다.

다행히 재시도 가능한 retryretryWhen을 제공한다.


retry와 retryWhen

  • retry : 최대(기본값 Int.MAX_VALUE) 횟수를 지정하여 재시도 가능하다.
  • retryWhen : 재시도 횟수를 카운트하여 처리가 가능하며, catch 발생 시 catch에서와 마찬가지로 emit 값을 흘려보낼 수 있다.

그럼 어떤 걸 선택하는 게 좋을까? 제공해 주는 2개의 함수에서 이미 알 수 있지만

최대 재시도 횟수를 지정해두고 재시도 하도록 처리한다면 retry가 좋다.

  • 만약 IOException이 발생하면 1초 후에 최대 3회 재시도
flow.retry(3) { e ->
    // retry on any IOException but also introduce delay if retrying
    (e is IOException).also { if (it) delay(1000) }
}

하지만 UI 상 사용자가 선택해야 할 재시도 케이스라면 retryWhen이 기본 최대 카운트 없이 종료하기 더 좋다.

  • 만약 IOException이 발생하면 1초 후에 재시도하고, 그렇지 않으면 재시도하지 않는다.
flow.retryWhen { cause, attempt ->
    if (cause is IOException) {    // retry on IOException
        emit(RetryWrapperValue(e))
        delay(1000)                // delay for one second before retry
        true
    } else {                       // do not retry otherwise
        false
    }
}

이 코드에서는 역시나 무제한 재시도 될 수 있기 때문에 좀 더 개선하면 아래와 같아야 한다.

  • 만약 IOException이 발생하고, 최대 2회가 넘지 않는 선에서 재시도해 줘
flow.retryWhen { cause, attempt ->
    if (cause is IOException && attempt < 2L) {    // retry on IOException
        emit(RetryWrapperValue(e))
        delay(1000)                // delay for one second before retry
        true
    } else {                       // do not retry otherwise
        false
    }
}

attempt와 retry의 최대 retries 횟수는 엄청난 차이가 발생한다.


retryWhen을 활용하자.

retryWhen을 잘 활용하면 더 좋은데 이와 같이 활용이 가능하다.

fun Flow<T>.exception(tryRetry: Boolean): Flow<T> =
  retryWhen { cause, attempt ->
    when (cause) {
       is UnknownHostException -> {
          false
       }
       is HttpException -> {
        // 상황에 따른 tryRetry
       }
       else -> tryRetry // default 오류 처리 후 tryRetry
    }
  }

알 수 없는 주소가 떨어지는 케이스는 인터넷이 불가능하거나, 서버 주소가 잘못되었을 경우에 발생할 수 있으니 그 상황을 한 번 더 고려해야겠다.

  • 비행기 모드인 경우에도 이 값이 떨어지고.
  • 네트워크 불가능 케이스에도 여기에 값이 떨어진다.

HttpException은 retrofit 활용 시 떨어지는 오류로 400, 500 등의 오류 발생 시 이 값을 활용한다.

body에서 값을 꺼내서 파싱 해 활용하는 케이스가 여기에 해당한다.

그 외 케이스도 발생할 수 있으니 else에서 한 번 더 감싸준다.

이렇게 적용하면 flow에서 발생하는 오류들을 방지할 수 있다.


retryWhen을 활용하는 이유?

button을 눌렀을 때 exception 발생한다고 하더라도, 사용자가 버튼을 눌러 재시도할 수 있어야 한다.

  • 첫 화면에서 데이터를 부르다가 실패한다면 서버에서 반응하지 못할 수 있으니 현재 화면은 종료한다.
  • 모든 Button으로부터 시작하는 이벤트는 재시도 가능해야 한다.

하지만 retryWhen을 적용하지 않는 경우라면 complete로 변경됨을 알 수 있었는데, 이 경우 retryWhen을 적용해 재시도 가능 상태로 대기시켜야 한다.


retryWhen의 위치가 중요하다.

flow 상 retryWhen의 위치가 매우 중요하고, 무한 반복되지 않도록 처리되어야 한다. 그래서 몇 가지 꼼수 같은 기법이 중요하다.

Case 1 - MutableStateFlow에서 활용되는 경우

MutableStateFlow는 마지막 값을 replay 한다. StateFlow에 retryWhen을 아래와 같이 처리하는 경우 무한 반복을 시도한다.

@Test
fun `test exception`() {
    val flowStateFlow = MutableStateFlow("a")

    flowStateFlow
        .onEach {
            throw Exception("Exception")
        }
        .retryWhen { cause, attempt ->
            true
        }
        .collect { 
          // 
        }
}

그래서 아래와 같이 꼼수를 적용해 줘야 한다.

@Test
fun `test exception`() {
    val flowStateFlow = MutableStateFlow("a")

    flowStateFlow
        .filter { it.isNotEmpty() }
        .onEach {
          flowStateFlow.value = "" // filter 후 값을 초기화 시킨다.
        }
        .onEach {
            throw Exception("Exception")
        }
        .retryWhen { cause, attempt ->
            true
        }
        .collect { 
          // 
        }
}

이렇게 처리되어야 1회 반복하고 새로운 값에 대한 대응이 가능해진다.

case 2 MutableSharedFlow는?

그럼 replay를 지정하지 않는 MutableSharedFlow는 어떨까?

@Test
fun `test exception`() {
    val flowSharedFlow = MutableSharedFlow<String>(
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    
    flowSharedFlow
        .onEach {
            throw Exception("Exception")
        }
        .retryWhen { cause, attempt ->
            true
        }
        .collect {
          // 
        }
}

replay를 지정해 주는 경우라면 MutableStateFlow와 동일한 처리가 필요하지만 이와 같은 코드에선 무한 반복하지 않는다.

case 3 flatMap을 활용했다면 retry 위치는?

flatMap을 활용해 처리하다가 예외 처리가 필요해졌다. 이 경우 retry를 어디에 두어야 할까?

@Test
fun `test exception`() {
    val flowSharedFlow = MutableSharedFlow<String>(
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val flowStateFlow = MutableStateFlow("a")

    flowSharedFlow
        .flatMapLatest {
            flowStateFlow
                .onEach {
                    throw Exception("Exception")
                }
                .retryWhen { cause, attempt ->
                    true
                }
        }
        .onEach {
            throw Exception("Exception")
        }
        .retryWhen { cause, attempt ->
            true
        }
        .collect {
          // 
        }
}

이런 경우이다. retryWhenflatMapLatest 안에도 두고, 본선에도 두었다.

이 경우 무한 루프가 돌까? 답은 Yes.

무한 루프가 flatMapLatest 안에서 발생한다.

그럼 어떻게 해야 할까?

이 코드에서는 flatMapLastest에서 처리한 오류 처리를 제거하고, flowSharedFlow 스트림에 만 적용하는 것이다.

@Test
fun `test exception`() {
    val flowSharedFlow = MutableSharedFlow<String>(
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val flowStateFlow = MutableStateFlow("a")

    flowSharedFlow
        .flatMapLatest {
            flowStateFlow
                .onEach {
                    throw Exception("Exception")
                }
        }
        .onEach {
            throw Exception("Exception")
        }
        .retryWhen { cause, attempt ->
            true
        }
        .collect {
          // 
        }
}

예외 처리를 여러 곳에 둘 필요가 없고, 한곳에 잘 묶어서 관리해야 편하다.


마무리

flow를 활용한 예외 처리 방법에 대해서 살펴보았다.

크게

  • 단순 오류 처리 catch
  • 카운트만큼 재시도 : retry
  • 재시도 횟수를 나중에 정하는 : retryWhen

이 있다. 스트림에서의 오류 처리는 단순하지만 매우 복잡하다. RxJava를 생각해 보면 재시작 가능한

여러 곳에서 문제가 발생하더라도 지속 가능한 형태로 만드는 게 핵심 목표라 위와 같이 3가지 케이스로 분리해 보았는데, 무한 반복될 수 있으니 재시작 조건을 잘 만들어둘 필요가 있다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments