
코루틴을 조금 배우고 나면 안드로이드에서 비동기 작업은 구조와 어떻게 연결될까라는 질문이 다시 남습니다. 이번 글에서는 코루틴 문법보다 앱 구조 관점에 집중합니다. UI, ViewModel, Repository, Lifecycle, 취소, 상태 업데이트가 어떻게 이어져야 자연스러운지 검색 화면 예시로 차근차근 정리해보겠습니다.
안드로이드에서 비동기 작업은 구조와 어떻게 연결될까
안드로이드에서 비동기 작업을 그냥 백그라운드 코드로만 보면 금방 헷갈립니다. 더 중요한 질문은 누가 작업을 시작하는가, 누가 결과를 화면 상태로 바꾸는가, 누가 그 상태를 읽어서 화면을 그리는가입니다. 이 기준으로 보면 구조가 훨씬 단순해집니다.
- UI는 사용자 입력을 받고 상태를 그린다
- ViewModel은 화면 단위 비동기 흐름을 시작하고 상태를 바꾼다
- Repository는 실제 데이터 작업을 수행한다
즉, 비동기 작업은 코루틴 문법보다 책임 분리 안에서 이해해야 덜 헷갈립니다.
화면에서 시작하면 왜 꼬일까
검색 화면처럼 로딩, 성공, 실패, 재시도, 화면 재생성을 함께 다뤄야 하는 상황에서는 Fragment 안에서 모든 비동기 흐름을 처리하고 싶어집니다. 작은 예제에서는 돌아가지만, 요구사항이 늘어날수록 화면 코드가 상태 보관, 예외 처리, 취소, 렌더링까지 모두 떠안게 됩니다.
class SearchFragment : Fragment() {
private fun onSearchClicked(query: String) {
showLoading(true)
lifecycleScope.launch {
runCatching {
api.search(query)
}.onSuccess { items ->
showLoading(false)
showItems(items)
}.onFailure { throwable ->
showLoading(false)
showError(throwable.message ?: "오류가 발생했습니다.")
}
}
}
}결국 어려운 것은 비동기 호출 자체보다 그 결과를 누가 책임질지입니다. 로딩 상태, 이전 결과 유지 여부, 재시도, 화면 재생성 이후 흐름이 모두 화면 객체 안에 섞이기 시작합니다.
전체 흐름
- UI가 사용자 이벤트를 ViewModel에 전달한다
- ViewModel이 코루틴을 시작한다
- Repository가 실제 데이터 작업을 수행한다
- ViewModel이 결과를 UI state로 바꾼다
- UI는 state를 읽고 렌더링한다

