Kotlin Coroutines 가이드

코루틴 Deep Dive

Kotlin Coroutines
1. 기본 개념
  • 중단(suspend) / 재개(resume)
  • 협력적 멀티태스킹
  • 컴파일러 수준의 언어 기능
  • 핵심 용어 정리
2. Continuation & 내부 동작
  • CPS 변환
  • 상태 머신(State Machine)
  • COROUTINE_SUSPENDED 마커
  • resumeWith() 재개
3. 스레드 vs 코루틴
  • OS 커널 관리 vs 유저 스페이스
  • 선점형 vs 협력적
  • 스택 기반 vs 스택리스
  • 메모리 오버헤드
4. 코루틴 빌더
  • launch / async / runBlocking
  • produce / actor
  • Job vs Deferred 반환 타입
  • 지연 시작(Lazy start)
🔗
5. 컨텍스트 & Job
  • CoroutineContext (인덱싱된 집합)
  • Job 생명주기 상태
  • SupervisorJob
  • 구조화된 동시성
🎯
6. 스코프 & 디스패처
  • Main / IO / Default 디스패처
  • viewModelScope / lifecycleScope
  • coroutineScope vs supervisorScope
  • withContext로 디스패처 전환
7. 예외 처리
  • try-catch / CEH(CoroutineExceptionHandler)
  • CancellationException 재전파
  • runCatching 함정
  • async 예외 전파 주의점
🌊
8. Flow 스트림
  • Cold Flow vs Hot Flow
  • StateFlow / SharedFlow
  • flowOn / buffer 연산자
  • FusibleFlow 최적화
🔌
9. 채널 & 연산자
  • 채널 유형 (4가지)
  • flatMap 변형 (3가지)
  • callbackFlow / channelFlow
  • join() / yield() 함수
코루틴 아키텍처 전체 구조
CoroutineScope | +-- CoroutineContext (Element의 인덱싱된 집합) | | | +-- Job .............. 생명주기, 취소, 부모-자식 관계 | | | | | +-- SupervisorJob .. childCancelled() = false | | +-- Deferred<T> .... Job + 결과값 (await) | | | +-- Dispatcher ........ 실행 스레드 결정 | | | | | +-- Main / IO / Default / Unconfined | | | +-- CoroutineName ... 디버깅용 이름 | +-- CEH ............... 최후의 예외 핸들러 (CoroutineExceptionHandler) | +-- 코루틴 빌더 | +-- launch -> Job (결과 없는 실행) +-- async -> Deferred<T> (결과 반환) +-- produce -> ReceiveChannel +-- actor -> SendChannel
Flow 데이터 스트림 아키텍처
Cold Flow Hot Flow | | flow { emit() } SharedFlow / StateFlow | | -- 중간 연산자 -- -- 항상 활성 상태 -- | | map / filter / flowOn / buffer MutableStateFlow.value = ... | | -- 터미널 연산자 -- lifecycleScope에서 collect | collect / launchIn / toList --- 특수 빌더 --- channelFlow { launch { send() } } 멀티 코루틴 생산 callbackFlow { awaitClose { ... } } 콜백/리스너 -> Flow 변환
⚡ 코루틴 기본 개념 핵심

Coroutine: 실행을 일시 중단(suspend)했다가 재개(resume)할 수 있는 작업의 단위

핵심: 코루틴은 단순한 라이브러리가 아닌 컴파일러 수준의 언어 기능. suspend 함수를 상태 머신으로 변환하는 것은 컴파일러가 담당한다.

동작 원리

  • 코루틴이 suspend되면 해당 스레드는 해제되어 다른 작업에 사용 가능
  • 이후 코루틴이 resume되면 중단된 지점부터 이어서 실행
  • 최소한의 오버헤드로 수천 개의 태스크를 동시에 실행 가능

코루틴 빌더

  • launchJob 반환, 결과가 필요 없는 태스크용
  • asyncDeferred<T> 반환, await()로 결과 획득
fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Hello from Coroutine!")
    }
    println("Hello from Main!")
}

핵심 용어

용어설명
Suspending Functionsuspend 키워드로 표시, 다른 suspending 함수/코루틴 내에서만 호출 가능
Continuationsuspend 지점에서 코루틴의 상태를 나타냄. "나머지 실행 로직"을 캡슐화
Suspension Point코루틴 실행이 중단될 수 있는 위치
Suspending Lambdalaunch { }, async { } 뒤의 블록
Coroutine Buildersuspending 람다를 받아 코루틴을 생성하는 함수
⚙ Continuation이란 무엇인가? 내부

