[Android] Coroutine

성승모·2025년 10월 14일

Android

목록 보기
4/8

프로세스와 쓰레드 그리고 Coroutine

 CS 면접에서 단골로 나오는 개념이다. 프로세스는 실행 중인 프로그램을 뜻하고, 쓰레드는 그 프로세스에서 실행되는 작업 단위이다. 따라서, 프로세스는 여러 개의 쓰레드를 가지고 있기 때문에 여러 작업을 동시에 실행할 수 있고 이 병렬 실행을 가능하게 하는 논리적 흐름 제어가 비동기 프로그래밍이다.

Coroutine은 쓰레드에서 실행되며, 한 쓰레드에서 중지되었다가 다른 쓰레드에서 다시 실행될 수도 있다. 즉, 코루틴끼리는 쓰레드 풀을 공유한다. 경량 쓰레드라고 불리기도 하는데, 생성 및 유지에 큰 리소스가 드는 쓰레드에 비해 더 적은 리소스로 실행 및 관리할 수 있기 때문이다. 단, context switching 같은 부분도 고려해야한다.

// Coroutine
suspend fun printPeriods() = coroutineScope { // this: CoroutineScope
    // Launches 50,000 coroutines that each wait five seconds, then print a period
    repeat(50_000) {
        this.launch {
            delay(5.seconds)
            print(".")
        }
    }
}

// Thread
repeat(50_000) {
    thread {
         Thread.sleep(5000L)
         print(".")
    }
}

 위 쓰레드 코드를 실행시키면 50,000개의 쓰레드가 생성되고 각각의 쓰레드는 본인만의 Stack 영역을 요구한다. 따라서, 이는 100 GB까지 리소스를 요구할 수 있는 반면, 코루틴 코드는 같은 개수더라도 약 500 MB 정도의 리소스만 요구한다. 따라서, 이번 포스팅에서는 이 코루틴에 대해 정리해보고자 한다.

개요

 Kotlin에서는 주로 Coroutine을 활용하여 비동기 프로그래밍을 수행한다. Coroutine은 비동기 코드를 자연스럽고 절차적인 스타일로 작성할 수 있게 도우며 이 때 suspending 함수를 이용한다. 위에서 언급했다시피 쓰레드보다 훨씬 가벼우며 시스템을 중지하지 않고 resource-friendly하게 이용할 수 있다.

suspending 함수

 Coroutine는 suspending 함수 위에서 작동한다. suspending 함수는 쓰레드를 막지 않고 비동기 프로그래밍을 하도록 도우며, suspend 키워드로 정의할 수 있다. 함수 내 내용은 순차적으로 실행되며, suspending 함수는 Coroutine Scope에서만 호출이 가능하다. 또한, suspending 함수를 정의하지 않고 coroutine을 생성하려면 코루틴 빌더를 이용하면 된다.

Coroutine Scope

interface CoroutineScope

 코루틴이 실행되고 관리되는 범위(Scope)를 정의한다. 따라서, 모든 빌더 함수들은 CoroutineScope 내에서 실행되며, 이 scope는 Coroutine Context를 통해 상위 scope의 context를 전달받는다. 즉, 부모 Scope가 취소되면 자식 코루틴들도 함께 취소되고, 예외나 종료 상태 역시 전파된다. 이로써 코루틴은 명시적인 생명주기와 일관된 관리 구조를 가지게 된다.

Convention for structured concurrency

 직접적인 implementaion은 권장되지 않으며 위임자를 통해 구현되어야 한다. 이는 구조적 동시성(structured concurrency)을 유지하기 위함이다. 모든 빌더 함수와 scoping 함수는 scope과 함께 job을 제공하도록 되어있다. 위에서 언급한 "절차적인 스타일"을 구현하기 위해 job들은 차례대로 실행되며 각각의 job이 완료될 때까지 해당 부모 job은 잠시 멈추도록 되어있다.

Job에 대해 알아보기
 간단히 말해서 모든 백그라운드 작업을 말한다. 빌더 함수의 반환 값도 Job이며 코루틴이 지향하는 부모에서 자식으로의 cancellation의 전파, 자식에서 부모로의 Exception의 전파를 수행할 수 있다.
 기본적으로 반환값은 없고, 외부와 상호작용을 위한 즉 부수 효과를 위해 존재한다. 따라서, 결과값을 갖기 위해선 Job을 확장한 Deferred를 이용해야 한다. 빌더 함수 launchJob이고, asyncDeferred이므로 둘의 차이는 결과값 유무라는 것도 알 수 있다.

빌더

CoroutineScope.launch()

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
): Job

