|

안드로이드 코루틴 기초 정리: suspend, launch, Dispatcher, ViewModelScope까지

안드로이드 코루틴 기초 정리 대표 이미지
안드로이드 코루틴 핵심 개념 요약

안드로이드 코루틴은 네트워크, Room, 파일 처리 같은 비동기 작업을 더 읽기 쉽게 만들고 생명주기와 취소를 더 안전하게 다루기 위한 핵심 도구입니다. 이 글에서는 suspend, launch, Dispatcher, viewModelScope, lifecycleScope를 처음 배우는 기준으로 차근차근 정리합니다.


안드로이드에서 코루틴을 왜 배우는가

안드로이드 UI는 메인 스레드에서 동작합니다. 그래서 네트워크 요청, 데이터베이스 조회, 파일 처리 같은 작업을 잘못 올리면 화면 멈춤이나 ANR로 이어질 수 있습니다. 코루틴의 장점은 읽기 쉬운 비동기 코드, scope 기반 생명주기 관리, 취소 전파를 함께 가져갈 수 있다는 점입니다.

  • 비동기 코드를 동기 코드처럼 읽기 쉽게 만든다
  • 작업이 어느 범위에서 살아야 하는지 scope로 묶을 수 있다
  • 부모 작업이 끝나면 자식 작업도 함께 정리되는 structured concurrency를 적용할 수 있다
  • 안드로이드 생명주기와 자연스럽게 연결할 수 있다

안드로이드 코루틴은 정확히 무엇인가

코루틴은 중단했다가 다시 이어서 실행할 수 있는 작업 단위입니다. 스레드와 같은 개념이 아니라 더 가벼운 추상화이며, 안드로이드에서는 보통 suspend function, CoroutineScope, Dispatcher 세 축으로 이해하는 것이 가장 덜 헷갈립니다.


suspend, launch, async는 어떻게 다른가

suspend: 중단 가능한 함수라는 표시

suspend는 자동으로 백그라운드에서 실행된다는 뜻이 아닙니다. 핵심은 이 함수가 코루틴 안에서 중단되었다가 다시 이어질 수 있다는 점입니다. Kotlin 공식 문서도 코루틴을 설명할 때 suspension 개념과 읽기 쉬운 비동기 코드 구조를 강조합니다.

suspend fun loadUserName(): String {
    delay(500)
    return "BSCodeLab"
}

launch: 결과값 없이 작업 시작

launch는 코루틴을 시작하고 Job을 반환합니다. 즉 결과값보다는 생명주기와 취소를 다루는 데 더 어울립니다. 화면 진입 시 데이터 로드, 버튼 클릭 후 상태 갱신 같은 기본 안드로이드 패턴에서 자주 보게 됩니다.

async: 결과가 필요한 비동기 작업

asyncDeferred<T>를 반환하고 나중에 await()로 값을 꺼냅니다. 그래서 여러 비동기 결과를 조합할 때 적합합니다. 다만 입문 단계에서는 무조건 async를 쓰기보다 정말 결과값 조합이 필요한 상황인지 먼저 판단하는 습관이 중요합니다.


Dispatcher는 언제 바꿔야 하나

Dispatcher는 코루틴이 어떤 실행 환경에서 일할지 정하는 기준입니다. 안드로이드 입문 단계에서는 Dispatchers.Main, Dispatchers.IO, Dispatchers.Default 세 가지만 먼저 정확히 구분해도 큰 도움이 됩니다.

  • Dispatchers.Main: UI 업데이트, 사용자 입력 처리
  • Dispatchers.IO: 네트워크, DB, 파일처럼 블로킹 가능성이 큰 IO 작업
  • Dispatchers.Default: 정렬, 파싱, 계산처럼 CPU 사용량이 큰 작업

여기서 중요한 점은 suspend 함수라고 해서 자동으로 IO나 Default로 이동하지는 않는다는 것입니다. Android Developers의 best practices는 필요한 dispatcher 전환을 함수 내부에서 처리해 main-safe suspend function을 만드는 방향을 강조합니다.

suspend fun loadArticle(repository: ArticleRepository): Article {
    return withContext(Dispatchers.IO) {
        repository.fetchArticle()
    }
}

ViewModelScope와 LifecycleScope는 어디에 붙는가

안드로이드 코루틴의 핵심은 단순 문법보다 어디에 scope를 붙이느냐입니다. viewModelScope는 ViewModel에 묶여 있어 ViewModel이 정리될 때 함께 취소되고, lifecycleScope는 Activity나 Fragment의 Lifecycle과 함께 움직입니다.