면접 포인트: "suspend 함수가 내부적으로 어떻게 동작하는지 설명해주세요"라는 질문에 CPS 변환과 상태 머신을 설명할 수 있어야 합니다.

한 줄 정의

Continuation은 "중단된 지점 이후의 나머지 실행"을 캡슐화한 객체입니다. 코루틴이 suspend되면, "다음에 무엇을 할 것인가"를 Continuation 객체에 담아두고, 나중에 이 객체를 통해 실행을 이어갑니다.

Continuation 인터페이스

interface Continuation<in T> {
    val context: CoroutineContext  // 어떤 스레드에서 실행할지 등의 정보
    fun resumeWith(result: Result<T>)  // 결과를 받아 실행을 재개
}

일상 비유: 북마크가 꽂힌 책

책을 읽다가 전화가 온 상황을 상상해보세요: 1. 읽던 페이지에 북마크를 꽂는다 label (어디까지 실행했는지) 2. 어디까지 읽었는지 메모한다 상태 머신의 필드 (중간 변수 저장) 3. 책을 덮고 전화를 받는다 SUSPENDED 반환 (스레드 해제) 4. 전화 끝나면 북마크 펴서 이어 읽는다 resumeWith() (실행 재개) 이 전체 과정을 하나의 객체로 묶은 것이 바로 Continuation입니다.
🔄 CPS 변환: 컴파일러가 하는 일

Kotlin 컴파일러는 모든 suspend 함수를 CPS(Continuation-Passing Style)로 변환합니다. 핵심은 간단합니다: 마지막 파라미터로 Continuation 객체가 추가됩니다.

변환 예시 1: 단순한 suspend 함수

// 우리가 작성한 코드
suspend fun fetchData(): String {
    delay(1000L)
    return "Data fetched"
}

// 컴파일러가 변환한 결과 (개념적)
fun fetchData(continuation: Continuation<String>): Object {
    // suspend 키워드 사라짐
    // 반환 타입이 Object로 변경 (SUSPENDED 마커도 반환할 수 있어야 하므로)
    // Continuation 파라미터 추가
}

변환 예시 2: 파라미터가 있는 경우

// 변환 전
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 함수에 적용됩니다.

⚙ 상태 머신: 함수 본문의 변환

시그니처 변환은 첫 단계일 뿐입니다. 진짜 핵심은 함수 본문이 상태 머신으로 변환되는 것입니다.

Step 1: 원본 코드 — 우리가 작성한 3줄

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)으로 나뉩니다.

Step 2: 컴파일러가 만드는 상태 머신 객체

// 컴파일러가 자동 생성하는 내부 클래스
class FetchUserStateMachine(
    val completion: Continuation<User>  // 최종 결과를 받을 곳
) : ContinuationImpl {
    var label = 0       // 북마크: 어디까지 실행했는지
    var result: Any? = null  // 이전 단계의 결과
    var id: String? = null   // 지역 변수 보관
    var profile: Profile? = null // 중간 결과 보관
}

핵심: 일반 함수의 지역 변수는 스택에 저장되어, 함수가 끝나면 사라집니다. 하지만 상태 머신의 변수는 힙의 객체 필드에 저장되어, 함수를 빠져나갔다가 다시 들어와도 값이 유지됩니다. 이것이 코루틴이 "스택리스"인 이유입니다.

Step 3: 변환된 함수 본문 — 3개의 토막

// 컴파일러가 변환한 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)  // 완료!
        }
    }
}
⏰ 시간순으로 보는 전체 실행 흐름

위 코드가 실제로 어떻게 실행되는지 시간 순서대로 따라가 봅시다.

━━━ 1단계: 최초 호출 (label=0) ━━━ [스레드 A] fetchUser("june", sm) 호출 ├─ sm.id = "june" // 변수 저장 ├─ sm.label = 1 // 북마크 설정 ├─ fetchProfile("june", sm) 호출 │ └─ 네트워크 요청 시작... 기다려야 함! │ └─ COROUTINE_SUSPENDED 반환 └─ fetchUser도 COROUTINE_SUSPENDED 반환 └─ 스레드 A는 해방! 다른 일을 할 수 있음 ... 500ms 후, 네트워크 응답 도착 ... ━━━ 2단계: 재개 (label=1) ━━━ [스레드 B] sm.resumeWith(Result.success(profileData)) ├─ sm.result = profileData // 결과 저장 ├─ fetchUser(null, sm) 다시 호출! │ └─ sm.label == 1 이므로 → 토막 ②로 점프 ├─ profile = sm.result // 결과 꺼냄 ├─ sm.label = 2 // 북마크 업데이트 ├─ fetchFriends("june", sm) 호출 │ └─ 또 네트워크 요청... 기다려야 함! │ └─ COROUTINE_SUSPENDED 반환 └─ 스레드 B도 해방! ... 300ms 후, 네트워크 응답 도착 ... ━━━ 3단계: 최종 완료 (label=2) ━━━ [스레드 C] sm.resumeWith(Result.success(friendsData)) ├─ fetchUser(null, sm) 세 번째 호출! │ └─ sm.label == 2 이므로 → 토막 ③으로 점프 ├─ friends = sm.result └─ return User(profile, friends) // 완료!

