코딩

[코루틴 (3)] Structured Concurrency와 코루틴 스코프

Eastpark 2025. 1. 17. 22:41
728x90
반응형

지난 2편에서는 코루틴의 기초 API인 suspend, launch, async에 대해 살펴보았습니다.

이번 글에서는 Structured Concurrency 개념에 집중해, 부모-자식 코루틴 구조와 코루틴 스코프가 어떻게 동작하는지 알아보겠습니다.

 

핵심 키워드

Structured Concurrency

부모-자식 코루틴

coroutineScope { }

supervisorScope { }

예외 전파와 취소(Cancellation)


1. Structured Concurrency란?

 

1.1 기존 비동기 모델의 문제점

 

코루틴이 등장하기 전에는, 콜백 기반 비동기 처리 혹은 스레드 직접 관리 방식을 통해 여러 작업을 동시에 수행했습니다. 하지만 다음과 같은 문제가 빈번히 발생했습니다.

스레드 누수(Thread Leak): 생성된 스레드를 제대로 정리하지 않아 앱 리소스를 낭비

콜백 지옥(Callback Hell): 중첩된 콜백 구조로 인해 코드 가독성과 유지보수성 저하

취소와 에러 전파 난이도: 비동기 실행 중 예외나 취소가 필요한 상황에서, 어디서부터 어떻게 처리해야 하는지 명확하지 않음

 

1.2 코루틴이 제시하는 구조적 동시성

 

Structured Concurrency는 코루틴이 제공하는 “부모-자식 관계로 비동기 작업을 묶어 관리”하는 개념입니다. 한마디로, 코루틴이 어디서 시작되어 어디서 끝나는지를 코드 구조로 명확히 표현합니다.

부모 코루틴(Parent)

자식 코루틴을 생성하고, 생명주기를 함께 관리합니다.

자식 코루틴(Child)

부모 스코프 내부에서 동작하며, 부모가 취소되면 자식들도 자동으로 취소됩니다.

 

이러한 구조적 동시성 덕분에, 비동기 로직에서 예외 전파, 취소 등이 훨씬 직관적으로 이뤄질 수 있습니다.


2. coroutineScope { }와 자식 코루틴

 

2.1 coroutineScope { ... }의 역할

 

coroutineScope { ... } 블록은 “이 범위 내에서 실행되는 모든 코루틴이 끝나야만 반환된다”는 특징을 가집니다.

suspend fun parallelTasks() = coroutineScope {
    launch {
        println("Task A start")
        delay(500)
        println("Task A end")
    }
    launch {
        println("Task B start")
        delay(300)
        println("Task B end")
    }
    println("All tasks launched")
} // 여기서 A, B 코루틴이 모두 완료돼야 블록을 빠져나감

coroutineScope자신을 호출한 상위 코루틴(혹은 스코프)와 부모-자식 관계를 형성합니다.

블록 내부에서 생성된 launchasync 코루틴이 모두 종료될 때까지, parallelTasks() 함수는 반환되지 않습니다.

 

2.2 예외 전파와 취소

coroutineScope 블록 내부에서 예외가 발생하면, 해당 예외가 부모에게 전파되어 스코프 전체가 취소됩니다.

예를 들어, A 코루틴에서 예외가 발생하면 B 코루틴도 함께 취소될 수 있으며, 상위 스코프에서는 이 예외를 처리해야 할 수 있습니다.

반응형

3. supervisorScope { }는 무엇이 다른가?

 

3.1 Supervisor 스코프와 독립적인 자식

 

supervisorScopecoroutineScope와 달리, 자식 코루틴 중 하나가 실패해도 다른 자식 코루틴에는 영향을 주지 않습니다.

suspend fun supervisorExample() = supervisorScope {
    val jobA = launch {
        println("Job A start")
        delay(500)
        throw RuntimeException("Error in A")
    }
    val jobB = launch {
        println("Job B start")
        delay(1000)
        println("Job B end")
    }
    // Job A가 예외로 종료되더라도 Job B는 계속 수행
    println("Supervisor scope end")
}

만약 A에서 예외가 발생해도, B는 계속 진행됩니다.

단, 상위 스코프(supervisorScope를 감싸는 스코프)에서 예외 처리를 별도로 하지 않으면, A의 예외가 최종적으로 전파될 수도 있습니다.

 

3.2 예외 처리와 SupervisorJob

supervisorScope 내부의 자식 코루틴은 각각 독립적으로 예외를 처리합니다.

상위 스코프에서 SupervisorJob을 명시적으로 사용하면, 더욱 세밀하게 예외 전파 방식을 제어할 수 있습니다.


