HOME > android > tips

안드로이드에서 Coroutine을 사용하는 방법

By JS | 26 Oct 2019

Coroutine은 가벼운 쓰레드(Light-weight thread)라고 할 수 있습니다. 쓰레드는 아니지만 비동기적인(asynchronous) 프로그래밍이 가능하게 만들어줍니다.

Coroutines은 Co + Routines 약자로,

  • Co 는 Cooperation을 의미하고
  • Routines는 functions를 의미합니다.

서로 협력하는 함수들이라는 의미인데, 이걸 간단히 Coroutine이라고 합니다. Coroutine은 여러 함수들이 번갈아가면서 실행되어 비동기적인 프로그래밍이 가능하다고 이해하시면 됩니다.

Kotlline의 Coroutine은 Framework으로 구현되어, 어떻게 동작하는지 코드를 보진 않았지만 다음 글을 읽으시면 어느정도 이해할 수 있습니다.

이 글에서는 원론적인 설명보다, 코루틴을 처음 접하시는 분들이 빠르게 코루틴을 이용할 수 있도록 가이드하려고 합니다.

라이브러리 설정

안드로이드에서 코루틴을 사용하려면 gradle에 다음 라이브러리를 추가해야 합니다.

dependencies {
  ...
  implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0'
  implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
}

가벼운 쓰레드

코루틴은 쓰레드는 아니지만, 쓰레드처럼 비동기적 프로그래밍이 가능합니다. 코드만 보면, 동기적으로(synchronous) 동작하는 것 같지만 비동기적으로 동작합니다. 먼저 코드와 실행 결과를 보시면 어떻게 동작하는지 이해할 수 있습니다.

다음 코드는 메인쓰레드에서 코루틴을 실행하는 예제입니다.

Log.d(TAG, "doing something in main thread")    // 1

GlobalScope.launch {    // 2
    delay(3000)
    Log.d(TAG, "done something in Coroutine")   // 3
}

Log.d(TAG, "done in main thread")     // 4

"// 1"처럼 숫자로 표기한 것을 아래에 자세히 설명하였습니다.

  1. main thread에서 로그를 출력합니다.
  2. launch { ... } 는 코루틴에서 작업을 수행하는 명령어입니다. 괄호 안의 코드들이 비동기적으로 수행됩니다.
  3. 코루틴에서 3초 쉬고, 로그를 출력합니다.
  4. 코루틴을 실행하고 메인쓰레드에서 다시 로그를 출력합니다.

결과를 보면, 메인쓰레드의 로그가 모두 출력되고, 코루틴은 delay로 인해 나중에 로그가 나중에 출력되었습니다.

10-26 19:52:42.975  7699  7699 D MainActivity: doing something in main thread
10-26 19:52:42.985  7699  7699 D MainActivity: done in main thread
10-26 19:52:45.992  7699  7727 D MainActivity: done something in Coroutine

코드를 보면 delay()가 먼저 실행되어 메인쓰레드의 로그가 나중에 출력될 것 같았지만, 코루틴은 비동기적으로 수행되기 때문에 메인쓰레드 코드들이 먼저 호출되었습니다.

GlobalScope는 코루틴이 실행되는 구간을 의미합니다. Scope는 아래에서 자세히 설명합니다.

launch, async

launchasync는 코루틴을 실행하는 명령어라는 공통점이 있지만 다음과 같은 차이점이 있습니다.

  • launch는 리턴 값이 없습니다.
  • async는 Deferred<T> 객체를 리턴합니다.

Deferred<T> 클래스는 await() 메소드를 제공합니다. 이 메소드는 작업이 완료되는 것을 기다리고 T 타입의 객체를 리턴합니다.

// Deferred.kt
public interface Deferred<out T> : Job {
  public suspend fun await(): T
  ...
}

다음 예제는 launch와 리턴값이 있는 async의 사용 방법을 보여줍니다.

GlobalScope.launch {    // 1
    launch {    // 2
        Log.d(TAG, "Launch has NO return value")
    }

    val value: Int = async {   // 3
        1 + 2  // 4
    }.await()   // 5

    Log.d(TAG, "Async has return value: $value")
}
  1. "GlobalScope.launch"는 코루틴을 실행합니다.
  2. GlobalScope.launch{ ... } 안에 launch { ... } 로 다른 코루틴을 실행할 수 있습니다.
  3. async{ ... } 로 코루틴을 실행합니다.
  4. "1 + 2" 연산 후 3을 리턴합니다.
  5. await()은 async의 코루틴이 끝날 때까지 기다리고 결과를 리턴합니다.

결과는 다음과 같습니다.

10-26 20:52:13.597  8158  8187 D MainActivity: Launch has NO return value
10-26 20:52:13.599  8158  8186 D MainActivity: Async has return value: 3

Suspend functions