주목할 점: 같은 함수(fetchUser)가 3번 호출되었지만, label 덕분에 매번 다른 토막이 실행됩니다. 그리고 실행 스레드가 A → B → C로 바뀔 수 있습니다. 코루틴은 특정 스레드에 종속되지 않습니다.

🔗 Continuation이 연결되는 방식

suspend 함수 A가 suspend 함수 B를 호출하면, A의 상태 머신을 B에게 Continuation으로 전달합니다. B가 완료되면 A의 resumeWith가 호출되어 A가 이어서 실행됩니다.

// 코루틴 내부에서 실제로 일어나는 콜백 체인 launch { // Continuation 0: 코루틴 자체 fetchUser("june") // Continuation 1: fetchUser의 상태 머신 fetchProfile("june") // Continuation 2: fetchProfile의 상태 머신 delay(1000) // Continuation 3: delay의 구현 완료 순서 (안에서 밖으로): delay 완료 fetchProfile의 resumeWith 호출 fetchProfile 완료 fetchUser의 resumeWith 호출 fetchUser 완료 launch 코루틴의 resumeWith 호출

실제 예시: delay()는 어떻게 재개되는가

// delay의 내부 동작 (단순화)
suspend fun delay(timeMillis: Long) {
    // 현재 Continuation을 가져옴
    suspendCancellableCoroutine { cont ->
        // 타이머를 등록하고, 시간이 지나면 재개
        scheduler.schedule(timeMillis) {
            cont.resume(Unit)  // 시간 후 resumeWith 호출!
        }
        // 여기서 SUSPENDED 반환 → 스레드 해제
    }
}

콜백 vs Continuation: 결국 Continuation은 "고급 콜백"입니다. 하지만 일반 콜백과 달리 로컬 변수, 실행 위치, 컨텍스트 정보를 모두 담고 있어서, 마치 순차 코드처럼 자연스럽게 이어서 실행할 수 있습니다.

🛠 실전 예시: 콜백을 suspend 함수로 변환

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))
            }
        }
    }
}

변환 후: suspendCoroutine 사용

// 콜백 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 핵심 요약
개념실체역할
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 위치부터 실행을 재개합니다."

⇄ 스레드 vs 코루틴 핵심
비교 항목스레드코루틴
관리 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 반환

빌더 비교

빌더반환 타입사용 사례비고
launchJob결과 불필요 (UI 업데이트, 저장)예외 즉시 부모로 전파
asyncDeferred<T>결과 반환이 필요한 병렬 연산예외를 Deferred에 저장
runBlockingTmain() / 테스트 전용앱 코드에서 절대 사용 금지
produceReceiveChannel채널 기반 값 스트림 생성완료 시 채널 자동 닫힘
actorSendChannel메시지 기반 동시성메일박스 패턴

launch 내부 흐름

1. newCoroutineContext(context) // 부모에서 상속 + 병합 2. StandaloneCoroutine(newContext) // 또는 LazyStandaloneCoroutine 3. coroutine.start(start, coroutine, block) // 실행 스케줄링 4. return coroutine // Job으로 반환

launch vs async 핵심 차이

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
🔗 CoroutineContext & Job 핵심

CoroutineContext란?

코루틴이 "어디서, 어떻게, 누구 밑에서" 실행될지를 정의하는 설정 묶음입니다.

CoroutineContext = Job + Dispatcher + Name + CEH(CoroutineExceptionHandler) 어디서 실행? Dispatcher (Main / IO / Default) 누구 밑에서? Job (부모-자식 관계, 취소 관리) 이름이 뭐야? CoroutineName (디버깅용) 실패하면 누가? CEH (CoroutineExceptionHandler, 처리되지 않은 예외 처리)

핵심 특성 3가지

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
    }
}

Element의 설계: Composite Pattern

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() 사용 가능
CoroutineContext ← 공통 인터페이스 ├── Element ← 잎(Leaf) — 단일 요소이면서 곧 Context │ ├── Job │ ├── CoroutineDispatcher │ ├── CoroutineName │ └── CoroutineExceptionHandler ├── CombinedContext ← 복합체(Composite) — 여러 요소 묶음 └── EmptyCoroutineContext ← 빈 상태

