본문 바로가기
코틀린

코루틴을 사용한 안전한 예외처리 - 3

by RWriter 2022. 1. 14.
반응형

코틀린은 자바와 다르게 함수형 패러다임을 많이 지원해주고 있지만 완벽하게 함수형프로그래밍을 지원하는 것은 아니다. 

 

함수형 패러다임이라고 하면 여러가지 철학,개념이 있지만 한마디로 하스켈의 방식을 따라 가는 것이라고 생각하면 좋다.

 

그래서 함수형 프로그래밍을 제대로 경험해보고 싶다면 하스켈을 학습해보라는 말이 있다.

https://www.haskell.org/

 

Haskell Language

Statically typed Every expression in Haskell has a type which is determined at compile time. All the types composed together by function application have to match up. If they don't, the program will be rejected by the compiler. Types become not only a form

www.haskell.org

 

함수형 프로그래밍 (줄여서 FP) 에서는 함수를 실행함에 있어서 부수효과라는 것을 용납하지 않는데,

놀랍게도 print, throw exception, network io, file io, 전역 변수에 접근 등 함수 블럭 밖의 상태를 건드리고 변경시키는 모든 것을 부수효과(side effect) 로 취급한다. 

 

 

오잉, 하겠지만 방법이 있다. 

 

타입 시스템에서 제네릭이란 List<T>, Stream<T>, Optional<T> 등 안에 내용물(타입)보다는 감싸고 있는 행위와 의미에 추상화가 가능할때 사용되는 개념이다. 

 

  • 리스트 T 는 여러개가 있다는 의미
  • 스트림 T 는 여러개가 순서대로 흘러갈 것이라는 의미 (조금 어색한가..)
  • 옵셔널 T 는 값이 있을수도 없을수도 있다는 의미

FP에서 부수 효과(effect) 는 IO<T> 라는 제네릭 타입으로 다뤄진다. 효과가 영향을 끼치는 모든 범위를 IO 로 감싸놓고 전개한다는 의미이다. 흔히 IO 모나드라고 부른다. (모나드가 무엇인지 모르겠지만 Optional, Stream, Webflux 등 에서 map, flatMap 을 잘 이용하고 있다면 모나드를 이해하고 있는 것이다!)

 

 

코틀린에서는 모나드를 비롯한 함수형 개념들을 하스켈의 방식으로 지원해주지 않지만 코틀린의 함수형 라이브러리인 Arrow 에서 그것들을 지원해주고 있다.

 

(스칼라에서 scalaz, cats 를 이용하는 것과 목적이 유사하다.)

 

https://arrow-kt.io/

 

Λrrow

Functional companion to Kotlin's Standard Library

arrow-kt.io

 

 

이번 글이 FP와 Arrow 에 대한 주제는 아니므로 여기까지 이야기 하고,

 

arrow의 fx 모듈에서는 effect (부수효과) 를 안전하게 다루는 방법을 소개하고 있다.

 

effect 는 IO 모나드를 통해 다뤄지는 것이 FP의 방식이지만, arrow 에서는 특이하게 suspend function 을 통해 io 를 다룰 수 있다.

IO를 drop 하고 suspend 를 이용하게 된 이유는 사용성, 성능 면에서 IO 를 만들어 쓰는 것보다 좋았기 때문이라고 한다.

why suspend over IO monad 

 

그래서 arrow에서는 코루틴을 활용한 IO 라이브러리들이 좀 있는데, 살펴보면 꽤나 실용적이다.

 

디팬던시 추가

dependencies {
    implementation(kotlin("stdlib"))
    implementation("io.arrow-kt:arrow-core:1.0.1")
    implementation("io.arrow-kt:arrow-fx-coroutines:1.0.1")
    implementation("io.arrow-kt:arrow-fx-stm:1.0.1")
}

 

parZip

zip 이란 두개의 값을 하나로 합치는 것을 말하는데 (zipper, 지퍼가 하는 역할), parZip 은 병렬로 zip 연산을 하는 것이다.

연산의 성격에 따라 Dispatcher 를 인자로 넘길 수 있다.

 

각 연산은 1초씩 멈췄다가 1 또는 2를 반환하고 마지막 더하기를 하여 3을 반환한다.

suspend fun parZipDelay() = parZip(
    fa = {
        delay(1000)
        1
    },
    fb = {
        delay(1000)
        2
    },
    f = { a, b -> a + b }
)