4. 예제: 병렬 네트워크 호출과 예외 전파

 

아래 예시를 통해, coroutineScopesupervisorScope 간의 차이를 좀 더 구체적으로 살펴보겠습니다.

 

4.1 coroutineScope 예시

suspend fun fetchParallelData() = coroutineScope {
    val jobA = async {
        println("Fetching data A...")
        delay(300)
        throw RuntimeException("A failed!")
    }
    val jobB = async {
        println("Fetching data B...")
        delay(500)
        "Data B result"
    }
    try {
        val resultA = jobA.await()
        val resultB = jobB.await()
        resultA + resultB
    } catch (e: Exception) {
        println("Caught exception: ${e.message}")
        "Fallback data"
    }
}

1. A에서 예외가 발생하면, B도 함께 취소됩니다.

2. coroutineScope는 내부적으로 A, B가 모두 종료되어야 빠져나갑니다.

3. try/catch로 예외를 받아 적절히 처리한 후, “Fallback data”를 리턴할 수도 있습니다.

 

4.2 supervisorScope 예시

suspend fun fetchIndependentData() = supervisorScope {
    val jobA = async {
        println("Fetching data A...")
        delay(300)
        throw RuntimeException("A failed!")
    }
    val jobB = async {
        println("Fetching data B...")
        delay(500)
        "Data B result"
    }
    // A에서 예외가 발생해도 B는 독립적으로 계속 수행
    // jobA.await() 시점에서만 해당 예외가 발생
    val resultB = jobB.await()
    try {
        val resultA = jobA.await()
        "Combined: $resultA + $resultB"
    } catch (e: Exception) {
        println("A failed, but B: $resultB")
        "Use B data only"
    }
}

1. A가 예외로 종료되더라도, B는 정상 수행됩니다.

2. A의 결과를 await()하는 시점에 예외가 발생하면, catch 블록에서 처리할 수 있습니다.

3. 상위 스코프(여기서는 supervisorScope)는 A의 예외로 인해 자동 취소되지 않으므로, “B” 데이터를 계속 활용할 수 있습니다.

728x90

5. Structured Concurrency 정리

 

1. 부모-자식 관계: 코루틴 스코프 내에서 launch, async를 호출하면, 이들 코루틴은 스코프와 계층적 관계를 맺게 됩니다.

2. 예외와 취소: 기본적으로 한 자식이 예외로 종료되면 다른 자식도 취소되는 방향(coroutineScope). 다만, supervisorScope에서는 독립성을 보장해줄 수 있습니다.

3. 명확한 범위: 어떤 코루틴이 어디서 시작해서 어디까지 살아 있는지를 코드 구조에서 바로 파악할 수 있어 유지보수성이 높아집니다.

 

이를 통해 어떤 비동기 작업이, 어느 구간에서 실행되며, 실패 혹은 취소가 어떻게 전파되는지를 쉽게 추적할 수 있습니다.


이번 [3편]에서는 Structured Concurrency의 주요 개념과, coroutineScope·supervisorScope 간의 차이점을 예제를 통해 살펴보았습니다. 핵심은 코루틴 스코프를 통해 비동기 작업을 구조적으로 묶고, 예외나 취소를 체계적으로 관리한다는 점입니다.

 

다음 4편에서는 예외 처리와 취소(Cancellation)를 더욱 깊이 있게 다룰 예정입니다.

“코루틴이 도중에 취소되면 어떻게 되는가?”

“예외가 발생했을 때 어디서 처리해야 하는가?”

CoroutineExceptionHandler는 어떤 역할을 하는가?”

이러한 궁금증을 구체적인 예제와 함께 풀어보도록 하겠습니다.


시리즈 전체 목차

0. 코루틴 탄생 배경과 기본 개념

1. 코루틴 기초(suspend, CoroutineScope, Dispatcher)

2. Structured Concurrency와 코루틴 스코프 관리

3. 예외 처리와 취소(Cancellation) 기법

4. 코루틴 채널(Channel)과 select

5. Flow 심화(Flow, StateFlow, SharedFlow)

6. 안드로이드 실무 적용(Retrofit, Room, ViewModel)

7. 동시성 문제 해결(Mutex, Semaphore)

8. 코루틴 테스트 & 디버깅 전략

9. 고급 퍼포먼스 최적화

10. WorkManager + 코루틴 연동

11. LifecycleScope & 안드로이드 생명주기 대응

12. Compose + 코루틴 + Flow 시너지

13. Compose Navigation + 코루틴 취소/재시작 사례

728x90
반응형