Job을 생성할 수 있는 빌더 함수이다.

  • context: CoroutineScope으로부터 받을 수 있다. 만약 어떤 dispatcher나 ContinuationInterceptor가 없다면 Dispatchers.Default를 사용하게 된다.
  • start: 보통 바로 실행되는 CoroutineStart.DEFAULT를 가진다. 이 밖에도 필요할 때 실행하는 Lazy, 실행 중 취소하더라도 취소되지 않는 Atmoic, 초기화를 위해 사용하는 Undispatched 등이 있다.

CoroutineScope.async()

fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext, 
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> T
): Deferred<T>

 launch: Job에서 Deferred로 바뀌었다. 즉, 결과값이 있는 코루틴을 생성한다.

코루틴 실행 함수
: start = CoroutineStart.LAZY로 생성된 Job/Deferred을 실행시키기 위해서 다음 함수들이 필요하다.

  • start(): 코루틴을 시작한다. suspend 시키지 않는다.
  • join(): 시작된 코루틴이 완료될 때까지 기다린다.
  • await(): 시작되지 않은 코루틴이라면 start()시키고, suspend시켜 결과값도 받는다. Deferred만 가능하다.

runBlocking()

expect fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext, 
    block: suspend CoroutineScope.() -> T
): T

 현 쓰레드에서 새로운 코루틴을 생성하고 블러킹하여 실행한다. 보통 다른 라이브러리를 사용할 때 suspending 스타일로 작성하는 것을 돕거나 테스트를 위해 사용된다. 또한, 현 쓰레드에서 블로킹하여 실행되기 때문에 suspend 함수에 이를 사용하는 것은 redundant하며, 불필요하게 쓰레드를 블로킹하는 꼴이다.

coroutineScope()

fun CoroutineScope(context: CoroutineContext): CoroutineScope

  주입받은 context를 이용하는 코루틴을 만든다. 만약 받은 context에 job element가 없다면 기본 job을 생성한다.

Context

 Context는 위에서 말한 코루틴의 Job과 어떤 쓰레드에서 실행할지 정하는 Dispatcher로 이루어져있다. 이를 통해 어떤 환경에서 Job을 실행할지 결정하기 때문에 빌더 함수는 Context를 받아 코루틴을 생성한다.

dispatcher

 그렇다면 Dispatcher의 역할은 무엇일까?? 간단하게 다음과 같이 요약이 가능하다.

어떤 쓰레드 또는 쓰레드 풀에서 실행할지 결정한다.

 따라서, 빌더 함수에 dispatcher를 넘기는 것만으로도 쉽게 스레드를 이동하며 작업을 수행할 수 있다. 사용할 수 있는 dispatcher는 다음과 같다.

  • Default: 빌더 함수에 기본값으로 정의되어 있는 dispatcher로, CPU를 사용하기 때문에 동시에 생성되는 양은 CPU의 코어 수와 같다. 따라서, 빠른 연산이 필요한 경우 이용하는 것이 좋다.
  • IO: 이름 그대로 입출력을 위해 디자인되었으며, 쓰레드가 IO로 인해 중지되는 부담을 줄여준다.
  • Main: 메인 쓰레드 dispatcher로, 전체 시스템이 작동하기 때문에 UI 관련 작업을 제외하고는 피하는 것이 좋다.
  • Unconfined: 특정 쓰레드에 국한되지 않고, 기존 코루틴을 다른 쓰레드에서 실행시킬 수 있도록 한다. 첫 suspend 함수를 만날 때까지는 호출한 context의 쓰레드에서 실행되다가 그 이후로는 suspend 함수가 호출되는 쓰레드를 따라간다. 공식 문서에선 특정 목적을 제외하고는 사용을 지양하라고 한다.

성능 최적화 및 팁

병렬 처리 + SupervisorJob

 자식에서 부모로의 에러 전파는 편리하지만 병렬 처리나 개발자의 의도에 따라 이것이 오히려 방해가 될 수 있다. 이를 관리할 수 있게 하는 것이 바로 SupervisorJob이다.

// https://kotlinworld.com/153
suspend fun main() {
    val supervisor = SupervisorJob()

    CoroutineScope(Dispatchers.IO).launch {
        val firstChildJob = launch(Dispatchers.IO + supervisor) {
            throw AssertionError("첫 째 Job이 AssertionError로 인해 취소됩니다.")
        }
        val secondChildJob = launch(Dispatchers.Default) {
            delay(1000)
            println("둘 째 Job이 살아있습니다.")
        }

        firstChildJob.join()
        secondChildJob.join()
    }.join()
}

 위 예시와 같이 dispatcher에 + 연산자와 함께 넘겨주면 된다. 위 코드의 결과는 exception이 뜨더라도 첫번째 자식 코루틴만 중지될 뿐 두번째와 부모 코루틴에는 영향이 없다. 하지만, 이는 부모-자식 관계를 깨뜨려 부모 코루틴이 supervisor Job을 기다리지 않고 종료되기 때문에 이를 깨드리고 싶지 않다면 다음과 같이 이용하자.

