코루틴 빌더, 컨텍스트, 작업 관리

aufcl4858·2025년 6월 3일
post-thumbnail

코루틴 빌더 - 코루틴을 생성하는 세 가지 방법

코루틴을 실행하려면 코루틴 빌더(Coroutine Builder)를 사용해야 합니다. 각각의 빌더는 서로 다른 목적과 특성을 가지고 있어, 상황에 맞게 선택해야 합니다.

launch - 실행하고 잊어버리기

launch는 가장 일반적으로 사용되는 코루틴 빌더입니다. 결과를 반환하지 않고, 백그라운드에서 작업을 수행할 때 사용합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Job을 반환하는 launch
    val job = launch {
        delay(1000)
        println("World!")
    }
    
    println("Hello,")
    job.join() // 코루틴 완료까지 대기
}

launch는 block 파라미터의 리시버 타입이 CoroutineScope이므로 코루틴 스코프 내에서만 호출할 수 있습니다.

launch의 특징:

  • Job 객체를 반환
  • 결과값을 반환하지 않음 (Unit)
  • 부모 코루틴과 독립적으로 실행
  • 예외가 발생하면 부모에게 전파

async - 결과가 필요한 비동기 작업

async는 결과값을 반환하는 코루틴을 만들 때 사용합니다. 병렬 처리가 필요한 상황에서 특히 유용합니다.

fun main() = runBlocking {
    // Deferred<String>을 반환하는 async
    val deferred1 = async {
        delay(1000)
        "첫 번째 결과"
    }
    
    val deferred2 = async {
        delay(1500)
        "두 번째 결과"
    }
    
    // 두 작업이 병렬로 실행되어 총 1.5초만 소요
    println("${deferred1.await()} + ${deferred2.await()}")
}

async 또한 block 파라미터의 리시버 타입이 CoroutineScope이므로 코루틴 스코프 내에서만 호출할 수 있습니다.

async의 특징:

  • Deferred<T> 객체를 반환 (Job의 하위 타입)
  • await()로 결과값을 받아올 수 있음
  • 여러 작업을 병렬로 실행할 때 효과적
  • 지연 실행(lazy) 옵션 제공

runBlocking - 동기와 비동기의 다리

runBlocking은 코루틴 세계와 일반 함수 세계를 연결하는 역할을 합니다. 현재 스레드를 블록하면서 코루틴을 실행합니다. 다른 코루틴 빌더와 달리 CoroutineScope의 확장 함수가 아니므로 자식 코루틴이 될 수 없고, 루트 코루틴으로만 사용될 수 있습니다. 이는 runBlocking이 다른 코루틴 빌더와 근본적으로 다른 쓰임새를 가지는 이유입니다.

과거에는 테스트 코드용 빌더로 많이 사용되었지만, 현재는 가상 시간으로 실행시키는 runTest가 더 많이 사용되고 있어서 현재는 거의 사용되지 않습니다.

fun main() {
    // 메인 스레드를 블록하면서 코루틴 실행
    runBlocking {
        delay(1000)
        println("runBlocking 완료!")
    }
    println("이 메시지는 1초 후에 출력됩니다")
}

runBlocking 사용 시기:

  • main 함수에서 코루틴 실행
  • 기존 동기 코드에서 코루틴 사용

코루틴 컨텍스트와 스코프 - 실행 환경 관리

코루틴 컨텍스트 (CoroutineContext)

코루틴 컨텍스트는 코루틴이 실행되는 환경을 정의합니다. 여러 요소들의 조합으로 구성됩니다.

// 컨텍스트 구성 요소들
val context = Dispatchers.IO + 
              CoroutineName("MyCoroutine") + 
              CoroutineExceptionHandler { _, exception ->
                  println("예외 발생: $exception")
              }

launch(context) {
    // 이 코루틴은 IO 스레드에서 실행되고
    // "MyCoroutine"이라는 이름을 가지며
    // 지정된 예외 핸들러를 사용함
}

주요 컨텍스트 요소:

  • Dispatcher: 어떤 스레드에서 실행할지 결정
  • Job: 코루틴의 생명주기 관리
  • CoroutineName: 디버깅용 이름
  • CoroutineExceptionHandler: 예외 처리

코루틴 스코프 (CoroutineScope)

