Kotlin Coroutines의 GlobalScope을 어떻게 사용할 수 있을까? - 사실 추천하지 않는다.



개인 광고 영역

코루틴의 runBlocking에 대해서 이야기했다. Kotlin Coroutines의 runBlocking은 언제 써야 할까? 잘 알고 활용하자! 결국 잘 알고 사용하는 건 문제가 없다. 역시나 잘 못 알고 사용하는 경우라면 사실 사용치 않는 게 맞는다고 생각한다.

이번 글은 코루틴 관련 2 번째 글로 GlobalScope에 대해서 알아보려고 한다.

이 역시 Android 개발에서는 lifecycle과 관련이 깊다. 일반적인 서버나 웹에서도 역시 GlobalScope을 무작정 사용하는 건 맞지 않을 것이다.

CoroutineScope은 각각의 라이프 사이클을 별도로 가지고 종료를 할 수 있는 반면 GlobalScope은 싱글톤으로 만들어져있어, 잘못 활용한다면 프로그램 전체에 악영향을 미칠 수 있다.

싱글톤 자체만으로도 사실 악영향을 미칠 수 있는데, GlobalScope을 이용해 새로운 컨텍스트를 만들어 사용하는 케이스도 있다.

결국 상황에 따라 잘 활용하면 이득을 볼 수 있으니 이 글에서는 GlobalScope에 대해서 좀 더 알아보려고 한다.


이 글에서 알아볼 내용

  • GlobalScope의 내부 코드를 살펴본다.
  • GlobalScope을 이용한 Android UI 코드를 살펴본다.
  • GlobalScope을 사용할 때 그나마 할 수 있는 예외를 살펴본다.


단 하나의 object로 동작하는 GlobalScope

GlobalScope은 내부 코드는 아래와 같이 생겼다.

Kotlin에서 object는 바로 자바에서 말하는 싱글톤에 해당한다. 이 object는 메모리에 올라가면서 즉시 생성되기 때문에 안전한 싱글톤에 해당하는데 여기서는 다루지 않을 테니 문서를 참고하시길

public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

문서에도 나와있지만 아래와 같이 설명되어 있다.

top-level에서 동작하는 코루틴이고, application의 라이프사이클 동안 동작하는데, 각각의 화면과 무관하게 종료된다.

Global scope is used to launch top-level coroutines which are operating on the whole application lifetime and are not cancelled prematurely. Another use of the global scope is operators running in Dispatchers.Unconfined, which don’t have any job associated with them.

단순하게 생각하면 백그라운드에서 동작해야 하는 작업을 GlobalScope에 밀어 넣어 작업하면 되는 것 아닌가?라고 생각할 수 있다.

하지만 안드로이드의 경우 백그라운드로 내려갔을 때 동작을 보증하지 않기 때문에 안드로이드에서는 이런 코드를 함부로 사용하지 않고, AAC WorkManager를 활용하고, 비동기 처리에서 CoroutineScope을 활용할 수 있다.

이미 과거에도 작성한 CoroutineScope과 GlobalScope 관련 글이 있다. Android에서 사용하는 CoroutineScope을 알아보자


GlobalScope 예를 만들어보자.

결론은 Android 개발 환경에서는 Lifecycle을 따르는 게 당연하다.

아무리 전역으로 돌아간다고 하더라도 Lifecycle을 따르도록 AAC WorkManager를 함께 사용하도록 하는 게 중요하다.

결국 메모리도 잘 해지할 수 있고, cancel도 잘 할 수 있도록 만들어야 하는데, 우선 아래와 같은 코드를 작성하고, 동작을 해보도록 하자.

fun loadGlobal() = GlobalScope.launch {
    android.util.Log.w("TEMP", "Thread ${Thread.currentThread()}")
    count++

    // 밖에 있는 launch와 별도로 500ms 후에 updateView를 호출한다.
    GlobalScope.launch {
        delay(500)
        withContext(Dispatchers.Main) {
            updateView("count $count Thread!!!!!! ${Thread.currentThread()}")
        }
    }

    // 5초 후에 count를 출력하는 코드이다.
    delay(5000)
    launch(Dispatchers.Main) {
        updateView("count $count Thread!!!!!! ${Thread.currentThread()}")
    }
}

이 코드는 현재 보이는 화면에서는 동작하는데 문제가 없다.

sample_01


먼저 오류를 내보자

GlobalScope의 launch에서 동작하는 코드에 5초의 delay를 걸고 Exception을 발생시켜보자.

