본문 바로가기
Android/Coroutine

[Android] 코루틴(Coroutine) Scope에 대한 고찰 - Deep Dive

by 태크민 2024. 11. 9.

Scope

코루틴의 Scope는 코루틴의 수명을 관리하는 역할을 합니다.

코루틴은 스코프 내에서만 실행되며, 스코프가 종료되면 그 스코프 내에서 실행된 모든 코루틴도 자동으로 취소됩니다.

이는 구조화된 동시성을 구현하는 중요한 개념으로, 비동기 작업이 여러 개 실행되더라도 일정한 흐름 안에서 안전하게 관리될 수 있도록 해줍니다.

코루틴은 여러 개의 스코프에서 동작할 수 있으며, 아래와 같이 분류할 수 있습니다.

 

CoroutineScope

코루틴이 실행되는 범위로, 코루틴을 실행하고 싶은 Lifecycle에 따라 원하는 Scope를 생성하여 코루틴이 실행될 작업 범위를 지정할 수 있습니다.

  • 사용자 지정 CoroutineScope: CoroutineScope(CorountineContext)
    ex) CoroutineScope(Dispatchers.Main) // Dispatchers.Main, Dispatchers.Default, Dispatchers.IO

CoroutineScope 는 기본적으로 CoroutineContext 하나만 멤버 속성으로 정의하고 있는 인터페이스 입니다.

public interface CoroutineScope {
    /**
     * Context of this scope.
     */
    public val coroutineContext: CoroutineContext
}

 

우리가 사용하는 모든 코루틴 빌더들(예> 코루틴 빌더- launch, async -, 스코프 빌더- coroutineScope, withContext - 등등)은 CoroutineScope 의 확장 함수로 정의 됩니다. 다시말해, 이 빌더들은 CoroutineScope의 함수들인 것이고 이들이 코루틴을 생성할 때는 소속된 CoroutineScope 에 정의 된 CoroutineContext 를 기반으로 필요한 코루틴들을 생성해 내게 됩니다.

 

자, 이제 CoroutineScope를 통해 장기 비동기 작업을 실행하고 싶다고 가정해 봅시다.

작업이 잘 진행된다면 예상대로 완료되고 종료될 것입니다.

하지만 작업이 긴 시간 동안, 그리고 사용자가 더 이상 사용할 의도가 없을 때에도 여전히 실행되고 있다면?

우리는 그 작업이 사용자의 CPU와 메모리 자원을 낭비하는 것을 원하지 않기 때문에 어느 시점에서 그것을 중단해야 합니다.

만약 Fragment 또는 Activity와 같은 일반적인 코루틴 스코프를 만들어 작업중이라면, LifecycleOwner가 Destoryed 될 때 실행중인 코루틴을 취소하기 위해 명시적으로 CoroutineContext.cancel()을 호출해줘야 합니다.

 

우리는 CoroutineScope를 사용하여 실행 중인 Coroutine을 추적함으로써 작업을 더 이상 실행할 필요가 없을 때 취소할 수 있습니다.

class ExampleActivity: AppCompatActivity() {
  ...
  private lateinit var mCoroutineScope: CoroutineScope
  ...
  private fun coroutineTest() {
      mCoroutineScope = CoroutineScope(Dispatchers.Main)
      mCoroutineScope.launch {
        println("loading..")
        delay(3000)
        println("job is done")
      }
  }
  override fun onDestroy() {
    super.onDestroy()
    if(::mCoroutineScope.isInitialized && mCoroutineScope.isActive) {                    
      mCoroutineScope.cancel() 
    }
}

클래스 내에 mCoroutineScope라는 변수를 정의하고 아무 CoroutineContext를 사용해 실행할 Coroutine의 scope를 정의합니다. 이 경우 Dispatchers.Main을 사용합니다. 그런 다음 Coroutine scope 내의 작업을 취소하고 싶을 때에는 간단히 mCoroutine.cancel()을 호출하면 됩니다. 위의 코드 스니펫에서 Activity가 destroy 된다면 mCoroutineScope 내의 코루틴 실행을 취소할 것입니다.

 

혹은 아래와 같이 Activity를 CoroutineScope에 상속하는 방법도 고려할 수 있습니다.

class Activity : CoroutineScope {
    lateinit var job: Job