Composite Pattern (부분-전체 패턴): 하나(부분)를 다루는 것과 여러 개(전체)를 다루는 것을 같은 방식으로 취급하는 패턴입니다. 파일과 폴더가 둘 다 "파일 시스템 항목"이라 복사/삭제를 동일하게 적용할 수 있는 것처럼, Dispatchers.IO 하나도, IO + Job() 조합도, 빈 Context도 모두 같은 CoroutineContext 타입이라 get, plus, fold를 동일하게 적용할 수 있습니다.

Key가 companion object인 이유

public interface Job : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<Job>  // Key가 companion object!
}

// 그래서 클래스 이름 자체를 Key처럼 쓸 수 있음
context[Job]          // Job이라는 이름이 곧 Job.Key를 가리킴
context[Job.Key]      // 같은 의미

Element의 기본 구현이 영리한 점

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
}

Context 연산

연산설명
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 }

Job = "누구 밑에서" 실행되는가

코루틴을 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)가 퇴근(취소)하면 부하(자식)도 퇴근해야 하고, 부하가 사고(예외)치면 상사에게 보고되는 구조입니다.

Job 생명주기 상태

New ----> Active ----> Completing ----> Completed | v Cancelling ----> Cancelled

구조화된 동시성 규칙

  1. 하향 취소: 부모 취소 → 모든 자식 자동 취소
  2. 상향 실패 전파: 자식 실패 → 부모 취소 → 형제도 취소
  3. 완료 대기: 부모는 모든 자식 완료 전까지 Completed로 전이하지 않음

SupervisorJob

핵심 인사이트: SupervisorJob은 단 하나의 메서드만 오버라이드합니다: childCancelled() = false. 이 한 줄이 자식 실패의 부모/형제 전파를 차단합니다.

// SupervisorJob 내부 - 딱 한 줄 차이!
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

// viewModelScope는 내부적으로 SupervisorJob 사용
return CloseableCoroutineScope(dispatcher + SupervisorJob())

SupervisorJob은 구조화된 동시성에 반하는가?

아닙니다. 구조화된 동시성의 변형(variant)이지 위반이 아닙니다. 3가지 규칙 중 하나만 변경합니다.

구조화된 동시성 규칙일반 JobSupervisorJob
① 하향 취소 (부모 취소 → 자식 취소)✔ 동일✔ 동일
② 상향 실패 전파 (자식 실패 → 부모 취소)✔ 전파✘ 차단 (이것만 다름)
③ 완료 대기 (부모는 자식 완료까지 대기)✔ 동일✔ 동일

부모-자식 관계가 유지되고, 생명주기가 계층적으로 관리됩니다. "자식이 실패해도 형제를 살려두겠다"는 정책 선택일 뿐입니다.

진짜 구조화된 동시성을 깨는 것

패턴구조화된 동시성이유
SupervisorJob✔ 유지부모-자식 관계 유지, 전파 방식만 조정
GlobalScope.launch✘ 깨짐부모 없음, 생명주기 관리 불가, 메모리 누수 위험
CoroutineScope(Job())
+ cancel 안 함
✘ 깨짐생명주기 관리를 방기, 코루틴이 영원히 남을 수 있음
🎯 스코프 & 디스패처 Android

디스패처란?

디스패처는 스레드 풀을 관리하며, 코루틴에 스레드를 배정/회수하는 관리자입니다.

Dispatchers.IO (스레드 풀) ┌─────────────────────────────────────────┐ 스레드#1: [코루틴 A 실행 중] 스레드#2: [코루틴 B 실행 중] 스레드#3: [idle — 대기 중] └─────────────────────────────────────────┘ 실행 대기 큐: [코루틴 C, 코루틴 D, 코루틴 E] 코루틴 A가 suspend 스레드#1이 idle로 전환 디스패처가 대기 큐에서 코루틴 C를 꺼내 스레드#1에 배정

"스레드 해제"의 의미: 코루틴이 suspend되면 스레드가 디스패처의 풀로 반환되어, 같은 디스패처의 다른 대기 코루틴에 재배정됩니다. 코루틴이 직접 스레드를 선택하는 것이 아니라, 디스패처에게 "실행해줘"라고 요청하면 디스패처가 풀에서 적절한 스레드를 골라 배정합니다. resume 시에는 같은 스레드가 아닌 풀의 다른 스레드가 배정될 수 있습니다.

디스패처스레드사용 사례
Dispatchers.MainUI 스레드UI 업데이트, 가벼운 작업
Dispatchers.Main.immediateUI 스레드 (재디스패치 생략)이미 Main이면 즉시 실행
Dispatchers.IO공유 스레드 풀 (64개 이상)네트워크, 파일 I/O, DB
Dispatchers.DefaultCPU 코어 수CPU 집약적 연산
Dispatchers.Unconfined호출자 스레드테스트 / 특수 목적 전용

