Theme:

코루틴을 처음 접했을 때 "경량 스레드"라는 설명을 듣고, 그냥 작은 스레드인가 보다 하고 넘어갔습니다. 그런데 면접에서 "코루틴이 정확히 뭔가요?"라는 질문을 받으면 그 설명만으로는 부족합니다. suspend가 뭘 중단하는 건지, launch와 async는 왜 따로 있는 건지 — 이 글에서 정리해 봅니다.

코루틴이란 — "경량 스레드"가 아닌 진짜 이유

코루틴(Coroutine)은 협력적으로 멀티태스킹을 수행하는 프로그래밍 구성 요소입니다.

OS 스레드와 비교하면 이렇습니다.

구분OS 스레드코루틴
생성 비용높음 (스택 메모리 ~1MB)낮음 (수십 바이트)
스케줄링OS 커널 (선점형)코루틴 런타임 (협력형)
컨텍스트 스위칭비쌈 (커널 모드 전환)저렴 (유저 영역에서 처리)
동시 실행 수수천 개가 한계수십만 개도 가능

핵심은 코루틴이 스레드를 대체하는 게 아니라, 스레드 위에서 실행된다는 점입니다. 중단(suspend)되면 스레드를 블로킹하지 않고 양보하기 때문에, 하나의 스레드에서 여러 코루틴이 번갈아 실행될 수 있습니다.

KOTLIN
// 10만 개의 코루틴을 만들어도 문제없음
fun main() = runBlocking {
    repeat(100_000) {
        launch {
            delay(1000L)
            print(".")
        }
    }
}
// 같은 걸 스레드로 하면? OutOfMemoryError

면접에서 "코루틴 = 경량 스레드"라고만 답하면 후속 질문이 날아옵니다. **"그럼 스레드와 어떻게 다른데요?"**라는 질문에 "협력적 멀티태스킹"과 "suspend/resume 메커니즘"을 설명할 수 있어야 합니다.

suspend 함수 — 중단하는 건 스레드가 아니라 코루틴

suspend 키워드는 이 함수가 코루틴을 중단할 수 있다는 표시입니다.

KOTLIN
suspend fun fetchUser(): User {
    // 네트워크 요청 동안 코루틴이 중단됨
    // 이 사이에 스레드는 다른 코루틴을 실행할 수 있음
    return apiService.getUser()
}

컴파일러 관점에서 보면, suspend 함수는 내부적으로 **Continuation Passing Style(CPS)**로 변환됩니다.

KOTLIN
// 우리가 작성하는 코드
suspend fun fetchUser(): User

// 컴파일러가 변환한 코드 (개념적)
fun fetchUser(continuation: Continuation<User>): Any?
  • Continuation은 "나중에 여기서부터 이어서 실행해줘"라는 콜백입니다
  • 반환값이 COROUTINE_SUSPENDED이면 실제로 중단된 것
  • 중단 후 결과가 준비되면 continuation.resume()으로 재개

이게 면접에서 자주 나오는 포인트입니다. suspend는 스레드를 블로킹하는 게 아니라, 코루틴의 실행을 잠시 멈추고 나중에 이어서 실행할 수 있게 하는 것입니다.

suspend 함수 호출 규칙

KOTLIN
// 코루틴 또는 다른 suspend 함수에서만 호출 가능
suspend fun loadData() {
    val user = fetchUser()  // OK — suspend 함수에서 호출
}

fun main() {
    // fetchUser()  // 컴파일 에러! 일반 함수에서 호출 불가
    runBlocking {
        fetchUser()  // OK — 코루틴 빌더 안에서 호출
    }
}

CoroutineScope — 코루틴의 생명주기 관리

코루틴은 반드시 스코프(Scope) 안에서 생성됩니다. 스코프는 코루틴의 생명주기를 관리하고, 취소가 전파되는 범위를 결정합니다.

KOTLIN
class MyViewModel : ViewModel() {
    // viewModelScope: ViewModel이 소멸되면 모든 코루틴 자동 취소
    fun loadData() {
        viewModelScope.launch {
            val data = repository.fetchData()
            _state.value = data
        }
    }
}

주요 스코프를 정리하면 이렇습니다.

  • GlobalScope: 앱 전체 생명주기. 누수 위험이 있어서 실무에서는 지양
  • runBlocking: 현재 스레드를 블로킹하며 코루틴 실행. 테스트나 main 함수에서 사용
  • coroutineScope: suspend 함수 안에서 새로운 스코프 생성. 자식 코루틴이 모두 끝나야 반환
  • viewModelScope / lifecycleScope: Android에서 생명주기에 맞춰 자동 취소