    fun create() {
        job = Job()
    }

    fun destroy() {
        job.cancel()
    }

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Default + job

    fun doSomething() {
        // launch ten coroutines for a demo, each working for a different time
        repeat(10) { i ->
            launch {
                delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
                println("Coroutine $i is done")
            }
        }
    }
}

 

CoroutineScope는 인터페이스이기 때문에CoroutineScope.coroutineContext 멤버를 오버라이드해야 하는데, Acitivity의 생명주기와 매핑된 Job을 연결해주면, 액티비티가 종료될 때 함께 취소할 수 있습니다.

 

만일 Coroutine Scope를 취소를 안하면 어떻게 되나요?

Coroutine Scope를 취소하지 않으면, 메모리 누수가 발생할 가능성이 있습니다.

코루틴이 일시적인 작업을 수행하는 경우에는 작업이 끝난 후 자동으로 종료됩니다. 그러나 장기적으로 실행되는 코루틴이라면 CoroutineScope는 애플리케이션의 생명주기와 유사한 상태로 유지될 수 있습니다.

이는 GlobalScope와 비슷한 상황을 초래하며, 명시적으로 cancel()을 호출하지 않으면 앱이 종료될 때까지 코루틴이 계속 실행됩니다.

이로 인해 Activity나 Fragment가 종료되더라도 해당 코루틴은 계속 실행될 수 있습니다.
만약 코루틴 내부에서 Activity나 Fragment를 참조하고 있다면, 해당 객체들이 가비지 컬렉션(GC) 대상이 되지 않아 메모리 누수(Memory Leak)로 이어질 수 있습니다.

 

예를들어, 아래 코드와 같이 코루틴 내부에서 Activity를 참조하고 있는 상황이라면 Activity가 종료됬음에도 불구하고 Activity는 메모리에서 해제되지 않을 것입니다. 

class MainActivity : AppCompatActivity() {

    private val scope = CoroutineScope(Dispatchers.Main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        scope.launch {
            delay(10000)
            // Activity의 메서드 또는 속성 참조
            this@MainActivity.runOnUiThread {
                println("Activity 참조 중")
            }
        }
    }
}

 

코루틴이 Activity를 강하게 참조하고 있기 때문에, GC가 수거해가지 않기 때문입니다. 결국 메모리 누수(Leak)가 발생하는거죠

 

따라서, 이러한 상황을 방지하기 위해서는 적절한 시점에 CoroutineScope.cancel()을 호출하거나, LifeCycleScope , ViewModelScope 와 같은 생명주기 인식 스코프를 사용하는 것이 중요합니다.

 


 

CoroutineScope를 구현(Implementation)한 Scope

CoroutineScope 인터페이스를 구현한 Scope로는 GlobalScope, LifeCycleScope, ViewModelScope가 있습니다.

각 Scope의 특징은 아래와 같습니다.

 

GlobalScope

앱이 실행될 때부터 종료될 때까지 실행
   (Activity가 종료되어도 존재하는 코루틴이므로 구분해서 사용해야 함)


GlobalScope는 애플리케이션이 살아있는 한 작동하는, 최고 수준의 CoroutineScope입니다.

보통 애플리케이션 scope에서 실행중인 작업을 launch할 때 앱이 죽을 때까지 작업을 유지하기 위해 사용합니다. 

GlobalScope는 애플리케이션의 생명주기 동안 살아있는 싱글톤 객체입니다. 

GlobalScope는 우리가 launch의 파라미터에 CoroutineContext를 정의해주지 않아도 기본적으로 Dispatchers.Default라는 CoroutineContext를 가지고 있습니다. 

GlobalScope.launch {
  println("loading..")
  delay(3000)
  println("job is done")
}

위의 스니펫 코드에서 사용 예시를 볼 수 있습니다. 

 

GlobalScope 내부 구조

GlobalScope 내부 코드를 살펴보면, CoroutineScope를 구현하고 있는 것을 확인할 수 있습니다.

Scope의 context를 가져올 때 EmptyCoroutineContext를 가져오는데, 아래에서 바로 알아보도록 하죠

 

GlobalScope 는 Singleton object 로써 EmptyCoroutineContext 를 그 컨텍스트로 가지고 있습니다. EmptyCoroutineContext 는 구현해야할 모든 CoroutineContext 멤버 함수들에 대해서 기본 구현만 정의한 컨텍스트 입니다. 이 기본 컨텍스트는 어떤 생명주기에 바인딩 된 Job 이 정의되어 있지 않기 때문에 애플리케이션 프로세스와 동일한 생명주기를 갖게 됩니다.

 

다시 말해 GlobalScope.launch{} 로 실행한 코루틴은 애플리케이션이 종료되지 않는 한 필요한 만큼 실행을 계속해 나갑니다.

실행 중인 작업을 취소하고 싶다면, CoroutineScope와 동일하게 cancel 메소드를 사용하거나 개별적으로 작업을 취소할 수 있습니다.

 

 

LifecycleScope

LifecycleScope를 사용하면, 객체 대상(Activity, Fragment, Service...)의 Lifecycler이 끝날 때 코루틴 작업이 자동으로 취소됩니다.

 

우리는 Activity 또는 Fragment에서 CoroutineScope를 사용할 때 메모리 누수와 자원 낭비를 피하기 위해 Activity 또는 Fragment를 destroy 할 때 실행 중인 Coroutine이 멈추도록 해야 합니다.

따라서 우리는 lifecycle owner가 destory 될 때 CoroutineScope를 확인하고 취소해야 합니다.

이 경우, LifecycleScope를 사용하면 lifecycle owner가 destroy 되기 전에 실행 중인 Coroutine을 수동으로 확인하고 취소할 필요가 없습니다. 

LifecycleScope는 lifecycle에 연결되어 있으므로 Coroutine 수명은 lifecycler owner의 수명을 따를 것입니다.

 

LifecycleScope를 사용하면 특별한 launch 조건을 사용할 수도 있습니다.