suspend fun parZipSleep() = parZip(
    Dispatchers.IO,
    fa = {
        Thread.sleep(1000)
        1
    },
    fb = {
        Thread.sleep(1000)
        2
    },
    f = { a, b -> a + b }
)

fun main() = runBlocking<Unit> {
    println(parZipDelay()) // 3 
    println(parZipSleep()) // 3
}

 

public suspend inline fun <A, B, C> parZip(
  ctx: CoroutineContext = EmptyCoroutineContext,
  crossinline fa: suspend CoroutineScope.() -> A,
  crossinline fb: suspend CoroutineScope.() -> B,
  crossinline f: suspend CoroutineScope.(A, B) -> C
): C = coroutineScope {
  val a = async(ctx) { fa() }
  val b = async(ctx) { fb() }
  f(a.await(), b.await())
}

suspend 함수 두개를 받고, 각각 async를 실행한 후 f 함수를 이용해 합쳐준다. 

 

 

만약 둘중 하나에서 예외가 발생한 경우는 어떻게 될까?

suspend fun parZipDelay() = parZip<Int, Int, Int>(
    fa = {
        delay(500)
        throw RuntimeException("Boom")
    },
    fb = {
        delay(1000)
        println("never printed")
        2
    },
    f = { a, b -> (a + b).also(::println) }
)

fun main() = runBlocking {
    println(parZipDelay())
}

/**
Exception in thread "main" java.lang.RuntimeException: Boom
**/

 

0.5 초 뒤에 에러가 발생했기 때문에 아무것도 프린트 되지 못하고 예외가 밖으로 전파된다. fb 는 cancel이 되었을 것이다.

 

 

반대로 fb에서 예외를 던진다면? 

fa 는 코루틴이 프린트를 하고 종료될 것이지만 최종 f 함수로는 진행하지 못하고 예외가 밖으로 전파될 것이다.

suspend fun parZipDelay() = parZip<Int, Int, Int>(
    fa = {
        delay(500)
        println("will printed")
        1
    },
    fb = {
        delay(1000)
        throw RuntimeException("Boom")
    },
    f = { a, b -> (a + b).also(::println) }
)

fun main() = runBlocking {
    println(parZipDelay())
}

/**
will printed
Exception in thread "main" java.lang.RuntimeException: Boom
**/

 

 

arrow 에서는 코루틴이 어떻게 종료되는지에 따라 콜백 함수를 통해 후처리를 할 수 있는 유틸 함수를 제공하고 있다.

guarantee

실행할 suspend 함수 이외에 finalizer 라는 인자를 추가로 받아 예외 상황에서도후처리가 가능하다.

public suspend inline fun <A> guarantee(
  fa: suspend () -> A,
  crossinline finalizer: suspend () -> Unit
): A {
  val res = try {
    fa.invoke()
  } catch (e: CancellationException) {
    runReleaseAndRethrow(e) { finalizer() }
  } catch (t: Throwable) {
    runReleaseAndRethrow(t.nonFatalOrThrow()) { finalizer() }
  }
  
  // NonCancellable 컨텍스트로 finalizer 는 cancel 전파가 없이 무조건 실행하게 된다.
  withContext(NonCancellable) { finalizer() }
  return res
}

 

guaranteeCase

finalizer 에서는 ExitCase 타입을 인자로 받는 함수를 넣어줄 수 있다.

ExitCase 는 코루틴이 종료될 수 있는 세가지 경우를 표현하며 Completed, Cancelled, Failure 이다.

public suspend inline fun <A> guaranteeCase(
  fa: suspend () -> A,
  crossinline finalizer: suspend (ExitCase) -> Unit
): A {
  val res = try {
    fa()
  } catch (e: CancellationException) {
    runReleaseAndRethrow(e) { finalizer(ExitCase.Cancelled(e)) }
  } catch (t: Throwable) {
    runReleaseAndRethrow(t.nonFatalOrThrow()) { finalizer(ExitCase.Failure(t.nonFatalOrThrow())) }
  }
  withContext(NonCancellable) { finalizer(ExitCase.Completed) }
  return res
}

 

코루틴 종료 케이스에 따른 후처리

fa 가 먼저 예외를 던지는 상황으로 finalizer 콜백 함수가 두 코루틴 모두 실행되었다.

fa 는 Failure 상태, fb 는 Cancelled 상태로 끝났고, coroutineScope 안에서 일어났기 때문에 호출한 부모까지 예외가 전파되었다.

