Android 개발/Kotlin

코루틴 처음 만났을 때 가장 헷갈렸던 것들

stackD 2026. 5. 4. 20:00

 

코루틴은 스레드를 멈추지 않습니다. 근데 코드는 딱 멈춘 것처럼 실행돼요. 이 모순처럼 보이는 한 줄이 사실 코루틴의 전부거든요.

 

저도 처음 배울 때 suspend 키워드를 보고 "여기서 스레드가 멈추는 거구나"라고 생각했는데, 실제로는 전혀 그렇지 않더라구요. 그때부터 멘붕의 연속이었습니다. 코틀린(Kotlin) 코루틴은 개념 하나하나는 이해가 되는데, 동시에 여러 개를 이해해야 전체 그림이 잡히는 구조라서 특히 처음이 힘들거든요. 제가 가장 헷갈렸던 다섯 가지를 솔직하게 정리해봤습니다.

 

코루틴 suspend 함수가 스레드를 블로킹하지 않는 이유

suspend라는 단어를 처음 보면 "일시정지"라고 자연스럽게 읽게 됩니다. 실제로 맞아요. 근데 핵심은 무엇이 일시정지되느냐거든요. 스레드가 멈추는 게 아닙니다. 코루틴 자체가 잠깐 멈추는 거예요.

 

suspend 함수가 실행을 일시정지하는 동안, 스레드는 다른 코루틴을 처리하러 갑니다. 비유하자면, 카페 직원이 에스프레소 추출을 기다리는 동안 멀뚱히 서 있는 게 아니라, 잠깐 다른 손님 주문을 받으러 가는 거예요. 그 덕분에 스레드 하나로 수천 개의 코루틴을 동시에 처리할 수 있는 거구요.

 

suspend fun fetchUser(): User {
    return withContext(Dispatchers.IO) {
        // 기다리는 동안 스레드는 다른 코루틴 처리 가능
        api.getUser()
    }
}

 

기존 스레드 방식이라면 응답이 올 때까지 스레드 하나가 통째로 묶여 있었겠지만, 코루틴은 그 낭비를 없앱니다. 이게 코루틴이 존재하는 가장 큰 이유이기도 해요.

 

launch vs async, 반환값 있을 때와 없을 때 구분법

launch를 쓰면서 반환값을 기다리려다가 낭패 본 적, 한 번쯤은 있을 거예요. 저는 딱 그랬거든요.

launch는 결과값을 반환하지 않습니다. DB 저장, 로그 기록처럼 "실행만 하면 되는" 작업에 쓰는 거예요. 반면 async는 Deferred<T>를 반환하고, .await()으로 결과값을 받을 수 있습니다.

 

// 결과값이 필요 없을 때
viewModelScope.launch {
    repository.saveUser(user)
}

// 단일 결과값이 필요할 때 (async 불필요)
viewModelScope.launch {
    // fetchUser()가 suspend 함수라면 일반 함수처럼 바로 값을 받을 수 있습니다.
    val user = repository.fetchUser() 
    _uiState.value = UiState.Success(user)
}

// 여러 결과값을 동시에(병렬로) 기다려야 할 때
viewModelScope.launch {
    val userDeferred = async { repository.fetchUser() }
    val configDeferred = async { repository.fetchConfig() }
    
    // 두 작업이 동시에 진행되고, 둘 다 완료될 때까지 기다림
    val user = userDeferred.await()
    val config = configDeferred.await()
}

 

둘을 헷갈리면 두 가지 문제가 생기더라구요. launch에서 값을 받으려다 컴파일 에러가 나거나, 굳이 async를 남발해서 코드가 불필요하게 복잡해지는 경우예요.

 

반환값이 필요하면 async, 없으면 launch — 이 기준 하나만 먼저 잡으시면 나머지는 자연스럽게 따라옵니다.

 

 

개인적으로는 처음에 async를 남발하던 시기가 있었어요. 코드 리뷰에서 피드백을 받고 나서야 제대로 이해하게 됐는데, 역할이 나눠진 데는 이유가 있더라구요. async가 코드에 보이면 읽는 사람은 "아 병렬 처리하는 구나"라고 자연스럽게 기대하거든요.

 

Dispatchers.IO와 Main, 잘못 고르면 ANR이 납니다

디스패처는 코루틴이 어느 스레드 풀에서 실행될지를 결정합니다. 처음엔 그냥 선택지처럼 보이는데, 잘못 고르면 앱이 완전히 멈추는 ANR(Application Not Responding)이 날 수 있거든요.

 

Dispatchers.IO는 네트워크 요청이나 DB 접근 같은 블로킹 작업에 씁니다. 오래 기다려도 괜찮게 설계된 스레드 풀이에요.

 