CoroutineScope(Dispatchers.IO).launch { 
	val supervisorJob = SupervisorJob(parent = coroutineContext.job)
    ...
} 

supervisorScope { ... }
: 바디를 SupervisorJob으로 처리한다. 따라서, 부모로의 에러 전파를 원하지 않는 여러 Job을 묶어 한 번에 처리할 수 있다.

suspend fun foo() = supervisorScope {
    launch { println("Child 1") }
    launch { error("예외 발생 😵") }
    launch { println("Child 2") }
    launch { println("Child 3") }
}
suspend fun main() {
    println("시작")
    foo()
    println("끝")
}
// 결과
// 시작 / Child1 / Child2 / Child3 / 예외 발생 😵 / 끝

위와 같이 SupervisorJob()으로 일일히 Context에 넣어주지 않아도 된다.

이를 활용해 안전한 병렬 처리도 수행해보자.

  1. Job
  val jobList = listOf<Job>()	// job은 Lazy
  jobList.forEach { it.start() }

  jobList.joinAll()
  1. Deferred
  val deferList = listOf<Deferred<Int>>()  // Deferred은 Lazy
  deferList.forEach { it.start() }  // awaitAll은 start를 자동으로 하기 때문에 생략 가능

  val results = deferList.awaitAll()

 위와 같이 수행할 수 있다. 하지만 만약 여러 Job을 수행하는 중간에 에러가 발생한다면 어떻게 될까?? 당연히 그 에러는 부모로 전파되고 다른 자식 코루틴도 중지시킬 것이다. 따라서, 병렬 처리 시 Job 선언에 supervisorJob을 사용하는 것이 타당해보인다.

Dispatchers 선택과 전환 최적화

위에서 언급했다시피 각 Dispatcher는 다음과 같이 사용하는 것이 좋다.

  • Main: 기본 메인 스레드에서 코루틴을 실행하기 때문에 UI와 상호작용하고 빠른 작업을 실행하기 위해서만 사용해야 한다.
  • IO: 기본 스레드 외부에서 디스크 또는 네트워크 I/O를 실행하도록 최적화되어 있다.
  • Default: CPU를 많이 사용하는 작업을 기본 스레드 외부에서 실행하도록 최적화되어 있다.

하지만, 위 사항을 지키기 위해서 분명 Dispatcher의 전환이 필요한 순간이 있을 것이다. 이를 간편하게 해주는 것이 바로 withContext(disptacher)이다.

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

 간단하게 사용이 가능하다. 그렇다면 runBlocking처럼 중첩 사용하는 것에 대해 궁금할 것이다. 다행히 똑똑하게 현재 dispatcher와 withContext에 주어진 dispatcher가 같다면 스위칭하지 않는다. 거기에 더해, IO 작업과 CPU를 이용한 빠른 계산 작업은 서로 연관있는 경우가 많기 때문인지 Dispatchers.IODispatchers.Default 간 스위칭은 매우 최적화되어 있다.

주의점

동시성 문제 및 임계 영역

 쓰레드와 같이 메모리를 공유하므로 동시성 문제가 일어날 수 있다. 이를 위해 임계 영역을 관리할 수 있는 API도 제공한다. Kotlin에서는 Mutex를 제공하고, JVM에서는 synchronized를 제공한다. synchronized는 쓰레드를 블로킹하기 때문에 코루틴과 친환적이지 않으므로 Mutex만 알아보겠다.

  • Mutex
     Locked, Unlocked 두 상태를 통해 임계 영역을 관리한다. lock된 영역에는 접근할 수 없다. 이를 직접 활용할 수 있는 함수 mutex.lock()mutex.unlock()을 제공한다. 임계 영역 시작 및 끝에 이 함수를 이용할 순 있지만, 중간에 에러가 일어나면, unlock이 호출되지 않아 계속 lock된 상태가 될 수 있다. 따라서 다음 함수를 이용하는 것이 안전하다.
  • withLock(owner: Any? = null, action: () -> T): T
     람다로 받아 내부 블록을 실행하고 반환한다. 당연히 중간에 에러가 일어나도 안전하게 unlock을 수행하며 owner도 받을 수 있다.
  • Semaphore
     동시에 처리 가능한 작업의 수를 제한하는 것이 목적이며, 시스템 과부화 방지 등에 사용한다. Mutex와 목적은 다르지만 같은 패키지에 있기 때문에 정리한다.
    class LimitedNetworkUserRepository(
        private val api: UserApi,
    ) {
        // 동시 요청을 10개로 제한합니다.
        private val semaphore = Semaphore(10)

        suspend fun requestUser(userId: String) = semaphore.withPermit {
            api.requestUser(userId)
        }
    }
  • withPermit(action: () -> T): T
    비슷한 결로 Semaphore에 허용된 양만큼 action을 수행하도록 제한한다.

