전체 비유: 항공사 운항 시스템#

항공사 운항 시스템에서 하나의 편 지연이 전체 스케줄에 영향을 줄 수 있지만, SupervisorJob처럼 각 노선을 독립적으로 관리하면 한 편의 결항이 다른 편에 영향을 주지 않습니다.

항공사 비유Kotlin 코루틴역할
항공 관제 데이터CoroutineContext코루틴 실행 정보 묶음
편명 식별자CoroutineName코루틴 이름 지정
노선별 독립 스케줄SupervisorJob자식 실패가 형제에 영향 없음
항공편 탑승구 채널Channel<T>코루틴 간 안전한 데이터 전달
먼저 도착한 편 처리select { }여러 채널 중 먼저 도착한 것 처리

대상 독자: 코루틴 기초와 Flow를 이해한 개발자 선수 지식: 코루틴 기초, Flow와 비동기 스트림 소요 시간: 약 45~55분 이 문서를 읽으면: CoroutineContext를 직접 조합하고, 예외 전파 구조를 제어하며, Channel과 select로 복잡한 비동기 통신을 설계할 수 있습니다.

TL;DR
  • CoroutineContext는 Dispatcher, Job, CoroutineName 등의 요소를 +로 조합합니다.
  • SupervisorJob/supervisorScope를 쓰면 자식 코루틴의 실패가 다른 자식에 전파되지 않습니다.
  • CoroutineExceptionHandlerlaunch에서 잡히지 않은 예외를 처리합니다.
  • Channel은 코루틴 간 안전한 큐로, Rendezvous/Buffered/Unlimited 세 가지 모드가 있습니다.
  • select { }는 여러 채널 중 먼저 준비된 것을 선택합니다.

CoroutineContext — 코루틴의 DNA#

CoroutineContext는 코루틴 실행에 필요한 정보를 담는 불변 컨테이너입니다. + 연산자로 요소를 조합합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 여러 컨텍스트 요소를 + 로 조합
    val context = Dispatchers.IO +
                  CoroutineName("DataLoader") +
                  CoroutineExceptionHandler { _, e -> println("예외: $e") }

    launch(context) {
        println("실행 중 스레드: ${Thread.currentThread().name}")
        println("코루틴 이름: ${coroutineContext[CoroutineName]?.name}")
    }
}
// 실행 중 스레드: DefaultDispatcher-worker-1
// 코루틴 이름: DataLoader

주요 CoroutineContext 요소:

요소타입역할
Dispatchers.IOCoroutineDispatcher실행 스레드 풀 지정
Job()Job생명주기 관리, 취소 전파
CoroutineName("name")CoroutineName디버깅용 이름
CoroutineExceptionHandlerCoroutineExceptionHandler미처리 예외 핸들러

CoroutineScope — 생명주기 관리의 핵심#

CoroutineScopeCoroutineContext를 감싸고, 해당 스코프 내 모든 코루틴의 생명주기를 관리합니다.

import kotlinx.coroutines.*

class UserRepository {
    // 이 Repository와 생명주기를 같이 하는 스코프
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    fun loadUser(id: Int) {
        scope.launch {
            // DB 조회
            delay(100)
            println("사용자 $id 로드 완료")
        }
    }

    fun close() {
        scope.cancel()  // 모든 자식 코루틴 취소
    }
}

fun main() = runBlocking {
    val repo = UserRepository()
    repo.loadUser(1)
    repo.loadUser(2)
    delay(200)
    repo.close()
}

커스텀 스코프 생성:

import kotlinx.coroutines.*

// Job()을 직접 넘기면 해당 Job으로 취소를 제어할 수 있음
val myScope = CoroutineScope(Dispatchers.Default + Job())

// supervisorScope는 자식 실패가 독립적
val supervisorScopeExample = CoroutineScope(Dispatchers.Default + SupervisorJob())

예외 전파 구조#

코루틴의 예외 전파는 launchasync에서 다르게 동작합니다.

launch에서의 예외:

launch로 시작한 코루틴에서 예외가 발생하면, 예외는 즉시 부모 Job으로 전파됩니다. 부모는 취소되고, 다른 자식도 모두 취소됩니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        coroutineScope {
            launch {
                delay(100)
                println("자식 1: 정상 완료")
            }
            launch {
                delay(50)
                throw RuntimeException("자식 2 실패!")
            }
        }
    } catch (e: Exception) {
        println("부모가 받은 예외: ${e.message}")
    }
    // 자식 2가 실패하면 자식 1도 취소됨
}
// 부모가 받은 예외: 자식 2 실패!

async에서의 예외:

