[Kotlin] Coroutine `suspend` 함수 활용

H43RO·2021년 8월 22일
13

Kotlin 과 친해지기

목록 보기
6/18
post-thumbnail

💡 코틀린 공식 문서를 참고하여 작성한 글입니다 - Composing suspending functions | Kotlin

이전 포스팅과 이어집니다! [Kotlin] Coroutine 살짝쿵 더 맛보기

Suspending Function 지지고 볶기!

🚀 suspend 함수 순차 실행 해보기

아래와 같은 두 가지 suspend 함수가 있다. 이 둘은 각각 실생활로 접목해보자면 네트워킹, DB 트랜잭션 등의 동작을 한다고 가정해보자. 더 쉬운 이해를 위해 delay 를 걸어줌으로써 동작 지연을 시켜보자.

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 대충 개 쩌는 일을 하는 중
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 대충 개 쩌는 일을 하는 중
    return 29
}

그럼 만약, 첫 번째 함수의 결과를 기반으로 두 번째 함수를 호출하여 최종적으로 결과를 얻고 싶다면 어떻게 해야할까? 어거지로 예를 들어서 13 + 29 를 수행하기 위해 13이라는 결과가 먼저 필요하다고 가정해보자.

13 + 29 을 수행하기 위해, 첫 번째 함수를 호출하여 '13'이라는 결과를 얻은 뒤 두 번째 함수를 호출하여 '29'라는 결과를 받아 이 둘을 합치는 동작을 생각해볼 수 있다. 아래처럼 말이다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val result = doSomethingUsefulOne() + doSomethingUsefulTwo()
        println("13 + 29 는 $result 입니다")
    }
    println("Completed in $time ms")
}

measureTimeMillis 를 활용하여 '42' 라는 결과가 나오기까지의 소요 시간을 측정해보자. 어떤가?

예상한 대로다. 두 함수가 모두 1초씩 딜레이 되기 때문에, 같은 코루틴 스코프 내에서 이 둘을 순차적으로 진행했을 때 당연하게도 약 2초가 소요된다.


🚀 async 를 활용하여 동시성 보장해보기

그럼 만약, 이 두 함수가 반환하는 값 사이에 아무런 상관관계가 없어서 동시에 호출을 해도 되는 상황이라고 가정해보자. 즉, 동시성 프로그래밍을 접목하는 상황이다. 이럴 땐 async 라는 녀석을 사용한다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("13 + 29 는 ${one.await() + two.await()} 입니다")
    }
    println("Completed in $time ms")
}

이전 포스팅에서 다뤘던 launch 라는 녀석과 형태, 용법이 비슷한 느낌이다. async 역시 각각 분리된 새로운 코루틴을 생성하여 다른 코루틴들과 동시에 함께 동작하게끔 한다. launch 와의 차이점이라 하면, launchJob 객체를 반환하는 반면 asyncDeferred 라는 녀석을 반환한다. Deferred 녀석은, 해당 코루틴 스코프 내에 정의된 모든 동작을 수행 한 뒤에 언젠가 어떤 결과를 꼭 제공해주겠다는 약속을 한다.

따라서 우리는 이러한 Deferred (지연) 되는 값에 대해서, await() 를 사용하여 언젠가 최종적인 수행 결과를 얻어볼 수 있다. 물론, Deferred 객체도 Job 객체의 일종이기 때문에 언제든지 작업을 취소할 수 있다.

아무튼, async 스코프를 통해 두 함수를 호출하고, 결과 출력에 있어 Deferred 객체 각각의 await() 메소드를 사용하게 되면 아래와 같이 명백하게 동시성이 보장되는 것을 확인할 수 있다. 코루틴 동작 코드를 보면, 코루틴 객체 각각이 동시에 실행된다는 것 자체가 명료하게 표현되어 있어 편리하다.


🚀 게으르게(?) async 동작 시작해보기

프로그래밍 세계관에서 항상 등장하는 Lazy 를 깔끔하게 한국어로 옮기지를 못하겠다. 실제로 깔끔하게 옮긴 사례를 본 적이 없기도 하고.

