(코틀린/Kotlin) Coroutines Structured Concurrency 완전 정리


1. Structured Concurrency란?

구조화된 동시성은 코루틴의 생명주기가 부모-자식 관계의 트리 구조로 관리되는 설계 철학입니다.

부모 코루틴이 끝나지 않으면 자식 코루틴도 끝나지 않은 것으로 간주
부모가 취소되면 모든 자식도 자동으로 취소됨
자식의 예외는 부모에게 전파됨

이 원칙 덕분에 코루틴이 누수되거나 좀비처럼 떠도는 일이 없습니다.


2. 구조화되지 않은 동시성의 문제

// GlobalScope: 구조화되지 않음 — 부모와 연결이 끊김
fun loadData() {
    GlobalScope.launch {
        delay(5000)
        println("데이터 로드 완료")  // 화면이 이미 닫혀도 계속 실행됨!
    }
}

3. 구조화된 동시성의 기본 형태

suspend fun loadUserProfile() = coroutineScope {  // 새로운 Job 생성
    val user = async { fetchUser() }       // 자식 코루틴 1
    val posts = async { fetchPosts() }     // 자식 코루틴 2

    UserProfile(user.await(), posts.await())
    // coroutineScope는 모든 자식이 완료될 때까지 대기
}
coroutineScope (부모)
  ├── async { fetchUser() }   (자식1)
  └── async { fetchPosts() }  (자식2)

부모는 자식1, 자식2가 모두 끝나야 자신도 끝남

4. Job 트리와 취소 전파

부모 취소 → 모든 자식 취소

val parentJob = launch {
    launch {
        delay(1000)
        println("자식1 완료")  // 실행 안 됨
    }
    launch {
        delay(1000)
        println("자식2 완료")  // 실행 안 됨
    }
    delay(2000)
}

delay(100)
parentJob.cancel()  // 부모 취소 → 모든 자식도 즉시 취소
parentJob (취소됨)
  ├── 자식1 (자동 취소됨)
  └── 자식2 (자동 취소됨)

자식 예외 → 부모와 형제 모두 취소 (기본 Job)

coroutineScope {
    launch {
        delay(100)
        throw RuntimeException("자식1 실패")
    }
    launch {
        delay(1000)
        println("자식2 완료")  // 실행되지 않음! 자식1의 예외로 전체 취소
    }
}
자식1 예외 발생 → 부모에게 전파 → 부모가 취소 → 형제(자식2)도 취소

이것이 기본 Job의 동작입니다. 한 자식이 실패하면 전체가 실패로 간주됩니다.


5. SupervisorJob — 예외 전파 차단

자식의 실패가 다른 형제에게 영향을 주지 않도록 하려면 supervisorScope를 사용합니다.

supervisorScope {
    launch {
        delay(100)
        throw RuntimeException("자식1 실패")
        // 자식1만 실패, 부모와 형제는 영향 없음
    }
    launch {
        delay(1000)
        println("자식2 완료")  // 정상 실행됨!
    }
}
SupervisorJob
  ├── 자식1 (실패, 격리됨)
  └── 자식2 (영향 없이 정상 완료)

6. coroutineScope vs supervisorScope

구분 coroutineScope supervisorScope
자식 예외 전파 부모와 형제에게 전파 격리 (해당 자식만 실패)
사용 시점 모든 자식 결과가 함께 필요할 때 독립적인 작업들을 병렬 실행할 때
예시 여러 API를 합쳐 화면 1개 구성 여러 위젯을 독립적으로 갱신
// coroutineScope: 모든 데이터가 필요한 화면 — 하나가 실패하면 전체 실패 처리
suspend fun loadDashboard() = coroutineScope {
    val user = async { fetchUser() }
    val stats = async { fetchStats() }
    Dashboard(user.await(), stats.await())
}

// supervisorScope: 독립적인 위젯들 — 하나 실패해도 나머지는 정상 표시
suspend fun loadWidgets() = supervisorScope {
    launch { loadWeatherWidget() }   // 실패해도 무관
    launch { loadNewsWidget() }      // 독립적으로 정상 작동
    launch { loadCalendarWidget() }
}

7. CoroutineScope 직접 생성 시 주의

// 위험: 직접 생성한 Scope는 자동으로 부모-자식 관계에 묶이지 않음
class MyRepository {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun startBackgroundWork() {
        scope.launch {
            // 이 Scope의 생명주기를 누군가 명시적으로 관리해야 함
        }
    }

    fun cleanup() {
        scope.cancel()  // 직접 정리해야 누수 방지
    }
}

ViewModel처럼 생명주기가 자동 관리되는 Scope(viewModelScope, lifecycleScope)를 우선 사용하고, 직접 Scope를 만든다면 반드시 정리 로직을 작성해야 합니다.


8. 구조화된 동시성의 핵심 보장사항

suspend fun structuredExample() = coroutineScope {
    println("시작")

    launch {
        delay(1000)
        println("자식 작업 완료")
    }

    println("끝")
    // ⚠️ "끝"이 출력되어도 함수는 자식이 끝날 때까지 반환하지 않음!
}

// 출력 순서:
// 시작
// 끝
// (1초 후)
// 자식 작업 완료
// → 그 후에야 structuredExample() 함수가 실제로 반환

coroutineScope모든 자식이 완료되기 전까지 자기 자신도 완료되지 않습니다.
이것이 “구조화”의 핵심: 부모 함수가 끝났다는 것은 모든 자식 작업도 끝났다는 것을 의미합니다.


9. 실전 패턴 — 타임아웃과 구조화된 동시성

suspend fun loadWithTimeout(): Result<Data> = coroutineScope {
    try {
        withTimeout(5000) {
            val data = async { fetchData() }
            Result.success(data.await())
        }
    } catch (e: TimeoutCancellationException) {
        Result.failure(e)
        // withTimeout 내부의 모든 자식 코루틴도 함께 취소됨
    }
}

10. 정리



Related Posts