async의 예외는 await() 호출 시점에 전파됩니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async {
        delay(100)
        throw RuntimeException("async 실패!")
    }

    try {
        deferred.await()  // 여기서 예외 발생
    } catch (e: Exception) {
        println("await에서 잡힌 예외: ${e.message}")
    }
}

SupervisorJob과 supervisorScope#

SupervisorJob을 사용하면 자식 코루틴의 실패가 형제나 부모에게 전파되지 않습니다. 독립적인 작업들(예: 여러 사용자 데이터 로드, 다수 API 병렬 호출)에 적합합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // supervisorScope: 자식 실패가 다른 자식에 영향 없음
    supervisorScope {
        val child1 = launch {
            delay(100)
            println("자식 1: 정상 완료")
        }

        val child2 = launch {
            delay(50)
            throw RuntimeException("자식 2 실패!")
            // 자식 1은 영향 없이 계속 실행됨
        }

        // child2 실패를 개별적으로 처리
        child2.join()
        println("child2 상태: ${child2.isCancelled}")
    }
    println("supervisorScope 종료")
}
// 자식 2 실패!
// 자식 1: 정상 완료
// child2 상태: true  (예외로 종료된 launch 코루틴은 isCancelled가 true)
// supervisorScope 종료

Job vs SupervisorJob 비교:

flowchart TD
    subgraph Normal["Job (기본)"]
        P1["부모 Job"] --> C1["자식 1"]
        P1 --> C2["자식 2 (실패)"]
        C2 -->|"예외 전파"| P1
        P1 -->|"취소"| C1
    end

    subgraph Supervisor["SupervisorJob"]
        P2["부모 SupervisorJob"] --> D1["자식 1 (계속 실행)"]
        P2 --> D2["자식 2 (실패)"]
        D2 -->|"전파 차단"| P2
    end

그림: Job과 SupervisorJob 예외 전파 비교 — 기본 Job은 자식 실패가 부모와 형제 코루틴까지 취소시키는 반면, SupervisorJob은 실패한 자식만 독립적으로 종료됨을 보여줍니다.


CoroutineExceptionHandler#

launch 코루틴에서 잡히지 않은 예외를 처리하는 마지막 수단입니다. async에서는 동작하지 않습니다. CoroutineExceptionHandler는 구조화된 동시성에서 루트 코루틴의 미처리(uncaught) 예외만 받는 핸들러인데, async의 예외는 Deferred 안에 캡슐화되어 await()를 통해서만 노출되기 때문에 Deferred를 직접 받는 호출자가 try-catch로 처리해야 합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { context, exception ->
        val name = context[CoroutineName]?.name ?: "이름없음"
        println("[$name] 예외 처리: ${exception.message}")
    }

    val scope = CoroutineScope(
        Dispatchers.Default + SupervisorJob() + handler
    )

    scope.launch(CoroutineName("작업A")) {
        delay(100)
        throw IllegalStateException("작업A 실패")
    }

    scope.launch(CoroutineName("작업B")) {
        delay(200)
        println("작업B: 정상 완료")
    }

    delay(300)
    scope.cancel()
}
// [작업A] 예외 처리: 작업A 실패
// 작업B: 정상 완료
CoroutineExceptionHandler 주의사항

CoroutineExceptionHandler루트 코루틴 에서만 동작합니다. 자식 코루틴에 핸들러를 달아도 예외가 부모로 먼저 전파되므로 핸들러는 호출되지 않습니다. 또한 async의 예외는 Deferred에 캡슐화되어 await()로만 노출되므로 호출자가 try-catch로 처리해야 합니다.

추가로, coroutineScope 안에서 자식 async가 실패하면 다른 자식의 await()가 호출되기 전이라도 스코프 전체가 취소되어 부모로 예외가 올라갑니다. 자식 실패를 격리하려면 supervisorScope 또는 SupervisorJob을 사용해야 합니다.


예외 처리 결정 트리#

지금까지 살펴본 launch/async, Job/SupervisorJob, try-catch/CoroutineExceptionHandler의 선택지를 한눈에 정리합니다. 새로운 코드를 작성할 때 다음 흐름에 따라 결정하세요.