실무 감각으로 구분하면, 오래 살아야 하는 화면 상태 로딩이나 비즈니스 로직 시작점은 ViewModel 쪽이 더 자연스럽고, 화면이 STARTED 상태일 때만 반응해야 하는 수집 작업은 Lifecycle 쪽이 더 적합합니다. 공식 문서도 Architecture coroutines guide에서 repeatOnLifecycle 패턴을 함께 안내합니다.

안드로이드 코루틴 ViewModelScope와 IO 전환 흐름도
viewModelScope에서 시작해 IO 작업을 수행하고 다시 Main으로 돌아오는 기본 흐름

structured concurrency와 취소를 왜 이해해야 하나

structured concurrency는 부모 코루틴이 자식 코루틴의 생명주기를 책임지는 구조를 뜻합니다. Kotlin 공식 문서의 cancellation 설명처럼 부모가 취소되면 자식도 함께 취소됩니다. 이 규칙 덕분에 안드로이드에서 화면이 사라진 뒤에도 불필요한 작업이 계속 도는 문제를 크게 줄일 수 있습니다.

또 하나 중요한 점은 코루틴 취소가 cooperative하게 동작한다는 것입니다. 즉 취소 신호가 와도 suspend 지점에 도달하거나 직접 취소 상태를 확인해야 잘 멈춥니다. 오래 도는 계산 루프라면 isActive, ensureActive(), yield() 같은 패턴을 이해해야 합니다.

viewModelScope.launch(Dispatchers.Default) {
    while (isActive) {
        doHeavyCalculationStep()
    }
}

실전 예제로 한 번에 보기

아래 코드는 안드로이드 초급 단계에서 가장 자주 보게 되는 기본 패턴입니다. ViewModel에서 viewModelScope.launch로 작업을 시작하고, repository의 suspend 함수 안에서 withContext(Dispatchers.IO)로 실제 IO 작업을 수행한 뒤 결과를 UI 상태에 반영합니다.

class ArticleViewModel(
    private val repository: ArticleRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
    val uiState: StateFlow<ArticleUiState> = _uiState

    fun loadArticle(articleId: Long) {
        viewModelScope.launch {
            _uiState.value = ArticleUiState.Loading

            runCatching {
                repository.loadArticle(articleId)
            }.onSuccess { article ->
                _uiState.value = ArticleUiState.Success(article)
            }.onFailure { throwable ->
                _uiState.value = ArticleUiState.Error(
                    throwable.message ?: "알 수 없는 오류가 발생했습니다."
                )
            }
        }
    }
}

class ArticleRepository(
    private val api: ArticleApi
) {
    suspend fun loadArticle(articleId: Long): Article {
        return withContext(Dispatchers.IO) {
            api.getArticle(articleId)
        }
    }
}

Lifecycle에서 수집할 때의 기본 패턴

class ArticleFragment : Fragment(R.layout.fragment_article) {

    private val viewModel: ArticleViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    render(state)
                }
            }
        }
    }
}

초보자가 자주 하는 실수

  1. suspend면 자동으로 백그라운드에서 실행된다고 생각하기
  2. 결과값이 필요하지 않은데도 async를 습관적으로 쓰기
  3. 무거운 IO 또는 계산 작업을 Main dispatcher에서 처리하기
  4. 생명주기와 무관한 scope를 남발해 취소 시점을 잃어버리기
  5. 긴 루프나 계산에서 취소를 고려하지 않기

안드로이드 생명주기를 먼저 이해하고 싶다면 안드로이드 액티비티 생명주기 기초 정리도 함께 읽어보면 좋습니다. 생명주기 감각이 있어야 왜 lifecycle-aware scope가 중요한지도 더 쉽게 보입니다.


핵심 정리

  1. 안드로이드 코루틴은 읽기 쉬운 비동기 코드와 안전한 생명주기 관리를 함께 가져가기 위한 도구다
  2. suspend는 중단 가능한 함수라는 뜻이지 자동 백그라운드를 의미하지 않는다
  3. launch, async, withContext는 역할이 다르며 특히 withContext는 실행 환경 전환에 중요하다
  4. Dispatchers.Main, IO, Default를 구분해야 성능과 안정성을 함께 챙길 수 있다
  5. viewModelScope, lifecycleScope, structured concurrency, cancellation을 함께 이해해야 실무에서 덜 헷갈린다

공식 문서는 Kotlin Coroutines Guide, Cancellation and Timeouts, Coroutines on Android, Architecture coroutines guide, Best practices for coroutines를 순서대로 보면 좋습니다.

함께보면 좋은 글