Main vs Main.immediate — 왜 구분하는가?

// 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 반응 속도를 높이기 위해서입니다.

Android 전용 스코프

스코프생명주기 연동내부 설정
viewModelScopeViewModel 클리어 시 취소Main.immediate + SupervisorJob()
lifecycleScopeDESTROYED 시 취소Main.immediate + SupervisorJob()
repeatOnLifecycle상태 기반 (STARTED 등)상태 전이 시 시작/취소

coroutineScope vs supervisorScope

coroutineScope { } supervisorScope { } 자식 A 실패 자식 A 실패 부모도 취소 자식 A만 영향 자식 B도 함께 취소 자식 B는 계속 실행
// 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실패 격리연쇄 실패 방지.

CoroutineExceptionHandler 규칙

  • 루트 코루틴에서만 동작 (부모가 없거나, 부모가 SupervisorJob인 경우)
  • 코루틴이 이미 종료된 후 호출 - 복구 불가능
  • async에서는 동작하지 않음 (예외가 Deferred에 저장되므로)
  • 용도: 로깅, 사용자 알림, 앱 재시작 트리거

CancellationException - 핵심 규칙

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)
    }
}

async 예외 주의점

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")
        }
    }
}
🌊 Flow 스트림 핵심

Cold Flow vs Hot Flow

비교 항목Cold FlowHot Flow
데이터 생성collect() 호출 시 시작구독자 유무와 무관하게 활성
구독 독립성각 수집자마다 새로 실행하나의 스트림을 여러 수집자가 공유
대표 타입FlowStateFlow, SharedFlow
사용 사례네트워크 / DB 요청UI 상태 관리, 실시간 이벤트

Flow 내부 아키텍처

Flow 인터페이스는 단 하나의 메서드만 정의: suspend fun collect(collector: FlowCollector<T>). 이 단순함이 Flow를 "cold"로 만듭니다 - 실행 설계도일 뿐입니다.

  • flow { } 빌더는 SafeFlow를 생성 → AbstractFlow를 확장
  • AbstractFlow는 collector를 SafeCollector로 래핑해 컨텍스트 보존을 강제
  • flow { } 내부에서 컨텍스트를 변경하면 IllegalStateException 발생 → flowOn() 사용

flowOn vs buffer

연산자역할내부 구현
flowOn(Dispatchers.IO)업스트림의 실행 디스패처 변경Channel + 새 코루틴 도입
buffer(capacity)업스트림/다운스트림 속도 분리Channel 도입 (생산자-소비자)
// 버퍼 없이: 순차 실행 (1200ms) emit collect emit collect emit collect 100 300 100 300 100 300 // 버퍼 사용: 동시 실행 (~1000ms) 생산자: emit emit emit 100 100 100 (~300ms total) | ↓ buffer 소비자: collect collect collect 300 300 300

FusibleFlow 최적화

연산자 퓨전: 인접한 채널 기반 연산자(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 주의)데이터 유실 불가 시

flatMap 변형

연산자모델순서사용 사례
flatMapConcat순차적보장순서가 중요한 처리
flatMapMerge동시적미보장병렬 네트워크 요청
flatMapLatest이전 취소최신만검색 자동완성
flatMapConcat: A1 A2 B1 B2 (A 완료 후 B 처리) flatMapMerge: A1 B1 A2 B2 (교차, 병렬 실행) flatMapLatest: A1 B1 B2 (B 도착 시 A 취소)

callbackFlow vs channelFlow

비교 항목channelFlowcallbackFlow
목적멀티 코루틴 생산콜백/리스너 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) } }
}

join() vs yield()

함수목적suspend 방식
join()특정 Job의 완료를 대기invokeOnCompletion 콜백 사용
yield()다른 코루틴에 실행 기회 양보디스패처 대기열 맨 뒤에 재등록
면접 Q&A 실전 연습

각 질문을 클릭하면 답변이 펼쳐집니다.

Q1 코루틴이 가벼운 이유는 무엇인가요?

코루틴은 OS 스레드처럼 전용 스택(~1MB)을 할당하지 않고, 힙에 작은 Continuation 객체만 생성합니다. 시스템 콜 없이 Kotlin 런타임이 관리하며, 컨텍스트 스위칭도 커널 개입 없이 메서드 호출 수준으로 처리됩니다. 이 덕분에 수만~수백만 개의 코루틴을 동시에 실행할 수 있습니다.

