[번역] Kotlin Coroutines 101 - 코루틴 입문자를 위한 기본 사용법부터 테스팅까지

이 글은 위 영상(Kotlin Coroutines 101 - Android Conference Talks)을 번역/정리한 글입니다.
번역 오류, 오타 등 개선 사항이 있다면 댓글 또는 깃허브에 이슈나 PR 만들어 주시기 바랍니다 :)

이 글에서는 코루틴을 소개하고, 코루틴이 해결하려는 문제가 무엇인지, 안드로이드에서 코루틴을 어떻게 사용하는지, 테스트는 어떻게 하는지 알아보도록 하겠습니다.

Problems

코루틴이 해결하려는 문제는 비동기 프로그래밍을 간단히 하는 것입니다.
먼저 동기(synchronous) 프로그래밍에 대해 알아볼게요.

Synchronous

fun loadData() {
    val data = networkRequest()
    show(data)
}

데이터를 불러와서 화면에 띄워주는 loadData()라는 함수가 있습니다.
이 함수는 동기식이기 때문에 실행되는 중에는 쓰레드를 막고(Blocking) 있게 됩니다.
만약 이 함수를 메인 쓰레드(UI thread)에서 실행한다면 networkRequest()가 그 쓰레드를 막게 되는 것이죠.
그래서 OS가 onDraw()를 실행할 수 없고, 사용자는 networkRequest()가 끝나기 전까지 멈춰 있는 UI를 보게 됩니다.

networkRequest()는 아래와 같습니다.

fun networkRequest(): Data {
    // Blocking network request code
}

쓰레드를 막는 네트워크 요청 코드가 있고, 결과를 반환하는 함수입니다.

안드로이드에서 UI 쓰레드를 막는 일은 없어야 합니다.
그러면 어떻게 이 함수를 다른 쓰레드로 옮길까요?
한 가지 방법으론 콜백을 사용하는 것이 있습니다.


Asynchronous (Callback)

fun loadData() {
    networkRequest { data ->
        show(data)
    }
}

같은 버전의 loadData()이지만, networkRequest()를 다른 쓰레드에서 실행 되게 한 것일 뿐입니다.

이 경우에는 메인 UI 쓰레드에서 onDraw()를 계속 실행할 수 있습니다.
networkRequest()가 끝나면 콜백이 실행돼서 화면에 결과를 보여줄 것입니다.

여기서 networkRequest()의 코드를 살펴보면,

fun networkRequest(onSuccess: (Data) -> Unit) {
    DefaultScheduler.execute {
        // Blocking network request code
        postToMainThread {
            onSuccess(result)
        }
    }
}

DefaultScheduler.execute()로 이 함수의 실행을 다른 쓰레드로 옮겼고, (다른 쓰레드에서 실행된다는 것이 중요하기 때문에 DefaultScheduler.execute()는 모르셔도 됩니다.)
Data를 반환하는 대신에, onSuccess()라는 콜백을 호출하고 있습니다.

콜백은 몇몇 상황에서는 좋은 해결책이 될 수 있지만, 여전히 몇 가지 문제점이 있습니다.
지금은 콜백이 하나만 있는 간단한 함수지만, 만약 순차적으로 여러 작업을 실행해야 한다면 콜백은 계속 중첩될 것입니다.
이런 걸 바로 콜백 지옥 이라고 부르죠…

(생각만 해도 끔찍하네요😭)

여기서 바로 코루틴이 등장하게 됩니다.

Asynchronous (Coroutines)

suspend fun loadData() {
    val data = networkRequest()
    show(data)
}

아까와 같은 기능을 하는 함수 loadData()입니다. 첫 번째 코드에서 suspend 키워드만 붙었어요.
suspend를 붙이는 것은 기본적으로 코틀린 컴파일러에게 이 함수는 코루틴 안에서 실행되어야 한다고 알려주는 역할을 합니다.

코루틴은 쓰레드를 막지 않고 함수의 실행을 늦출 수 있습니다.

이 경우에 networkRequest()의 코드는 이렇게 생겼습니다.

suspend fun networkRequest(): Data {
    withContext(Dispatchers.IO) {
        // Blocking network request code
    }
}

