본문 바로가기
안드로이드/기타

Coroutine(2) - 코틀린에서 코루틴 사용하기

by 나이아카 2026. 2. 4.
반응형

https://no-dev-nk.tistory.com/140

 

Coroutine(1) - 코루틴이란?

안드로이드 개발자로서 코루틴은 뗄레야 뗄 수 없는 사이가 되었습니다. 당연하게도 모든 앱에서 코루틴이 들어가고 있죠. 하지만 간만에 봤던 전화면접에서 코루틴에 대해서 설명하라고 하는

no-dev-nk.tistory.com

 지난 번에는 코루틴에 대해서 정리했습니다. 이번에는 안드로이드 개발자 답게 코틀린에서 어떻게 코루틴을 사용하고 어떤식으로 정의해두었는지 하나씩 살펴보려고 합니다.


CoroutineScope

 이전에 코루틴의 정의에 대해서 알아봤으니, 코틀린에서 코루틴을 사용하기 위해 만들어진 것들이 무엇이 있는지 알아보겠습니다. 코틀린에서는 가장 기본적으로 CoroutineScope를 이용해 코루틴을 실행합니다. 코루틴은 기본적으로 Scope 내부에서 실행되어야 하는데요. 이때 그 Scope을 정하는 것을 CoroutineScope 객체라고 볼 수 있겠습니다. 이 CoroutineScope은 코루틴을 실행할 수 있는 범위(Scope)를 제공하며, 단순히 launch를 호출하는 객체가 아니라 내부에 코루틴이 실행되는 환경 정보(Dispatcher, 코루틴 이름 등)와 이 scope 내에서 실행되는 작업 등을 가지고 있습니다. 즉, CoroutineScope는 Job + CoroutineContext를 묶어서 코루틴의 생명주기와 실행 환경을 관리하는 역할을 합니다.

 

Job과 CoroutineContext

 그렇다면 CoroutineScope에서 관리하는 Job과 CoroutineContext는 무엇일까요? 먼저 JJob은 코루틴의 생명주기(시작/완료/취소) 를 표현하고 제어하는 핸들입니다. 이 Job을 통해 현재 작업 중인 코루틴을 취소하거나, 실행 상태를 확인하거나 하는 상태 확인 및 조절 기능을 가능하게 합니다. 뿐만 아니라 하나의 scope 내에 여러가지 launch같은 것들을 통해 자식 코루틴들이 구성되면 이를 부모 코루틴을 통해 제어하는 기능도 할 수 있습니다. 단, 부모 코루틴이 취소되는 경우 자식 코루틴들도 같이 취소됩니다(구조적 동시성). 이렇게 생성된 CoroutineScope을 관리하기 위해 Job이라는 객체를 이용합니다.

 CoroutineContext는 코루틴의 정보를 담고 있다고 생각하면 될 것 같습니다. 코루틴의 Dispatcher나 Job, 그리고 실행중인 코루틴의 이름등을 key-value 형태로 보관하고 있습니다.

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

코틀린에서는 위와 같이 + 기호를 통해 여러 Context를 조합할 수 있습니다. 기본적으로 파라미터를 주입하지 않는 경우, 코루틴은 기본적으로 시작한 Scope의 Context를 그대로 상속하며, 안드로이드에서는 viewModelScope 같은 Scope가 보통 Main을 기본으로 가지기 때문에 Main에서 실행되는 경우가 많습니다. 이때 Context를 변경하고 싶으면 scope 내부에서 withContext를 통해 변경해주면 됩니다. 주로 아래와 같이 사용됩니다.

