코틀린/코루틴

Flow 4주차 2차 스터디 CatchingDeclaratively까지

강한 맷돼지 2023. 11. 23. 17:50

Flow exceptions

자자자 이제야 나온다 오류 우하하하하하하하

 

Flow는 emit하는 도중, 연산자(중간,터미널) 에서 exception을 던지는 상황이 나온다면 exception으로 종료될 수 있다.

이에 대처하는 방법에 대해 다룬다고 한다.

 

Collector try and catch

그냥 코틀린 tryCatch로 잡는 방법을 소개한다.

뭐 당연스럽게 runCatching으로 잡아도 될것이다.

fun simple(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i) // emit next value
    }
}
​
fun main() = runBlocking<Unit> {
    try {
        simple().collect { value ->         
            println(value)
            check(value <= 1) { "Collected $value" }
        }
    } catch (e: Throwable) {
        println("Caught $e")
    } 
}            
​
//결과
Emitting 1
1
Emitting 2
2
Caught java.lang.IllegalStateException: Collected 2

collect 단계에서 exception이 발생하는 예시를 보여주는데

 

당연스럽게도 exception이 발생한 후 flow자체 즉 발행하는 부분도 끝나버린다.(흐름이 끊긴다)

그리고 그 catch를 통해 exception을 핸들링 할 수도 있다.

 

Everything is caught

여기서는 flow 발행자, 중간연산자, 터미널 연산자 어디에서든 exception이 나면 터미널 연산자 쪽 즉 사용하는 쪽으로 exception이 전파되는것을 보여준다. 즉 터미널 연산자 쪽에 오류처리를 해준다면 그냥 모든 exception을 잡아낼 수 있는것이다.

예시는 생략하겠지만 중간연산자에서 exception이 발생하는것을 collect에서 잡아내는것을 볼 수 있다.

 

Exception transparency

예외 투명성 이라는데 말이 참 난해하다. 말좀 쉽게하던지 풀어서 설명하던지 했으면 좋겠다.(누가 코틀린 공식문서 친절하댔어 킹받네)

flow 발행자의 예외처리를 캡슐화 시키는 방법론에 대해서 논하고있다.

 

Flow의 예외는 투명성을 보장해야하는데 flow빌더 코드안에서 tryCatch를 하는것은 이를 위반하는 행위하고 한다.

이렇게 tryCatch를 터미널쪽에 감싸면 어떤 예외던 터미널 연산자쪽에서 오류를 핸들링할수 있는 것을 보장한다고 한다.(내가 해석한건데 틀릴수도 ...)

 

발행자의 오류처리를 캡슐화하려면 catch 중간연산자를 사용하여 예외 투명성을 보장하고 예외 핸들링을 캡슐화 해야한다고 한다.

catch 연산자에 넘기는 행위로(람다 함수) 예외를 분석하거나 각 예외에 따라 다르게 반응하도록 해야한다.

 

이에 예외 처리의 예시를 제시하고있다.

 

1. 예외 rethrow

 

2. 예외를 값으로 변경해서 emit 해주기

simple()
    .catch { e -> emit("Caught $e") } // emit on exception
    .collect { value -> println(value) }

 

3. 예외를 무시하고 로그를 찍거나 다른 코드를 통해 처리하기

 

Transparent catch

catch 중간연산자는 예외 투명성을 보장한다고 한다.

catch 연산자는 upstream의 예외들만 잡아내고(catch 연산자가 등장하기 이전 예외를 다 잡아냄)

하지만 downstream 에서 오류가나면 잡지않고 전파하게 된다.

 

Catching declaratively

선언적으로 오류를 잡는다는데 사실 별 내용은 아니고 catch중간연산자를 이용하여 모든 오류처리를 잡아내기위한 방법을 소개하고있다.

이방법은 collect에 들어갈 구문을 catch 연산자 바로앞에서 onEach로 옮겨실행하는 방법이다

근데 딱히 좋아보이지는 않는데(매우 편법같은 냄새가...) 근데 공식문서에서 소개하니 모두가 아는 방법일테고 더좋은 방법이 없다면 사용을 고려해봐야겠다.

simple()
    .onEach { value ->
        check(value <= 1) { "Collected $value" }                 
        println(value) 
    }
    .catch { e -> println("Caught $e") }
    .collect()
​
//결과
Emitting 1
1
Emitting 2
Caught java.lang.IllegalStateException: Collected 2

결론적으로 코드를 이런식으로 작성할 수 있다 정도만 알면 될것 같다.

 

 

멧돼지 부록: 그래서 예외 투명성이 뭔데

예외투명성을 검색해보면 코틀린 플로우 이야기밖에 안나온다.