일단 suspend 키워드가 붙었다는 것을 확인할 수 있지만 withContext(Dispatchers.IO)라는 처음 보는 함수가 나왔습니다.
withContext()는 파라미터로 Dispatcher를 받는 코루틴의 함수입니다.
Dispatcher는 기본적으로 “난 이걸 다른 특정한 쓰레드에서 실행하고 싶어!” 라고 코루틴에게 알려주는 역할을 하고, 이 경우엔 그 특정한 쓰레드가 IO 쓰레드입니다.
이 안에서 네트워크 코드를 실행함으로써 메인 쓰레드를 막지 않게 되죠.

Dispatchers

코루틴의 Dispatcher는 위에서 나온 Dispatchers.IO를 포함하여 총 세 개가 있습니다.

Dispatchers.Main
Dispatchers.IO
Dispatchers.Default

IO는 네트워크나 디스크 작업 즉, Input/Output 작업에 최적화되어있습니다.
Default는 CPU를 많이 사용하는 작업에 사용합니다.
마지막으로 Main은 UI 작업이나, 쓰레드를 막지 않고 빨리 실행되는 작업에 사용합니다.


지금까지 코루틴이 왜 나오게 되었는지, 코루틴이 해결하려는 문제가 무엇인지 알아봤습니다.
그럼 이제 본격적으로 코루틴에 대해 알아볼까요?

Coroutines

코루틴은 특정 블록에 있는 코드를 가져와, 다른 쓰레드에서 실행해줍니다.
비동기로 이루어지는 작업이 순차적으로 표현될 수 있어서 코드를 읽고 이해하기가 쉬워지기도 하고, 예외 처리와 Cancellation(중간에 실행을 취소하는 것)도 쉽게 할 수 있습니다.

다시 loadData()로 넘어와서, 사용자가 버튼을 클릭할 때마다 이 함수를 실행시키기 위해서 아래처럼 쓴다면,

fun onButtonClicked() {
    loadData()
}

suspend fun loadData() {
    val data = networkRequest()
    show(data)
}

오류를 내놓으면서 컴파일되지 않습니다.

Suspend fun `loadData` must be called from a coroutine

suspend 함수는 코루틴 안에서 실행되어야 해서 이런 오류가 발생하는 데요, 우리는 아직 코루틴을 어떻게 만드는지 모릅니다.
자세한 것은 뒤에서 알아보고, 일단 launch { }라는 함수를 사용하면 작동한다고 가정해 보겠습니다. (원래는 안됩니다.)

fun onButtonClicked() {
    launch {
        loadData()
    }
}

suspend fun loadData() {
    val data = networkRequest()
    show(data)
}

이렇게 할 수 있다면 아주 간단하지만 몇 가지 문제가 있습니다.
이 실행을 취소할 방법이 없다는 것입니다.
예를 들어, 사용자가 화면에서 나가면 이 실행을 어떻게 멈출까요?
그리고 실행이 실패해도 예외(Exceptions)를 받아줄 것이 없습니다.

이 문제를 해결하기 위해 새로운 개념인 Coroutine Scope가 나옵니다.

Scope

스코프는 그 스코프에서 생성된 코루틴을 계속해서 주시하면서 실행을 취소하거나, 실패 시 예외를 처리할 수 있게 해줍니다.

다시 코드를 보면, 이 코드는 사실 컴파일 에러를 내놓습니다.

fun onButtonClicked() {
    launch { // Error : Launch must be called in a scope
        loadData()
    }
}

launch는 스코프 안에서 호출되어야 한다고 하네요.
원래 있는 스코프(GlobalScope 등)를 쓸 수도 있지만(권장하지 않음) 코루틴의 생명주기(Lifecycle)를 제어하고 싶다면 코루틴 스코프를 만들어야 합니다.

이 경우에는 이렇게 만들어 보겠습니다.

val scope = CoroutineScope(Dispatchers.Main)

fun onButtonClicked() {
    scope.launch {
        loadData()
    }
}

이렇게 스코프 안에서 실행됨으로써, 그 스코프의 생명주기를 따라가게 됩니다.
만약 loadData()에서 예외(Exception)가 발생하면 그 스코프가 예외를 받고, 몇 가지 방법으로 처리(Handling)할 수 있죠.

그리고 코루틴은 스코프에 대해 계층 구조를 형성합니다.
우리가 만든 변수 scope가 부모(Parent)가 되고 그 스코프로 만들어진 코루틴(scope.launch { })이 자식(Child)입니다.