scope.launch(Dispatchers.Main) {
    val result = withContext(Dispatchers.IO) {
        // IO에서 실행
        "done"
    }
    // 다시 Main으로 돌아옴
}

 그렇다면 여기서 나온 Dispatcher가 무엇인지 한 번 확인해야 할 텐데요. Dispatcher는 쓰레드를 관리하는 추상화 계층으로, 코루틴을 실행할 스레드 풀을 정하는 선택지같은 것이라고 생각하면 되겠습니다. 기본적으로 코루틴은 코루틴을 시작한 scope이나 Dispatcher에 의해 정해진 스레드에서 동작하게 되는데, 필요하다면 withContext 같은 방법으로 명시적으로 다른 Dispatcher로 전환해 원하는 스레드 풀에서 실행되도록 할 수 있습니다.

 Dispatcher의 경우 대표적으로 IO, Main, Default 이렇게 세가지가 존재하고 있습니다.

 IO의 경우 읽기/쓰기를 관리하는 부분으로 네트워크 통신이나 DB, 파일의 읽기/쓰기 등 대기시간이 긴 동작들을 수행하는데 사용됩니다. 연산량보다는 기다림이 긴 경우 IO로 보낸다고 생각하면 조금 쉽게 이해할 수 있을 것 같습니다.

 그 다음은 Main 인데요. 주로 UI를 담당하고 있는데, 안드로이드의 경우 Main Thread가 이 풀에 속해있습니다. 기본적으로 안드로이드 프레임워크 상에서 별다른 조치없이 사용하는 모든 코드는 이 Main에서 동작한다고 보면 되겠습니다.

 마지막으로 Default는 CPU의 연산이 많은 작업을 구성할 때 선택하면 되겠습니다. 실제로 하드웨어 상에서 명시적으로 IO와 Default가 구별되어 서로를 전혀 침범하지 않는다 이런 개념은 아니지만, 내부적으로 합리적인 방안을 선택해 스레드를 관리하게 되므로 규칙과 같이 두고 사용하면 좋습니다.

 자주 사용되지는 않지만, Dispatchers.Unconfined 라는 선택지도 존재합니다. 이는 특정 스레드에 구속되지 않고 일단 현재 호출한 스레드 내에서 실행되지만, 추후 중단 후 재개되었을 때 재개 시점에 따라 스레드풀에 구애받지 않고 어떤 스레드에서든 실행될 수 있습니다. 이때 어떤 스레드에서 다시 작업이 재개되는 지 알 수 없으므로 실행 스레드를 예측하기 어렵습니다. 그래서 실행 스레드에 대한 가정이 전혀 없는 코드에서만 제한적으로 사용하는 편입니다.

 그러나 이 Dispatcher 자체로 스레드의 스케줄러 역할을 하는 것은 아닙니다. 디스패쳐는 지정된 선택지를 스케쥴러에게 전달해 스케쥴러가 이를 기반으로 좀 더 효율적으로 판단할 수 있게 돕는 역할을 합니다. 그렇기 때문에 Dispatcher.Default로 실행하더라도 Dispatcher.IO와 동일한 스레드에 배정될 수도 있습니다(하지만 Main의 경우 정확하게 분리됩니다).

 

Android에서 사용하는 Scope

 안드로이드에서는 CoroutineScope를 직접 생성해서 사용하기보다는 생명주기에 종속된 다른 방식들을 더 많이 사용합니다. 코루틴 역시 프로그램의 상태에 따라 관리되지 않으면 메모리 누수등의 이슈가 발생할 수 있기 때문에, 안드로이드에서는 뷰의 생명주기에 맞는 viewLifecycleowner.lifecyclescope나 viewModel에 종속되는 viewModelScope 등을 많이 사용합니다. 대부분의 경우 화면을 벗어나는 경우 실행중이던 코드가 취소되는 편이 더 합리적이기 때문에 생명주기에 종속시켜 사용하게 됩니다. 물론 화면을 벗어나더라도 호출중인 데이터를 그대로 받아들이는 경우에는 coroutineScope나 GlobalScope를 사용할 수 있긴 하지만 다른 여러가지 대안들이 존재하고 있어 권장되는 방식은 아닙니다.

 안드로이드에서는 코루틴을 4가지 방식으로 사용할 수 있습니다.

  • viewModelScope: ViewModel의 생명주기에 존속
  • lifecycleScope: Activity/Fragment(viewLifecycleowner.lifecyclescope)의 생명주기에 존속
  • CoroutineScope: 수명/취소 관리 책임을 개발자가 가지는 Scope
  • GlobalScope: 특정 화면/컴포넌트와 무관하게 앱 프로세스 수명에 가깝게 동작하며, 프로세스 종료 시 함께 종료(관리가 어려워 직접 사용은 지양하는 편)

 먼저 viewModelScope부터 알아보겠습니다. viewModelScope은 viewModel의 생명주기에 맞추기 위해서 정의된 scope입니다. 이는 ViewModel이 사라지는 시점(onCleared)에 연결된 코루틴도 같이 종료가 된다는 뜻인데요. ViewModel에서 사용하는 코루틴은 아마도 ViewModel 내에서 비즈니스 로직을 처리해 UI로 전달하려는 목적이 강할 것입니다. 그렇다는 건, ViewModel이 사라지고 난 후에는 코루틴의 쓰임이 끝이 난다고 볼 수 있죠. 이러한 경우 매 번 onCleared에서 scope.cancel을 하는 것을 방지하기 위해 viewModelScope이 등장하게 되었습니다.

public val ViewModel.viewModelScope: CoroutineScope
    get() =
        synchronized(VIEW_MODEL_SCOPE_LOCK) {
            getCloseable(VIEW_MODEL_SCOPE_KEY)
                ?: createViewModelScope().also { scope ->
                    addCloseable(VIEW_MODEL_SCOPE_KEY, scope)
                }
        }
        
        