이 흐름으로 보면 문법보다 역할이 먼저 보입니다. 그래서 어디서 요청을 시작하고 어디서 상태를 바꿔야 하는지가 훨씬 분명해집니다.
역할 나누기
UI
UI 계층은 클릭, 입력, 스와이프 같은 이벤트를 전달하고, 현재 상태를 읽어 로딩, 성공, 실패 화면을 그리는 역할에 집중하는 편이 자연스럽습니다. 즉 UI는 작업을 오래 끌고 가는 곳보다 상태를 보여주는 곳에 가깝습니다.
ViewModel
Android Developers의 코루틴 best practices는 The ViewModel should create coroutines라는 방향을 분명히 제시합니다. 이 말은 화면 수준 비동기 작업의 시작점을 ViewModel 쪽에 두는 편이 자연스럽다는 뜻입니다. 이렇게 해야 로딩, 성공, 실패를 UI state로 묶어 관리하기 쉽습니다.
Repository
Repository는 API 호출, Room 조회, 파일 처리처럼 실제 데이터를 다루는 일에 집중합니다. 공식 가이드는 data layer의 suspend 함수가 main-safe 하게 동작하도록 만드는 방향을 설명합니다. 쉽게 말하면 UI 계층이 dispatcher를 일일이 고민하지 않게 하는 것입니다.
상태 만들기
먼저 화면 상태를 하나로 묶습니다. 비동기 작업의 결과는 값 하나보다 화면 상태 변화로 읽는 편이 더 자연스럽기 때문입니다.
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<SearchItem> = emptyList(),
val errorMessage: String? = null
)상태를 이렇게 모아두면 로딩을 켜고 끄는 흐름, 성공 결과 반영, 에러 메시지 정리를 한 자리에서 읽기 쉬워집니다. 즉 비동기 작업의 핵심은 값을 받아오는 것보다 상태를 어떻게 바꿀지에 더 가깝습니다.
ViewModel에서 시작하기
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState
fun onQueryChanged(newQuery: String) {
_uiState.value = _uiState.value.copy(query = newQuery)
}
fun onSearchClicked() {
val query = _uiState.value.query
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isLoading = true,
errorMessage = null
)
runCatching {
repository.search(query)
}.onSuccess { items ->
_uiState.value = _uiState.value.copy(
isLoading = false,
items = items
)
}.onFailure { throwable ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = throwable.message ?: "오류가 발생했습니다."
)
}
}
}
}여기서 핵심은 `viewModelScope.launch` 문법보다 작업의 시작점이 화면 객체가 아니라 ViewModel이라는 점입니다. 그래야 결과를 UI state로 정리하기 쉽고, 화면이 다시 만들어져도 흐름이 덜 흔들립니다.
Repository에서 처리하기
class SearchRepository(
private val api: SearchApi
) {
suspend fun search(query: String): List<SearchItem> {
return withContext(Dispatchers.IO) {
api.search(query)
}
}
}이렇게 두면 UI 계층은 dispatcher를 직접 신경 쓰지 않고, Repository는 실제 데이터 작업을 더 안전한 실행 환경에서 처리할 수 있습니다. 즉 UI는 화면 기준으로 생각하고, Repository는 데이터 기준으로 생각하면 구조가 더 자연스럽습니다.
화면은 그리기만 하기
ViewModel이 상태를 만든 뒤에는 UI가 그 상태를 lifecycle-aware 하게 수집하면 됩니다. View 기반 화면에서는 Android Developers가 `repeatOnLifecycle` 패턴을 공식 가이드에 포함하고 있습니다.
class SearchFragment : Fragment(R.layout.fragment_search) {
private val viewModel: SearchViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
}
private fun render(state: SearchUiState) {
showLoading(state.isLoading)
showItems(state.items)
showError(state.errorMessage)
}
}이 방식의 장점은 분명합니다. 화면이 보일 때만 수집하고, 보이지 않을 때는 불필요한 업데이트를 줄일 수 있습니다. 즉 비동기 작업의 끝은 콜백이 아니라 상태 렌더링이라고 이해하면 구조가 훨씬 단순해집니다.
수명 맞추기
화면은 계속 살아 있지 않지만 작업은 화면보다 오래 갈 수 있습니다. 이 차이를 고려하지 않으면 화면이 사라졌는데도 결과를 그리려 하거나, 이미 필요 없는 요청이 계속 돌거나, 재생성 후 흐름이 어색하게 초기화되는 문제가 생깁니다.
- 화면 수준 작업 시작점은 보통 ViewModel 수명에 붙인다
- 화면 표시용 수집은 viewLifecycleOwner의 Lifecycle에 붙인다
- 아주 짧은 UI 반응은 UI 계층 안에서 처리할 수 있다
그래서 비동기 작업은 단순 백그라운드 코드가 아니라 어느 수명에 붙여야 하는가를 함께 보는 구조 문제입니다. 이전 흐름은 생명주기를 알아야 설계가 쉬워지는 이유에서 더 먼저 연결해서 볼 수 있습니다.
취소 이해하기
Kotlin 공식 문서는 코루틴 취소가 Job과 structured concurrency를 통해 전파된다고 설명합니다. 쉽게 말하면 부모 작업이 취소되면 자식 작업도 함께 정리되는 구조입니다. 이 규칙 덕분에 화면이 사라졌을 때 더 이상 필요 없는 작업도 같이 멈출 수 있습니다.
또 하나 중요한 점은 취소가 cooperative하게 동작한다는 것입니다. 즉 취소 신호가 왔다고 무조건 즉시 멈추는 것이 아니라 suspend 지점에 도달하거나 직접 취소 상태를 확인해야 잘 멈춥니다.
viewModelScope.launch(Dispatchers.Default) {
while (isActive) {
doNextCalculationStep()
}
}이건 문법 팁이 아니라, 화면이 더 이상 필요 없어졌을 때 작업도 멈춰야 한다는 구조 규칙에 가깝습니다. 코루틴 기초 개념은 안드로이드 코루틴 기초 정리에서 더 넓게 다시 볼 수 있습니다.
상태 업데이트
비동기 작업은 결과값 하나를 받아오는 일처럼 보이지만, 실제 화면에서는 상태 변화가 더 중요합니다. 검색 요청 하나만 해도 보통 idle, loading, success 또는 error 같은 흐름을 지나갑니다. 그래서 결과를 바로 View에 꽂기보다 UI state로 바꿔 내보내는 구조가 더 읽기 쉽습니다.
- 로딩 스피너가 켜졌는가
- 이전 결과를 유지할 것인가
- 새 결과를 보여줄 것인가
- 실패 메시지를 어디에 띄울 것인가
즉 비동기 작업이 끝났다는 사실보다 화면이 어떤 상태가 되었는가가 더 중요합니다. 바로 앞 글인 화면 상태는 어디에 두는 게 맞을까와도 자연스럽게 이어집니다.
자주 하는 실수
- Activity나 Fragment가 비동기 흐름을 다 떠안는 경우
- Repository가 UI 상태까지 직접 아는 경우
- suspend면 자동으로 백그라운드에서 실행된다고 생각하는 경우
- 결과를 바로 View에 꽂고 상태 모델을 만들지 않는 경우
- 취소와 lifecycle을 별개 문제로 보는 경우
이 실수들의 공통점은 코루틴을 문법으로만 보고 구조로 보지 않는다는 점입니다. 특히 ViewModel이 필요한 이유는 ViewModel은 왜 필요할까에서 먼저 연결해서 보면 더 잘 보입니다.
빠른 점검표
- 이 작업은 화면 수준 이벤트에 반응하는가
- 결과를 화면 상태로 바꿔야 하는가
- 화면이 사라지면 멈추거나 중단되어야 하는가
- 실제 데이터 작업은 어느 계층이 더 잘 아는가
- UI는 상태를 직접 만들고 있는가, 아니면 상태를 받아서 그리고 있는가
이 질문에 답해보면 보통 아래 구조가 자연스럽습니다. UI는 이벤트 전달과 상태 렌더링, ViewModel은 코루틴 시작과 상태 업데이트, Repository는 실제 데이터 처리와 필요 시 dispatcher 전환을 맡는 방식입니다.
마무리
안드로이드에서 비동기 작업은 코루틴 문법만 안다고 끝나지 않습니다. 더 중요한 것은 그 작업이 앱 구조 안에서 어디에 붙어야 하는지입니다. UI는 사용자 이벤트를 전달하고 상태를 그리며, ViewModel은 화면 단위 비동기 작업을 시작하고 UI state를 만들고, Repository는 실제 데이터 작업을 수행합니다. Lifecycle은 언제 수집하고 언제 멈출지를 결정하고, 취소는 구조 안정성을 위한 기본 규칙이 됩니다.
이전 흐름을 먼저 정리하고 싶다면 생명주기를 알아야 설계가 쉬워지는 이유, ViewModel은 왜 필요할까, 화면 상태는 어디에 두는 게 맞을까를 먼저 이어서 읽어보면 좋습니다. 공식 기준은 Coroutines best practices, Architecture coroutines guide, ViewModel overview, Kotlin cancellation guide를 함께 보면 더 분명해집니다. 다음 글에서는 시리즈를 마무리하면서 안드로이드 앱 구조를 볼 때 무엇부터 판단해야 할까를 전체 기준으로 정리해보겠습니다.