KOTLIN
suspend fun loadAllData() = coroutineScope {
    val users = async { fetchUsers() }
    val posts = async { fetchPosts() }
    // 둘 다 끝나야 coroutineScope가 반환됨
    combineData(users.await(), posts.await())
}

launch vs async — 언제 뭘 쓰나

둘 다 코루틴을 시작하는 빌더이지만, 반환값이 다릅니다.

launch — "실행하고 잊기 (Fire and Forget)"

KOTLIN
val job: Job = scope.launch {
    // 결과를 반환하지 않음
    saveToDatabase(data)
    sendNotification()
}

// 필요하면 취소하거나 완료를 기다릴 수 있음
job.cancel()
job.join()  // 완료될 때까지 대기

async — "결과가 필요할 때"

KOTLIN
val deferred: Deferred<User> = scope.async {
    // 결과를 반환함
    fetchUser(userId)
}

val user: User = deferred.await()  // 결과를 기다림

병렬 실행 패턴

KOTLIN
// 순차 실행 — 총 2초
suspend fun sequential() {
    val user = fetchUser()       // 1초
    val posts = fetchPosts()     // 1초
}

// 병렬 실행 — 총 1초
suspend fun parallel() = coroutineScope {
    val user = async { fetchUser() }     // 동시에 시작
    val posts = async { fetchPosts() }   // 동시에 시작
    println("${user.await()}, ${posts.await()}")
}

면접에서 가장 많이 나오는 질문 중 하나가 "launch와 async의 차이"입니다. **launch는 Job(생명주기 관리용), async는 Deferred(결과값 반환용)**이라고 기억하면 됩니다.

Dispatchers — 어떤 스레드에서 실행할 것인가

코루틴이 실행될 스레드(풀)를 결정하는 것이 Dispatcher입니다.

Dispatcher용도스레드 풀
Dispatchers.DefaultCPU 집약적 연산CPU 코어 수만큼
Dispatchers.IO파일, 네트워크, DB최대 64개 (또는 코어 수)
Dispatchers.MainUI 업데이트 (Android)메인 스레드 1개
Dispatchers.Unconfined특수 용도 (테스트 등)호출한 스레드에서 시작
KOTLIN
scope.launch(Dispatchers.IO) {
    // I/O 작업
    val data = readFile("data.json")

    withContext(Dispatchers.Main) {
        // UI 업데이트
        textView.text = data
    }
}

withContext는 코루틴을 중단하고 다른 디스패처에서 재개하는 함수입니다. 스레드 전환이 필요할 때 사용합니다.

KOTLIN
// 잘못된 예: I/O 작업을 메인 스레드에서 실행
scope.launch(Dispatchers.Main) {
    val data = readFile("data.json")  // 메인 스레드 블로킹!
}

// 올바른 예: withContext로 I/O 디스패처 전환
scope.launch(Dispatchers.Main) {
    val data = withContext(Dispatchers.IO) {
        readFile("data.json")
    }
    textView.text = data  // 다시 메인 스레드
}

코루틴 취소 — 협력적 취소의 의미

코루틴의 취소는 **협력적(cooperative)**입니다. 그냥 강제로 중단시키는 게 아니라, 코루틴이 스스로 취소를 확인해야 합니다.

KOTLIN
val job = launch {
    repeat(1000) { i ->
        println("작업 중: $i")
        delay(100)  // delay는 취소 가능한 suspend 함수
    }
}

delay(500)
job.cancel()  // 취소 요청
job.join()    // 취소 완료 대기 (cancelAndJoin()으로 합칠 수 있음)

delay, yield 같은 suspend 함수는 내부적으로 취소 여부를 확인합니다. 하지만 CPU 집약적인 루프에서는 직접 확인해야 합니다.

KOTLIN
val job = launch {
    var i = 0
    while (isActive) {  // isActive로 취소 여부 확인
        i++
        // CPU 집약적 작업
    }
}

정리

코루틴의 핵심 포인트를 면접용으로 요약하면 이렇습니다.

  • 코루틴 ≠ 스레드: 스레드 위에서 협력적으로 실행되는 경량 실행 단위
  • suspend: 코루틴을 중단/재개할 수 있다는 표시. CPS로 변환됨
  • launch vs async: 결과가 필요 없으면 launch(Job), 결과가 필요하면 async(Deferred)
  • Dispatcher: 코루틴이 실행될 스레드(풀)를 결정. IO/Default/Main 구분 중요
  • 취소는 협력적: suspend 함수 또는 isActive로 취소 지점을 만들어야 함
댓글 로딩 중...