그래서 이 경우에 우리가 ViewModel안에 있다고 가정하고, 코루틴이 필요 없어질 때 즉, onCleared()가 호출될 때 scope.cancel()을 실행할 수 있습니다.
스코프를 cancel 하는 것은 이 스코프가 실행한 모든 자식 코루틴의 실행을 멈춘다는 의미입니다. cancel된 스코프는 다른 코루틴을 시작할 수 없고, 사용할 수 없게 됩니다.

fun onCleared() {
    scope.cancel()
}

다시 loadData()를 보면, 앞에 붙은 suspend는 코루틴 스코프 안에서 실행된다는 의미로 해석할 수 있습니다.

그리고 또 한 가지 중요한 것은, 일반 함수가 return 하면 그 함수의 작업이 끝났다는 의미인 것처럼, suspend 함수도 마찬가지입니다.

Exception Handling

이제 예외 처리를 어떻게 하는지 알아보겠습니다.

스코프는 아래 코드처럼 Job을 가질 수 있습니다.

val scope = CoroutineScope(
    Dispatchers.Main + Job()
)

fun onButtonClicked() {
    scope.launch {
        loadData()
    }
}

Job은 스코프와 코두틴의 생명주기를 지정할 수 있습니다.
그리고 우리가 스코프에 Job을 넘겨준다는 것은 특별한 방법으로 예외 처리를 하겠다는 뜻입니다.
만약 어떤 한 자식에서 예외가 발생한다면 스코프가 그것을 알아차리고 다른 모든 자식의 실행을 중지시킵니다.

반면에 SupervisorJob을 사용하면 얘기가 달라집니다.

val scope = CoroutineScope(
    Dispatchers.Main + SupervisorJob()
)

SupervisorJob을 사용하면 한 자식에서 예외가 발생해도 다른 자식들에게는 영향을 미치지 않습니다.

하지만 두 경우 모두 try catch로 예외 처리를 해야 하는 것은 같습니다. (아래에 예시가 나옵니다.)

Job의 주된 용도에는 생명주기 관리도 있지만, 여기에선 예외 처리의 관점에서만 설명했습니다.

Create Coroutines

지금까지는 launch()를 사용하여 코루틴을 만들었지만 이번엔 코루틴을 어떻게 만드는지 다른 방법도 자세히 알아보겠습니다.
코루틴을 만드는 방법에는 대표적으로 launch()async()가 있습니다.

이 둘의 공통점이라면

  1. 새로운 코루틴을 만든다.
  2. 하나의 Dispatcher를 가진다.
  3. 스코프 안에서 실행된다.
  4. suspend 함수가 아니다.

이런 것들이 있겠습니다.

하지만 이 둘은 사용 목적 자체가 다릅니다.

launch()는 단순히 그 자리에서 실행시키고 끝나는, async()는 어떤 값을 반환하는 코루틴입니다.
userId를 파라미터로 받고, User 객체를 반환하는 한 suspend 함수로 예를 들어보겠습니다.

suspend fun getUser(userId: String): User =
    coroutineScope { // 이 suspend function을 실행할 때 사용된 스코프를 사용할 수 있게 해줍니다.
        val deferred = async(Dispatchers.IO) {
            userService.getUser(userId)
        }
        deferred.await()
    }

async()Deferred라는 객체(자바에서 Future 또는 Promise와 비슷한)를 반환합니다.
그리고 await()를 호출하면 그 때 async() 블록이 실행되고 그 실행의 결과(User 객체)를 반환하게 됩니다.
이는 최종적으로 getUser()의 반환 값이 되죠.

이런 성질 때문에 예외 처리에도 차이가 있습니다.
launch()는 그 자리에서 바로 예외를 발생시키지만 async()await()를 만나면 예외를 발생시킵니다.

launch

scope.launch(Dispatchers.IO) {
    try {
        userService.getUser(userId)
    } catch(e: Exception) {
        // Handle Exception
    }
}

launch()의 경우 우리가 알던 대로 예외 처리를 해주면 됩니다.

async

suspend fun getUser(userId: String): User =
    coroutineScope {
        val deferred = async(Dispatchers.IO) {
            userService.getUser(userId)
        }
        try {
            deferred.await()
        } catch(e: Exception) {
            // Handle exception
        }
    }

하지만 async()는 실제 실행이 await()에서 이루어지기 때문에 deferred.await() 부분을 감싸주어야 합니다.

