Kotlin

[Kotlin/코루틴] CoroutineContext 중 Job과 Dispatcher 정리하기

yujinius 2025. 1. 15. 00:57

 

Coroutine은 세 가지 주요 구성 요소로 이루어져 있다

바로 CoroutineScope, CoroutineContext, 그리고 CoroutineBuilder이다.

코루틴 구성 요소 3가지

  • CoroutineScope: 코루틴의 실행 범위를 정의.
  • CoroutineContext: 디스패처(Dispatcher)와 잡(Job) 등 코루틴의 실행 환경을 포함.
  • CoroutineBuilder: 코루틴을 생성하고 실행하는 빌더(launch, async 등).

이번 포스팅에서는 코루틴의 동작을 정의하는 CoroutineContext에 대해 학습한 것을 정리해보겠다.


🌠 CoroutineContext   

  • CoroutineContext는 다음 요소들을 사용하여 코루틴의 동작을 정의한다.
    • Job : 코루틴 수명 주기 제어
    • CoroutineDispatcher : 적절한 스레드에 작업 전달
    • CoroutineName : 디버깅에 유용한 코루틴의 이름
    • CoroutineExceptionHandler : 포착되지 않은 예외를 처리
오늘은 여기에서 Job과 CoroutineDispatcher에 대해 정리해보겠다.

✨ Job 코루틴의 생명주기 제어 (feat. Deffered, SupervisorJob)     


1. Job이란?

Job은 코루틴의 생명주기를 제어하는 중요한 구성 요소이다.

모든 코루틴에는 하나의 Job 인스턴스가 있으며, 이는 해당 코루틴의 상태(예: 활성, 취소, 완료 등)를 나타내고 제어하는 역할을 한다.

Job의 주요 기능:

  1. 코루틴 상태 추적:
    • Job은 코루틴의 상태를 나타낸다. (예: Active, Completing, Cancelled, Completed)
  2. 취소(Cancellation):
    • Job.cancel()을 호출하여 해당 코루틴을 취소할 수 있다.
    • 취소는 부모-자식 관계를 통해 전파된다.
  3. 구조적 동시성(Structured Concurrency):
    • Job은 부모와 자식 관계를 가지며, 부모 코루틴이 종료되면 자식 코루틴도 모두 취소된다.
  4. 완료 대기:
    • Job.join()을 호출하여, 해당 코루틴이 완료될 때까지 대기할 수 있다.

2. Deferred와 Job의 차이

Deferred는 Job을 확장한 인터페이스로, 코루틴이 결과값을 반환할 수 있도록 한다.

즉, Deferred는 Job의 모든 기능을 상속받으며, 추가적으로 결과값을 처리할 수 있는 기능을 제공한다.

특징 Job Deferred

기능 코루틴의 생명주기 제어 생명주기 제어 + 결과값 반환
결과값 반환 여부 없음 있음 (await() 메서드 사용)
예외 전파 부모-자식 관계를 통해 전파 예외가 발생하면 즉시 호출부로 전파
주요 사용 예 Fire-and-Forget 작업 결과값이 필요한 작업

Job 예제:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        delay(1000)
        println("Job completed")
    }

    println("Waiting for job to finish...")
    job.join() // 코루틴이 완료될 때까지 대기
    println("Job finished")
}

Deferred 예제:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        "Deferred result"
    }

    println("Waiting for result...")
    val result = deferred.await() // 결과값 반환
    println("Result: $result")
}


3. SupervisorJob이란?

SupervisorJob은 부모-자식 관계에서 예외 전파를 제어하기 위해 사용되는 특수한 Job이다.

기본 Job에서는 자식 코루틴 중 하나에서 예외가 발생하면 부모와 다른 자식들도 모두 취소된다. 하지만 SupervisorJob에서는 자식의 예외가 부모나 다른 자식들에게 전파되지 않는다.

SupervisorJob의 특징:

  1. 독립적인 자식 작업:
    • 자식 코루틴 중 하나에서 예외가 발생해도, 다른 자식 코루틴은 영향을 받지 않고 계속 실행된다.
  2. 부모-자식 관계의 수정:
    • 기본 Job은 부모-자식 관계에서 예외를 전파하지만, SupervisorJob은 전파를 막는다.
  3. 주로 사용되는 상황:
    • 여러 자식 코루틴이 서로 독립적으로 실행되어야 하는 경우.
    • 특정 자식의 실패가 전체 작업에 영향을 미치지 않도록 하고 싶을 때.