스코프는 코루틴의 생명주기를 관리하는 영역입니다. 모든 코루틴은 스코프 내에서 실행되어야 합니다.

class MyRepository : CoroutineScope {
    private val job = SupervisorJob()
    override val coroutineContext = Dispatchers.IO + job
    
    fun loadData() {
        launch {
            // 이 코루틴은 Repository의 스코프에서 실행
            val data = fetchDataFromNetwork()
            saveToDatabase(data)
        }
    }
    
    fun cleanup() {
        job.cancel() // 모든 자식 코루틴 취소
    }
}

안드로이드에서의 스코프 활용:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // lifecycleScope: 액티비티 생명주기와 연동
        lifecycleScope.launch {
            // 액티비티가 destroy되면 자동으로 취소
        }
        
        // viewModelScope: ViewModel 생명주기와 연동
        viewModelScope.launch {
            // ViewModel이 clear되면 자동으로 취소
        }
    }
}

Job 관리와 취소 메커니즘

Job의 특성과 상속

Job은 코루틴이 상속하지 않은 유일한 코루틴 컨텍스트입니다. 모든 코루틴은 자신만의 Job을 생성하고, 인자 또는 부모 코루틴으로부터 온 Job은 새로운 Job의 부모로 사용됩니다.

부모 Job은 자식 Job 모두를 참조할 수 있으며 자식 또한 부모를 참조할 수 있기 때문에 코루틴 스코프 내에서 취소와 예외처리 구현이 가능합니다.

Job 변수 할당의 목적

Job을 변수에 할당하는 이유는 코루틴을 제어하기 위해서입니다. launch는 변수에 할당되든 안 되든 항상 즉시 실행되며, 변수 할당은 단순히 Job 참조를 보관하여 나중에 제어할 수 있게 하는 것입니다.

fun demonstrateJobControl() = runBlocking {
    // ❌ 변수에 할당하지 않으면 제어 불가능
    launch {
        repeat(10) {
            println("제어 불가능한 코루틴: $it")
            delay(500)
        }
    }
    // 이 코루틴은 취소하거나 완료를 기다릴 방법이 없음!
    
    // ✅ 변수에 할당하면 제어 가능
    val controlledJob = launch {
        repeat(10) {
            println("제어 가능한 코루틴: $it")
            delay(500)
        }
    }
    
    delay(2000)
    controlledJob.cancel("사용자가 중단 요청")
    controlledJob.join() // 취소 완료까지 대기
}

Job 제어의 주요 목적들:

  • 생명주기 제어: cancel(), join(), cancelAndJoin()
  • 상태 모니터링: isActive, isCompleted, isCancelled
  • 실용적 시나리오: 사용자 취소, 화면 전환 시 작업 정리, 새 요청 시 이전 요청 취소

Job의 생명주기와 상태

Job은 코루틴의 생명주기를 나타내는 객체로, 다음과 같은 6가지 상태를 가집니다:

  • New: 생성되었지만 아직 시작되지 않은 상태
  • Active: 실행 중인 상태
  • Completing: 실행이 완료되어 자식들의 완료를 기다리는 상태
  • Completed: 모든 작업이 완료된 상태
  • Cancelling: 취소 중인 상태 (자식들의 취소를 기다림)
  • Cancelled: 취소가 완료된 상태
fun demonstrateJobStates() = runBlocking {
    val job = launch {
        delay(1000)
        println("작업 완료")
    }
    
    println("Active: ${job.isActive}")       // true (Active 상태)
    println("Completed: ${job.isCompleted}") // false
    
    job.join() // Completed나 Cancelled 상태에 도달할 때까지 대기
    println("Completed: ${job.isCompleted}") // true (Completed 상태)
}

join() 메서드는 지정한 Job이 Completed나 Cancelled와 같은 마지막 상태에 도달할 때까지 기다리는 중단 함수입니다. 이는 코루틴을 다시 실행하는 것이 아니라, 이미 실행 중인 코루틴의 완료를 기다리는 것입니다.

fun demonstrateJoinBehavior() = runBlocking {
    val job = launch {
        println("1. 코루틴 시작!")
        delay(2000)
        println("2. 코루틴 완료!")
    }
    
    println("3. launch 호출 직후 (코루틴은 이미 실행 중)")
    job.join() // 여기서 완료까지 기다림 (재실행 아님)
    println("4. join() 완료 - 코루틴이 끝났음")
}

