코루틴 Deep Dive
마인드맵 개요
Coroutine: 실행을 일시 중단(suspend)했다가 재개(resume)할 수 있는 작업의 단위
핵심: 코루틴은 단순한 라이브러리가 아닌 컴파일러 수준의 언어 기능. suspend 함수를 상태 머신으로 변환하는 것은 컴파일러가 담당한다.
fun main() = runBlocking {
launch {
delay(1000L)
println("Hello from Coroutine!")
}
println("Hello from Main!")
}
| 용어 | 설명 |
|---|---|
| Suspending Function | suspend 키워드로 표시, 다른 suspending 함수/코루틴 내에서만 호출 가능 |
| Continuation | suspend 지점에서 코루틴의 상태를 나타냄. "나머지 실행 로직"을 캡슐화 |
| Suspension Point | 코루틴 실행이 중단될 수 있는 위치 |
| Suspending Lambda | launch { }, async { } 뒤의 블록 |
| Coroutine Builder | suspending 람다를 받아 코루틴을 생성하는 함수 |
면접 포인트: "suspend 함수가 내부적으로 어떻게 동작하는지 설명해주세요"라는 질문에 CPS 변환과 상태 머신을 설명할 수 있어야 합니다.
Continuation은 "중단된 지점 이후의 나머지 실행"을 캡슐화한 객체입니다. 코루틴이 suspend되면, "다음에 무엇을 할 것인가"를 Continuation 객체에 담아두고, 나중에 이 객체를 통해 실행을 이어갑니다.
interface Continuation<in T> {
val context: CoroutineContext // 어떤 스레드에서 실행할지 등의 정보
fun resumeWith(result: Result<T>) // 결과를 받아 실행을 재개
}
Kotlin 컴파일러는 모든 suspend 함수를 CPS(Continuation-Passing Style)로 변환합니다. 핵심은 간단합니다: 마지막 파라미터로 Continuation 객체가 추가됩니다.
// 우리가 작성한 코드
suspend fun fetchData(): String {
delay(1000L)
return "Data fetched"
}
// 컴파일러가 변환한 결과 (개념적)
fun fetchData(continuation: Continuation<String>): Object {
// suspend 키워드 사라짐
// 반환 타입이 Object로 변경 (SUSPENDED 마커도 반환할 수 있어야 하므로)
// Continuation 파라미터 추가
}
// 변환 전
suspend fun fetchUser(id: String): User
suspend fun saveData(key: String, value: Int): Boolean
// 변환 후 — 기존 파라미터 뒤에 Continuation이 추가됨
fun fetchUser(id: String, cont: Continuation<User>): Object
fun saveData(key: String, value: Int, cont: Continuation<Boolean>): Object
규칙: suspend 제거 + 반환 타입 Object + 마지막에 Continuation<원래반환타입> 추가. 예외 없이 모든 suspend 함수에 적용됩니다.
시그니처 변환은 첫 단계일 뿐입니다. 진짜 핵심은 함수 본문이 상태 머신으로 변환되는 것입니다.
suspend fun fetchUser(id: String): User {
val profile = fetchProfile(id) // ← suspend 지점 ①
val friends = fetchFriends(id) // ← suspend 지점 ②
return User(profile, friends) // ← 최종 결과
}
suspend 지점이 2개이므로, 함수는 3개의 토막(label 0, 1, 2)으로 나뉩니다.
// 컴파일러가 자동 생성하는 내부 클래스
class FetchUserStateMachine(
val completion: Continuation<User> // 최종 결과를 받을 곳
) : ContinuationImpl {
var label = 0 // 북마크: 어디까지 실행했는지
var result: Any? = null // 이전 단계의 결과
var id: String? = null // 지역 변수 보관
var profile: Profile? = null // 중간 결과 보관
}
핵심: 일반 함수의 지역 변수는 스택에 저장되어, 함수가 끝나면 사라집니다. 하지만 상태 머신의 변수는 힙의 객체 필드에 저장되어, 함수를 빠져나갔다가 다시 들어와도 값이 유지됩니다. 이것이 코루틴이 "스택리스"인 이유입니다.
// 컴파일러가 변환한 fetchUser 내부 (개념적)
fun fetchUser(id: String, cont: Continuation): Object {
// 상태 머신을 가져오거나 새로 생성
val sm = cont as? FetchUserStateMachine
?: FetchUserStateMachine(cont)
when (sm.label) {
// ═══ 토막 ①: 함수 시작 ~ fetchProfile 호출 ═══
0 -> {
sm.id = id // 지역 변수를 힙에 저장
sm.label = 1 // 북마크를 "다음은 1번" 으로 설정
val result = fetchProfile(id, sm) // sm 자신을 전달!
if (result == COROUTINE_SUSPENDED)
return COROUTINE_SUSPENDED // 함수 탈출, 스레드 해제
sm.result = result
}
// ═══ 토막 ②: fetchProfile 완료 → fetchFriends 호출 ═══
1 -> {
val profile = sm.result as Profile // 이전 결과 꺼냄
sm.profile = profile // 중간 결과 보관
sm.label = 2 // 북마크를 "다음은 2번"
val result = fetchFriends(sm.id, sm)
if (result == COROUTINE_SUSPENDED)
return COROUTINE_SUSPENDED // 또 탈출!
sm.result = result
}
// ═══ 토막 ③: 모든 데이터 준비됨 → 최종 결과 반환 ═══
2 -> {
val friends = sm.result as List<User>
return User(sm.profile, friends) // 완료!
}
}
}
위 코드가 실제로 어떻게 실행되는지 시간 순서대로 따라가 봅시다.
주목할 점: 같은 함수(fetchUser)가 3번 호출되었지만, label 덕분에 매번 다른 토막이 실행됩니다. 그리고 실행 스레드가 A → B → C로 바뀔 수 있습니다. 코루틴은 특정 스레드에 종속되지 않습니다.
suspend 함수 A가 suspend 함수 B를 호출하면, A의 상태 머신을 B에게 Continuation으로 전달합니다. B가 완료되면 A의 resumeWith가 호출되어 A가 이어서 실행됩니다.
// delay의 내부 동작 (단순화)
suspend fun delay(timeMillis: Long) {
// 현재 Continuation을 가져옴
suspendCancellableCoroutine { cont ->
// 타이머를 등록하고, 시간이 지나면 재개
scheduler.schedule(timeMillis) {
cont.resume(Unit) // 시간 후 resumeWith 호출!
}
// 여기서 SUSPENDED 반환 → 스레드 해제
}
}
콜백 vs Continuation: 결국 Continuation은 "고급 콜백"입니다. 하지만 일반 콜백과 달리 로컬 변수, 실행 위치, 컨텍스트 정보를 모두 담고 있어서, 마치 순차 코드처럼 자연스럽게 이어서 실행할 수 있습니다.
Continuation을 직접 다루는 가장 흔한 사례는 콜백 기반 API를 suspend 함수로 변환하는 것입니다.
// 콜백 기반 — 중첩이 깊어짐
fun loadUser(id: String, callback: (User) -> Unit) {
api.getUser(id) { user ->
api.getFriends(user.id) { friends ->
api.getPhotos(user.id) { photos ->
callback(user.copy(friends = friends, photos = photos))
}
}
}
}
// 콜백 API를 suspend 함수로 래핑
suspend fun getUser(id: String): User =
suspendCoroutine { cont -> // cont = Continuation 객체
api.getUser(id) { user ->
cont.resume(user) // 성공: 결과를 전달하며 재개
}
}
suspend fun getFriends(id: String): List<User> =
suspendCoroutine { cont ->
api.getFriends(id,
onSuccess = { friends -> cont.resume(friends) },
onError = { error -> cont.resumeWithException(error) } // 실패 처리
)
}
// 이제 순차적으로 작성 가능!
suspend fun loadUser(id: String): User {
val user = getUser(id) // 내부적으로 suspend → resume
val friends = getFriends(user.id) // 내부적으로 suspend → resume
val photos = getPhotos(user.id) // 내부적으로 suspend → resume
return user.copy(friends = friends, photos = photos)
}
suspendCoroutine의 동작: (1) 현재 코루틴의 Continuation을 람다에 전달 (2) 람다 실행 후 SUSPENDED 반환 (3) 누군가가 cont.resume()을 호출하면 코루틴 재개. 실전에서는 취소를 지원하는 suspendCancellableCoroutine을 사용합니다.
| 개념 | 실체 | 역할 |
|---|---|---|
| Continuation | 인터페이스 (resumeWith 메서드) | "나머지 실행"을 담은 콜백. 코루틴 재개의 핵심. |
| 상태 머신 (sm) | ContinuationImpl을 상속한 힙 객체 | label(북마크)과 지역 변수를 보관. Continuation 역할 겸임. |
| label | 정수 필드 (0, 1, 2...) | when 분기 기준. 어느 토막부터 이어서 실행할지 결정. |
| COROUTINE_SUSPENDED | 특수 싱글톤 마커 값 | "아직 안 끝남" 신호. 이 값을 반환하면 스레드가 해제됨. |
| resumeWith(result) | Continuation의 메서드 | 결과(또는 예외)를 전달하며 상태 머신의 다음 토막을 실행. |
| suspendCoroutine | 표준 라이브러리 함수 | 현재 Continuation을 직접 접근. 콜백→suspend 변환에 사용. |
면접 한 줄 답변: "Kotlin 컴파일러는 모든 suspend 함수를 Continuation 파라미터를 받는 상태 머신으로 변환합니다. 중단 시 COROUTINE_SUSPENDED를 반환해 스레드를 해제하고, 작업 완료 시 Continuation의 resumeWith를 호출해 저장된 label 위치부터 실행을 재개합니다."
| 비교 항목 | 스레드 | 코루틴 |
|---|---|---|
| 관리 | OS 커널 (시스템 콜 발생) | 유저 스페이스 (Kotlin 런타임) |
| 메모리 | 전용 스택 ~1MB 이상 | 힙의 작은 Continuation 객체 |
| 스케줄링 | 선점형 (OS 스케줄러) | 협력적 (suspend 지점에서만) |
| 컨텍스트 스위칭 | 비용 큼 (커널 트랩, 레지스터 저장/복원) | 매우 저렴 (메서드 호출 수준) |
| 블로킹 | 스레드 전체 블로킹, 메모리 유지 | 스레드 해제, 상태만 힙에 저장 |
| 확장성 | 수백~수천 개 | 수만~수백만 개 |
레스토랑 비유: 스레드 = 경험 부족한 웨이터 (테이블 하나에 한 명, 음식 기다리는 동안 대기). 코루틴 = 효율적인 웨이터 (여러 테이블 서빙, 절대 유휴 상태 없음).
핵심: 코루틴은 스레드를 대체하는 것이 아니라, 스레드 위에서 실행되는 고수준 추상화입니다. Dispatcher가 어떤 스레드에서 실행할지 결정합니다.
// 100,000 coroutines - OK
repeat(100_000) {
launch { delay(1000L); println("Coroutine $it") }
}
// 100,000 threads - OutOfMemoryError!
repeat(100_000) {
Thread { Thread.sleep(1000L); println("Thread $it") }.start()
}
코루틴 빌더는 코루틴을 생성하고 시작하는 함수입니다. 일반 함수에서는 suspend 함수를 직접 호출할 수 없기 때문에, 코루틴 세계로 진입하는 입구(bridge) 역할을 합니다.
// 일반 함수에서 suspend 함수를 바로 호출할 수 없음
fun main() {
fetchData() // ❌ 컴파일 에러! suspend 함수는 코루틴 안에서만 호출 가능
}
// 코루틴 빌더가 코루틴 세계로의 입구를 열어줌
fun main() {
runBlocking { // ← 코루틴 빌더가 코루틴을 생성
fetchData() // ✅ 이제 suspend 함수 호출 가능!
}
}
내부적으로 하는 일: (1) CoroutineContext 생성 (부모 상속 + 병합) → (2) 코루틴 객체 인스턴스화 (StandaloneCoroutine 등) → (3) Dispatcher에 실행 스케줄링 → (4) Job 또는 Deferred 반환
| 빌더 | 반환 타입 | 사용 사례 | 비고 |
|---|---|---|---|
| launch | Job | 결과 불필요 (UI 업데이트, 저장) | 예외 즉시 부모로 전파 |
| async | Deferred<T> | 결과 반환이 필요한 병렬 연산 | 예외를 Deferred에 저장 |
| runBlocking | T | main() / 테스트 전용 | 앱 코드에서 절대 사용 금지 |
| produce | ReceiveChannel | 채널 기반 값 스트림 생성 | 완료 시 채널 자동 닫힘 |
| actor | SendChannel | 메시지 기반 동시성 | 메일박스 패턴 |
launch: 예외 발생 → handleJobException → 부모로 즉시 전파 → CoroutineExceptionHandler에서 처리
async: 예외 발생 → Deferred에 저장 → await() 호출 시 재발생. 주의: 부모 스코프로도 전파될 수 있음!
// launch - 결과 불필요한 실행
val job: Job = launch {
delay(1000L)
println("Done")
}
job.cancel()
// async - 결과 반환
val deferred: Deferred<String> = async {
delay(1000L)
"Result"
}
val result = deferred.await() // suspends until result
코루틴이 "어디서, 어떻게, 누구 밑에서" 실행될지를 정의하는 설정 묶음입니다.
1) Key-Value 구조 — 각 Element는 고유한 Key로 식별
val ctx = Dispatchers.IO + CoroutineName("MyWork")
ctx[ContinuationInterceptor] // → Dispatchers.IO
ctx[CoroutineName] // → CoroutineName("MyWork")
ctx[Job] // → null (없음)
2) 불변(Immutable) — +로 결합하면 기존 객체는 변하지 않고 새 객체가 반환됨
3) 상속 — 자식 코루틴은 부모의 Context를 물려받고, 명시적으로 전달한 Element가 상속값을 덮어씀
val scope = CoroutineScope(Dispatchers.Main + CoroutineName("Parent"))
scope.launch {
// Dispatcher = Main (부모에게 상속)
// Name = "Parent" (부모에게 상속)
// Job = 새로 생성된 자식 Job (부모 Job의 자식으로 등록)
launch(Dispatchers.IO) {
// Dispatcher = IO (명시적으로 덮어씀)
// Name = "Parent" (여전히 상속)
// Job = 또 다른 새 자식 Job
}
}
CoroutineContext의 핵심 설계 트릭: Element가 CoroutineContext를 상속합니다.
public interface Element : CoroutineContext { // ← 핵심!
public val key: Key<*>
}
즉, Element 하나가 그 자체로 Context입니다. 이 덕분에:
// Element 하나도 CoroutineContext 타입
val ctx: CoroutineContext = Dispatchers.IO // OK!
// Element끼리 + 연산이 바로 가능
val combined = Dispatchers.IO + Job() + CoroutineName("X")
// Element Element Element
// ↑ 각각이 이미 CoroutineContext이므로 plus() 사용 가능
Composite Pattern (부분-전체 패턴): 하나(부분)를 다루는 것과 여러 개(전체)를 다루는 것을 같은 방식으로 취급하는 패턴입니다. 파일과 폴더가 둘 다 "파일 시스템 항목"이라 복사/삭제를 동일하게 적용할 수 있는 것처럼, Dispatchers.IO 하나도, IO + Job() 조합도, 빈 Context도 모두 같은 CoroutineContext 타입이라 get, plus, fold를 동일하게 적용할 수 있습니다.
public interface Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job> // Key가 companion object!
}
// 그래서 클래스 이름 자체를 Key처럼 쓸 수 있음
context[Job] // Job이라는 이름이 곧 Job.Key를 가리킴
context[Job.Key] // 같은 의미
Element는 "요소가 1개인 Context"이므로, 모든 연산이 자기 자신만 보면 됩니다:
public interface Element : CoroutineContext {
// "나한테 이 key 있어?" → 내 key면 나를, 아니면 null
override fun get(key: Key): E? =
if (this.key == key) this as E else null
// "모든 요소 순회해" → 나 하나뿐이니 한 번만 실행
override fun fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
// "이 key 빼줘" → 내 key면 빈 Context, 아니면 나 자신
override fun minusKey(key: Key): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
| 연산 | 설명 |
|---|---|
| get(key) | 타입 안전한 키 조회 (context[Job]) |
| plus(context) | 두 컨텍스트 결합 (같은 키는 오른쪽이 우선) |
| fold(initial, op) | 모든 Element를 순회하며 값 누적 |
| minusKey(key) | 특정 키의 Element 제거 (새 컨텍스트 반환) |
// 예시 1: Key가 모두 다름 → 충돌 없이 3개 모두 포함
val ctx = Dispatchers.IO + CoroutineName("MyCoroutine") + myJob
// 결과: { IO(Dispatcher), "MyCoroutine"(Name), myJob(Job) }
// 예시 2: 같은 Key 충돌 → 오른쪽이 우선
val left = JobA + DispatcherA // { JobA, DispatcherA }
val right = JobB + NameB // { JobB, NameB }
val result = left + right
// Step 1: JobB 합침 → JobA와 같은 Key(Job) → JobA 제거, JobB 추가
// Step 2: NameB 합침 → 새 Key → 그냥 추가
// 결과: { DispatcherA, JobB, NameB }
코루틴을 launch하면, 컨텍스트에서 부모의 Job을 찾아 자기를 자식으로 등록합니다. 이 부모-자식 관계가 구조화된 동시성의 기반입니다.
val scope = CoroutineScope(Dispatchers.Main + parentJob)
scope.launch { // 이 순간 내부에서 일어나는 일:
// ... // 1. parentJob을 컨텍스트에서 꺼냄
} // 2. 새 자식 Job 생성
// 3. parentJob.attachChild(childJob) 호출
// → 부모-자식 관계 형성!
주의 — Job()을 직접 전달하면 부모-자식 관계가 끊어집니다!
// 기본: parentJob의 자식으로 등록됨
scope.launch { /* 부모 = parentJob */ }
// Job()을 직접 전달: 부모가 바뀜 → 구조화된 동시성 깨짐!
scope.launch(Job()) { /* 부모 = 새 Job (parentJob과 무관) */ }
// → parentJob이 취소돼도 이 코루틴은 안 죽음
// → Activity가 파괴됐는데 코루틴은 계속 실행 → 메모리 누수!
| 코드 | 부모 Job | 위험도 |
|---|---|---|
| scope.launch { } | scope의 Job | 안전 — scope 취소 시 함께 취소 |
| scope.launch(Job()) { } | 새 독립 Job | 위험 — scope과 연결 끊김 |
| coroutineScope { launch { } } | coroutineScope의 Job | 안전 — 스코프 끝나면 함께 취소 |
| GlobalScope.launch { } | 부모 없음 | 위험 — 아무도 관리 안 함 |
비유: Context 안의 Job은 "이 코루틴의 상사가 누구인지"를 결정합니다. 상사(부모 Job)가 퇴근(취소)하면 부하(자식)도 퇴근해야 하고, 부하가 사고(예외)치면 상사에게 보고되는 구조입니다.
핵심 인사이트: SupervisorJob은 단 하나의 메서드만 오버라이드합니다: childCancelled() = false. 이 한 줄이 자식 실패의 부모/형제 전파를 차단합니다.
// SupervisorJob 내부 - 딱 한 줄 차이!
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean = false
}
// viewModelScope는 내부적으로 SupervisorJob 사용
return CloseableCoroutineScope(dispatcher + SupervisorJob())
아닙니다. 구조화된 동시성의 변형(variant)이지 위반이 아닙니다. 3가지 규칙 중 하나만 변경합니다.
| 구조화된 동시성 규칙 | 일반 Job | SupervisorJob |
|---|---|---|
| ① 하향 취소 (부모 취소 → 자식 취소) | ✔ 동일 | ✔ 동일 |
| ② 상향 실패 전파 (자식 실패 → 부모 취소) | ✔ 전파 | ✘ 차단 (이것만 다름) |
| ③ 완료 대기 (부모는 자식 완료까지 대기) | ✔ 동일 | ✔ 동일 |
부모-자식 관계가 유지되고, 생명주기가 계층적으로 관리됩니다. "자식이 실패해도 형제를 살려두겠다"는 정책 선택일 뿐입니다.
| 패턴 | 구조화된 동시성 | 이유 |
|---|---|---|
| SupervisorJob | ✔ 유지 | 부모-자식 관계 유지, 전파 방식만 조정 |
| GlobalScope.launch | ✘ 깨짐 | 부모 없음, 생명주기 관리 불가, 메모리 누수 위험 |
| CoroutineScope(Job()) + cancel 안 함 | ✘ 깨짐 | 생명주기 관리를 방기, 코루틴이 영원히 남을 수 있음 |
디스패처는 스레드 풀을 관리하며, 코루틴에 스레드를 배정/회수하는 관리자입니다.
"스레드 해제"의 의미: 코루틴이 suspend되면 스레드가 디스패처의 풀로 반환되어, 같은 디스패처의 다른 대기 코루틴에 재배정됩니다. 코루틴이 직접 스레드를 선택하는 것이 아니라, 디스패처에게 "실행해줘"라고 요청하면 디스패처가 풀에서 적절한 스레드를 골라 배정합니다. resume 시에는 같은 스레드가 아닌 풀의 다른 스레드가 배정될 수 있습니다.
| 디스패처 | 스레드 | 사용 사례 |
|---|---|---|
| Dispatchers.Main | UI 스레드 | UI 업데이트, 가벼운 작업 |
| Dispatchers.Main.immediate | UI 스레드 (재디스패치 생략) | 이미 Main이면 즉시 실행 |
| Dispatchers.IO | 공유 스레드 풀 (64개 이상) | 네트워크, 파일 I/O, DB |
| Dispatchers.Default | CPU 코어 수 | CPU 집약적 연산 |
| Dispatchers.Unconfined | 호출자 스레드 | 테스트 / 특수 목적 전용 |
// Dispatchers.Main — 항상 큐에 넣고 나중에 실행
launch(Dispatchers.Main) {
println("B") // 큐에 넣어짐 → 현재 작업 끝난 후 실행
}
println("A") // 먼저 실행
// 출력: A → B
// Dispatchers.Main.immediate — 이미 Main이면 즉시 실행
launch(Dispatchers.Main.immediate) {
println("B") // 이미 Main 스레드 → 큐에 안 넣고 바로 실행!
}
println("A") // B 다음에 실행
// 출력: B → A
viewModelScope, lifecycleScope는 기본이 Main.immediate입니다. 이미 Main 스레드에 있을 때 불필요한 재디스패치(큐 대기)를 건너뛰어 UI 반응 속도를 높이기 위해서입니다.
| 스코프 | 생명주기 연동 | 내부 설정 |
|---|---|---|
| viewModelScope | ViewModel 클리어 시 취소 | Main.immediate + SupervisorJob() |
| lifecycleScope | DESTROYED 시 취소 | Main.immediate + SupervisorJob() |
| repeatOnLifecycle | 상태 기반 (STARTED 등) | 상태 전이 시 시작/취소 |
// withContext - 같은 코루틴 내에서 디스패처 전환
suspend fun fetchData(): Data {
return withContext(Dispatchers.IO) {
api.getData() // IO 스레드에서 실행
} // 원래 디스패처로 복귀
}
// repeatOnLifecycle - STARTED 상태에서만 수집
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
updateUI(state)
}
}
}
| 전략 | 범위 | 비고 |
|---|---|---|
| try-catch | 코루틴 내부 | 가장 기본적. 직접 처리. |
| CoroutineExceptionHandler | 루트 코루틴 전용 | 최후의 수단. 복구 불가능. |
| SupervisorJob / supervisorScope | 실패 격리 | 연쇄 실패 방지. |
CancellationException은 반드시 재전파(rethrow)해야 합니다! 삼키면 구조화된 동시성이 깨지고, 부모에게 취소 신호가 전달되지 않습니다.
// 올바른 패턴
suspend fun fetchData(): Result<Data> {
return try {
Result.success(api.getData())
} catch (e: CancellationException) {
throw e // MUST rethrow!
} catch (e: Exception) {
Result.failure(e)
}
}
runCatching + suspend = 버그! runCatching은 CancellationException을 포함한 모든 Throwable을 잡아 재전파 대신 Result.failure()로 감쌉니다.
// 위험 - suspend 함수와 함께 사용 금지!
suspend fun fetchUser(): Result<User> = runCatching {
api.getUser() // CancellationException gets swallowed!
}
// 안전한 대안
suspend inline fun <R> runSuspendCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (c: CancellationException) {
throw c
} catch (e: Throwable) {
Result.failure(e)
}
}
await()를 try-catch로 감싸는 것만으로는 부족합니다! 일반 scope(non-supervisor)에서 async 자식의 예외는 await()에서 잡아도 부모로 전파됩니다.
// 해결: supervisorScope 사용
runBlocking {
supervisorScope {
val deferred = async { throw RuntimeException("error") }
try {
deferred.await()
} catch (e: Exception) {
println("Safely caught: $e")
}
}
}
| 비교 항목 | Cold Flow | Hot Flow |
|---|---|---|
| 데이터 생성 | collect() 호출 시 시작 | 구독자 유무와 무관하게 활성 |
| 구독 독립성 | 각 수집자마다 새로 실행 | 하나의 스트림을 여러 수집자가 공유 |
| 대표 타입 | Flow | StateFlow, SharedFlow |
| 사용 사례 | 네트워크 / DB 요청 | UI 상태 관리, 실시간 이벤트 |
Flow 인터페이스는 단 하나의 메서드만 정의: suspend fun collect(collector: FlowCollector<T>). 이 단순함이 Flow를 "cold"로 만듭니다 - 실행 설계도일 뿐입니다.
| 연산자 | 역할 | 내부 구현 |
|---|---|---|
| flowOn(Dispatchers.IO) | 업스트림의 실행 디스패처 변경 | Channel + 새 코루틴 도입 |
| buffer(capacity) | 업스트림/다운스트림 속도 분리 | Channel 도입 (생산자-소비자) |
연산자 퓨전: 인접한 채널 기반 연산자(flowOn, buffer)가 하나의 채널로 병합됩니다. flow.buffer(10).flowOn(IO)는 채널을 2개가 아닌 1개만 생성합니다.
// 컨텍스트 보존 규칙
flow {
emit(1)
withContext(Dispatchers.IO) {
emit(2) // IllegalStateException!
}
}
// 올바른 방법: flowOn 사용
flow { emit(heavyComputation()) }
.flowOn(Dispatchers.Default) // upstream runs on Default
.collect { updateUI(it) } // downstream runs on caller's context
| 유형 | send() 중단 조건 | 동작 | 사용 사례 |
|---|---|---|---|
| Rendezvous (기본) | 수신자 없을 때 | 직접 전달 | 엄격한 동기화 |
| Buffered (capacity > 0) | 버퍼가 가득 찼을 때 | N개까지 큐에 저장 | 일반적 생산자-소비자 |
| Conflated | 중단 안 됨 | 최신 값 하나만 유지 | UI 상태, 센서 데이터 |
| Unlimited | 중단 안 됨 | 무제한 버퍼 (OOM 주의) | 데이터 유실 불가 시 |
| 연산자 | 모델 | 순서 | 사용 사례 |
|---|---|---|---|
| flatMapConcat | 순차적 | 보장 | 순서가 중요한 처리 |
| flatMapMerge | 동시적 | 미보장 | 병렬 네트워크 요청 |
| flatMapLatest | 이전 취소 | 최신만 | 검색 자동완성 |
| 비교 항목 | channelFlow | callbackFlow |
|---|---|---|
| 목적 | 멀티 코루틴 생산 | 콜백/리스너 API → Flow 변환 |
| awaitClose | 선택 | 필수 (없으면 ISE 발생) |
| 전형적 패턴 | 블록 내 launch { send() } | register + awaitClose { unregister } |
// callbackFlow - 콜백 API를 Flow로 변환
fun locationFlow(): Flow<Location> = callbackFlow {
val listener = LocationListener { location ->
trySend(location)
}
locationManager.requestUpdates(listener)
awaitClose { // MUST have this!
locationManager.removeUpdates(listener)
}
}
// channelFlow - 여러 소스 병합
val combined = channelFlow {
launch { sourceA.collect { send(it) } }
launch { sourceB.collect { send(it) } }
}
| 함수 | 목적 | suspend 방식 |
|---|---|---|
| join() | 특정 Job의 완료를 대기 | invokeOnCompletion 콜백 사용 |
| yield() | 다른 코루틴에 실행 기회 양보 | 디스패처 대기열 맨 뒤에 재등록 |
각 질문을 클릭하면 답변이 펼쳐집니다.
코루틴은 OS 스레드처럼 전용 스택(~1MB)을 할당하지 않고, 힙에 작은 Continuation 객체만 생성합니다. 시스템 콜 없이 Kotlin 런타임이 관리하며, 컨텍스트 스위칭도 커널 개입 없이 메서드 호출 수준으로 처리됩니다. 이 덕분에 수만~수백만 개의 코루틴을 동시에 실행할 수 있습니다.
컴파일러가 CPS(Continuation-Passing Style) 변환을 수행합니다. suspend 키워드가 제거되고, 마지막 파라미터로 Continuation 객체가 추가됩니다. 함수 본문은 label 기반 상태 머신으로 변환되어, 각 suspend 지점에서 상태를 저장하고 COROUTINE_SUSPENDED 마커를 반환합니다. 재개 시 dispatcher가 resumeWith()를 호출하면 저장된 label로 분기해 중단 지점부터 실행을 이어갑니다.
코루틴의 생명주기를 부모-자식 관계로 계층화하는 설계 원칙입니다. 세 가지 핵심 규칙이 있습니다: (1) 하향 취소 - 부모 취소 시 모든 자식 자동 취소, (2) 상향 실패 전파 - 자식 실패 시 부모와 형제도 취소, (3) 완료 대기 - 부모는 모든 자식 완료 전까지 Completed로 전이하지 않음. SupervisorJob은 childCancelled() = false로 상향 전파만 차단합니다.
ViewModel에서 여러 비동기 작업(네트워크 요청, DB 조회 등)을 동시에 실행할 때, 하나의 작업이 실패해도 나머지 작업은 계속 실행되어야 합니다. SupervisorJob이 없으면 하나의 네트워크 에러가 전체 UI 로직을 취소할 수 있습니다. 내부 구현: CloseableCoroutineScope(dispatcher + SupervisorJob())
CancellationException은 오류가 아닌 취소 신호입니다. 이를 삼키면 부모 코루틴이 취소 요청을 인지하지 못해 구조화된 동시성이 깨집니다. runCatching은 모든 Throwable을 잡아 Result.failure()로 감싸므로, suspend 함수와 함께 사용하면 CancellationException도 삼켜집니다. 대안으로 runSuspendCatching을 직접 구현해 CancellationException은 rethrow하도록 해야 합니다.
Cold Flow: collect 호출 시 시작, 각 수집자마다 독립 실행, 네트워크/DB 요청에 적합. Hot Flow (StateFlow, SharedFlow): 구독자 유무와 무관하게 방출, 하나의 실행을 여러 수집자가 공유, UI 상태 관리에 적합. 내부적으로 Flow 인터페이스는 suspend fun collect() 하나만 정의하며, 이 단순함이 "cold" 특성을 만듭니다.
둘 다 내부적으로 Channel을 도입하지만 목적이 다릅니다. flowOn은 업스트림의 실행 디스패처를 변경합니다(+ 채널 도입). buffer는 동일 디스패처에서 업스트림/다운스트림 사이에 버퍼 채널만 추가해 동시성을 도입합니다. 둘 다 ChannelFlow 기반이며, FusibleFlow 인터페이스를 통해 인접한 연산자끼리 하나의 채널로 병합(fusion)됩니다.
launch: Job 반환, 결과 없는 fire-and-forget 작업용. 예외는 즉시 부모로 전파되며 CoroutineExceptionHandler로 처리. async: Deferred<T> 반환, await()로 결과 획득. 예외는 Deferred에 저장되어 await() 호출 시 재발생. 주의: 일반 scope에서 async의 예외는 try-catch로 감싸도 부모로 전파될 수 있으므로, supervisorScope 사용을 권장합니다.
channelFlow: 여러 코루틴이 동시에 값을 생산하는 Flow 생성. launch로 자식 코루틴을 띄워 send() 가능. awaitClose는 선택. callbackFlow: channelFlow를 상속한 특수화된 구현. 콜백/리스너 API를 Flow로 변환할 때 사용. awaitClose가 필수(없으면 IllegalStateException). register + awaitClose { unregister } 패턴으로 리소스 누수를 방지합니다.
flatMapConcat: 순차적 - 하나의 내부 Flow를 끝까지 수집한 후 다음 처리. 순서 보장. flatMapMerge: 동시적 - 각 내부 Flow를 별도 코루틴에서 병렬 수집, 결과 병합. 순서 미보장. concurrency 파라미터로 동시성 제한 가능. flatMapLatest: 취소 기반 - 새 값이 도착하면 이전 내부 Flow를 cancel하고 최신 Flow만 수집. 검색 자동완성에 적합.
면접 준비에서 가장 흔한 실수는 모든 걸 동일한 깊이로 파는 것입니다. 아래 가이드로 에너지를 효율적으로 배분하세요.
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | 코루틴 정의, suspend/resume, 논블로킹, 협력적 멀티태스킹 | 필수 |
| ■ Lv.1 | launch, async, runBlocking의 차이와 사용 시점 | 필수 |
| ■ Lv.2 | Suspending Function, Suspending Lambda, Coroutine Builder 용어 구분 | 권장 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | suspend 함수가 Continuation 파라미터를 받는 함수로 변환됨 | 필수 |
| ■ Lv.1 | 상태 머신으로 변환, label로 재개 지점 추적, SUSPENDED 반환으로 스레드 해제 | 필수 |
| ■ Lv.2 | CPS 변환의 구체적 과정 (시그니처 변경 규칙) | 권장 |
| ■ Lv.2 | suspendCoroutine으로 콜백 → suspend 변환 패턴 | 권장 |
| ■ Lv.3 | 상태 머신 객체의 구체적 필드 구조, ContinuationImpl 상속 | 선택 |
| ■ Lv.4 | CoroutineSingletons의 UNDECIDED/RESUMED, 내부 경쟁 처리 | 불필요 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | 메모리(스택 vs 힙), 스케줄링(선점 vs 협력), 확장성 차이 | 필수 |
| ■ Lv.1 | 코루틴은 스레드를 대체하는 게 아니라 위에서 실행되는 추상화 | 필수 |
| ■ Lv.2 | 스택리스 코루틴의 의미, 멀티스레딩 vs 멀티프로세싱 | 권장 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | Context = Job + Dispatcher + Name + CEH(CoroutineExceptionHandler)의 조합 | 필수 |
| ■ Lv.1 | Job의 생명주기 상태 (Active → Completing → Completed / Cancelled) | 필수 |
| ■ Lv.1 | 구조화된 동시성 3규칙 (하향 취소, 상향 전파, 완료 대기) | 필수 |
| ■ Lv.1 | SupervisorJob이 필요한 이유, viewModelScope와의 관계 | 필수 |
| ■ Lv.2 | Context의 + 연산자 동작 (오른쪽 우선), 인덱싱된 집합 구조 | 권장 |
| ■ Lv.3 | JobSupport의 lock-free 상태 머신, attachChild 내부 | 선택 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | Main / IO / Default 디스패처의 용도와 스레드 풀 차이 | 필수 |
| ■ Lv.1 | viewModelScope, lifecycleScope, repeatOnLifecycle 사용법 | 필수 |
| ■ Lv.1 | withContext로 디스패처 전환, coroutineScope vs supervisorScope | 필수 |
| ■ Lv.2 | Main.immediate vs Main의 차이 | 권장 |
| ■ Lv.3 | CoroutineScope 인터페이스 내부, ScopeCoroutine 구현 | 선택 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | CancellationException은 반드시 재전파해야 함 | 필수 |
| ■ Lv.1 | runCatching이 suspend 함수와 위험한 이유 | 필수 |
| ■ Lv.1 | try-catch, CoroutineExceptionHandler, SupervisorJob 세 가지 전략 | 필수 |
| ■ Lv.2 | CEH(CoroutineExceptionHandler)가 루트 코루틴에서만 동작하는 이유, async 예외 전파 주의점 | 권장 |
| ■ Lv.2 | runSuspendCatching 직접 구현 | 권장 |
| ■ Lv.3 | handleJobException, childCancelled 내부 전파 경로 | 선택 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.1 | Cold Flow vs Hot Flow (StateFlow, SharedFlow) 차이 | 필수 |
| ■ Lv.1 | flowOn, map, filter, collect 기본 연산자 사용법 | 필수 |
| ■ Lv.1 | StateFlow로 UI 상태 관리, repeatOnLifecycle로 수집 | 필수 |
| ■ Lv.2 | buffer, conflate, flatMapLatest/Merge/Concat 차이 | 권장 |
| ■ Lv.2 | callbackFlow로 콜백 API를 Flow로 변환, awaitClose 필수인 이유 | 권장 |
| ■ Lv.3 | Flow 컨텍스트 보존 규칙, AbstractFlow/SafeCollector 내부 | 선택 |
| ■ Lv.4 | FusibleFlow, ChannelFlowOperatorImpl, 연산자 퓨전 내부 | 불필요 |
| 깊이 | 내용 | 우선순위 |
|---|---|---|
| ■ Lv.2 | 채널 4가지 유형 (Rendezvous, Buffered, Conflated, Unlimited) | 권장 |
| ■ Lv.2 | channelFlow vs callbackFlow 차이 | 권장 |
| ■ Lv.2 | join(), yield() 역할 | 권장 |
| ■ Lv.3 | 채널 내부의 송수신 큐, produce/actor 빌더 내부 | 선택 |
집중: Lv.1 전체 + 실전 코드 작성 능력. 면접관은 "코루틴을 실제 프로젝트에서 올바르게 쓸 수 있는가"를 봅니다.
집중: Lv.1 + Lv.2 전체. 면접관은 "설계 결정의 이유를 이해하고 있는가"를 봅니다.
집중: Lv.1~3 + 아키텍처 설계 능력. 면접관은 "복잡한 동시성 문제를 설계 수준에서 해결할 수 있는가"를 봅니다.
흔한 실수: 내부 구현(Lv.3~4)에 시간을 쏟고 정작 "viewModelScope에서 왜 SupervisorJob을 쓰나요?" 같은 Lv.1 질문에 막히는 경우가 많습니다. 넓고 얕게 먼저, 깊이는 여유가 있을 때.
아래 질문에 30초 이내로 답할 수 있다면 준비 완료입니다.
| 질문 | |
|---|---|
| ☐ | 코루틴이란 무엇이고, 스레드와 어떻게 다른가요? |
| ☐ | launch와 async의 차이점은? |
| ☐ | Dispatchers.Main, IO, Default는 각각 언제 쓰나요? |
| ☐ | 구조화된 동시성이란? 부모가 취소되면 자식은? |
| ☐ | viewModelScope는 왜 SupervisorJob을 사용하나요? |
| ☐ | CancellationException은 왜 재전파해야 하나요? |
| ☐ | Cold Flow와 Hot Flow의 차이는? |
| ☐ | StateFlow와 SharedFlow는 각각 언제 쓰나요? |
| 질문 | |
|---|---|
| ☐ | suspend 함수가 내부적으로 어떻게 변환되나요? |
| ☐ | CoroutineExceptionHandler는 왜 루트 코루틴에서만 동작하나요? |
| ☐ | coroutineScope와 supervisorScope의 차이는? |
| ☐ | runCatching이 suspend 함수와 위험한 이유는? |
| ☐ | flowOn과 buffer의 차이는? |
| ☐ | callbackFlow는 언제, 왜 사용하나요? |
| ☐ | flatMapLatest는 어떤 경우에 사용하나요? |
| 질문 | |
|---|---|
| ☐ | Continuation 객체와 상태 머신의 관계를 코드 수준에서 설명해주세요 |
| ☐ | Job의 Completing 상태는 왜 존재하나요? |
| ☐ | Flow의 컨텍스트 보존 규칙이란? |
| ☐ | 여러 flowOn/buffer가 체이닝되면 내부적으로 어떻게 최적화되나요? |