suspend fun parZipDelay() = parZip<Int, Int, Int>(
    fa = {
        guaranteeCase({
            delay(500)
            throw RuntimeException("Boom")
        }, { exitCase -> println("fa coroutine finalized with: $exitCase") })
    },
    fb = {
        guaranteeCase({
            delay(1000)
            println("never printed")
            2
        }, { exitCase -> println("fb coroutine finalized with: $exitCase") })
    },
    f = { a, b -> (a + b).also(::println) }
)

fun main() = runBlocking {
    println(parZipDelay())
}

/**
fa coroutine finalized with: Failure(failure=java.lang.RuntimeException: Boom)
fb coroutine finalized with: Cancelled(exception=kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=ScopeCoroutine{Cancelling}@7670910a)
Exception in thread "main" java.lang.RuntimeException: Boom
**/

 

반대로 더 오래걸리는 fb에서 예외가 발생했을 때는 어떨까?

fa는 Completed 상태로 끝났지만 fb는 Failure로 끝났음을 알 수 있다. 그리고 최종 예외를 잡지 못해 Exception도 던져진 모습이다.

suspend fun parZipDelay() = parZip<Int, Int, Int>(
    fa = {
        guaranteeCase({
            delay(500)
            println("will printed")
            1
        }, { exitCase ->
            println("fa coroutine finalized with: $exitCase")
        })
    },
    fb = {
        guaranteeCase({
            delay(1000)
            throw RuntimeException("Boom")
        }, { exitCase -> println("fb coroutine finalized with: $exitCase") })
    },
    f = { a, b -> (a + b).also(::println) }
)

/**
will printed
fa coroutine finalized with: ExitCase.Completed
fb coroutine finalized with: Failure(failure=java.lang.RuntimeException: Boom)
Exception in thread "main" java.lang.RuntimeException: Boom
**/

 

추가로 finalizer 에서 예외가 발생한다면? 

fa 는 Completed 되었지만 finalizer에 의해 fb까지 영향을 받아 Cancelled 되었다.


suspend fun parZipDelay() = parZip<Int, Int, Int>(
    fa = {
        guaranteeCase({
            delay(500)
            println("will printed")
            1
        }, { exitCase ->
            println("fa coroutine finalized with: $exitCase")
            throw RuntimeException("??")
        })
    },
    fb = {
        guaranteeCase({
            delay(1000)
            2
        }, { exitCase -> println("fb coroutine finalized with: $exitCase") })
    },
    f = { a, b -> (a + b).also(::println) }
)

/**
will printed
fa coroutine finalized with: ExitCase.Completed
fb coroutine finalized with: Cancelled(exception=kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=ScopeCoroutine{Cancelling}@f48107a)
Exception in thread "main" java.lang.RuntimeException: ??
**/

 

 

코루틴을 병렬로 실행했을 때는 위와 같이 상태에 따른 처리는 필수적이며, arrow 에서 유용한 함수를 제공해주기 때문에 발생할 수 있는 상황을 미리 짐작해보고 대응할 수 있다.

 

하지만 최종으로 예외를 처리하는 것은 개발자의 몫인데 FP관점에서 보면 실패할 수 있는 상황에 대해선 Result, Either 와 같은 클래스를 사용하여 감싸도록 권하고 있다. (try catch 도 상관없다)

 

Either 를 사용한 실패 처리

정상적인 상황은 Either.Right 로, 예외 상황은 Either.Left로 표현된다.

catch 를 하였기 때문에 메인 스레드까지 예외가 전파되지 않는다.


fun main() = runBlocking {
    val result = Either.catch { parZipDelay() }
    println(result)
}

/**
will printed
fa coroutine finalized with: Failure(failure=java.lang.RuntimeException: Boom)
fb coroutine finalized with: Cancelled(exception=kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=ScopeCoroutine{Cancelling}@517b536a)
Either.Left(java.lang.RuntimeException: Boom)
**/

 

 

정리

코루틴은 주로 IO 상황을 다루기 때문에 안전하게 접근할 필요가 있다.

arrow 에서 제공하는 유틸 함수들로 다양한 상태에 따른 후처리를 할 수 있고, 부수 효과를 발생시키지 않도록 try-catch, Either 등을 통해 코루틴이 사용되는 지점마다 예외처리를 해주는 것이 좋다.

반응형

댓글