async 녀석은 Job 의 일종이기 때문에 마찬가지로 시작 시점을 마음대로 정할 수 있다. async 의 생성자에다가 CoroutineStart.LAZY 라고 기입해주면, 다른 거 하다가 해당 코루틴의 동작이 필요할 때 시작할 수 있도록 구현할 수 있다.

예를 들어 아래 코드와 같이, LAZY 한 코루틴 객체 두 녀석을 만들고 대충 의미있는 동작을 하다가 언젠가 13 + 29 의 결과가 필요할 때, 이들을 호출하는 것이다. 결국 DefferedJob 의 일종이기 때문에 start() 를 통해서 코루틴 실행을 시작할 수 있고, 마찬가지로 await() 를 통해 결과를 받아 이용하면 된다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }

        // 대충 의미있는 동작 하다가 ...

        one.start() // 첫 번째 코루틴 동작 시작
        two.start() // 두 번째 코루틴 동작 시작
        println("13 + 29 는 ${one.await() + two.await()} 입니다")
    }
    println("Completed in $time ms")
}

이전처럼 바로 코루틴 동작을 시작해서 결과를 받아볼 수 있을 뿐만 아니라, LAZY 하게 동작하도록 하여 프로그래머 (개발자) 가 코루틴 각각의 시작 시점도 정해볼 수 있다는 점이 핵심이다.

그럼 만약, 이 때 start() 를 호출하지 않게 되면 어떻게 될까? LAZYasync 두 녀석을 선언해두고 await() 로 결과만 받아보자. 아래처럼 말이다.

fun main() = runBlocking {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }

        // 대충 의미있는 동작 하다가 ...

//        one.start() // 첫 번째 코루틴 동작 시작
//        two.start() // 두 번째 코루틴 동작 시작
        println("13 + 29 는 ${one.await() + two.await()} 입니다")
    }
    println("Completed in $time ms")
}

결과는 다음과 같다.

왜 이러는 걸까? 동시성이 보장되지 않았다. 이유는, asyncLAZY 하게 선언해놓고 start() 를 호출하지 않은 채 await() 를 호출하게 된다면 해당 코루틴의 결과가 나올 때까지 기다리며 실행되는 특성이 있어 순차적으로 진행되어 버리기 때문이다. 따라서 LAZY 의 경우 상황에 맞게 적절히 사용하는 것이 중요하다.


🚀 suspend 없이 일반 함수로 비동기 구현해보기

우리는 GlobalScopeasync 를 활용하여, 일반 함수도 비동기적으로 동작할 수 있게 할 수 있다. 비동기적으로 동작함을 명시하기 위해, 함수명 뒤에 Async 를 붙여주자! (추후에 GlobalScope 의 정체에 대해서 포스팅을 한 번 해보겠다)

// 이 녀석들도 나름 `async` 이기 때문에 Deferred 를 반환한다
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async {
    doSomethingUsefulOne()
}

@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulTwoAsync() = GlobalScope.async {
    doSomethingUsefulTwo()
}

⛔️ 헷갈리지 말자!

이들은 Suspending Function 이 절대 아니다. 코루틴 스코프가 아니여도 어디서든 쓰일 수 있다. 하지만, 이들은 항상 비동기적으로 (동시성을 띄며) 동작하도록 한다.

// 이전 예제들과 다르게 main() 에 붙은 runBlocking 사라짐 
fun main() {
    val time = measureTimeMillis {
        // 이런 식으로, 코루틴 스코프 밖에서 Deferred 객체 생성이 가능하다!
        val one = somethingUsefulOneAsync()
        val two = somethingUsefulTwoAsync()

        // 하지만, 결과를 받아보는 `await()` 등의 동작은 무조건 코루틴 스코프 내에서 이루어져야 한다.
        // 아래 runBlocking 을 통해 13 + 29의 결과인 42가 나올 때 까지 메인 쓰레드를 블로킹하여 시간을 잰다.
        runBlocking {
            println("13 + 29 는 ${one.await() + two.await()} 입니다")
        }
    }
    println("Completed in $time ms")
}

🙅🏻 하지만 절대 이러한 패턴을 사용하면 안된다. (알려줘놓고..?)

