6 minute read

코루틴을 사용하다 보면 정말 중요하지만, 쉽게 이해하기 어려운 것이 코루틴의 예외 전파다. 이를 위해서는 코루틴의 구조적 동시성 (structured concurrency), 예외 전파 (exception propagation) 등에 대해 이해해야 한다. 이를 이론적으로 소개하기 보다는, 내가 직접 경험했던 케이스를 약간 단순화해서 소개해 보고자 한다.

업무 중 다루던 코드를 개편해 가는 과정을, 각 시도와 결과를 단계별로 소개하며 차근차근 설명한다. 코틀린에 익숙한 안드로이드 혹은 서버 개발자이시라면 도움이 될 내용이며, 코루틴에 대해 어느 정도 이해하고 읽는 것이 좋다.

무슨 일이 발생했나?

아래와 같은 서비스 코드를 리팩토링한다고 가정하자.

fun refreshItem(accountId: Long): ItemRegisterResponse {
    val transactionId = itemRequestTransactionService.initRequest(accountId, ItemRequestType.REFRESH)
    val accountAuth = userInformationRepository.findAccountAuthInfo(accountId)

    try {
        CoroutineScope(dispatcher).launch {
            val items = itemRepository.findAllByAccountId(accountId)
            items.forEach {
                val response = itemRemoteService.refreshItem(accountId, it, accountAuth.name)
                updateItem(it, response)
            }

            itemRequestTransactionService.setDone(transactionId)
        }
    } catch (e: ItemRegisterError) {
        itemRequestTransactionService.setFail(transactionId, e.error)
    } catch (e: Exception) {
        itemRequestTransactionService.setFail(transactionId, ItemErrorType.READ_TIMEOUT)
    }

    return ItemRegisterResponse(transactionId)
}

특정 사용자가 가지고 있는 모든 아이템들의 정보를 갱신하는 코드이다.

아래와 같은 목적을 가지고 리팩토링을 시도했다.

  • 병렬(동시) 처리
    • 각 아이템 정보 갱신을 동시적으로 실행
    • 아이템 정보를 받아 오는 API는 응답까지 시간이 오래 걸리기 때문에, 병렬(동시) 처리가 필요
  • 예외 전파
    • n개의 아이템 정보 중 1개 이상의 시도에서 예외가 발생할 경우, 전체 list에 대해 catch를 통해 handling
  • 비동기 처리
    • 갱신작업을 별도 스레드에 넘기고, refreshItem는 종료
  • 코루틴 필요 여부 분리
    • 코루틴을 필요로 하지 않는 부분은 스코프 바깥으로 분리

위 목적을 바탕으로 수정한 코드는 아래와 같다.

첫 수정

fun refreshItem(accountId: Long): ItemRegisterResponse {
    val transactionId = itemRequestTransactionService.initRequest(accountId, ItemRequestType.REFRESH)
    val accountAuth = userInformationRepository.findAccountAuthInfo(accountId)
    val items = itemRepository.findAllByAccountId(accountId)    // 스코프 밖으로 분리
    CoroutineScope(dispatcher).launch {
        try {   // try-catch 블록을 코루틴 안으로 이동
            items.forEach { item ->
                coroutineScope {    // 예외 전파를 위한 coroutineScope 사용
                    val response = itemRemoteService.refreshItem(accountId, item, accountAuth.name)
                    updateItem(item, response)
                }
            }

            itemRequestTransactionService.setDone(transactionId)
        } catch (e: ItemRegisterError) {
            itemRequestTransactionService.setFail(transactionId, e.error)
        } catch (e: Exception) {
            itemRequestTransactionService.setFail(transactionId, ItemErrorType.READ_TIMEOUT)
        }
    }

    return ItemRegisterResponse(transactionId)
}

위 코드는 앞에서 정의한 목적에 일부 부합하게 되었다.

  • 코루틴 필요 여부 분리
  • 예외 전파
  • 비동기 처리 (기존에도 부합하였음)

그렇지만, 해결되지 않은 문제가 하나 있었는데,

  • 병렬 (동시적) 처리
    • 위 코드는 실행 시 item.forEach의 내용을 suspend 없이 실행하며, 따라서 처리 속도가 forEach의 크기에 비례하게 된다.

언급한 모든 문제를 해결하는 과정을 테스트 코드를 통해서 각각 다루며 진행해 보자.

우선, 1차적으로 수정한 코드 내의 로직을 단순화해서 아래와 같이 작성했다.

@DisplayName("기본 코드 - 동시적 실행이 안 됨. 예외는 잘 잡아 줌")
fun `basic code`() = runTest {
    val list = (0..5).map { it }

    CoroutineScope(dispatcher).launch {
        try {
            list.forEach { num ->
                coroutineScope {
                    delay((1000L..2000L).random())
                    calledMethod(num)
                }
            }
        } catch (e: IllegalArgumentException) {
            println("illegal argument exception: ${e.message}")
        } catch (e: Exception) {
            println("exception: ${e.message}")
        }
    }
}