internal fun createViewModelScope(): CloseableCoroutineScope {
    val dispatcher =
        try {
            // In platforms where `Dispatchers.Main` is not available, Kotlin Multiplatform will
            // throw
            // an exception (the specific exception type may depend on the platform). Since there's
            // no
            // direct functional alternative, we use `EmptyCoroutineContext` to ensure that a
            // coroutine
            // launched within this scope will run in the same context as the caller.
            Dispatchers.Main.immediate
        } catch (_: NotImplementedError) {
            // In Native environments where `Dispatchers.Main` might not exist (e.g., Linux):
            EmptyCoroutineContext
        } catch (_: IllegalStateException) {
            // In JVM Desktop environments where `Dispatchers.Main` might not exist (e.g., Swing):
            EmptyCoroutineContext
        }
    return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob())
}

 위 코드는 viewModelScope이 생성되는 코드입니다. 안드로이드 개발자라면 보통 ViewModel을 상속받는 클래스 내에서 viewModelScope.launch를 이용해 코루틴을 생성하고 사용할 것입니다. 이때 호출하는 함수는 위의 ViewModel.viewModelScope이며 여기서 createViewModelScope()를 호출해 코루틴을 생성하는 작업을 합니다.

createViewModelScope 메소드는 기본 Dispatcher로 Dispatchers.Main.immediate를 선택하고, 해당 플랫폼에서 Dispatchers.Main을 사용할 수 없는 경우에는 EmptyCoroutineContext로 대체한 뒤 CloseableCoroutineScope를 반환합니다. 여기서 Main.immediate를 기본으로 두는 이유는 이미 메인 스레드에서 실행 중인 상황이라면 메인 작업 큐에 다시 넣는 과정을 생략할 수 있기 때문입니다.
 메인 스레드는 메시지 큐(작업 큐)를 돌리면서 큐에 들어온 작업을 하나씩 실행합니다. Dispatchers.Main은 코루틴 블록을 실행할 때 그 블록을 메시지 큐에 한 번 넣고 다음 턴에 실행되게 만드는 경우가 많습니다. 이 큐에 넣는 과정(큐잉)이 한 번 더 발생하면, 이미 메인에서 실행 중인데도 작업이 뒤로 밀릴 수 있습니다.

 반면 Dispatchers.Main.immediate는 이미 메인 스레드에서 실행 중인 경우 이 큐잉을 생략할 수 있습니다. 코루틴 블록을 메인 메시지 큐에 다시 넣지 않고, 현재 실행 흐름 안에서 바로 실행을 시작할 수 있게 해주는 것입니다. 여기서 말하는 불필요한 큐잉이란, 이미 메인인데도 다시 메인에 실행 요청을 넣어서 큐에 한 번 더 쌓이게 만드는 동작을 뜻합니다.

 

 다시 돌아가자면, createViewModelScope에서 catch 구문은 이 코드를 실행하는 현재 플랫폼이 Distpachers.Main을 사용할 수 없다면 catch 구문으로 들어가 EmptyCoroutineContext를 가진 새로운 CloseableCoroutineScope을 반환하게 됩니다. 여기서 CloseableCoroutineScope은 AutoCloseable과 CoroutineScope을 상속하고 있습니다.

internal class CloseableCoroutineScope(
    override val coroutineContext: CoroutineContext,
) : AutoCloseable, CoroutineScope {

    constructor(coroutineScope: CoroutineScope) : this(coroutineScope.coroutineContext)

    override fun close() = coroutineContext.cancel()
}

  AutoCloseable은 좀 더 자세히 다뤄야 하는 내용이지만, 이해를 위해 간단히 정리하자면 메모리 누수를 방지하기 위해 명시적으로 종료를 해줘야 하는 리소스를 관리하는 interface라고 볼 수 있겠습니다. viewModelScope 역시 viewModel의 생명주기가 다할 때 명시적으로 종료되어야 하므로, Closeable 객체를 통해 코루틴을 관리한다고 생각하면 좋을 것 같습니다.

 그러니 정리하자면 createViewModelScope은 명시적으로 ViewModel의 생명주기에 맞춰 종료를 지원하기 위한 ViewModel 종속적인 코루틴을 생성하는 기능을 제공하는 메소드이고, 이를 개발자가 동기적으로 호출하기 편하게 만들어둔 것이 ViewModel.viewModelScope 확장함수인 것입니다.

 

 그 다음은 lifecycleScope를 확인해보겠습니다. lifecycleScope은 Activity의 생명주기를 따르는 Scope인데요. 보통 Activity가 살아있는 동안 전달되는 데이터를 주고받는데 주로 사용할 것입니다. 