코루틴 안에서 일반적인 메소드는 호출할 수 없습니다. 코루틴의 코드는 잠시 실행을 멈추거나(suspend) 다시 실행될(resume) 수 있기 때문입니다. 코루틴에서 실행할 수 있는 메소드를 만드려면 함수를 정의할 때 suspend를 붙여주면 됩니다. suspend 함수는 안에서 다른 코루틴을 실행할 수도 있습니다.

다음은 suspend 메소드의 예제입니다.

GlobalScope.launch {
    doSomething()
    Log.d(TAG, "done something")
}

private suspend fun doSomething() {
    GlobalScope.launch {
        sleep(1000)
        Log.d(TAG, "do something in a suspend method")
    }
}

결과는 다음과 같습니다.

10-26 21:05:18.990  8418  8446 D MainActivity: done something
10-26 21:05:19.990  8418  8447 D MainActivity: do something in a suspend method

Thread

코루틴은 여러 함수를 번갈아가면서 동작됩니다. 우리는 코루틴이 실행되는 쓰레드를 지정할 수 있습니다. 쓰레드를 지정해줘야 하는 이유는, 작업이 종류에 따라서 빠르게 처리되어야 하는 것이 있고, 안드로이드의 UI 작업은 Main thread에서만 수행되어야 하기 때문에 이런 경우 Main thread에서 코루틴이 실행되도록 해야 합니다.

다음은 코루틴이 IO 쓰레드에서 실행되도록 하는 코드입니다. launch()에 인자로 쓰레드 타입을 넘겨주면, 코루틴은 그 쓰레드에서 실행됩니다.

GlobalScope.launch(Dispatchers.IO) {
    doOnIOthread() // do on IO thread
}

안드로이드는 3개의 Dispatchers를 제공합니다.

  • Dispatchers.Main : 안드로이드의 메인 쓰레드입니다. UI 작업은 여기서 처리되어야 합니다.
  • Dispatchers.IO : Disk 또는 네트워크에서 데이터 읽는 I/O 작업은 이 쓰레드에서 처리되어야 합니다. 예를들어, 파일을 읽거나 AAC의 Room 등도 여기에 해당됩니다.
  • Dispatchers.Default : 그외 CPU에서 처리하는 대부분의 작업들은 이 쓰레드에서 처리하면 됩니다.

Main 쓰레드에서 코루틴을 실행하되, 몇몇 코루틴은 다른 쓰레드에서 실행하도록 할 수도 있습니다.

GlobalScope.launch(Dispatchers.Main) {    // 1
    val userOne = async(Dispatchers.IO) {     // 2
      fetchFirstUser()
    }
    val userTwo = async(Dispatchers.Default) {     // 3
      fetchSeconeUser()
    }
    showUsers(userOne.await(), userTwo.await())    // 4
}
  1. 코루틴을 Main 쓰레드에서 실행시킵니다.
  2. 이 작업은 IO 쓰레드에서 수행합니다.
  3. 이 작업은 Default 쓰레드에서 수행합니다.
  4. 이 코드는 Main 쓰레드에서 실행되며, 2와 3의 작업이 모두 끝날 때까지 기다리고 결과를 출력합니다.

withContext() 라는 메소드도 있습니다. 이것은 async와 동일한 역할을 하는 키워드입니다. 차이점은 await()을 호출할 필요가 없다는 것입니다. 결과가 리턴될 때까지 기다립니다.

suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T (source)

다음은 withContext를 사용하는 예제입니다.

GlobalScope.launch(Dispatchers.IO) {
    Log.d(TAG, "Do something on IO thread")   //  1
    val name = withContext(Dispatchers.Main) {    // 2
        sleep(2000)
        "My name is Android"
    }   
    // 3
    Log.d(TAG, "Result : $name")    // 4
}
  1. 이 코드는 IO 쓰레드에서 실행됩니다.
  2. 이 코드는 Main 쓰레드에서 실행됩니다.
  3. withContext() 다음 코드를 수행하지 않습니다. await()을 호출한 것처럼 결과가 리턴되기를 기다립니다.
  4. withContext()의 코루틴이 모두 수행되면 이 코드가 수행됩니다.

결과는 다음과 같습니다.

10-26 22:44:16.488  9723  9752 D MainActivity: Do something on IO thread
10-26 22:44:18.649  9723  9752 D MainActivity: Result : My name is Android

Scope

Scope는 코루틴이 실행되는 범위입니다. 지금까지 GlobalScope를 사용하였는데 이 스코프는 Application이 종료될 때 까지 코루틴을 실행시킬 수 있습니다. 만약 Activity에서 코루틴을 GlobalScope영역에서 실행시켰다면, Activity가 종료되도 코루틴은 작업이 끝날 때까지 동작합니다.

Activity에서 보여줄 이미지를 다운받고 있는데 Activity가 종료되었다면 그 작업은 불필요한 리소스를 낭비하고 있는 것입니다. Activity가 종료될 때 실행중인 코루틴도 함께 종료되길 원한다면 Activity의 Lifecycle과 일치하는 Scope에 코루틴을 실행시키면 됩니다.