Q2 suspend 함수의 내부 동작 원리를 설명해주세요.

컴파일러가 CPS(Continuation-Passing Style) 변환을 수행합니다. suspend 키워드가 제거되고, 마지막 파라미터로 Continuation 객체가 추가됩니다. 함수 본문은 label 기반 상태 머신으로 변환되어, 각 suspend 지점에서 상태를 저장하고 COROUTINE_SUSPENDED 마커를 반환합니다. 재개 시 dispatcher가 resumeWith()를 호출하면 저장된 label로 분기해 중단 지점부터 실행을 이어갑니다.

Q3 구조화된 동시성(Structured Concurrency)이란?

코루틴의 생명주기를 부모-자식 관계로 계층화하는 설계 원칙입니다. 세 가지 핵심 규칙이 있습니다: (1) 하향 취소 - 부모 취소 시 모든 자식 자동 취소, (2) 상향 실패 전파 - 자식 실패 시 부모와 형제도 취소, (3) 완료 대기 - 부모는 모든 자식 완료 전까지 Completed로 전이하지 않음. SupervisorJob은 childCancelled() = false로 상향 전파만 차단합니다.

Q4 viewModelScope는 왜 SupervisorJob을 사용하나요?

ViewModel에서 여러 비동기 작업(네트워크 요청, DB 조회 등)을 동시에 실행할 때, 하나의 작업이 실패해도 나머지 작업은 계속 실행되어야 합니다. SupervisorJob이 없으면 하나의 네트워크 에러가 전체 UI 로직을 취소할 수 있습니다. 내부 구현: CloseableCoroutineScope(dispatcher + SupervisorJob())

Q5 CancellationException을 왜 재전파해야 하나요?

CancellationException은 오류가 아닌 취소 신호입니다. 이를 삼키면 부모 코루틴이 취소 요청을 인지하지 못해 구조화된 동시성이 깨집니다. runCatching은 모든 Throwable을 잡아 Result.failure()로 감싸므로, suspend 함수와 함께 사용하면 CancellationException도 삼켜집니다. 대안으로 runSuspendCatching을 직접 구현해 CancellationException은 rethrow하도록 해야 합니다.

Q6 Cold Flow와 Hot Flow의 차이점은?

Cold Flow: collect 호출 시 시작, 각 수집자마다 독립 실행, 네트워크/DB 요청에 적합. Hot Flow (StateFlow, SharedFlow): 구독자 유무와 무관하게 방출, 하나의 실행을 여러 수집자가 공유, UI 상태 관리에 적합. 내부적으로 Flow 인터페이스는 suspend fun collect() 하나만 정의하며, 이 단순함이 "cold" 특성을 만듭니다.

Q7 flowOn과 buffer의 차이점은?

둘 다 내부적으로 Channel을 도입하지만 목적이 다릅니다. flowOn은 업스트림의 실행 디스패처를 변경합니다(+ 채널 도입). buffer는 동일 디스패처에서 업스트림/다운스트림 사이에 버퍼 채널만 추가해 동시성을 도입합니다. 둘 다 ChannelFlow 기반이며, FusibleFlow 인터페이스를 통해 인접한 연산자끼리 하나의 채널로 병합(fusion)됩니다.

Q8 launch와 async의 차이점은?

launch: Job 반환, 결과 없는 fire-and-forget 작업용. 예외는 즉시 부모로 전파되며 CoroutineExceptionHandler로 처리. async: Deferred<T> 반환, await()로 결과 획득. 예외는 Deferred에 저장되어 await() 호출 시 재발생. 주의: 일반 scope에서 async의 예외는 try-catch로 감싸도 부모로 전파될 수 있으므로, supervisorScope 사용을 권장합니다.

Q9 callbackFlow와 channelFlow의 차이점은?

channelFlow: 여러 코루틴이 동시에 값을 생산하는 Flow 생성. launch로 자식 코루틴을 띄워 send() 가능. awaitClose는 선택. callbackFlow: channelFlow를 상속한 특수화된 구현. 콜백/리스너 API를 Flow로 변환할 때 사용. awaitClose가 필수(없으면 IllegalStateException). register + awaitClose { unregister } 패턴으로 리소스 누수를 방지합니다.

Q10 flatMapConcat, flatMapMerge, flatMapLatest의 차이점은?

flatMapConcat: 순차적 - 하나의 내부 Flow를 끝까지 수집한 후 다음 처리. 순서 보장. flatMapMerge: 동시적 - 각 내부 Flow를 별도 코루틴에서 병렬 수집, 결과 병합. 순서 미보장. concurrency 파라미터로 동시성 제한 가능. flatMapLatest: 취소 기반 - 새 값이 도착하면 이전 내부 Flow를 cancel하고 최신 Flow만 수집. 검색 자동완성에 적합.