SupervisorJob 예제:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()

    val scope = CoroutineScope(supervisor)

    scope.launch {
        delay(1000)
        println("Child 1 completed")
    }

    scope.launch {
        delay(500)
        throw Exception("Child 2 failed") // 예외 발생
    }

    scope.launch {
        delay(1500)
        println("Child 3 completed") // Child 2가 실패해도 실행됨
    }

    delay(2000)
    println("Supervisor scope finished")
}

결과:

Child 2 failed
Child 1 completed
Child 3 completed
Supervisor scope finished


4. 요약 정리

  • Job: 코루틴의 생명주기를 제어하며, 상태 관리와 취소를 담당.
  • Deferred: Job의 확장으로, 코루틴의 결과값을 반환할 수 있는 기능 추가.
  • SupervisorJob: 자식 코루틴 간의 독립성을 보장하며, 특정 자식의 예외가 부모나 다른 자식에게 영향을 미치지 않도록 설계된 Job.

✨ CoroutineDispatcher


OS에서의 Dispatcher 개념

https://velog.io/@ss-won/OS-CPU-Scheduler와-Dispatcher

Dispatcher

  • CPU의 제어권을 CPU Scheduler에 의해 선택된 프로세스에게 넘긴다.
  • CPU 스케줄러 내부에 포함된 것으로, 단기 스케줄러가 선택한 프로세스에 실질적으로 프로세서를 할당하는 역할을 한다.
  • 프로세스의 레지스터를 적재하고(문맥교환), 운영체제 모드(Kernel Mode)에서 사용자 상태(User Mode)로 전환시켜주며 프로세스가 다시 시작할 때 사용자 프로그램이 올바른 위치를 찾을 수 있도록 한다. 👉🏻 dispatcher의 처리속도를 빠르면 빠를수록 좋다.

코루틴에서의 Dispatcher : CoroutineDispatcher

  • 코루틴에서의 Dispatcher인 CoroutineDispatcher는 코루틴이 어떤 스레드 또는 스레드 풀(Thread Pool)에서 실행될지를 결정하는 구성 요소이다.
  • 코루틴은 특정 디스패처를 통해 실행될 환경(스레드 또는 스레드 풀)을 지정받는다.
    • 코루틴이 실행될 스레드를 스케줄링.
    • 작업의 종류에 따라 적절한 스레드 환경을 제공.

이 하위에서부터는 CoroutineDispatcher를 다뤄보겠다.


주요 CoroutineDispatcher 종류

1) Dispatchers.Default

  • 기본 디스패처로, launch, async 등 표준 빌더에서 디스패처를 명시하지 않을 경우 사용
  • 주로 CPU 집약적인 작업에 최적화되어 있으며, 가용 CPU 코어 수를 기반으로 스레드 풀을 구성
  • 예: 계산 작업, 데이터 처리, 알고리즘 실행.

2) Dispatchers.IO

  • 블로킹 I/O 작업(파일 읽기/쓰기, 네트워크 요청 등)을 처리하도록 설계된 디스패처
  • 공유 스레드 풀을 사용하며, 필요에 따라 추가 스레드를 생성하여 대규모 I/O 작업도 처리할 수 있다.
  • 안드로이드 환경에서는 네트워크 작업이나 데이터베이스 접근 시 자주 사용된다.

3) Dispatchers.Main

  • UI 객체와 작업할 때 사용하는 디스패처로, 주로 메인 스레드에 바인딩된다.
  • 일반적으로 싱글 스레드로 동작하며, UI 업데이트 작업에 사용된다.
  • Android 환경에서 가장 많이 사용되는 디스패처 중 하나이다.

4) Dispatchers.Unconfined

  • 특정 스레드에 고정되지 않는 디스패처이다.
  • 코루틴의 초기 실행은 현재 호출 프레임에서 수행되며, 이후에는 suspend 함수에 따라 실행될 스레드가 결정된다.
  • 특정 스레드 정책을 요구하지 않는 간단한 작업에 적합하지만, 예측하기 어려운 실행 흐름 때문에 일반적으로 권장되지 않는다.
  • 중첩된 코루틴은 이벤트 루프(event-loop)를 형성하여 스택 오버플로우를 방지한다.

