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
로 변경되어, 더 이상 스트림 활용이 불가능해진다.
다행히 재시도 가능한 retry
와 retryWhen
을 제공한다.
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 {
//
}
}
이런 경우이다. retryWhen
을 flatMapLatest
안에도 두고, 본선에도 두었다.
이 경우 무한 루프가 돌까? 답은 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가지 케이스로 분리해 보았는데, 무한 반복될 수 있으니 재시작 조건을 잘 만들어둘 필요가 있다.
Comments