🎯 학습 깊이 가이드: 어디까지 파야 할까?

면접 준비에서 가장 흔한 실수는 모든 걸 동일한 깊이로 파는 것입니다. 아래 가이드로 에너지를 효율적으로 배분하세요.

██████████ Lv.1 반드시 답해야 함 — 못 하면 감점 ███████ Lv.2 알면 플러스 — 설명하면 좋은 인상 ████ Lv.3 시니어 레벨 — 깊이 있는 이해 증명 ██ Lv.4 라이브러리 기여자 — 면접에서 안 나옴
📖 주제별 학습 깊이

1. 코루틴 기본 개념

깊이내용우선순위
■ Lv.1코루틴 정의, suspend/resume, 논블로킹, 협력적 멀티태스킹필수
■ Lv.1launch, async, runBlocking의 차이와 사용 시점필수
■ Lv.2Suspending Function, Suspending Lambda, Coroutine Builder 용어 구분권장

2. Continuation & 내부 동작

깊이내용우선순위
■ Lv.1suspend 함수가 Continuation 파라미터를 받는 함수로 변환됨필수
■ Lv.1상태 머신으로 변환, label로 재개 지점 추적, SUSPENDED 반환으로 스레드 해제필수
■ Lv.2CPS 변환의 구체적 과정 (시그니처 변경 규칙)권장
■ Lv.2suspendCoroutine으로 콜백 → suspend 변환 패턴권장
■ Lv.3상태 머신 객체의 구체적 필드 구조, ContinuationImpl 상속선택
■ Lv.4CoroutineSingletons의 UNDECIDED/RESUMED, 내부 경쟁 처리불필요

3. 스레드 vs 코루틴

깊이내용우선순위
■ Lv.1메모리(스택 vs 힙), 스케줄링(선점 vs 협력), 확장성 차이필수
■ Lv.1코루틴은 스레드를 대체하는 게 아니라 위에서 실행되는 추상화필수
■ Lv.2스택리스 코루틴의 의미, 멀티스레딩 vs 멀티프로세싱권장

4. CoroutineContext & Job

깊이내용우선순위
■ Lv.1Context = Job + Dispatcher + Name + CEH(CoroutineExceptionHandler)의 조합필수
■ Lv.1Job의 생명주기 상태 (Active → Completing → Completed / Cancelled)필수
■ Lv.1구조화된 동시성 3규칙 (하향 취소, 상향 전파, 완료 대기)필수
■ Lv.1SupervisorJob이 필요한 이유, viewModelScope와의 관계필수
■ Lv.2Context의 + 연산자 동작 (오른쪽 우선), 인덱싱된 집합 구조권장
■ Lv.3JobSupport의 lock-free 상태 머신, attachChild 내부선택

5. Scope & Dispatcher

깊이내용우선순위
■ Lv.1Main / IO / Default 디스패처의 용도와 스레드 풀 차이필수
■ Lv.1viewModelScope, lifecycleScope, repeatOnLifecycle 사용법필수
■ Lv.1withContext로 디스패처 전환, coroutineScope vs supervisorScope필수
■ Lv.2Main.immediate vs Main의 차이권장
■ Lv.3CoroutineScope 인터페이스 내부, ScopeCoroutine 구현선택

6. 예외 처리

깊이내용우선순위
■ Lv.1CancellationException은 반드시 재전파해야 함필수
■ Lv.1runCatching이 suspend 함수와 위험한 이유필수
■ Lv.1try-catch, CoroutineExceptionHandler, SupervisorJob 세 가지 전략필수
■ Lv.2CEH(CoroutineExceptionHandler)가 루트 코루틴에서만 동작하는 이유, async 예외 전파 주의점권장
■ Lv.2runSuspendCatching 직접 구현권장
■ Lv.3handleJobException, childCancelled 내부 전파 경로선택

7. Flow

깊이내용우선순위
■ Lv.1Cold Flow vs Hot Flow (StateFlow, SharedFlow) 차이필수
■ Lv.1flowOn, map, filter, collect 기본 연산자 사용법필수
■ Lv.1StateFlow로 UI 상태 관리, repeatOnLifecycle로 수집필수
■ Lv.2buffer, conflate, flatMapLatest/Merge/Concat 차이권장
■ Lv.2callbackFlow로 콜백 API를 Flow로 변환, awaitClose 필수인 이유권장
■ Lv.3Flow 컨텍스트 보존 규칙, AbstractFlow/SafeCollector 내부선택
■ Lv.4FusibleFlow, ChannelFlowOperatorImpl, 연산자 퓨전 내부불필요