  1. launchWhenCreated는 lifecycle이 최소한 create 상태에 있으면 Coroutine을 launch하고 destroy 상태에 있으면 cancle() 됩니다.
  2. launchWhenStarted는 lifecycle이 최소한 start 상태에 있으면 Coroutine을 시작하고 stop 상태에 있으면 suspend 됩니다.
  3. launchWhenResumed는 lifecycle이 최소한 resume 상태에 있으면 Coroutine을 시작하고 pause 상태에 있으면 suspend 됩니다.
lifecycleScope.launchWhenResumed {
  println("loading..")
  delay(3000)
  println("job is done")
}

예를 들어, lifecycle owner(Activity 또는 Fragment)가 최소한 onResumed 라면 위의 코드 스니펫이 실행됩니다. lifecycle owner가 여전히 onCreated 거나 onStarted 라면 Coroutine은 실행되지 않습니다. 

※추가
현재 launchWhenXXX는 Deprecated되었으므로, repeatOnLifecycle을 권장합니다.


LifecycleScope 내부 코드

lifecycleScope는 LifecycleOwner의 확장 프로퍼티로 정의되어 있기 때문에, ActivityFragment와 같이 LifecycleOwner를 구현한 클래스에서만 사용할 수 있습니다.

내부적으로 lifecycle을 멤버 변수로 가지고 있기 때문에, 생명 주기에 따라 CoroutineContext를 효과적으로 관리할 수 있는 것이 특징입니다.

 

또한, lifecycleScope는 LifecycleCoroutineScope를 반환하는데, 아래 코드를 보면 LifecycleCoroutineScope가 결국 CoroutineScope를 상속한 추상 클래스임을 확인할 수 있습니다.

따라서 lifecycleScope 역시 CoroutineScope의 구현체라고 할 수 있습니다.

 

그럼 LifecycleScope의 기본 Context는 어떻게 구성되어 있을까요?

코드를 살펴보면 LifecycleScope의 기본 CoroutineContext는 다음 두 가지로 구성되어 있습니다.