뭔가 개발에서 통용적으로 쓰이는 용어같은데 희안하다.

 

그래서 자료를 찾기 매우 힘들었고 그 정의또한 모호하였다. 그래도 여러 문서 혹은 블로그들을 뒤져보며 해석을 종합한 결과는 이렇다.

 

예외투명성 : 예외투명성이란 예외의 출처를 파악하기 쉬운 구조를 만드는것이며, 예외를 처리 혹은 전파할때 어디에서 발생했는지 명확하게 알 수 있는 방식으로 처리해야한다.

 

예시를 들자면 collect자체를 tryCatch로 감싸놓을시 전파되는 에러가 생산자, 중간자, 소비자 중 어느 위치에서 일어난 에러인지 구분해내기 힘들며 이로인해 에러 핸들링하는 코드가 거대화될 가능성이 크다.

 

또한 flow는 예외투명성, 컨텍스트 보존을 지켜 upStream과 downStream을 캡슐화하여 각각이 개별적으로 구현이 가능하게 구분짓도록 되어있다.

 

이런 관점에서 생산자, 중간자, 소비자를 각각 tryCatch 를 통해서 오류를 감싸보면 생산자에서 오류처리를 했는데 소비자에서 오류가 잡히는 이상한 기작이 일어난다. -> (첫번째, 세번째 글 참고)

 

이러한 일반적인 생각과 다른 예시를 첨부하지만 아직 flow의 동작 원리를 제대로 다 파악못해서 이에대한 분석은 생략하려한다.(언젠가 이해하면 다시와서 작성해야지)

// 에러가 나온 시점에 flow가 취소되어 새로운 값이 emit되지는 않지만 왜 다운스트림에서 생긴 오류가 스트림을 역행하고 타고올라와서 위쪽에서 처리되는지 동작원리를 알수없다.
class FlowTest1 {
​
    fun <T> Flow<T>.handleErrors(): Flow<T> = flow {
        try {
            collect { value -> emit(value) }
        } catch (e: Throwable) {
            print(e)
        }
    }
​
    fun simple(): Flow<Int> = flow {
        for (i in 1..5) {
            println("Emitting $i")
            emit(i)
        }
    }
​
    @Test
    fun first() = runBlocking<Unit> {
        simple()
            .handleErrors()
            .collect { value ->
                check(value <= 1) { "Collected $value" }
                println(value)
            }
    }
}
​
//test1번 코드에 생산자에 tryCatch를 추가했는데 애초에 flow자체가 종료되지도 않아 계속해서 emit되었지만 이미 flow에서 지난 emit에서 exception이 발생하여 오류 투명성이 깨졌다는 exception이 계속 던져진다.
class FlowTest2 {
​
    fun <T> Flow<T>.handleErrors(): Flow<T> = flow {
        try {
            collect { value -> emit(value) }
        } catch (e: Throwable) {
            print(e)
        }
    }
​
    fun simple(): Flow<Int> = flow {
        for (i in 1..5) {
            try {
                println("Emitting $i")
                emit(i)
            } catch (e: Throwable) {
                println("멧돼지 $e")
            }
        }
    }
​
    @Test
    fun second() = runBlocking<Unit> {
        simple()
            .handleErrors()
            .collect { value ->
                check(value <= 1) { "Collected $value" }
                println(value)
            }
    }
}
​
//생산자에서 오류처리를 하고 중간연산자에서 오류가났는데 생산자로 역행해서 오류가 잡힌다. 
class FlowTest3 {
    class FunnyException : Exception()
​
    @Test
    fun thrid() = runBlocking<Unit> {
        try {
            flow {
                emit(0)
                try {
                    emit(1)
                } catch (e: FunnyException) {
                    println("돼지 $e")
                    emit(2)
                }
            }.onEach {
                if (it == 1) throw FunnyException()
            }.collect {
                println(it)
            }
        } catch (e: Throwable) {
            println("Caught $e")
        }
    }
}
​

이러한 이유로 예외투명성이 깨지게되고 이를 해결하는 방법은 Flow.catch를 사용하면 우리가 생각하듯이 upstream에서만 오류를 잡으며 flow가 적절히 취소되기에 catch블록을 사용해야한다.

 

결론적으로 tryCatch 를 쓰면 이런 이상한 문제들이 일어나니 Flow.catch 를 이용하여 예외투명성을 지키며 flow를 구성해야한다.

 

예외투명성 관련글

Exceptions in Kotlin Flows

Kotlin coroutine flow

Why are downstream exceptions propagated upwards in Flow?

Kotlin Coroutine Flows: Deep Dive (Part 1 Cold Flows)