fun loadGlobal() = GlobalScope.launch {
    // 생략

    GlobalScope.launch {
        // 생략
    }

    delay(5000)

    // Exception을 추가했다.
    throw Exception("Error!!!!!!!!!!!")

    launch(Dispatchers.Main) {
        // 생략
    }
}

당연하게도 5초 후에 Error!!!! 메시지를 노출하고 종료 처리한다.


문제점을 해결해보자.

문제점은

  • 5 초후에 Exception이 발생하고, 전체적인 동작에 영향을 미친다.

CoroutineScope의 서로 다른 launch 동작에 대한 영향을 다른 자식의 launch 영향 가지 않도록 하기 위해서는 2개의 코드를 추가할 수 있다.

  • SupervisorJob : Exception을 상위 parent에게 통지하지 않는다. 결국 child 들에서 발생한 Exception이 다른 child에 영향을 주지 않는다.
  • CoroutineExceptionHandler : Exception을 받아 처리한다.

아래와 같이 추가하면 Exception과 무관하게 동작할 수 있다.

fun loadGlobal() = GlobalScope.launch(exception + SupervisorJob()) {
    // 생략
    GlobalScope.launch {
        // 생략
    }
    throw Exception("Error!!!!!!!!!!!")
    // 생략
}

private val exception = CoroutineExceptionHandler { coroutineContext, throwable ->
  // 여기서 예외 처리
}


이번엔 종료 처리는 어떨까?

오류는 위와 같이 처리할 수 있는데, 이번엔 종료는 어떨까?

대략 예측할 순 있겠지만 Lifecycle을 따르도록 만들지 않았기에 종료라는 건 잃어나지 않는다.

화면을 종료해도 delay에 따라 이 코루틴은 종료하지 않고 동작하는데, 상황에 따라 오류가 발생할 수도 있다.

  • Activity, Fragment가 즉시 종료되어 view를 날렸을 경우 오류가 발생할 수 있다.

job을 별도로 생성하고, 사용하는 GlobalScope의 launch에 모두 job을 걸어준다.

private val job = SupervisorJob()

fun loadGlobal() = GlobalScope.launch(exception + job) {
    GlobalScope.launch(job) {
        // 생략
    }
    delay(5000)
    launch(Dispatchers.Main) {
        // 생략
    }
}

override fun onCleared() {
    super.onCleared()
    job.cancel()
}

사실 이렇게 해도 동작하는데 문제는 없다.


그래도 안전하지 않다.

그렇다 안전하지 않다. GlobalScope은 cancel을 걸지 않으면 지속적인 동작을 함을 확인했는데, 결국 GlobalScope 자체가 아닌 사용하려는 코드의 launch에 직접 걸어줘야 한다.

누군가 실수로 launch에 job과 exception에 대한 처리를 하지 않았다면 당연하게도 문제가 생긴다.

참고로 CoroutineScope이라도 자동으로 cancel 하는 것은 아니지만, AAC viewModelScope을 사용한다면 어느 정도 알아서 해결해 주니 문제는 없다.

val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

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

사실 이렇게 쓰면 알아서 해준다. 당연하게도 AAC ViewModel 초기화를 해야 동작하는 것도 당연하다.

그래도 싱글톤인 GlobalScope보단 안전하게 사용하는 게 가능하다.


정리하면

  • GlobalScope은 싱글톤으로 어플리케이션 생명주기에 따라 동작한다.
  • GlobalScope을 과도하게 사용하면 어플리케이션 동작에 문제를 줄 수 있다.
  • GlobalScope은 cancel 처리가 복잡하다.
  • GlobalScope은 throw exception 처리 역시 복잡하다.

복잡하다는건 누락할 수 있다는 말이다. 누락할 수 있다는 말은 작성하는 코드를 잘 알고 작성하지 않으면 언제든 생길 수 있는 문제를 가지고 감을 뜻한다.

결국 그냥 쓰지 않는 것을 추천한다.

당연하게도 안드로이드에선 Lifecycle을 따르도록 구현하고, 메모리 문제도 없도록 설계하도록 하는 게 좋다.

일부 GlobalScope을 사용하는 코드인 runBlocking을 보면 GlobalScope.newCoroutineContext(context + eventLoop)으로 새로운 컨텍스트를 생성해 사용한다. 최소한 context를 새로 주입한다는 말이다.

이 부분처럼 CoroutineScope 대신 GlobalScope을 사용하면 되는 것 아닌가라고 이야기할 수도 있으나, 그건 본인의 선택이다.

CoroutineScope을 추천하는 이유는 문서에도 잘 나와 있고, 이를 사용해 개발하는 것이 가장 기본에 해당한다.

다음 글에서 AAC-ViewModel에 대해서 좀 더 알아보도록 하겠다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments