면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

코틀린 코루틴에서 launch와 async의 차이

skydovesJaewoong Eum (skydoves)||11분 소요

코틀린 코루틴에서 launch와 async의 차이

코틀린 코루틴은 동시성 작업을 시작하기 위한 두 가지 주요 코루틴 빌더(coroutine builder)를 제공합니다. 바로 launchasync입니다. 두 빌더 모두 구조화된 동시성(structured concurrency) 스코프 내에서 새로운 코루틴을 생성하지만, 호출자가 동시 작업의 결과값을 필요로 하는지 여부에 따라 근본적으로 다른 목적으로 사용됩니다. 잘못된 빌더를 선택하면 미묘한 버그가 발생할 수 있습니다. async에서 예외가 삼켜지거나(swallowed), launch에서 불필요한 Deferred 래퍼가 생성되거나, 두 빌더를 잘못 사용하여 구조화된 동시성이 깨질 수 있습니다. 면접에서도 이 두 빌더의 차이는 매우 자주 등장하는 질문이므로, 정확히 이해하고 계시는 것이 중요합니다.

이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • JobDeferred의 내부 차이점, 그리고 코루틴 내부 엔진이 각각을 어떻게 디스패치하는지 설명할 수 있습니다.
  • launchasync 사이에서 예외 전파(exception propagation) 방식이 어떻게 다른지, 그리고 이것이 크래시 처리에 왜 중요한지 기술할 수 있습니다.
  • await() 없이 async를 사용할 때 예외가 어떻게 조용히 삼켜지는지, 그리고 구조화된 동시성이 이를 어떻게 완화하는지 파악할 수 있습니다.
  • async를 활용한 병렬 분해(parallel decomposition)와 launch를 활용한 순차적 fire-and-forget 패턴의 실행 흐름을 추적할 수 있습니다.
  • 병렬 네트워크 호출, 백그라운드 저장, UI 업데이트 등 실제 안드로이드 코드에서 올바른 빌더를 적용할 수 있습니다.

코루틴 빌더와 반환 타입

launchasync는 모두 CoroutineScope의 확장 함수입니다. 두 함수 모두 CoroutineContext(기본값은 스코프의 컨텍스트), CoroutineStart 모드, 그리고 코루틴 본문을 정의하는 suspend 람다를 매개변수로 받습니다. 핵심 차이점은 반환하는 타입에 있습니다.

launchJob을 반환합니다. Job은 코루틴의 생명주기를 관리하는 핸들 역할을 하며, 코루틴이 활성 상태인지, 완료되었는지, 취소되었는지를 확인할 수 있습니다. 또한 완료될 때까지 일시 중단하는 join()과 취소를 요청하는 cancel() 메서드를 제공합니다. 여기서 중요한 점은 Job이 결과값을 전달하지 않는다는 것입니다. launch로 생성한 코루틴에서 Job을 통해 반환값을 추출할 수 있는 방법은 없습니다.

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

asyncDeferred<T>를 반환합니다. Deferred<T>Job의 하위 타입으로, await() 함수가 추가되어 있습니다. Deferred<T>는 타입 T의 미래 결과값(future result)을 나타냅니다. async에 전달되는 suspend 람다의 반환 타입은 Unit 대신 T이며, Deferredawait()를 호출하면 결과가 준비될 때까지 호출자를 일시 중단합니다.

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

내부적으로 두 빌더 모두 AbstractCoroutine의 하위 클래스를 생성합니다. launchStandaloneCoroutine(지연 시작의 경우 LazyStandaloneCoroutine)을 생성하고, asyncDeferredCoroutine을 생성합니다. DeferredCoroutine은 람다의 결과를 내부 상태 머신(state machine)에 저장하고, await()가 호출될 때 이를 전달합니다. 코루틴 스케줄링, 일시 중단, 재개 등의 내부 메커니즘은 두 빌더에서 동일하며, 차이점은 오직 결과 처리와 예외 처리 방식에 있습니다.

예외 전파: 가장 중요한 동작 차이

반환 타입 외에 launchasync 사이에서 가장 중요한 차이점은 예외를 처리하는 방식입니다. 이 차이를 제대로 이해하지 못하고 잘못된 빌더를 선택하면 프로덕션 환경에서 버그가 발생하는 경우가 빈번합니다. 면접에서도 이 부분을 정확히 설명할 수 있으면 큰 플러스 요인이 됩니다.

launch 코루틴은 예외를 즉시 부모 스코프로 전파합니다. launch 블록 내부에서 잡히지 않은 예외가 발생하면, StandaloneCoroutinehandleCoroutineException()을 호출합니다. 이 메서드는 Job 계층 구조를 올라가며 CoroutineExceptionHandler를 탐색하고, 핸들러가 설치되어 있지 않으면 해당 스레드의 미처리 예외 핸들러(uncaught exception handler)로 전달합니다. 안드로이드 앱에서 별도의 예외 처리가 없다면, 이는 곧 앱 크래시로 이어집니다.

val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

scope.launch {
    throw RuntimeException("This crashes immediately")
    // 예외가 스코프의 예외 핸들러로 전파되거나,
    // 핸들러가 설치되어 있지 않으면 앱이 크래시됩니다.
}

반면, async 코루틴은 예외를 즉시 전파하지 않습니다. 예외는 Deferred 객체 내부에 캡슐화되며, await()를 호출할 때 비로소 예외가 발생합니다. 아무 코드도 await()를 호출하지 않으면, 구조화된 동시성이 이를 잡지 않는 한 예외는 조용히 사라질 수 있습니다. 이러한 동작이 존재하는 이유는 async가 결과(또는 실패)를 나중에 소비할 연산을 나타내기 때문입니다.

val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

val deferred = scope.async {
    throw RuntimeException("This does NOT crash immediately")
    // 예외가 Deferred 내부에 저장됩니다.
}

// 이 시점에서 앱은 정상적으로 계속 실행됩니다.

try {
    deferred.await()  // 이 시점에서 예외가 발생합니다.
} catch (e: RuntimeException) {
    Log.e("TAG", "Caught: ${e.message}")
}

이 면접 질문은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 면접 질문의 전체 내용을 볼 수 있습니다.

구독하기
면접 질문 목록으로 가기