flowchart TD
    Q1{"코루틴의 결과가<br>필요한가?"}
    Q1 -->|"필요 없음"| L["launch (Job 반환)"]
    Q1 -->|"필요함"| A["async (Deferred 반환)"]

    L --> Q2{"자식 실패가 형제·부모를<br>취소시켜야 하는가?"}
    A --> Q3{"호출자에서 결과 또는<br>예외를 받을 위치는?"}

    Q2 -->|"예 (작업 묶음)"| J["부모: 일반 Job 또는<br>coroutineScope"]
    Q2 -->|"아니오 (독립 작업)"| S["부모: SupervisorJob 또는<br>supervisorScope"]

    Q3 -->|"await() 시점"| TC1["try-catch로 await() 감싸기"]
    Q3 -->|"즉시(루트 launch)"| H["CoroutineExceptionHandler<br>(루트 launch에만 동작)"]

    J --> R1["예외는 즉시 부모로 전파<br>→ 다른 자식도 취소"]
    S --> R2["예외는 해당 자식만 종료<br>→ 형제는 계속 실행"]
    TC1 --> R3["Deferred의 예외는<br>await() 호출자가 처리"]
    H --> R4["미처리 launch 예외만 수신<br>async에는 동작하지 않음"]

그림: 코루틴 빌더·Job 유형·예외 처리 도구를 단계별로 선택하는 결정 트리. 결과 필요 여부 → 자식 격리 필요 여부 → 예외 수신 위치 순서로 묻습니다.

자주 만나는 시나리오 3가지를 매핑하면 다음과 같습니다.

시나리오빌더부모 Job예외 처리
백그라운드 작업 묶음(하나 실패 시 전체 취소)launch일반 Job / coroutineScope루트에 CoroutineExceptionHandler
여러 API 병렬 호출 후 결과 합치기async + awaitAllcoroutineScopetry-catchawaitAll() 감싸기
사용자별 알림 발송(한 사용자 실패가 다른 사용자에 영향 X)launchSupervisorJob / supervisorScope각 자식 내부 try-catch
기억하면 좋은 한 줄
  • 묶음 vs 독립: 함께 망해야 하면 Job, 격리해야 하면 SupervisorJob.
  • 결과 vs 통보: 결과가 필요하면 async + try-catch(await), 결과 없으면 launch + CoroutineExceptionHandler.
  • CoroutineExceptionHandler루트 launch에만 동작합니다. 자식이나 async에서는 동작하지 않습니다.

Channel — 코루틴 간 통신#

Channel은 코루틴 간에 데이터를 주고받는 안전한 큐 입니다. 여러 생산자-소비자 패턴을 구현할 수 있습니다.

Channel 종류:

종류생성 방법특징
RendezvousChannel()버퍼 없음. 송신자·수신자가 동시에 준비되어야 함
BufferedChannel(capacity)지정 크기의 버퍼. 버퍼 가득 차면 송신 suspend
UnlimitedChannel(UNLIMITED)버퍼 무한. 수신자 없어도 즉시 전달
ConflatedChannel(CONFLATED)최신 값만 유지. 버퍼 크기 1 + 덮어쓰기

기본 사용:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>()

    // 생산자
    launch {
        for (i in 1..5) {
            println("전송: $i")
            channel.send(i)
        }
        channel.close()  // 전송 완료 신호
    }

    // 소비자
    for (value in channel) {  // channel.close() 시 반복 종료
        println("수신: $value")
    }
    println("채널 소비 완료")
}

Buffered Channel:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>(capacity = 3)  // 버퍼 3개

    launch {
        for (i in 1..5) {
            channel.send(i)
            println("전송 완료: $i")  // 버퍼 가득 차면 3번에서 suspend
        }
        channel.close()
    }

    delay(100)  // 생산자가 버퍼를 채울 시간
    for (value in channel) {
        delay(50)  // 소비를 느리게
        println("수신: $value")
    }
}

produce와 consumeEach#

코루틴 스코프를 활용한 채널 생산자/소비자 패턴입니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

// produce: CoroutineScope 내에서 채널 생산자 정의
fun CoroutineScope.numbersProducer(from: Int, to: Int): ReceiveChannel<Int> = produce {
    for (i in from..to) {
        delay(100)
        send(i)
    }
}

fun main() = runBlocking {
    val channel = numbersProducer(1, 5)

    channel.consumeEach { value ->
        println("처리: $value")
    }
}

팬아웃과 팬인 패턴#

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

// 팬아웃: 하나의 채널을 여러 소비자가 나눠 처리
fun main() = runBlocking {
    val channel = Channel<Int>(capacity = 10)

    // 생산자
    launch {
        for (i in 1..10) {
            channel.send(i)
        }
        channel.close()
    }

    // 소비자 3명 (팬아웃)
    repeat(3) { workerId ->
        launch {
            for (value in channel) {
                delay(100)
                println("워커 $workerId: $value 처리")
            }
        }
    }

    delay(2000)
}

select 표현식#

select는 여러 채널 중 먼저 준비된 것 을 선택해서 처리합니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.selects.*

fun CoroutineScope.fastChannel(): ReceiveChannel<String> = produce {
    delay(50)
    send("빠른 서버 응답")
}