위 코드를 실행하면, 동시적 실행 외에 모든 내용은 의도대로 동작한다

test01

시도와 해결 과정

1. coroutineScope 사용은 적합하지 않다. Coroutine Builder인 launch를 사용해 보자.

우선, 기존 코드의 coroutineScopelaunch로 변경하였다.

하술하겠지만, coroutineScope는 scope builder이다. 여기서 필요한 것은 list 내 각 num 별로 새 코루틴을 만들어서 각각 실행시키는 것이다.

이를 위해 Coroutine builder 중 하나인 luanch(dispatcher)를 사용해서 동시성을 챙기도록 하였다.

@DisplayName("coroutineScope를 launch로 변경. 동시적 실행 잘 됨. 예외를 못 잡음")
@Test fun `swap coroutineScope to launch - concurrent works well - cannot catch exception`() = runTest {
    val list = (0..5).map { it }

    CoroutineScope(dispatcher).launch {
        try {
            list.forEach { num ->
                launch {	//  여기를 coroutineScope -> launch로 변경
                    delay((1000L..2000L).random())
                    calledMethod(num)
                }
            }
        } catch (e: IllegalArgumentException) {
            println("illegal argument exception: ${e.message}")
        } catch (e: Exception) {
            println("exception: ${e.message}")
        }
    }

    Thread.sleep(10000L)    // 코루틴 결과를 보기 위한 스레드 슬립
}

위 코드를 실행하면,

test02

동시성은 해결되었지만, 이번에는 예외 핸들링이 되지 않는다. 던진 예외를 잡지 못하는 모습이다.

bugfix

문제 하나를 해결하면 다른 문제가 나타나는 느낌…

2. try-catch를 바깥으로 옮겨 보자.

혹시 CoroutineScope.launch()가 예외를 throw하는 것일까? 하는 의문점이 생겼다. 이 부분을 확인해 보고자, try-catch를 아래와 같이 바깥으로 옮겨 보았다.

@DisplayName("try-catch를 바깥으로 옮겨 봄. 여전히 못 잡음")
@Test
fun `moved try-catch to outside - still not catching exception`() = runTest {
    val list = (0..5).map { it }
    try {   // try 블록을 바깥으로 옮기기
        CoroutineScope(dispatcher).launch {
            list.forEach { num ->
                launch {
                    delay((1000L..2000L).random())
                    calledMethod(num)
                }
            }
        }
    } catch (e: IllegalArgumentException) {
        println("illegal argument exception: ${e.message}")
    } catch (e: Exception) {
        println("exception: ${e.message}")
    }

    Thread.sleep(10000L)    // 코루틴 결과를 보기 위한 스레드 슬립
}

실행해 보면,

test03

딱히 바뀐 건 없다. 여전히 동시성은 해결되었지만, 예외 핸들링이 해결되지 않았다.

3. CoroutineScope(dispatcher.launch)를 coroutineScope로 교체해 보자.

위에서 예외 핸들링이 해결되지 않은 이유는, 코루틴에서 예외가 발생하는 경우 해당 코루틴을 발생시킨 코루틴 스코프에까지 예외를 전파시키기 때문이다 (구조화된 동시성 - structured concurrency).

예외 핸들링을 위한 별도의 스코프를 만들지 않았으니, 위 코드에서 예외가 발생한 경우 catch하지 못한 것은 실은 당연한 것이다. runTest 바깥까지 예외가 전파되게 된다.

exception-propagation

그렇다면, coroutine scope를 별도로 생성하지 않고, 이를 try-catch로 감싸 주면 어떨까?

이걸 위해서 scopeBuilder를 사용한다. scopeBuilder에는 앞에서 잠시 언급한 coroutineScope 외에도 globalScope 등이 존재하는데, 여기서는 단일 라이프사이클을 가지는 coroutineScope가 적합하다.

@DisplayName("coroutine Scope를 생성하고 이걸 try catch로 감싼다. 현재 함수가 대기해 버린다.")
@Test
fun `create a coroutine scope and wrap it with try catch - current function halts`() = runTest {
    val list = (0..5).map { it }
    try {
        coroutineScope {    // CoroutineScope.launch -> coroutineScope로 변경
            list.forEach { num ->
                launch {
                    delay((1000L..2000L).random())
                    calledMethod(num)
                }
            }
        }
    } catch (e: IllegalArgumentException) {
        println("illegal argument exception: ${e.message}")
    } catch (e: Exception) {
        println("exception: ${e.message}")
    }

    // Thread.sleep(10000L)  스레드를 슬립하지 않아도 결과를 볼 수 있다. 함수가 대기해 버려서.
}

이렇게 해 주면, callMethod() 호출 시 예외가 발생한다면, coroutineScope까지만 예외가 전파될 것이다.

이를 try-catch로 감싸 주었으니, 의도한 바와 같이 동작할 것이다.

test04

동시적 처리, 예외 처리 두 문제가 모두 해결된 것을 확인할 수 있다.