  • SupervisorJob
    • 자식 코루틴에서 발생한 예외를 부모 코루틴으로 전파하지 않도록 관리합니다.
  • Dispatchers.Main.immediate
    • 현재 스레드가 메인 스레드일 경우 지연 없이 즉시 실행합니다.

즉, launch 함수의 파라미터에 CoroutineContext를 명시하지 않아도, LifecycleScope는 기본적으로 SupervisorJob과 Dispatchers.Main.immediate를 갖춘 상태로 코루틴을 실행합니다.

 

그렇다면 Lifecycle에 따라 어떻게 CoroutineContext를 취소하는걸까요?

LifecycleScope는 LifecycleEventObserver를 구현하여 생명 주기 이벤트를 감지하고 관리합니다.

Lifecycle의 상태가 INITIALIZED 이상일 때, LifecycleObserver로 등록됩니다.

그리고 Lifecycle이 변경될 때, onStateChanged()가 호출되며, State가 Destoryed 상태인지 확인합니다. 

만약 DESTROYED 상태라면, 해당 시점에 CoroutineContext를 자동으로 취소(cancel)하여 메모리 누수와 불필요한 작업을 방지합니다.

결국 lifecycleScope는 컴포넌트의 생명 주기에 따라 CoroutineContext를 안전하게 관리하는 범위(Scope)로, 안정적인 비동기 처리리소스 관리 최적화를 제공합니다.

 

 

ViewModelScope

ViewModel 대상, ViewModel이 제거되면 코루틴 작업이 자동으로 취소됩니다.

 

ViewModelScope는 ViewModel에서 발생하는 것을 제외하고는 LifecycleScope와 유사합니다. 

ViewModelScope를 사용하면 Coroutine을 수동으로 취소할 필요가 없으며, ViewModel이 Cleared() 될 때 자동으로 취소되도록 할 수 있습니다.

viewModelScope.launch {
  println("loading..")
  delay(3000)
  println("job is done")
}

ViewModelScope의 사용 예는 위의 코드 스니펫에서 볼 수 있습니다. 

 

ViewModelScope 내부 코드

ViewModelScope는 CoroutineScope를 반환하는 확장 프로퍼티로, ViewModel의 생명 주기에 맞춰 코루틴을 안전하게 관리할 수 있도록 설계되었습니다.

내부적으로는 createViewModelScope() 함수를 통해 새로운 ViewModelScope를 생성하며, 생성된 Scope는 addCloseable()을 통해 등록됩니다.

이 등록을 통해 ViewModel이 메모리에서 해제되기 직전에 콜백(close)을 받을 수 있습니다. 

결과적으로 ViewModel이 onCleared() 호출 직전에 Scope를 안전하게 정리할 수 있도록 합니다.

 

아래는 createViewModelScope()함수 내부 코드입니다.

createViewModelScope() 함수는 ViewModelScope의 기본 CoroutineContext로 SupervisorJobDispatcher.Main.immediate를 설정하고, 이를 관리하기 위한 CloseableCoroutineScope 객체를 반환합니다.

따라서, ViewModelScope는 기본 CoroutineContext로 SupervisorJob과 Dispatcher.Main.immediate로 가지고 있는 것을 알 수 있습니다.

 

ClosableCoroutineScope는 아래와 같이 구성되어 있습니다.

CloseableCoroutineScope는 CoroutineScope를 래핑(wrapping)한 구현 클래스로, 코루틴의 수명 관리를 담당합니다.

위에서 createViewModelScope()로 생성한 Scope는 addCloseable()을 통해 ViewModel에 등록되는데, 이 등록을 통해 ViewModel이 메모리에서 해제되기 직전에 close() 메서드가 호출됩니다.

close() 메서드

  • ViewModel의 onCleared() 메서드가 호출되기 직전에 실행됩니다.
  • 이 메서드는 등록된 모든 코루틴 작업을 안전하게 취소(cancel) 하여 메모리 누수나 불필요한 작업이 남지 않도록 합니다.

coroutineContext.cancel()

  • 현재 Scope에서 실행 중인 모든 코루틴을 취소합니다.
  • 자원 누수를 방지하고, 백그라운드 작업을 효과적으로 종료할 수 있습니다.

이러한 구조를 통해 ViewModelScope는 안드로이드 앱에서 안정적인 비동기 처리효율적인 자원 관리를 보장합니다.

 


 

CoroutineContext, CoroutineScope 차이가 뭔가요?

CoroutineContext와 CoroutineScope는 코루틴에서 많이 사용되지만, 각각의 역할이 다릅니다.

개념 역할 주요 구성 요소
CoroutineContext 코루틴의 실행 환경을 정의하는 요소들의 모음 Job, CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler
CoroutineScope CoroutineContext를 포함하며, 코루틴을 실행하는 범위를 제공 CoroutineContext + launch, async 등의 빌더

1. CoroutineContext란?

