안드로이드/Coroutine

[Android Coroutine] 구조적 동시성 쉽게 이해하기 – Job과 launch의 관계

Moimeme Futur 2025. 5. 1. 14:16
반응형

launch를 launch 안에 또 썼는데… 이게 무슨 순서로 실행되는 거지?

 

viewModelScope 안에서 launch를 썼는데 화면이 닫혀도 계속 돌아가는 이유가 뭘까?

 

 

 

Android 개발자라면 한 번쯤은 겪어봤을 이 찜찜한 상황들. Kotlin Coroutines의 구조적 동시성 개념을 이해하지 못하면, 우리는 종종 이런 질문에 명확한 답을 내리지 못한 채 감에 의존한 코드를 짜게 됩니다.

이번 글에서는 『Kotlin Coroutines Deep Dive』 8장 “잡과 자식 코루틴 기다리기”의 핵심 내용을 바탕으로, Job과 구조적 동시성의 본질을 파헤치고 Android 실전에서 안정성과 신뢰성을 확보하는 방법을 함께 정리해보려고 합니다.

 

 

 

구조적 동시성(Structured Concurrency)이란?

구조적 동시성(Structured Concurrency)이라는 사고방식 Kotlin의 구조적 동시성은 단순한 기술이 아니라 "동시성을 책임 있게 설계하는 방식"이에요. 핵심은 부모 코루틴이 자식 코루틴을 책임지고, 자식은 반드시 부모 안에서 관리된다는 거죠.

이를 가능하게 하는 핵심 메커니즘은 Job이에요. launch로 만들어지는 코루틴은 자동으로 부모 Job에 연결되고, 이 덕분에

  • 부모가 cancel되면 자식도 함께 cancel되고,
  • 자식이 모두 끝나야 부모도 complete되고,
  • 자식에서 발생한 예외는 부모로 전파돼요.

 

이게 바로 launch 안에 launch를 써도 join 없이도 자동으로 대기되는 이유예요.

 

 

 

왜 Android 에서 구조적 동시성이 중요할까?

Android 앱은 생명주기가 아주 복잡하죠. 화면이 회전되고, 유저가 백그라운드로 이동하고, Fragment가 재생성되고요.

이런 상황에서 구조적 동시성이 없다면?

  • 자식 코루틴이 계속 살아남아 메모리를 잡아먹고,
  • 이미 사라진 Activity를 참조해 Crash가 발생하고,
  • 중복된 API 호출이 동시에 날아가 서버와 DB를 엉망으로 만들 수 있어요.

 

예를 들어 아래 코드를 볼게요.

viewModelScope.launch {
    launch {
        delay(1000)
        Log.d("TAG", "자식 A 완료")
    }
    launch {
        delay(1500)
        Log.d("TAG", "자식 B 완료")
    }
    Log.d("TAG", "부모 완료")
}

 

출력은 이렇게 되겠죠.

부모 완료
자식 A 완료
자식 B 완료

 

하지만 중요한 건

  • viewModelScope는 이 launch 안에 만들어진 모든 자식 Job이 끝날 때까지 자동으로 기다려줘요.
  • 별도로 join()할 필요가 없고요.
  • ViewModel이 파괴되면 자식들도 함께 cancel돼요.

이게 바로 책임 있는 동시성, 구조적 동시성이에요.

 

 

 

Android에서 GlobalScope 사용...?!

GlobalScope가 구조를 깨는 이유 초보자들이 가장 많이 저지르는 실수가 바로 GlobalScope.launch예요. 이건 구조적 동시성 관점에선 고아 Job이에요.

GlobalScope.launch {
    delay(3000)
    Log.d("TAG", "나는 어디에도 속하지 않아")
}
  • 부모가 없어요 → 아무도 cancel해주지 않아요
  • 생명주기와 무관해요 → 화면 닫혀도 돌아가요
  • 예외가 발생해도 → 누가 처리할지 알 수 없어요

결과적으로

  • 메모리 누수 발생 가능성
  • 알 수 없는 상태에서 예외 삼킴
  • 테스트와 디버깅 불가

Android에서는 반드시 viewModelScope, lifecycleScope, rememberCoroutineScope  생명주기에 속한 CoroutineScope를 써야 해요.

 

 

 

부모와 자식의 Job은 어떻게 어떻게 연결되어 있을까?

launch 안의 launch, Job은 어떻게 연결되는가? CoroutineScope.launch {}는 내부적으로 다음과 같은 흐름으로 동작해요:

  1. 현재 CoroutineScope의 context를 가져옴 → this.coroutineContext
  2. 새로 생성되는 Job에 부모로 연결 → Job(parent)
  3. 내부적으로 parent.attachChild(child) 호출됨

 

val scope = CoroutineScope(Job())

scope.launch {
    launch {
        val myJob = coroutineContext[Job]
        println("내 Job: $myJob")
        println("부모의 children: ${scope.coroutineContext[Job]?.children?.toList()}")
    }
}

 

이 구조 덕분에, 부모가 cancel되면 자식도 자동으로 cancel되고, 자식이 끝나야 부모가 complete돼요.

이것이 우리가 join() 없이도 자식이 끝나길 기다릴 수 있는 이유예요.

 

 

 

정리: launch를 쓰는 건 쉽습니다. 하지만 구조를 이해하고 쓰는 건 프로페셔널의 영역이에요.

『Kotlin Coroutines Deep Dive』 8장은 단순히 코루틴 문법이 아닌 동시성에 대한 철학과 태도를 알려줘요.

launch 하나를 쓴다고 해서 끝이 아니에요. 그 launch가 어디에 속해 있는가, 그리고 그 안의 자식은 어디에 귀속되어야 하는가까지 생각해야 비로소 Android 앱은 예측 가능한 상태로 동작할 수 있어요.

 

 

 

 

Android 개발자를 위한 CoroutineContext 깊이 이해하기

코틀린 코루틴을 쓰면서 CoroutineContext는 반드시 마주치는 개념이다. 하지만 공식 문서를 읽거나 튜토리얼을 보면, "Context에 여러 요소를 담는다"는 식의 설명만 있어서 막연하게 느껴지기 쉽다.

devgeek.tistory.com

 

반응형