코루틴 취소하기

코루틴에서의 취소는 코루틴 스스로 취소 신호를 확인하고 종료해야 합니다. 코루틴의 취소는 진행중에서 강제로 종료될 수 없어 중단점에서 취소가 진행됩니다.

cancel 메서드의 특징

cancel() 메서드는 다음과 같은 특징을 가집니다:

  • 즉시 반환: 취소 신호만 보내고 실제 취소 완료를 기다리지 않음
  • CancellationException 발생: 취소된 코루틴에서 중단 함수 호출 시 예외 발생
  • 자식 코루틴 전파: 모든 자식 코루틴에게도 취소 신호 전파
  • 원인 설정 가능: cancel(cause) 형태로 취소 원인 지정 가능
fun demonstrateCancellation() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("작업 중... $i")
                delay(500) // 취소 가능한 지점 - CancellationException 발생 지점
            }
        } catch (e: CancellationException) {
            println("작업이 취소되었습니다: ${e.message}")
            // 정리 작업 수행
        } finally {
            println("정리 작업 완료")
        }
    }
    
    delay(1300)
    job.cancel("사용자가 취소함") // 취소 원인과 함께 취소
    job.join() // 취소 완료까지 대기
    // 또는 job.cancelAndJoin() 사용 가능
}

취소 불가능한 코루틴 처리

코루틴은 CPU 집약적인 작업은 delay() 같은 중단점이 없어 취소가 어려울 수 있습니다.

fun handleNonCancellableWork() = runBlocking {
    val job = launch {
        var nextPrintTime = System.currentTimeMillis()
        var i = 0
        
        // isActive로 취소 상태 확인
        while (isActive && i < 5) {
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("작업 중... ${i++}")
                nextPrintTime += 500
            }
        }
    }
    
    delay(1300)
    job.cancelAndJoin()
}

예외 처리와 구조화된 동시성

코루틴에서의 예외 전파

코루틴에서 예외가 발생하면 부모-자식 관계에 따라 전파됩니다.

fun demonstrateExceptionPropagation() = runBlocking {
    try {
        launch {
            delay(100)
            throw Exception("자식 코루틴 예외")
        }
    } catch (e: Exception) {
        // 이 블록은 실행되지 않음!
        println("예외 잡힘: $e")
    }
    
    delay(200)
    println("부모 코루틴 완료") // 이것도 실행되지 않음
}

CoroutineExceptionHandler 활용

fun useExceptionHandler() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("예외 처리됨: $exception")
    }
    
    launch(handler) {
        throw Exception("처리될 예외")
    }
    
    delay(100)
    println("부모는 계속 실행됨")
}

SupervisorJob으로 독립적인 실패 처리

  • 일반적인 Job에서는 자식 코루틴 중 하나가 실패하면 모든 형제 코루틴이 취소됩니다. SupervisorJob은 이런 전파를 막아 각 자식 코루틴이 독립적으로 실패할 수 있게 합니다.
fun useSupervisorJob() = runBlocking {
    val supervisor = SupervisorJob()
    
    with(CoroutineScope(coroutineContext + supervisor)) {
        val child1 = launch {
            delay(100)
            throw Exception("첫 번째 자식 실패")
        }
        
        val child2 = launch {
            delay(200)
            println("두 번째 자식은 정상 완료")
        }
        
        // child1의 실패가 child2에 영향을 주지 않음
        joinAll(child1, child2)
    }
}

supervisorScope 활용

suspend fun handlePartialFailure() = supervisorScope {
    val results = mutableListOf<String>()
    
    // 여러 작업을 병렬로 실행, 일부 실패해도 다른 작업은 계속
    val jobs = List(5) { index ->
        async {
            delay(100)
            if (index == 2) throw Exception("작업 $index 실패")
            "작업 $index 완료"
        }
    }
    
    // 각 작업의 결과를 개별적으로 처리
    jobs.forEach { job ->
        try {
            results.add(job.await())
        } catch (e: Exception) {
            println("작업 실패: $e")
        }
    }
    
    results
}
profile
데브누누

0개의 댓글