  • 코루틴이 실행되는 환경을 정의하는 요소들의 모음.
  • CoroutineContext는 + 연산자를 사용하여 요소들을 결합할 수 있음.
  • 컨텍스트의 주요 요소들:
    • Job → 부모-자식 관계, 취소 관리
    • CoroutineDispatcher → 실행할 스레드 결정 (Dispatchers.IO, Dispatchers.Main, Dispatchers.Default)
    • CoroutineName → 코루틴의 이름을 지정하여 디버깅 용이
    • CoroutineExceptionHandler → 예외 처리

2. CoroutineScope란?

  • 코루틴을 실행하는 범위를 제공하는 컨테이너.
  • CoroutineContext를 포함하며, 코루틴의 생명주기를 관리.
  • launch, async 등을 실행할 때 필수적으로 CoroutineScope가 필요.
  • CoroutineScope를 사용하는 이유?
    • 부모-자식 관계를 유지하여 구조적 동시성(structured concurrency) 보장.
    • 부모가 취소되면 자식도 자동으로 취소됨.
    • 여러 개의 코루틴을 하나의 범위 내에서 관리 가능.

 

결론

결론적으로, 우리는 다음과 같이 Coroutine에 적절한 scope를 사용할 수 있습니다 :

  1. ViewModel에서 Coroutine을 실행하려면 ViewModelScope를 사용하세요.
  2. Lifecycle owner(Activity 또는 Fragment)에서 Coroutine을 실행하려면 LifecycleScope를 사용하세요.
  3. ViewModel 및 lifecycle owner 이외에서 Coroutine을 실행하려면 CoroutineScope를 사용하세요.
  4. 애플리케이션 scope 실행 작업으로 Coroutine을 실행하려면 GlobalScope를 사용하세요.

참고자료

https://velog.io/@no1ro1m/Kotlin-%EB%B2%88%EC%97%AD-%EC%97%AC%EB%9F%AC-Coroutine-Scope%EC%9D%98-%EC%B0%A8%EC%9D%B4-CoroutineScope-GlobalScope-%EB%93%B1

 

(번역) 여러 Coroutine Scope의 차이: CoroutineScope, GlobalScope 등

매끄럽지 못한 번역, 의역 관련 미리 양해 부탁드립니다 🥲Coroutine은 비동기 코드 실행을 달성하기 위한 안드로이드 개발 도구 중 하나입니다. 우리가 알고 있듯이 비동기 또는 non-blocking 프로그

velog.io

https://hooun.tistory.com/175

 

[Android] CoroutineScope, CoroutineContext, CoroutineBuilder, suspend Function

#CoroutineScope 코루틴이 실행되는 범위로, 코루틴을 실행하고 싶은 Lifecycle에 따라 원하는 Scope를 생성하여 코루틴이 실행될 작업 범위를 지정할 수 있습니다. 사용자 지정 CoroutineScope: CoroutineScope(Co

hooun.tistory.com

https://charlezz.com/?p=46044

 

생명주기에 맞춰 안전하게 코루틴 사용하기 | 찰스의 안드로이드

생명주기에 안전한 코루틴 lifecycle 컴포넌트를 사용한다면, 생명주기를 인식하는 코루틴을 만들 수 있다. LifecycleOwner로써 취급되는 AppCompatActivity(ComponentActivity) 또는 Fragment를 일반적으로 사용할

charlezz.com

https://jinn-blog.tistory.com/192

 

kotlin - coroutine (2) 코루틴 스코프 및 컨텍스트

2. 코루틴 스코프 및 컨텍스트2.1 코루틴 스코프(Coroutine Scope)란?코루틴 스코프는 코루틴의 수명을 관리하는 역할을 합니다. 코루틴은 스코프 내에서만 실행되며, 스코프가 종료되면 그 스코프 내

jinn-blog.tistory.com

https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-1-7ebb70a51910

 

코루틴 공식 가이드 읽고 분석하기- Part 1 — Dive1

CoroutineContext와 CoroutineScope 란 무엇인가?

myungpyo.medium.com