fun CoroutineScope.slowChannel(): ReceiveChannel<String> = produce {
    delay(200)
    send("느린 서버 응답")
}

fun main() = runBlocking {
    val fast = fastChannel()
    val slow = slowChannel()

    repeat(2) {
        val result = select<String> {
            fast.onReceive { it }
            slow.onReceive { it }
        }
        println("선택됨: $result")
    }

    fast.cancel()
    slow.cancel()
}
// 선택됨: 빠른 서버 응답
// 선택됨: 느린 서버 응답

select with onSend:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.selects.*

fun main() = runBlocking {
    val ch1 = Channel<String>(1)
    val ch2 = Channel<String>(1)

    launch {
        // 먼저 받을 수 있는 채널로 보내기
        select<Unit> {
            ch1.onSend("채널1로 전달") { println("ch1으로 보냄") }
            ch2.onSend("채널2로 전달") { println("ch2로 보냄") }
        }
    }

    delay(100)
    println(ch1.tryReceive().getOrNull() ?: ch2.tryReceive().getOrNull())
}

코루틴 누수 방지 패턴#

코루틴 누수는 취소되지 않은 코루틴이 계속 실행되는 상태입니다.

누수가 발생하는 패턴:

import kotlinx.coroutines.*

// 잘못된 예: GlobalScope 사용
// GlobalScope는 앱 전체 생명주기와 연결 → 취소 불가
fun badPattern() {
    GlobalScope.launch {   // 절대 사용하지 마세요!
        delay(10_000)
        println("이 코루틴은 언제 끝날까?")
    }
}

올바른 패턴:

import kotlinx.coroutines.*

class MyService {
    // SupervisorJob: 자식 실패가 스코프 전체를 취소하지 않음
    private val serviceScope = CoroutineScope(
        Dispatchers.IO + SupervisorJob() + CoroutineName("MyService")
    )

    fun start() {
        serviceScope.launch {
            // 서비스 작업
        }
    }

    fun stop() {
        serviceScope.cancel()  // 모든 작업 취소 + 자원 해제
    }
}

Kotlin 공식 권고: currentCoroutineContext() 전달:

import kotlinx.coroutines.*

// 콜백 기반 API를 코루틴으로 래핑할 때
suspend fun <T> awaitCallback(block: (callback: (T) -> Unit) -> Unit): T =
    suspendCancellableCoroutine { continuation ->
        block { result ->
            continuation.resume(result)
        }
        // 취소 시 자원 정리
        continuation.invokeOnCancellation {
            println("코루틴 취소 → 콜백 정리")
        }
    }

suspendCancellableCoroutine — 콜백 래핑#

기존 콜백 기반 API를 suspend 함수로 변환할 때 사용합니다.

import kotlinx.coroutines.*

// 예: 콜백 기반 라이브러리 래핑
interface NetworkCallback {
    fun onSuccess(data: String)
    fun onError(error: Exception)
}

fun fetchDataWithCallback(url: String, callback: NetworkCallback) {
    Thread {
        Thread.sleep(200)
        callback.onSuccess("데이터: $url")
    }.start()
}

// suspend 함수로 변환
suspend fun fetchData(url: String): String = suspendCancellableCoroutine { cont ->
    fetchDataWithCallback(url, object : NetworkCallback {
        override fun onSuccess(data: String) {
            if (cont.isActive) cont.resume(data)
        }

        override fun onError(error: Exception) {
            if (cont.isActive) cont.resumeWithException(error)
        }
    })

    cont.invokeOnCancellation {
        println("요청 취소됨: $url")
    }
}

fun main() = runBlocking {
    val result = fetchData("https://example.com/api")
    println(result)
}

핵심 포인트#

핵심 정리
  • CoroutineContext+로 Dispatcher, Job, Name, Handler를 조합합니다.
  • 기본 Job은 자식 실패 → 부모 취소 → 형제 취소 순으로 전파됩니다.
  • SupervisorJob/supervisorScope는 자식 실패를 격리합니다.
  • CoroutineExceptionHandler는 루트 launch 코루틴의 미처리 예외를 처리합니다.
  • Channel은 타입별로 Rendezvous/Buffered/Unlimited를 선택해서 사용합니다.
  • select { }로 여러 채널 중 먼저 준비된 것을 처리합니다.
  • GlobalScope 사용을 피하고 명시적 스코프로 누수를 방지합니다.

다음 단계#

💡 함께 읽기: 코루틴이 여러 서비스에 걸쳐 컨텍스트를 전파할 때는 분산 추적 관점이 필요합니다. CoroutineContext를 트레이스 ID와 결합하면 비동기 호출 흐름을 끊김 없이 관찰할 수 있습니다.