Dispatchers.Main은 안드로이드(Android)에서 UI 업데이트 전용이고요. 메인 스레드는 화면 그리는 작업만 해야 하는데, 여기서 무거운 작업을 돌리면 ANR이 터지더라구요.

 

viewModelScope.launch { // 기본적으로 Main 스레드에서 시작
    // 무거운 네트워크 작업이나 DB 접근만 IO 스레드로 전환해서 처리
    val data = withContext(Dispatchers.IO) {
        repository.fetchData() 
    }
    
    // 다시 블록 밖으로 나오면 Main 스레드이므로 바로 UI 업데이트 가능
    _uiState.value = data 
}

 

반대 상황도 주의해야 해요. IO 스레드에서 LiveData를 직접 업데이트하면 예외가 터질 수 있거든요. (참고로 StateFlow는 스레드 안전(Thread-safe)하게 설계되어 있어서 IO 스레드에서 값을 변경해도 예외가 발생하지 않습니다. 하지만 LiveData에서 넘어오신 분들이 많이 헷갈려 하는 부분이죠.) 디스패처 선택은 단순한 스타일 문제가 아니라 앱의 안정성과 직결되는 필수 규칙인 셈입니다.

 

 

 

viewModelScope로 코루틴 메모리 누수 방지하기

코루틴을 배우면서 제일 반가웠던 개념이 viewModelScope였어요. 예전에 스레드로 비동기 작업을 처리할 때는 화면이 닫혔는데도 네트워크 요청이 계속 살아있는 경우가 종종 있었거든요. 메모리 누수의 주범 중 하나였습니다.

 

viewModelScope를 쓰면 ViewModel이 소멸하는 순간, 그 안에서 실행 중이던 코루틴이 전부 자동으로 취소됩니다. 별도로 취소 코드를 작성할 필요가 없어요.

 

class UserViewModel : ViewModel() {
    fun loadUser() {
        viewModelScope.launch {
            val user = repository.fetchUser() // ViewModel 소멸 시 자동 취소
            _uiState.value = user
        }
    }
}

 

이게 "구조화된 동시성"이에요. 상위 스코프가 소멸하면 하위 코루틴도 함께 정리됩니다. 수동으로 취소를 챙기던 시절과 비교하면 코드가 정말 깔끔해지더라구요.

 

이 점은 여러번 강조해도 부족합니다. GlobalScope는 앱이 살아있는 한 절대 취소되지 않으니, 특별한 이유가 없다면 생명주기에 연결된 스코프를 써야 합니다.

 

 

withContext와 async, 순차 전환과 병렬 처리 구분법

마지막으로 헷갈렸던 게 withContext와 async의 차이였어요. 둘 다 다른 디스패처에서 코드를 실행할 수 있는 방식이라, 처음엔 뭘 써야 할지 감이 안 오더라구요.

 

핵심 차이는 실행 순서에 있습니다. withContext는 순차적이에요. 블록 안의 코드가 끝나야 다음으로 넘어가거든요. 반면 async는 병렬 처리용이에요. 여러 작업을 동시에 띄우고 나중에 결과를 한꺼번에 모으고 싶을 때 쓰는 거예요.

 

// withContext: 순차적 스레드 전환 후 결과 반환
suspend fun fetchProfile(): Profile {
    return withContext(Dispatchers.IO) {
        api.getProfile() // 끝나야 다음으로 넘어감
    }
}

// async: 두 요청 병렬로 실행
suspend fun fetchAll() {
    coroutineScope {
        val userDeferred = async { repository.fetchUser() }
        val postsDeferred = async { repository.fetchPosts() }
        val user = userDeferred.await()
        val posts = postsDeferred.await() // 두 요청이 동시에 실행됨
    }
}

 

저는 처음에 withContext가 있는 줄 모르고 모든 스레드 전환을 async { }.await() 패턴으로 쓰고 있었어요. 기능은 비슷하게 동작하지만, 코드를 읽는 사람은 "왜 굳이 async지?"라는 의문이 생기거든요. 순차 전환엔 withContext, 병렬 처리엔 async로 역할을 명확히 구분하면 의도가 잘 드러나는 코드가 나오는 편입니다.

 

마치며

코루틴은 익숙해지기까지 시간이 꽤 걸리는 편이에요. suspend, 디스패처, 스코프, launch와 async를 동시에 이해해야 전체 그림이 잡히는 구조라서, 처음엔 하나를 잡으면 다른 하나가 흔들리는 느낌이 나거든요.

 

그중에서도 "suspend는 스레드가 아닌 코루틴을 멈추는 것" 이라는 첫 번째 개념이 자리를 잡으면 나머지가 훨씬 빠르게 이해되더라구요. 그리고 viewModelScope로 생명주기를 연결해서 코루틴이 자동으로 정리되는 패턴까지. 이 두 가지만 먼저 단단히 잡고 시작하시라고 꼭 말씀드리고 싶네요.