다음 예제는 코루틴을 Activity scope에 실행시키는 코드입니다. 사실 Activity scope는 없기 때문에 Activity의 Lifecycle과 일치하는 Scope를 만들어줘야 합니다.

class MainActivity : AppCompatActivity(), CoroutineScope {    // 1
    private lateinit var job: Job     // 2
    override val coroutineContext: CoroutineContext   // 3
        get() = Dispatchers.Main + job
}
  1. CoroutineScope 인터페이스를 구현합니다.
  2. Job 객체를 선언합니다.
  3. CoroutineScope 인터페이스의 CoroutineContext 변수를 오버라이드합니다. "Dispatchers.Main"에 위에서 생성한 Job을 더 합니다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    job = Job()   // 1
    ....
}

override fun onDestroy() {
    super.onDestroy()
    job.cancel()    // 2
}
  1. Job 객체를 생성합니다.
  2. Activity가 종료(destroy)될 때 수행 중인 Job이 있다면 취소합니다.

이 Activity는 CoroutineScope를 구현했기 때문에 코루틴을 실행할 때 다음처럼 launch {} 키워드만 입력하면 됩니다.

launch {
    Log.d(TAG, "do something")
    ....
}

위처럼 클래스가 CoroutineScope를 구현하지 않고, 클래스 내에 객체를 생성해서 Scope를 만들 수 있습니다.

다음은 ViewModel과 동일한 Lifecycle을 같는 Scope를 생성하는 예제입니다. ViewModel 객체는 Activity가 완전히 소멸되면 함께 소멸됩니다.

class MyViewModel : ViewModel() {
    private val job = Job()     // 1
    private val uiScope = CoroutineScope(Dispatchers.Main + job)    // 2

    fun doSomeOperation() {
        uiScope.launch {    // 3
            val deferred = async(Dispatchers.Default) {
                10 + 10
            }
            Log.d(TAG, "await: ${deferred.await()}")
        }
    }

    override fun onCleared() {    // 4
        super.onCleared()
        job.cancel()     // 5
    }
}
  1. Job을 생성합니다.
  2. CoroutineScope 객체를 생성합니다.
  3. 코루틴을 실행할 때는 "uiScope.launch { .. }" 처럼, 위에서 생성한 uiScope를 사용합니다.
  4. ViewModel 객체가 소멸될 때 onCleared() 메소드가 호출됩니다.
  5. 실행 중인 코루틴이 있다면 Job을 취소합니다.

Exception handling

만약 아래 코드처럼 코루틴 안에서 Exception이 발생하면 어떻게 될까요?

GlobalScope.launch(Dispatchers.IO) {
    launch {
        throw Exception()
    }
}

앱이 갑자기 죽겠죠... 위의 코드는 다음과 같이 예외 처리할 수 있습니다.

GlobalScope.launch(Dispatchers.IO + handler) {    // 1
    launch {
        throw Exception()   // 2
    }
}

val handler = CoroutineExceptionHandler { coroutineScope, exception ->   // 3
    Log.d(TAG, "$exception handled!")
}
  1. launch()의 인자에 플러스 연산자로 "handler"를 추가합니다. 예외가 발생하면 이 handler로 콜백이 전달되어 예외처리를 할 수 있습니다.
  2. 예외를 발생시킵니다.
  3. 여기서 예외를 처리합니다.

위의 코드를 실행하면, 프로그램은 죽지 않고 아래와 같은 로그만 출력됩니다.

10-26 22:54:16.756 10279 10306 D MainActivity: java.lang.Exception handled!

asyncwithContext를 사용하는 경우 위의 방법으로 예외처리가 안됩니다. 아래와 같이 try-catch 구문으로 예외처리를 해야 합니다.

GlobalScope.launch(Dispatchers.IO) {
    try {
        val name = withContext(Dispatchers.Main) {
            throw Exception()
        }
    } catch (e: java.lang.Exception) {
        Log.d(TAG, "$e handled!")
    }
}

Proguard

Proguard(난독화)를 사용한다면 아래 Rule을 Proguard file에 추가해줘야 한다고 합니다.

# ServiceLoader support
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}

# Most of volatile fields are updated with AFU and should not be mangled
-keepclassmembernames class kotlinx.** {
    volatile <fields>;
}

정리

코루틴의 사용법 위주로 알아보았습니다. 코틀린이 무엇이고 정확히 어떻게 동작하는지 궁금하시다면 제가 참고한 글들을 더 읽어보시면 도움이 될 것 같습니다. 더 자세히 알고 싶으시다면 코드를 분석해보셔야할 것 같네요.

다른 분들의 블로그를 보면 코루틴을 사용하면서 성능 등의 문제가 발생해서 사용하면 안될 것 같다 라는 글들을 볼 수 있었습니다. 코루틴이 나온지 얼마 안되었기 때문에 예상하지 못한 버그를 만나실 수 있습니다. 비지니스 코드를 작성하시는 분들은 이런 점을 염두하시면서 사용하시면 좋을 것 같습니다.

참고