(코루틴에서 예외 처리를 하는 방법에는 try catch뿐만 아니라 CoroutineExceptionHandler 같은 것도 있으니 찾아보시면 도움이 될 것 같습니다 😊)

Cancelation

이제 다른 주제로 넘어와서, 아래와 같은 코드가 있다고 해봅시다.

scope.launch(Dispatchers.IO) {
    for (name in files) {
        readFile(name)
    }
}

이렇게 리스트에 있는 모든 파일을 읽어오는 작업을 하는 도중에 scope.cancel()을 실행하면 어떻게 될까요?
반복문의 실행을 멈출까요?

아쉽게도 그러지 않습니다.
무거운 작업을 하는 코루틴을 취소(Cancelation) 하기 위해서는 협력(Co-operation)을 해야 합니다.

위 코드에서 코루틴은 파일들을 읽어오기 바빠서 Cancelation을 인지할 여유가 없습니다.
따라서 무거운 작업을 할 때는 직접 Canceller를 작동하게 해줘야 합니다.

ensureActive() 또는 yield()로 코루틴이 살아 있는지(cancel()이 호출되었는지) 확인할 수 있습니다.

scope.launch(Dispatchers.IO) {
    for (name in files) {
        yield() // or ensureActive()
        readFile(name)
    }
}

스코프가 cancel 되고 ensureActive()yield()가 호출되면 실행 되고 있는 코루틴이 중지됩니다.

suspend를 써야 하는 경우

이 글에서 지금까지 많은 함수을 만들었는데요, 어떤 함수는 suspend가 붙어 있고, 어떤 함수는 그렇지 않습니다.
정확히 언제 suspend를 붙여야 할까요?

사실 간단합니다.
만들려는 함수 안에서 다른 suspend 함수를 호출할 때 suspend 키워드를 붙여주면 됩니다.

예를 들어, 위에서 만들었던 laodData()의 경우 suspend 함수입니다.

suspend fun loadData() {
    val data = networkRequest()
    show(data)
}

suspend fun networkRequest(): Data {
    withContext(Dispatchers.IO) {
        // Blocking network request code
    }
}

다른 suspend 함수인 networkRequest()를 호출하기 때문이죠.
그리고 networkRequest()는 또 다른 suspend 함수인 withContext()를 호출하기 때문에 suspend 함수입니다.

suspend를 쓸 필요가 없는 경우

suspend를 써야 하는 경우와 반대로, 함수 안에서 다른 suspend 함수를 호출하지 않는 경우엔 쓸 필요가 없습니다.
위에서 만들었던 onButtonClicked()의 경우 suspend 키워드가 붙지 않았습니다.

fun onButtonClicked() {
    scope.launch {
        loadData()
    }
}

launch { }suspend 함수가 아니기 때문이죠.

Testing

이제 코루틴 테스팅에 대해 알아보겠습니다.
(기존에 테스팅을 해본 경험이 있으면 이해하기 쉽습니다.)

두 가지 UseCase(쉽게 경우라고 이해하시면 됩니다.)를 볼건데요, 첫 번째는 새로운 코루틴을 생성하지 않는 경우입니다.

UseCase 1

위에서 만들었던 loadData()를 테스트해보겠습니다.

// MyViewModel.kt

suspend fun loadData() {
    val data = networkRequest()
    show(data)
}

loadData()async()launch()를 실행하지 않습니다.
networkRequest()withContext()로 다른 쓰레도르 이동한다 하더라도 새로운 코루틴을 생성하지는 않습니다.

이 경우엔 runBlocking { }을 사용하여 테스트할 수 있습니다. runBlocking { }은 코루틴 라이브러리 안에 있는 함수인데, 새로운 코루틴을 시작하고 그 블록 안의 모든 실행이 끝날 때까지 기다려줍니다.

그래서 뷰 모델 인스턴스를 만들고 loadData()를 실행하면 이는 순서대로 실행됩니다.

@Test
fun testLoadData() = runBlocking {
    val viewModel = MyViewModel()
    viewModel.loadData()

    // Assert something
}

loadData() 코드 다음에 올바르게 실행되었는지 assert 해주면 됩니다.

이번 건 꽤 쉬웠지만 UseCase 2가 조금 어려운 경우입니다.

UseCase 2

UseCase 2는 새로운 코루틴을 생성하는 것을 테스트하는 것입니다.

// MyViewModel.kt