이러한 비동기 스타일의 일반 함수를 사용하는 예제는, 공식 문서에도 나와있듯이 '다른 프로그래밍 언어에서 많이 사용되는 스타일이기 때문에 보여주기 식으로 제공' 되는 것이다. 코틀린에서는 이러한 스타일을 절대 사용하지 말 것을 권고한다. 이유는 다음과 같다.

만약 Async 스타일 함수를 호출하는 부분과 해당 함수의 Deferred 객체의 await() 를 호출하는 부분 사이에서 어떤 에러가 발생하여 프로그램이 Exception 을 쓰로잉하고 프로그램이 중단되는 경우를 생각해보자.

일반적으로 어떤 오류 핸들러가 이 Exception 을 감지해서 개발자에게 로그를 보여주는 등의 동작을 할 수도 있고, 이런 게 아니라면 그냥 다른 동작을 시작하기 마련이다.

하지만, 우리가 호출한 Async 함수는 이를 호출한 쪽은 이미 중단되었음에도 불구하고 백그라운드상으로 계속 실행되어 있게 되는 문제가 발생한다.

아래에서 설명하는 '구조적 동시성 프로그래밍' 기법에선 이러한 문제를 방지할 수 있다.

🚀 async 를 활용한 구조적 동시성 프로그래밍

한 번 아래와 같이 Suspending Function 으로 조금 위에서 사용했던 동시성 계산 코드를 빼보자.

suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

이렇게 하면 concurrentSum() 내부의 자식 코루틴 스코프 둘 중 하나에게 어떠한 에러가 발생하면, 상위 코루틴 스코프 coroutineScope 의 실행이 중단되어 모든 자식 코루틴이 종료된다!

fun main() = runBlocking {
    val time = measureTimeMillis {
        println("13 + 29 는 ${concurrentSum()} 입니다")
    }
    println("Completed in $time ms")
}

그리고 아래와 같이 정상적으로 한 코루틴 스코프 내의 두 코루틴 객체가 동시에 실행된다.

그리고 오류가 발생하더라도, 항상 상위 계층으로 전파된다.

아래의 예시를 보자. 오류가 발생하는(?) failedConcurrentSum() 내부에는 두 코루틴 객체 각각이 있고 두 번째 녀석은 ArithmeticException 을 발생하는 녀석이다. 이 함수 자체를 try-catch 로 감쌌을 때, 어떤 결과를 뱉는지 보자.

fun main() = runBlocking {
    try {
        failedConcurrentSum()
    } catch(e: ArithmeticException) {
        println("Computation failed with ArithmeticException")
    }
}

suspend fun failedConcurrentSum(): Int = coroutineScope {
    val one = async {
        try {
            delay(Long.MAX_VALUE) // 매우 오래 걸리는 연산 ㅇㅅㅇ
            42
        } finally {
            println("First child was cancelled")
        }
    }
    val two = async<Int> {
        println("Second child throws an exception")
        throw ArithmeticException()
    }
    one.await() + two.await()
}

failedConcurrentSum()두 번째 자식 코루틴 녀석 안에서 try-catch 로 감싸지지 않은 채 ArithmeticException 가 쓰로잉되어서, 첫 번째 코루틴 녀석도 취소되어 "First child was cancelled" 을 출력했다. 그리고 main() 안의 catch() 문에서 이 오류를 캐치해냈다.

이를 통해 coroutineScope() 안에서 오류가 발생하면 해당 코루틴 자체가 중단되어 다른 자식(?) 코루틴도 중단되고, 결국 최상위 계층까지 오류가 전파되는 사실을 알 수 있다. 따라서, 백그라운드 상으로 코루틴이 남아있는 문제는 발생하지 않는다.


이번 시간에는 Suspending Function 들을 구성하는 방법에 대하여 알아보았다. 다음 포스팅에선 코루틴 동작의 취소, 타임아웃 처리 등을 어떻게 하는 지 알아보겠다!

profile
어려울수록 기본에 미치고 열광하라

1개의 댓글

comment-user-thumbnail
2021년 11월 26일

코루틴 공부하는 중인데 넘 어렵네요 ㅠ

답글 달기