Todo 앱에 할 일 목록, 상세, 추가 화면을 나누면서 화면 전환을 어떻게 구성할지 정해야 했습니다. 안드로이드에서는 Navigation-Compose가 익숙하지만 이 라이브러리는 안드로이드 전용이라 iOS 타겟에서는 쓸 수 없습니다. commonMain에서 화면 전환 로직을 그대로 공유하기 위해 Voyager를 도입한 과정을 정리합니다.

왜 Navigation-Compose가 아니라 Voyager인가

Navigation-Compose는 NavController와 NavHost가 androidx.navigation 패키지에 속해 있고, 내부적으로 안드로이드의 SavedStateHandle과 Activity 생명주기에 의존합니다. iOS 타겟에는 이 클래스들이 아예 존재하지 않기 때문에 commonMain에 둘 수 없습니다.

Voyager는 순수 Kotlin으로 작성된 라이브러리로, 화면(Screen)과 내비게이터(Navigator)를 안드로이드/iOS 어느 쪽에도 종속되지 않는 API로 제공합니다. commonMain에 화면 전환 코드를 한 번만 작성하면 두 플랫폼에서 동일하게 동작합니다.

핵심 개념: Screen과 Navigator

Voyager에서 화면 하나는 Screen 인터페이스를 구현한 클래스입니다. Content()에 그 화면의 컴포저블을 정의합니다.

// composeApp/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("cafe.adriel.voyager:voyager-navigator:1.1.0-beta03")
            implementation("cafe.adriel.voyager:voyager-screenmodel:1.1.0-beta03")
            implementation("cafe.adriel.voyager:voyager-koin:1.1.0-beta03")
            implementation("cafe.adriel.voyager:voyager-transitions:1.1.0-beta03")
        }
    }
}
// commonMain/ui/TodoListScreen.kt
data class TodoListScreen(val filter: String) : Screen {
    @Composable
    override fun Content() {
        val navigator = LocalNavigator.currentOrThrow
        val screenModel = navigator.getScreenModel<TodoListScreenModel>()
        val todos by screenModel.todos.collectAsState()

        LazyColumn {
            items(todos) { todo ->
                TodoRow(
                    todo = todo,
                    onClick = { navigator.push(TodoDetailScreen(todo.id)) },
                )
            }
        }
    }
}

Navigator 컴포저블이 백스택을 들고 있고, navigator.push()/navigator.pop()으로 화면을 오갑니다. 앱 진입점에서는 최초 화면 하나만 넘겨주면 됩니다.

// commonMain/App.kt
@Composable
fun App() {
    MaterialTheme {
        Navigator(TodoListScreen(filter = "all")) { navigator ->
            SlideTransition(navigator)
        }
    }
}

실전 예시: ScreenModel과 Koin 연동

안드로이드의 ViewModel처럼 화면 회전이나 재구성에도 살아남는 상태 관리가 필요해서 ScreenModel을 씁니다. Koin으로 등록하면 getScreenModel()로 주입받을 수 있습니다.

// commonMain/ui/TodoListScreenModel.kt
class TodoListScreenModel(
    private val repository: TodoRepository,
) : ScreenModel {
    private val _todos = MutableStateFlow<List<Todo>>(emptyList())
    val todos: StateFlow<List<Todo>> = _todos.asStateFlow()

    init {
        screenModelScope.launch {
            repository.observeTodos().collect { _todos.value = it }
        }
    }
}
// commonMain/di/UiModule.kt
val uiModule = module {
    factory { TodoListScreenModel(get()) }
    factory { (id: String) -> TodoDetailScreenModel(id, get()) }
}

ScreenModel은 screenModelScope를 제공하므로 viewModelScope처럼 화면이 스택에서 제거될 때 자동으로 코루틴이 취소됩니다. 안드로이드 ViewModel과 달리 안드로이드/iOS 양쪽에서 동일한 클래스를 그대로 씁니다.

Screen을 데이터 클래스로 만들 때 주의할 점

TodoDetailScreen(val todoId: String, val onSaved: () -> Unit)처럼 콜백 람다를 Screen의 프로퍼티로 넣었다가 안드로이드 릴리즈 빌드에서 화면 복원 시 다음 에러를 만났습니다.

java.io.NotSerializableException: com.todoapp.ui.TodoDetailScreen$$Lambda$12

안드로이드에서 Voyager는 백스택을 Bundle에 저장하기 위해 Screen 구현체를 java.io.Serializable로 직렬화합니다. 데이터 클래스 자체는 자동으로 Serializable이 되지만, 프로퍼티로 들고 있는 람다는 직렬화 대상이 아니라서 프로세스가 재생성될 때 복원에 실패합니다.

해결 방법은 Screen에는 ID 같은 원시 값만 두고, 콜백이 필요하면 ScreenModel 안에서 상위 계층(예: 이벤트를 구독하는 Navigator)과 통신하도록 바꾸는 것이었습니다.

// 수정 전 - 콜백을 Screen 프로퍼티로 전달
data class TodoDetailScreen(val todoId: String, val onSaved: () -> Unit) : Screen

// 수정 후 - Screen은 ID만 들고, 저장 완료는 ScreenModel의 이벤트로 처리
data class TodoDetailScreen(val todoId: String) : Screen {
    @Composable
    override fun Content() {
        val navigator = LocalNavigator.currentOrThrow
        val screenModel = navigator.getScreenModel<TodoDetailScreenModel> { parametersOf(todoId) }

        LaunchedEffect(Unit) {
            screenModel.saved.collect { navigator.pop() }
        }

        val state by screenModel.state.collectAsState()
        Column(modifier = Modifier.padding(16.dp)) {
            TextField(value = state.title, onValueChange = screenModel::onTitleChanged)
            Button(onClick = screenModel::onSaveClick) { Text("저장") }
        }
    }
}

장단점 정리

  • 장점: commonMain에 화면 전환과 백스택 관리 코드를 한 번만 작성하면 됩니다. ScreenModel이 screenModelScope를 기본 제공해서 별도로 코루틴 생명주기를 관리할 필요가 없고, Koin 연동도 공식 모듈로 바로 지원됩니다.
  • 단점: 안드로이드에서는 백스택 복원을 위해 Screen이 직렬화 가능해야 한다는 제약이 암묵적으로 걸립니다. Navigation-Compose에 익숙한 상태에서 넘어오면 이 부분에서 한 번은 걸리게 됩니다. 커뮤니티 라이브러리라 Jetpack 공식 라이브러리만큼 마이그레이션 가이드가 풍부하지는 않습니다.

Todo 앱처럼 화면 개수가 적고 백스택 구조가 단순한 프로젝트에서는 도입 비용이 크지 않았습니다. iOS 타겟을 처음부터 염두에 두고 있다면 화면 전환 로직도 Ktor 네트워킹 레이어와 마찬가지로 commonMain에 두는 편이 나중에 다시 나누는 것보다 수월합니다.