public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
    get() = lifecycle.coroutineScope

 lifecycleScope는 위와 같은 코드를 지니고 있습니다. 여기서 lifecycle.coroutineScope으로 들어가보면 

public val Lifecycle.coroutineScope: LifecycleCoroutineScope
    get() {
        while (true) {
            val existing = internalScopeRef.get() as LifecycleCoroutineScopeImpl?
            if (existing != null) {
                return existing
            }
            val newScope =
                LifecycleCoroutineScopeImpl(this, SupervisorJob() + Dispatchers.Main.immediate)
            if (internalScopeRef.compareAndSet(null, newScope)) {
                newScope.register()
                return newScope
            }
        }
    }

이렇게 호출하는 것을 알 수 있습니다. LifecycleCoroutineScope는 lifecycle 필드와 deprecated된 launchWhenStarted와 같은 메소드들을 가지고 있는 CoroutineScope입니다. lifecycle을 가지고 있다는 것만 중요하게 보면 될 것 같습니다.

 Fragment의 경우 Activity와는 조금 다른 방식으로 사용할 수 있습니다. parentActivity의 lifecycleScope을 쓰는 것은 가능하지만, 그 경우 Fragment는 사라졌는데 Activity가 남아 Scope이 원하는 때에 사라지지 않는 경우가 발생할 수 있습니다. 이때 viewLifecycleOwner.lifecycleScope를 사용하면 Fragment의 생명주기에 맞는 lifecycleScope을 사용할 수 있습니다.

 

또한 CoroutineScope로 Scope을 생성해서 사용할 수 있습니다. 

CoroutineScope(Dispatchers.Main).launch {
    // 내부 코드
}

 아주 단순하게 위처럼 context에 Dispatcher를 파라미터로 넣어 원하는 Dispatcher를 사용할 수 있습니다. 이때 파라미터는 필수값이므로 항상 Dispatcher를 지정해주어야 합니다. 코틀린의 기본이 되는 코루틴이기도 하고 딱히 어디에 종속되거나 미리 선언된 코드가 존재하는 것이 아니기 때문에 많은 부분을 커스터마이징을 통해 사용할 수 있습니다. 이 CoroutineScope은 내부에 Job 객체를 가지고 있는데, 이 Job을 통해 Scope의 종료를 명시적으로 코드상에서 지정하거나 launch를 통해 블럭을 실행시킬 수 있습니다. 

 정리하자면 CoroutineScope는 코루틴을 실행하기 위한 범위이며, 그 안에서 실행된 코루틴들의 생명주기를 함께 관리하는 단위입니다. launch나 async와 같이 scope을 실행하는 메소드들은 항상 Scope 위에서 동작하며, Scope가 취소되면 그 안의 코루틴들도 함께 취소됩니다. viewModelScope나 lifecycleScope처럼 생명주기에 묶인 Scope와 달리, CoroutineScope를 직접 생성하는 경우에는 해당 Scope의 취소 시점을 개발자가 직접 관리해야 합니다.

 

 마지막으로 GlobalScope입니다. 저는 아직 사용해본 적이 없는데요. 이 GlobalScope는 코틀린이 제공하는 전역 싱글톤 Scope로, 화면이나 ViewModel 같은 특정 컴포넌트에 종속되지 않고 프로세스가 종료되면 함께 사라집니다. 실제 사용할 때는 눈에 잘 띄지 않아 CoroutineScope(...)로 만든 코루틴과 차이를 못 느낄 수 있는데, GlobalScope는 스코프 단위로 묶어서 관리하기가 어렵고, 보통은 GlobalScope.launch가 반환하는 개별 Job을 통해서만 취소를 제어하게 됩니다. 어디서든 GlobalScope.launch로 코루틴을 시작할 수 있지만, 이런 특성 때문에 제어가 어려워 많은 문서에서 직접 사용을 권장하지 않습니다.

 


 살짝 위 글에 넣기 애매한 느낌이라 생략했는데, CoroutineScope으로 생성한 Scope는 실제로 cancel을 해도 바로 취소된다는 보장이 없습니다. 코루틴은 내부에서 한 번 중단되거나 isActive등과 같이 취소 상태를 확인하는 코드가 없는 경우 하던 동작을 마저하려고 한다고 하네요. 그래서 만약 timeOut 같은 걸 두고 싶다면 withTimeOut 등을 통해 강제로 exception을 발생시켜 처리해야 합니다. 사실 이것도 실제로 테스트 해봤을 땐(일단 코테에서 결국 제대로 안되서 시간이 지나 문제를 못풀었었는데) 잘 안되는 느낌이라, 내부적으로 좀 더 고민해볼 필요가 있을 것 같습니다.

 여튼 코루틴은 봐도봐도 어려운 것 같습니다. 에혀...

댓글