데드락 (Dead Lock)

  1. 잘못된 runBlocking 사용
     위에서 언급했다시피 runBlocking은 현재 쓰레드를 블로킹한다. 코드 내부에선 자체 Dispatcher를 생성해 while문을 사용한 Event Loop로 비동기 작업을 처리하고 있기 때문이다. 따라서, 만약 A Dispatcher에서 runBlocking을 이용하면 A는 블로킹 될 것이다. 이 상태에서 그 runBlocking 코드 안에 launch(A Dispatcher) {...}를 추가한다면 A Dispatcher는 이미 블로킹되었기 때문에 데드락이 발생하게 된다.
     이는 왜 코루틴이 쓰레드는 블로킹하지 않고, 중지 함수를 추가하여 비동기 작업을 관리하려고 했는지에 대한 이유를 엿볼 수 있는 예제 같다.

  2. Mutex에서 다른 쓰레드의 suspend 함수 사용
     Mutex의 withLock은 현 쓰레드를 lock하고 action을 수행한다. 따라서, 직접 lock() + suspend 함수 + unlock() 하는 것은 데드락을 유발할 수 있으며 이를 withLock { ... }로 대체해야 한다. withLock은 suspend-aware하기 때문에 위 문제를 해결할 수 있다.

구조화된 동시성 위반

구조환된 동시성(Structured Concurrency)는 “모든 코루틴은 부모 스코프 안에서 생성되고, 부모가 끝나면 자식도 반드시 함께 끝나야 한다”는 뜻이다. 따라서 이를 위반하면 메모리 누수, 취소 불가, 예외 누락 등의 문제가 발생할 수 있다. 위반 사례들을 살펴보자.

  • GlobalScope 사용
    : GlobalScope은 전체 시스템과 같은 생명 주기를 갖기 때문에 부모 코루틴이 종료되더라도 끝까지 남아 메모리 누수를 일으킬 수 있다.

  • launch를 반환하거나 외부에 전달
    : fun startTask() = CoroutineScope(Dispatchers.IO).launch {...}와 같은 구조는 애초에 개발자가 Job을 관리할 수도 없으며 join이나 취소를 하지 않으면 추적할 수조차 없는 상태가 되버린다. 따라서, suspend 키워드를 사용하거나 해당 코드를 감싸는 형태로 만들어야 관리가 가능하다.

  • suspend 함수 내에서 새로운 스코프 생성
    : suspend 함수는 부모 코루틴의 context를 따라가기 때문에 추적할 수 있는 상태가 된다. 하지만, 불필요하게 새로운 scope의 코루틴을 생성하면 이 구조는 깨지게 되고 예상치 못한 결과를 일으킬 수 있다. 따라서, 자유로운 context 전환을 돕고 구조화도 깨뜨리지 않는 withContext를 사용하는 것이 좋다.

반복문에서의 suspend point 누락

 백그라운드에서 지속적으로 수행해야 할 작업을 위해 코루틴 내부에 무한한 반복문을 사용하는 경우가 있을 수 있다.

launch(Dispatchers.Default) {
    while (true) {
        getAppAnalystics()		// 사용자의 앱 사용 데이터를 꾸준히 업데이트
    }
}

 위와 같은 사용은 코루틴이 직접 선점하지 않는 특성으로 인해 문제가 된다. suspend point를 기준으로 스케줄러가 선점할 코루틴을 정해주기 때문에 꼭 suspend point를 넣어주는 것이 중요하다. 따라서 다음과 같이 수정하는게 좋다.

launch(Dispatchers.Default) {
    while (true) {
        getAppAnalystics()
        yield()		// 다른 코루틴에 제어 양보
    }
}

다만, 비교적 널널한 I/O 중심 작업보다는 CPU 연산 중심 루프에서 특히 주의해야 한다.

profile
안녕하세요!

0개의 댓글