주요 함수: shutdown

  • Default와 IO 디스패처를 종료하고, 관련된 모든 스레드를 중지시킨다.
  • 종료 후, 새로운 작업 요청을 모두 거부하며, 시간 관련 작업(delay, withTimeout)이나 기타 디스패처에서 거부된 작업도 처리하지 않는다.
  • 주의: 이 함수는 민감한 API(@DelicateCoroutinesApi)로 분류되며, 일반적으로 사용이 권장되지 않는다.

위에서 언급되는 ThreadPool이란 무엇인가?

  • 쓰레드 풀은 미리 일정 개수의 쓰레드를 생성하여 관리하는 기법이다.
  • 쓰레드 풀을 사용하면 스레드 생성 및 삭제에 따른 오버헤드를 줄일 수 있으며, 특정 시점에 동시에 처리할 수 있는 작업의 개수를 제한할 수 있다. 이를 통해 시스템의 자원을 효율적으로 관리하고 성능을 향상시킬 수 있다.

스레드 풀은 작업처리에 사용되는 스레드를 제한된 개수만큼 정해놓고 작업큐 (Queue)에 들어오는 작업들을 하나씩 스레드가 맡아 처리한다. 그렇게 하면 작업처리 요청이 폭증되어도 스레드의 전체개수가 늘어나지 않으므로(제한해서 하나씩 처리하기 때문)  시스템 성능이 급격히 저하되지 않는다.

참고: https://cheershennah.tistory.com/170

  • Executor는 스레드 풀을 관리하며, 작업을 제출하면 스레드 풀 내의 스레드가 이를 처리한다.
  • 스레드의 재사용이 가능해지고, 개발자가 직접 스레드를 관리해야 하는 부담이 줄어든다.
  • 코루틴에서의 디스패처는 일반적으로 스레드 풀을 통해 스레드 관리를 처리한다.

스레드 풀의 역할 정리:

  • 스레드 재사용: 작업이 끝난 스레드를 재활용하여 새로운 작업을 할당.
  • 스레드 생성 제한: 너무 많은 스레드가 생성되어 시스템 자원을 고갈시키는 것을 방지.
  • 작업 대기열 관리: 스레드가 부족할 경우 작업을 대기열에 넣고, 스레드가 비워질 때 실행.

코루틴에서 사용하는 스레드 풀:

  • Dispatchers.Default와 Dispatchers.IO는 내부적으로 공유 스레드 풀(Common ThreadPool)을 사용
    • Dispatchers.Default: CPU 코어 수에 기반한 크기의 스레드 풀.
    • Dispatchers.IO: 필요에 따라 더 많은 스레드를 생성할 수 있는 스레드 풀.

공유 스레드 관련

https://velog.io/@thisyoon97/CoroutineDispatcher
….

두 코드의 실행 결과를 보면 사용하는 스레드의 이름이 DefaultDispatcher-worker-1,3,4로 동일하다. 하지만 실제 공유 스레드풀을 보면 두 디스패처가 사용하는 스레드풀은 완전히 달라서 겹칠 수 없다. 그런데 도대체 왜 이름이 같은것일까?

Dispatchers.IO와 Dispatchers.Default가 사용하는 스레드의 이름이 유사한 것은 사실이다. 둘 다 "DefaultDispatcher-worker-X" 형식의 이름을 사용한다.

이것은 이 디스패쳐들이 스레드를 만들 때 사용하는 공유 스레드풀 때문이다. 코루틴 라이브러리는 스레드의 생성과 관리를 효율적으 로 할 수 있도록 애플리케이션 레벨의 공유 스레드풀을 제공한다. 이 공유 스레드풀에서는 스레드를 무제한으로 생성할 수 있고, 이 공유 스레드풀을 사용해 각각의 디스패처에서 사용할 스레드를 생성한다.

그림을 보면 DispatchersDefault의 스레드와 Dispatchers IO의 스레드가 나누어져 있는 것을 볼 수 있다. 이것이 바로 스레드의 이름의 앞부분이 동일했던 이유다.