8. 채널 & 기타 연산자

깊이내용우선순위
■ Lv.2채널 4가지 유형 (Rendezvous, Buffered, Conflated, Unlimited)권장
■ Lv.2channelFlow vs callbackFlow 차이권장
■ Lv.2join(), yield() 역할권장
■ Lv.3채널 내부의 송수신 큐, produce/actor 빌더 내부선택
💼 경력별 집중 전략

주니어 (0~2년) — "사용법을 정확히"

집중: Lv.1 전체 + 실전 코드 작성 능력. 면접관은 "코루틴을 실제 프로젝트에서 올바르게 쓸 수 있는가"를 봅니다.

  • viewModelScope에서 launch/async로 네트워크 호출하기
  • withContext(Dispatchers.IO)로 스레드 전환하기
  • StateFlow + repeatOnLifecycle로 UI 상태 수집하기
  • try-catch로 예외 처리, CancellationException 재전파 규칙

미드레벨 (2~5년) — "왜 그렇게 동작하는지"

집중: Lv.1 + Lv.2 전체. 면접관은 "설계 결정의 이유를 이해하고 있는가"를 봅니다.

  • 구조화된 동시성의 3규칙과 SupervisorJob이 필요한 이유
  • CoroutineExceptionHandler가 루트에서만 동작하는 이유
  • Cold vs Hot Flow 선택 기준, callbackFlow 사용 시점
  • CPS 변환과 상태 머신의 기본 원리 설명 가능

시니어 (5년+) — "내부를 꿰뚫고 있는가"

집중: Lv.1~3 + 아키텍처 설계 능력. 면접관은 "복잡한 동시성 문제를 설계 수준에서 해결할 수 있는가"를 봅니다.

  • 코루틴 기반의 앱 아키텍처 설계 (Repository → UseCase → ViewModel 전체 흐름)
  • Flow의 컨텍스트 보존 규칙, buffer/flowOn의 내부 채널 동작
  • JobSupport의 상태 전이, childCancelled 전파 경로
  • 테스트에서 Dispatchers.Main 교체, TestCoroutineDispatcher 활용
⏰ 면접 준비 시간 배분 추천
시간이 3일이라면: ███████████████ 1일차: 실전 사용법 (Lv.1) Scope, Dispatcher, Builder, 예외 처리, Flow 기본 → 코드를 직접 작성하며 익히기 ██████████ 2일차: 원리 이해 (Lv.2) CPS 변환, 구조화된 동시성, SupervisorJob, CEH(CoroutineExceptionHandler) 규칙 → "왜?"에 답할 수 있도록 정리 █████ 3일차: 면접 Q&A 연습 자주 나오는 질문에 30초 이내로 답변 연습 → 핵심만 간결하게, 꼬리 질문 대비

흔한 실수: 내부 구현(Lv.3~4)에 시간을 쏟고 정작 "viewModelScope에서 왜 SupervisorJob을 쓰나요?" 같은 Lv.1 질문에 막히는 경우가 많습니다. 넓고 얕게 먼저, 깊이는 여유가 있을 때.

✅ 면접 전 최종 체크리스트

아래 질문에 30초 이내로 답할 수 있다면 준비 완료입니다.

Lv.1 — 이것만은 꼭 (답 못하면 감점)

질문
코루틴이란 무엇이고, 스레드와 어떻게 다른가요?
launch와 async의 차이점은?
Dispatchers.Main, IO, Default는 각각 언제 쓰나요?
구조화된 동시성이란? 부모가 취소되면 자식은?
viewModelScope는 왜 SupervisorJob을 사용하나요?
CancellationException은 왜 재전파해야 하나요?
Cold Flow와 Hot Flow의 차이는?
StateFlow와 SharedFlow는 각각 언제 쓰나요?

Lv.2 — 알면 좋은 인상 (설명하면 플러스)

질문
suspend 함수가 내부적으로 어떻게 변환되나요?
CoroutineExceptionHandler는 왜 루트 코루틴에서만 동작하나요?
coroutineScope와 supervisorScope의 차이는?
runCatching이 suspend 함수와 위험한 이유는?
flowOn과 buffer의 차이는?
callbackFlow는 언제, 왜 사용하나요?
flatMapLatest는 어떤 경우에 사용하나요?

Lv.3 — 시니어 차별화 (깊이를 증명)

질문
Continuation 객체와 상태 머신의 관계를 코드 수준에서 설명해주세요
Job의 Completing 상태는 왜 존재하나요?
Flow의 컨텍스트 보존 규칙이란?
여러 flowOn/buffer가 체이닝되면 내부적으로 어떻게 최적화되나요?