class MyViewModel {
    val scope = CoroutineScope(
        Dispatchers.Main + SupervisorJob()
    )

    fun onButtonClicked() {
        scope.launch {
            loadData()
        }
    }
}

예를 들어 MyViewModelonButtonClicked()를 테스트한다고 하면, 이는 새로운 코루틴을 생성하기 때문에 runBlocking { }으로 테스트할 수 없습니다.

만약 runBlocking { } 으로 테스트한다고 하면 onButtonClicked()는 새로운 코루틴을 실행시키기만 하고 끝나버리기 때문에 테스트 함수도 그 코루틴이 끝나기 전에 끝날 것입니다.

Assert 할 때까지 일정 시간을 기다리게 하는 방법도 있지만, 이는 당연히 좋지 않은 방법입니다.

따라서 이런 경우에는 코루틴이 특정한 Dispatcher에서 실행되도록 강제하여 테스트 코드에서 제어할 수 있게 해야 합니다.

위의 예제에서는 MyViewModel의 생성자를 통해 Dispatcher를 주입해줄 수 있습니다.

// MyViewModel.kt

class MyViewModel(
    private val dispatcher: CoroutineDispatcher
) {
    val scope = CoroutineScope(
        Dispatchers.Main + SupervisorJob()
    )

    fun onButtonClicked() {
        scope.launch(dispatcher) {
            loadData()
        }
    }
}

그러면 테스트 코드에서는 테스트 전용으로 만들어진 Dispatcher를 만들어서 주입해줄 수 있습니다.

잠깐!
TestCoroutineDispatcherrunBlockingTest { }를 사용하기 위해서는 gradle에 아래의 의존성을 추가해야 합니다.
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.4'

// MyViewModelTest.kt

val testDispatcher = TestCoroutineDispatcher()

@Test
fun testButtonClicked() = testDispatcher.runBlockingTest {
    val viewModel = MyViewModel(testDispatcher)
    viewModel.onButtonClicked()

    // Assert something
}

그러면 onButtonClicked() 안에서는 testDispatcher에서 새로운 코루틴이 만들어질 것이고, 테스트 코드는 testDispatcher에서 runBlockingTest { }로 실행되기 때문에 모든 코루틴이 순서대로 실행됩니다.


그런데 onButtonClicked()에서 코루틴을 실행하기 전에 무언가 한다고 생각해봅시다.

// MyViewModel.kt

fun onButtonClicked() {
    // Do something else
    scope.launch(dispatcher) {
        loadData()
    }
}

이런 경우엔 약간의 코드를 추가해주어야 합니다.
TestCoroutineDispatcher에는 코루틴의 실행을 중지하고, 다시 시작할 수 있는 함수가 있습니다.

// MyViewModelTest.kt

val testDispatcher = TestCoroutineDispatcher()

@Test
fun testButtonClicked() = testDispatcher.runBlockingTest {
    val viewModel = MyViewModel(testDispatcher)

    testDispatcher.pauseDispatcher()
    viewModel.onButtonClicked()
    // 1. Assert onButtonClicked did something else

    testDispatcher.resumeDispatcher()
    // 2. Assert show did something
}

1번은 코루틴이 시작되기 전까지의 코드가 모두 끝난 시점, 2번은 코루틴까지 모두 끝난 시점입니다.

요약

  • 새로운 코루틴을 생성하지 않는 함수(주로 suspend fun)를 테스트할 때,
    runBlocking { } 사용
  • 새로운 코루틴을 생성하는 함수를 테스트할 때,
    Dispatcher를 주입해주고 runBlockingTest { } 사용

테스팅 부분은 아마 이 글 만으론 이해하기가 쉽지 않을 것으로 생각합니다. (제가 그랬거든요😅)
Testing Coroutines on Android - Android Developers youtube 이 영상과
Kotlin.github.io 공식 문서 이 글을 보면 이해 확실히 이해가 되실 겁니다.

마무리

저도 공부하는 겸 처음으로 쓴 번역 글인지라 부족한 점이 많습니다.
작은 오타나 단어 수정도 좋으니 피드백 주시면 정말 감사하겠습니다 😄

24분짜리 영상이니만큼 긴 글이었는데 읽어주셔서 감사하고, 도움이 되셨길 바랍니다 :)

Tags: , , ,

Categories:

Updated:

블로그에 기여하기

Leave a comment