안드로이드에서는 화면 상태를 ViewModel에 두고 viewModelScope로 코루틴을 관리하는 패턴이 표준입니다. 문제는 androidx.lifecycle:lifecycle-viewmodel이 오랫동안 안드로이드 전용 아티팩트였다는 점입니다. Compose 화면 코드를 commonMain에 공유해도 상태 관리 로직만큼은 안드로이드용 ViewModel과 iOS용 별도 클래스로 나눠 짜야 했습니다. androidx.lifecycle 2.8부터 lifecycle-viewmodel, lifecycle-viewmodel-compose, lifecycle-runtime-compose가 Kotlin Multiplatform 공통 타겟을 지원하면서, 이 부분도 commonMain 하나로 옮길 수 있게 됐습니다.
왜 필요한가
ViewModel을 플랫폼별로 나누면 상태 갱신 로직과 코루틴 스코프 관리를 두 번 짜야 합니다. 안드로이드 쪽은 androidx.lifecycle.ViewModel을 상속받아 viewModelScope로 코루틴을 관리하고, iOS 쪽은 별도의 순수 클래스에 CoroutineScope를 직접 만들어서 화면이 사라질 때 수동으로 cancel()을 호출하는 식이었습니다. 두 구현이 같은 로직을 다른 방식으로 담고 있다 보니, 상태 갱신 순서를 하나 바꿀 때마다 두 곳을 똑같이 고쳐야 했습니다. lifecycle-viewmodel의 공통 타겟 지원은 ViewModel 클래스와 viewModelScope를 commonMain에 그대로 노출해서, 이 중복을 없애줍니다.
핵심 개념
commonMain에 필요한 의존성을 추가합니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
}
}
}
ViewModel 클래스는 안드로이드에서 쓰던 것과 동일한 형태로 commonMain에 작성합니다. viewModelScope는 Dispatchers.Main.immediate를 기본으로 쓰는 코루틴 스코프를 제공하고, onCleared() 시점에 자동으로 취소됩니다.
// commonMain/counter/CounterViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
viewModelScope.launch {
_count.value += 1
}
}
}
화면 쪽에서는 viewModel { } 팩토리 컴포저블로 인스턴스를 가져오고, collectAsStateWithLifecycle()로 구독합니다.
// commonMain/counter/CounterScreen.kt
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel { CounterViewModel() }) {
val count by viewModel.count.collectAsStateWithLifecycle()
Button(onClick = viewModel::increment) {
Text("count: $count")
}
}
실전 예시: 생성자 인자가 있는 ViewModel
리포지토리처럼 생성자 인자를 받는 ViewModel은 viewModel { } 블록 안에서 직접 생성자를 호출하면 됩니다. Hilt의 @HiltViewModel처럼 어노테이션으로 주입받는 방식은 아니지만, Koin을 함께 쓰는 프로젝트라면 koinInject()로 의존성을 받아와 생성자에 넘기는 식으로 연결할 수 있습니다.
// commonMain/list/ItemListViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _items = MutableStateFlow<List<String>>(emptyList())
val items: StateFlow<List<String>> = _items.asStateFlow()
fun load() {
viewModelScope.launch {
_items.value = repository.fetchItems()
}
}
}
// commonMain/list/ItemListScreen.kt
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.koin.compose.koinInject
@Composable
fun ItemListScreen(
repository: ItemRepository = koinInject(),
viewModel: ItemListViewModel = viewModel { ItemListViewModel(repository) },
) {
val items by viewModel.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.load() }
LazyColumn {
items(items) { item -> Text(item) }
}
}
겪은 문제: iOS에서 화면을 나갔다 들어와도 상태가 초기화되지 않음
안드로이드에서는 화면(Activity/Fragment)이 완전히 종료되면 ViewModelStore도 함께 정리되면서 onCleared()가 호출됩니다. iOS 쪽에 UIViewControllerRepresentable로 Compose 화면을 얹는 구조를 그대로 가져다 썼더니, 리스트 화면을 나갔다가 다시 들어와도 ItemListViewModel의 상태가 초기화되지 않고 이전 값을 그대로 보여줬습니다.
원인은 viewModel { }이 참조하는 ViewModelStoreOwner를 iOS 쪽에서 화면 전환에 맞춰 새로 만들어주지 않고, 앱 전체에서 하나만 재사용하고 있었기 때문입니다. 안드로이드는 액티비티/프래그먼트 생명주기가 ViewModelStoreOwner를 자동으로 관리해주지만, iOS는 그런 시스템 레벨 생명주기가 없어서 이 부분을 직접 챙겨야 합니다. 화면을 감싸는 UIViewController가 해제될 때 ViewModelStore.clear()를 직접 호출하도록 연결하고 나서야, 화면을 나갔다 들어오면 새 ViewModelStoreOwner로 인스턴스가 새로 만들어지도록 정리됐습니다.
장단점 정리
장점
- 안드로이드에서 쓰던
ViewModel/viewModelScope코드를 그대로 commonMain으로 옮길 수 있어서, 상태 관리 로직을 플랫폼별로 중복 작성할 필요가 없음 collectAsStateWithLifecycle()이 함께 공통 타겟을 지원해서, 생명주기 인지 구독 코드도 commonMain에 둘 수 있음- 기존에 안드로이드 ViewModel 패턴에 익숙한 팀이라면 학습 비용이 거의 없음
단점
ViewModelStoreOwner의 생명주기를 안드로이드처럼 시스템이 자동으로 관리해주지 않는 플랫폼(iOS 등)에서는, 화면 전환에 맞춰 스토어를 정리하는 코드를 직접 연결해야 함- Hilt의
@HiltViewModel같은 자동 주입은 지원하지 않아서, 생성자 인자가 있는 ViewModel은 Koin 등 별도 DI 라이브러리와 직접 연결하는 코드가 필요함
화면 상태 로직을 이미 안드로이드 ViewModel 패턴으로 짜둔 프로젝트라면, Compose 코드를 공유하는 김에 ViewModel도 함께 commonMain으로 옮기는 편이 상태 관리 로직의 이원화를 막는 데 도움이 됩니다.