다만 기존에는 없는 새로운 문제가 생겼다. 실제 production 코드에서는 thread를 sleep 할 일이 없지만, 비동기 처리를 하는 경우, thread를 sleep하지 않으면 호출된 메서드가 종료되어 버리므로 결과를 확인할 수 없다.

그런데 위 코드의 경우에는 Thread.sleep()주석 처리되어 있다. 그럼에도 불구하고 결과를 확인할 수 있었다.

이 말은 비동기 처리가 정상적으로 이루어지지 않았다는 것을 의미한다.

4. 가장 바깥을 CoroutineScope().launch로 감싸 주자

앞서 비동기 처리를 위해서는 CoroutineScope를 사용해서 (coroutineScope 아님) 새 코루틴을 만들었다 (launch). 새 코루틴을 만듦으로써 해당 코루틴을 별도 스레드에서 실행되도록 분리했었다.

이쯤에서 CoroutineScopecoroutineScope의 차이점을 간단하게 짚어 보자. CoroutineScope는 코루틴이 소속되는 곳이며, 코루틴의 라이프사이클을 정의하기 위해 사용된다. coroutineScope는 suspend function 내에서 호출되며, 자식 코루틴들의 구조화된 동시성을 구성하기 위해 사용된다. CoroutineScope에 비해 라이프사이클이 더 짧기도 하다.

이 내용을 다시 적용해 보자. CoroutineScope().launch { }로 감싸 주면, 내부의 로직은 별도 코루틴으로 동시적으로 (concurrently) 동작하게 될 것이다.

structured-concurrency

@DisplayName("coroutine Scope를 생성하고 이걸 try catch로 감싼다. 현재 함수가 대기해 버린다.")
@Test
fun `create a coroutine scope and wrap it with try catch - current function halts`() = runTest {
    val list = (0..5).map { it }

    CoroutineScope(dispatcher).launch {
        try {
            coroutineScope {    // CoroutineScope.launch -> coroutineScope로 변경
                list.forEach { num ->
                    launch {
                        delay((1000L..2000L).random())
                        calledMethod(num)
                    }
                }
            }
        } catch (e: IllegalArgumentException) {
            println("illegal argument exception: ${e.message}")
        } catch (e: Exception) {
            println("exception: ${e.message}")
        }

    }

    // Thread.sleep(10000L)    // 코루틴 결과를 보기 위한 스레드 슬립
}

앞에서 예상한 바가 맞다면, 위의 코드를 실행 시

  • 동시적 처리
  • 예외 발생 시 핸들링
  • 비동기 처리

세 문제가 모두 해결되어야 한다.

test05_01

비동기로 잘 처리된 것으로 보인다.

그렇다면 Thread.sleep()의 주석을 풀고 한번 더 확인해 보자.

test05_02

최종적으로 작성하게 되는 production 코드는 아래와 같다.

fun refreshItem(accountId: Long): ItemRegisterResponse {
    val transactionId = itemRequestTransactionService.initRequest(accountId, ItemRequestType.REFRESH)
    val accountAuth = userInformationRepository.findAccountAuthInfo(accountId)
    val items = itemRepository.findAllByAccountId(accountId)

    CoroutineScope(dispatcher).launch {
        try {
            coroutineScope {
                items.forEach { item ->
                    launch(dispatcher) {
                        val response = itemRemoteService.refreshItem(accountId, item, accountAuth.name)
                        updateItem(item, response)
                    }
                }
            }
            itemRequestTransactionService.setDone(transactionId)
        } catch (e: ItemRegisterError) {
            itemRequestTransactionService.setFail(transactionId, e.error)
        } catch (e: Exception) {
            itemRequestTransactionService.setFail(transactionId, ItemErrorType.READ_TIMEOUT)
        }
    }

    return ItemRegisterResponse(transactionId)
}

번외

시도 2에서, 예외가 runTest 바깥까지 전파된다고 했었다. 그러면 만약 runTest 블록 전체를 try-catch로 감싸 준다면 어떨까?

@DisplayName("번외 - runTest를 try-catch로 감싸 보자")
@Test
fun `wrap the whole scope`() {
    try {
        runTest {
            val list = (0..5).map { it }
            CoroutineScope(dispatcher).launch {
                list.forEach { num ->
                    launch {    //  여기를 coroutineScope -> launch로 변경
                        delay((1000L..2000L).random())
                        calledMethod(num)
                    }
                }
            }
            Thread.sleep(10000L)    // 코루틴 결과를 보기 위한 스레드 슬립
        }
    } catch (e: IllegalArgumentException) {
        println("illegal argument exception: ${e.message}")
    } catch (e: Exception) {
        println("exception: ${e.message}")
    }
}

위에서 설명한 바에 따르면, calledMethod(num) 호출 시 예외가 발생하면, runTest에까지 해당 예외가 전파됩니다. 그렇다면 runTest를 try-catch로 감싸 주었으니, 예외 처리가 정상 동작해야 한다.

test_extra

예상대로 catch가 잘 된 것을 확인할 수 있다. 물론 기존에 정의했던 문제를 해결하는 적절한 방법은 아니다.

This post is also avilable in English.

Leave a comment