Navigation3 환경에서 Retain Presenter 적용 전략

easyhooon·2026년 8월 3일
post-thumbnail

서론

최근 Navigation3 기반 프로젝트에서 ViewModel을 걷어내고 Retain 기반 Presenter 구조를 실험했다.

처음에는 단순히 이렇게 생각했다.

ViewModel 대신 retain으로 Presenter를 화면의 백 스택 생명주기에 맞춰 유지하면 되지 않을까?

그러나 실제로 적용해 보니 생각보다 고민할 지점이 많았다.

  1. retain을 ViewModel 대체재처럼 사용해도 되는가?
  2. Navigation3와 retain은 어떤 생명주기를 가지는가?
  3. 기존 ViewModel의 init { load() } 패턴을 그대로 옮겨도 되는가?
  4. Flow 관찰은 Presenter에서 시작해야 하는가, UI에서 구독될 때 시작해야 하는가?
  5. Presenter를 일반 클래스로 둘 것인가, Composable 함수로 만들 것인가?
  6. Google 공식 문서가 권장하는 Retain Presenter 패턴이 있는가?

이번 글에서는 Retain을 ViewModel의 대체재로 무작정 사용하기보다, Navigation3 환경에서 Retain Presenter를 어떤 기준으로 적용하면 좋을지 정리한다.

Retain API 자체에 대한 설명은 공식 문서와 이전에 작성한 분석 글을 참고하면 이해하는 데 도움이 된다.

본론

본론에 들어가기 전에, Navigation3에서 retain store를 백 스택 엔트리에 연결해 운용하는 기본 구조는 공식 Navigation3 + Retain recipe를 참고하면 좋다.

이 글은 해당 recipe가 설명하는 retained value 운용 방식을 바탕으로 ViewModel과 유사한 Presenter를 구성하는 전략에 가깝다.

Navigation3 공식 문서에는 retain 관련 recipe가 있다.

해당 문서에서는 NavEntryDecorator를 만들어 각 NavEntryRetainedValuesStore를 붙이는 예제를 보여준다.

간단히 말하면 다음과 같은 역할을 한다.

백 스택에 남아 있는 화면의 retained value를 유지하고, 화면이 pop되어 백 스택에서 제거되면 해당 값도 정리한다.

즉 A 화면에서 B 화면으로 이동했을 때 A가 백 스택에 남아 있다면 A의 retained value는 유지된다.

반대로 B가 pop되어 백 스택에서 제거되면 B의 retained value도 함께 제거된다.

이 생명주기는 ViewModel의 NavBackStackEntry 스코프와 꽤 비슷해 보인다.

하지만 공식 retain recipe는 여기까지만 설명한다.

ViewModel을 대체하는 Presenter 구조나 코루틴 스코프, Flow 관찰, 초기 데이터 로딩 전략까지는 다루지 않는다.

오히려 Navigation3에서 ViewModel을 사용하고 싶다면 별도의 공식 방식이 있다.

rememberViewModelStoreNavEntryDecorator()

현재 공식 문서의 범위는 다음처럼 나뉜다.

  1. Navigation3 + ViewModel
    • lifecycle-viewmodel-navigation3 기반 recipe 제공
  2. Navigation3 + Retain
    • 백 스택에 연결된 retained value recipe 제공
  3. Navigation3 + Retain Presenter
    • 공식 아키텍처 recipe는 아직 제공되지 않음

따라서 Retain Presenter는 "공식이 이렇게 사용하라고 제시한 패턴"이라기보다 Retain API를 이용해 ViewModel과 유사한 state holder를 직접 구성하는 영역에 가깝다.

2026년 8월 기준으로 State lifespans in Compose 문서도 retain을 ViewModel과 유사한 아키텍처 컴포넌트를 만드는 기반으로 사용할 수 있다고 설명한다. 다만 코루틴과 saved state 지원은 기본으로 제공하지 않으며, 구체적인 컴포넌트 설계는 문서의 범위를 벗어난다고 명시한다. 공식 문서가 가능성은 열어 두었지만 Presenter 아키텍처까지 정답으로 제시한 것은 아니다.

Retain을 어디까지 믿을 수 있을까?

Retain은 구성 변경을 거쳐도 살아남으며, Navigation3 백 스택에 엔트리가 남아 있는 동안 값을 유지할 수 있다.

하지만 이것이 곧 ViewModel과 동일하다는 뜻은 아니다.

retain은 기본적으로 값을 일정 수명 동안 유지하는 API다.

다음처럼 ViewModel이 제공하던 기능까지 자동으로 제공하지는 않는다.

  1. viewModelScope
  2. onCleared()
  3. SavedStateHandle
  4. 프로세스 종료 후 복구
  5. DI 프레임워크의 ViewModel factory 연동

따라서 Retain Presenter를 사용하려면 최소한 다음 책임을 직접 정의해야 한다.

abstract class RetainedPresenter {
    protected val presenterScope =
        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    open fun close() {
        presenterScope.cancel()
    }
}

Navigation3 엔트리가 pop되어 retained value가 retire될 때는 close()를 호출해 스코프를 정리한다.

@Composable
inline fun <reified T : RetainedPresenter> rememberRetainedPresenter(
    vararg keys: Any?,
    crossinline factory: () -> T,
): T {
    val presenter = retain(*keys) { factory() }

    RetainedEffect(presenter) {
        onRetire { presenter.close() }
    }

    return presenter
}

여기서 중요한 점은 retain { ... } 안에서 사이드 이펙트를 시작하지 않는 것이다.

init에서 load()하지 않기

ViewModel을 사용할 때 흔히 다음과 같은 코드를 작성한다.

class HomeViewModel(
    private val repository: Repository,
) : ViewModel() {

    init {
        load()
        observe()
    }
}

ViewModel에서는 익숙한 패턴이지만, Retain Presenter에 그대로 옮기면 문제가 생긴다.

class HomePresenter(
    private val repository: Repository,
) : RetainedPresenter() {

    init {
        load()
        observe()
    }
}

이 코드는 Presenter가 생성되는 순간 사이드 이펙트를 시작한다.

그런데 retain { factory.create() }의 계산은 컴포지션 도중 실행된다.

공식 문서에서도 rememberretain의 계산 람다 또는 생성자에서 사이드 이펙트를 시작하지 말라고 설명한다.

그래서 처음에는 onStart() 같은 함수를 만들어 RetainedEffect에서 호출하는 방식을 생각했다.

RetainedEffect(presenter) {
    presenter.start()
    onRetire { presenter.close() }
}

하지만 이 방식도 완전히 만족스럽지는 않았다.

onStart()는 UI가 화면 상태를 실제로 구독하는지와 관계없이, 화면이 컴포지션에 들어오자마자 실행된다.

여기서 eager는 "필요해질 때까지 기다리지 않고 먼저 실행한다"는 뜻이다. 이 문맥에서는 UI가 화면 상태를 구독하기 전인데도 화면 진입만으로 작업이 시작되는 경우를 가리킨다.

필요해질 때까지 작업을 미루는 지연 관찰이라기보다, 실행 시점만 init에서 화면 진입 시점으로 옮긴 시작 함수에 가깝다.

지연 관찰(Lazy Observation)

ViewModel에서도 init { load() }LaunchedEffect { load() } 모두 미묘하다는 이야기가 있다.

대안으로 자주 언급되는 방식은 지연 관찰이다.

핵심은 다음과 같다.

UI가 화면 상태를 구독하기 전에는 데이터를 불러오지 않는다. 데이터 로딩과 Flow 수집은 실제 구독이 시작될 때 함께 시작한다.

Retain Presenter에서도 같은 방향을 적용할 수 있다.

protected fun <T> Flow<T>.stateInWhileSubscribed(
    initialValue: T,
): StateFlow<T> =
    stateIn(
        scope = presenterScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = initialValue,
    )

Composable Presenter를 선택한다면 repository의 Flow를 StateFlow로 만들고 present()에서 필요한 Flow를 구독할 수 있다.

class HomePresenter(
    private val repository: TripRepository,
) : RetainedPresenter() {

    val uiState: StateFlow<HomeUiState>
        field = MutableStateFlow<HomeUiState>(HomeUiState.Loading)

    private val tripsEffect = repository.myTripsFlow
        .onEach { trips ->
            uiState.update {
                HomeUiState.Success(trips = trips.toImmutableList())
            }
        }
        .stateInWhileSubscribed(emptyList())

    @Composable
    fun present(): HomeUiState {
        tripsEffect.collectAsStateWithLifecycle()
        return uiState.value
    }
}

이렇게 하면 Presenter가 retain으로 생성되더라도 실제 upstream Flow는 present()가 호출되고 collectAsStateWithLifecycle()로 구독될 때 시작된다.

화면이 생명주기상 비활성 상태가 되면 수집이 멈추고, SharingStarted.WhileSubscribed(5_000) 정책에 따라 upstream도 중단된다.

collectAsStateWithLifecycle은 어디에 있어야 할까?

처음에는 RetainedPresenter 기반 클래스에 다음과 같은 도우미 함수를 둘 수도 있다고 생각했다.

@Composable
protected fun StateFlow<*>.collectAsPresentEffect() {
    collectAsStateWithLifecycle()
}

하지만 이렇게 하면 책임이 섞인다.

Flow를 hot flow인 StateFlow로 변환하는 것은 Presenter의 책임일 수 있다.

하지만 collectAsStateWithLifecycle()은 Compose + Lifecycle 구독 API다.

따라서 기반 Presenter까지 lifecycle-runtime-compose에 의존할 필요는 없다.

최종적으로는 다음처럼 분리하는 쪽이 낫다고 판단했다.

  1. RetainedPresenter
    • 코루틴 스코프 제공
    • close() 제공
    • stateInWhileSubscribed() 제공
  2. rememberRetainedPresenter
    • retain {}으로 Presenter 보존
    • RetainedEffect.onRetire에서 정리
  3. Composable Presenter의 present()
    • 필요한 effect Flow를 collectAsStateWithLifecycle()로 구독
    • 현재 UI state 반환

이렇게 하면 RetainedPresenter 기반 클래스는 Compose 생명주기에 맞춘 구독 책임에서 벗어나고, Composable Presenter를 선택한 화면만 실제 구독 API를 사용한다.

Presenter를 Composable하게 만들 것인가?

Retain Presenter를 적용하면서 가장 크게 고민한 지점은 Presenter의 API 형태였다.

첫 번째 선택지는 Presenter를 일반 클래스로 유지하는 것이다.

class HomePresenter(
    private val repository: TripRepository,
) : RetainedPresenter() {

    val uiState: StateFlow<HomeUiState>
        field = MutableStateFlow<HomeUiState>(HomeUiState.Loading)

    fun onAction(action: HomeUiAction) {
        // ...
    }
}

그리고 화면에서는 기존 ViewModel을 사용할 때처럼 StateFlow를 구독한다.

@Composable
fun HomeRoute(
    presenter: HomePresenter,
) {
    val uiState by presenter.uiState.collectAsStateWithLifecycle()

    HomeScreen(
        uiState = uiState,
        onAction = presenter::onAction,
    )
}

이 방식은 기존 ViewModel 구조와 가장 비슷하다.

Presenter는 Compose Runtime에 거의 의존하지 않고 UI는 Presenter가 노출한 StateFlow를 구독한다.

마이그레이션 비용도 상대적으로 낮다. ViewModel.uiStatePresenter.uiState로 바꾸고 viewModelScope를 Presenter의 스코프로 대체하는 식으로 진행할 수 있기 때문이다.

다만 초기 로드나 Flow 관찰을 어디에서 시작할지는 여전히 모호하다.

init에서 시작하면 ViewModel의 init 패턴을 그대로 옮기는 셈이고, Route의 LaunchedEffect에서 시작하면 state holder 바깥에 시작 트리거가 생긴다.

두 번째 선택지는 Circuit처럼 Presenter의 present() 함수를 Composable로 만드는 것이다.

class HomePresenter(
    private val repository: TripRepository,
) : RetainedPresenter() {

    val uiState: StateFlow<HomeUiState>
        field = MutableStateFlow<HomeUiState>(HomeUiState.Loading)

    private val tripsEffect = repository.myTripsFlow
        .onEach { trips ->
            uiState.update {
                HomeUiState.Success(trips = trips.toImmutableList())
            }
        }
        .stateInWhileSubscribed(emptyList())

    @Composable
    fun present(): HomeUiState {
        tripsEffect.collectAsStateWithLifecycle()
        return uiState.value
    }
}

이 방식은 UI가 실제로 present()를 호출하고 effect Flow를 구독할 때 관찰을 시작할 수 있어 지연 관찰을 표현하기 쉽다.

화면도 state를 받고 이벤트를 전달하는 형태로 단순해진다.

반면 Presenter가 Compose Runtime과 lifecycle-runtime-compose에 의존한다는 트레이드오프가 생긴다.

Presenter를 순수 Kotlin state holder로 유지하고 싶다면 이 의존성이 부담스러울 수 있다.

현재로서는 두 방식 중 하나를 공식 권장 사례라고 보기는 어렵다.

Google의 Retain 문서는 retained value와 Navigation3 연동을 설명하지만, ViewModel과 유사한 Presenter 아키텍처까지 제시하지는 않는다.

따라서 이 선택은 공식 정답이라기보다는 프로젝트가 어떤 철학을 택할지의 문제에 가깝다.

일반 클래스 Presenter는 ViewModel을 제거하기 위한 보수적인 마이그레이션 경로에 가깝고, Composable Presenter는 Circuit에 가까운 상태 관리 모델을 실험하는 방향이라고 느꼈다.

초기 1회 로드는 어떻게 할까?

모든 작업을 repository Flow로 표현할 수 있으면 좋겠지만 실제 앱에서는 화면에 진입한 뒤 API를 한 번 호출해야 하는 경우도 많다.

예를 들면 다음과 같은 작업이다.

  1. 공지 상세 조회
  2. 초대장 상세 조회
  3. 프로필 최초 조회
  4. 국가 목록 최초 조회

이때는 retainedPresentEffect 같은 도우미 함수를 둘 수 있다.

protected fun retainedPresentEffect(
    block: suspend () -> Unit,
): StateFlow<Unit> {
    var hasRun = false

    return flow {
        if (!hasRun) {
            hasRun = true
            block()
        }
        emit(Unit)
    }.stateInWhileSubscribed(Unit)
}

사용은 다음처럼 한다.

class NoticeDetailPresenter(
    private val noticeId: Long,
    private val noticeRepository: NoticeRepository,
) : RetainedPresenter() {

    val uiState: StateFlow<NoticeDetailUiState>
        field = MutableStateFlow(NoticeDetailUiState())

    private val presentEffect = retainedPresentEffect {
        loadNoticeDetail()
    }

    @Composable
    fun present(): NoticeDetailUiState {
        presentEffect.collectAsStateWithLifecycle()
        return uiState.value
    }

    private fun loadNoticeDetail() {
        presenterScope.launch {
            // query
        }
    }
}

이 방식에서는 Presenter가 생성될 때가 아니라 present()가 구독될 때 초기 작업이 시작된다.

다만 지속 관찰 Flow에 launchIn(presenterScope)를 호출하면 UI의 구독 여부와 관계없이 곧바로 수집을 시작하고, presenterScope가 끝날 때까지 계속될 수 있다.

따라서 지속 관찰은 stateInWhileSubscribed, 최초 한 번의 로드는 retainedPresentEffect로 구분하는 편이 좋다.

인자는 어떻게 전달할까?

Navigation3에서는 화면을 표현하는 key를 사용한다.

data class SettlementDetailNavKey(
    val tripId: Long,
    val settlementId: Long,
) : NavKey

Presenter가 해당 인자를 필요로 한다면 factory로 전달한다.

val presenter = rememberRetainedPresenter(
    key.tripId,
    key.settlementId,
) {
    presenterFactory.create(
        tripId = key.tripId,
        settlementId = key.settlementId,
    )
}

중요한 점은 retain의 key에도 화면 인자를 포함하는 것이다.

인자가 바뀌면 다른 Presenter 인스턴스가 필요하기 때문이다.

DI는 Presenter 인스턴스가 아니라 Factory를 주입한다

Metro를 사용한다면 Presenter 인스턴스 자체를 graph에 직접 등록하고 싶을 수 있다.

하지만 Presenter는 화면 엔트리의 생명주기를 따라야 한다.

따라서 ActivityRetained graph나 Singleton graph에 Presenter 인스턴스를 직접 주입하면 생명주기가 어긋난다.

EntryProvider에는 factory를 주입하고 실제 Presenter 인스턴스는 rememberRetainedPresenter 안에서 생성한다.

@Inject
class SettlementPresenterFactory(
    private val repository: SettlementRepository,
) {
    fun create(tripId: Long): SettlementPresenter {
        return SettlementPresenter(
            tripId = tripId,
            repository = repository,
        )
    }
}
val presenter = rememberRetainedPresenter(tripId) {
    presenterFactory.create(tripId)
}

이렇게 하면 의존성은 DI에서 주입받고 Presenter 인스턴스의 생명주기는 Navigation3와 Retain이 관리한다.

정리

Retain은 ViewModel을 그대로 대체하는 완성형 아키텍처가 아니다.

공식 문서 기준으로 Retain은 retained value를 만들기 위한 API에 가깝고, Navigation3 recipe도 백 스택에 연결된 retained value store를 보여주는 수준이다.

따라서 Retain Presenter를 적용하려면 다음 기준을 명확히 가져가는 것이 좋다.

  1. retain {}에서는 Presenter 인스턴스만 생성한다.
  2. Presenter 생성자 또는 init에서 사이드 이펙트를 시작하지 않는다.
  3. RetainedEffect는 retire 시 정리만 담당한다.
  4. 지속 관찰 Flow는 stateIn(SharingStarted.WhileSubscribed)로 만든다.
  5. Composable Presenter를 택한다면 present()에서 collectAsStateWithLifecycle()로 구독한다.
  6. 일반 클래스 Presenter를 택한다면 Screen 또는 Route에서 StateFlow를 구독하되, 초기 로드 시작 지점을 별도로 설계한다.
  7. 최초 한 번의 로드는 Presenter 생성 시점이 아니라 UI 구독 시점에 맞춰 실행하는 방향을 우선 검토한다.
  8. 프로세스 종료 후 복구가 필요한 상태에는 Retain이 아닌 별도의 저장 전략을 사용한다.
  9. DI는 Presenter 인스턴스가 아니라 Factory를 주입한다.

개인적으로는 Composable Presenter까지 적용한 Retain Presenter 구조가 Circuit Presenter와 닮은 부분이 있다고 느꼈다.

다만 Circuit은 Presenter 자체가 프레임워크의 설계 철학 안에 있고, Retain은 더 낮은 수준의 primitive에 가깝다.

따라서 Retain을 사용한다면 "공식이 제시한 Presenter 아키텍처"라고 말하기보다 "Retain의 생명주기를 이용해 ViewModel과 유사한 state holder를 구성한 프로젝트 차원의 전략"이라고 보는 편이 더 정확하다.

이 글의 초고를 작성할 당시에는 Google 공식 문서에서 Retain Presenter의 구체적인 권장 사례를 찾기 어려웠다. 이후 실제 프로젝트에서 Retain을 적극적으로 사용한 사례를 발견해 아래 내용을 덧붙인다.

업데이트: DroidKaigi 2026 앱이 보여준 Retain 청사진

아래 내용은 2026년 8월 18일, DroidKaigi 2026 앱의 최신 커밋을 기준으로 확인했다. 이 프로젝트는 Google의 공식 샘플이 아니며, 당시 androidx.compose.runtime:runtime-retain:1.12.0-alpha03을 사용하고 있었다. 따라서 공식 권장안이라기보다 Android 커뮤니티의 실전 사례로 보는 편이 적절하다.

초고를 쓸 때만 해도 Retain을 Presenter 아키텍처에 적용한 규모 있는 공개 프로젝트를 찾기 어려웠다. 이후 DroidKaigi 2026 컨퍼런스 앱을 살펴보니, Navigation3와 Retain을 결합한 꽤 구체적인 청사진을 확인할 수 있었다.

이 앱의 접근은 앞에서 살펴본 RetainedPresenter 클래스와 조금 다르다. Presenter 객체와 전용 코루틴 스코프를 통째로 유지하기보다 다음 요소를 각각 필요한 수명에 맞춰 유지한다.

  1. NavEntry의 retained value store
  2. 화면 단위 DI graph
  3. Presenter가 소유한 화면 로컬 상태
  4. 아직 소비되지 않은 action과 action result

1. NavEntry마다 retained value store를 제공한다

앱의 NavDisplay에는 retainNavEntryDecorator()가 등록되어 있다.

NavDisplay(
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        retainNavEntryDecorator(),
        rememberSnackbarNavEntryDecorator(),
    ),
)

retainNavEntryDecorator()RetainedValuesStoreRegistry의 child store를 entry.contentKey별로 제공하고, 엔트리가 pop되면 clearChild로 해당 store를 비운다.

@Composable
fun <T : Any> retainNavEntryDecorator(): NavEntryDecorator<T> {
    val registry = retainRetainedValuesStoreRegistry()
    return remember(registry) {
        NavEntryDecorator(onPop = registry::clearChild) { entry ->
            registry.LocalRetainedValuesStoreProvider(entry.contentKey) {
                entry.Content()
            }
        }
    }
}

이 구조 덕분에 화면 내부의 retain 호출은 해당 백 스택 엔트리의 생명주기를 따른다. 글 앞부분에서 설명한 Navigation3 + Retain recipe를 앱 전체의 기반으로 구현한 셈이다.

2. Presenter 인스턴스 대신 화면 graph를 유지한다

NavEntryProvider에는 화면 graph의 factory가 주입된다. 실제 graph는 엔트리 콘텐츠 안에서 retain으로 생성한다.

entry<TimetableItemDetailNavKey> { key ->
    val graph = retain(key) {
        screenGraphFactory.createTimetableItemDetailScreenGraph(key.id)
    }
    context(graph.screenContext) {
        TimetableItemDetailScreenRoot(
            onNavigateBack = appNavigator::back,
        )
    }
}

화면 인자가 있는 경우 keyretain의 key로 사용한다는 점도 눈에 띈다. 인자가 바뀌면 새로운 graph가 필요하므로, 앞에서 정리한 "화면 인자를 retain key에 포함한다"는 기준과도 맞닿아 있다.

여기서 graph는 단순한 Presenter factory가 아니다. Metro의 화면 스코프와 ScreenContext, PresenterContext, query·mutation 의존성, 화면 navigator를 한데 묶는다. 즉 이 앱은 Presenter 인스턴스보다 화면의 의존성 경계 전체를 백 스택 엔트리에 맞춰 유지한다.

3. Presenter는 클래스가 아니라 Composable 함수다

DroidKaigi 앱의 Presenter는 대부분 UI state를 반환하는 Composable 함수다. 예를 들어 시간표 Presenter는 선택된 날짜만 retain으로 보존하고, 입력 데이터와 로컬 상태를 조합해 UiState를 반환한다.

@Composable
context(presenterContext: TimetablePresenterContext)
fun timetableScreenPresenter(
    screenChannel: ScreenChannel<TimetableScreenAction, TimetableScreenActionResult>,
    timetable: Timetable,
): TimetableScreenUiState {
    var selectedDay by retain {
        mutableStateOf(DroidKaigi2026Day.Day1)
    }

    ActionEffect(screenChannel) { action ->
        when (action) {
            is TimetableScreenAction.SelectDay -> selectedDay = action.day
            // ...
        }
    }

    return TimetableScreenUiState(
        day = selectedDay,
        // ...
    )
}

중요한 차이는 클래스 형태의 Presenter 자체를 유지하지 않는다는 점이다. 유지할 필요가 있는 화면 상태만 retain { mutableStateOf(...) }로 남긴다. 이 방식은 별도의 presenterScopeclose()를 만들지 않아도 되며, 효과의 시작과 종료는 Compose의 효과 API가 관리한다.

초고에서 고민했던 두 선택지 가운데 Composable Presenter 쪽에 가까운 실제 사례라고 볼 수 있다. 다만 "Presenter 객체를 유지한다"기보다 "Composable Presenter가 사용하는 최소 상태를 유지한다"는 표현이 더 정확하다.

4. 데이터 로딩과 UI state 계산을 분리한다

이 앱에서는 Presenter가 repository Flow를 직접 StateFlow로 바꾸고 구독하지 않는다. ScreenRoot가 Soil의 rememberQueryrememberSubscriptionSoilDataBoundary에 연결하고, 준비된 데이터를 Presenter에 전달한다.

SoilDataBoundary(
    state1 = rememberQuery(screenContext.timetableQueryKey),
    state2 = rememberSubscription(
        screenContext.favoriteTimetableIdsSubscriptionKey,
    ),
) { timetable, favoriteIds ->
    val uiState = context(screenContext.presenterContext) {
        timetableScreenPresenter(
            screenChannel = screenChannel,
            timetable = timetable.copy(bookmarks = favoriteIds),
        )
    }

    TimetableScreen(uiState = uiState, /* ... */)
}

따라서 초기 로딩과 지속 관찰의 수명은 query 계층과 Compose가 관리하고, Presenter는 이미 준비된 데이터와 사용자 action을 UI state로 변환한다. 이는 앞에서 제안한 stateInWhileSubscribed 방식의 대안이 될 수 있다.

두 방식의 목표는 같다. Presenter 생성자에서 장기 작업을 즉시 시작하지 않고, UI가 컴포지션에 참여할 때 필요한 관찰이 시작되도록 하는 것이다. 차이는 구독 책임을 Presenter 내부에 둘지, 별도의 데이터 경계에 둘지에 있다.

5. ScreenChannel로 통신 경계를 만들고 필요한 만큼만 유지한다

화면과 Presenter 사이의 action/result 전달에는 ScreenChannel을 사용한다. 단순히 Channel 두 개를 노출하지 않고, 화면에서 Presenter로 향하는 Action과 Presenter에서 화면으로 돌아오는 ActionResult를 하나의 타입으로 묶었다.

class ScreenChannel<Action, ActionResult>(
    internal val actions: Channel<Action> = Channel(Channel.BUFFERED),
    internal val results: Channel<ActionResult> = Channel(Channel.BUFFERED),
) {
    context(_: ScreenContext)
    fun send(action: Action) {
        actions.trySend(action)
    }

    context(_: PresenterContext)
    suspend fun emit(result: ActionResult) {
        results.send(result)
    }
}

@Composable
fun <A, R> retainScreenChannel(): ScreenChannel<A, R> =
    retain { ScreenChannel() }

ScreenChannelChannel의 전송 기능을 확장하는 래퍼가 아니다. 화면과 Presenter 사이의 통신 규약을 하나의 타입에 담는다. 화면은 send()로 action을 보내고 ActionResultEffect로 result를 수집한다. Presenter는 ActionEffect로 action을 수집하고 emit()으로 result를 보낸다. context parameter는 각 계층에서 호출할 수 있는 API를 제한한다.

채널 수집은 두 effect 함수가 LaunchedEffect에 연결한다. 덕분에 화면마다 수집 코루틴의 수명을 다시 구현할 필요가 없다. 현재 사용 방식에서는 action과 result마다 소비자가 하나이므로 Channel의 일대일 큐가 잘 맞는다. 소비한 이벤트를 다시 전달하지 않으면서, 아직 처리하지 못한 이벤트는 버퍼에 남길 수 있다. 테스트에서도 같은 action/result 경계를 사용한다.

ScreenChannel이라는 추상화와 Retain은 별개의 선택이다. remember로 생성해도 양방향 프로토콜과 역할 제한은 그대로 유효하다. Retain이 맡는 일은 채널의 정체성과 버퍼를 Navigation3 엔트리 수명에 맞추는 것이다.

엔트리 콘텐츠가 컴포지션에서 일시적으로 빠지면 ActionEffectActionResultEffectLaunchedEffect는 취소된다. 이때 채널까지 새로 만들어지면 아직 소비되지 않은 action이나 result는 이전 채널의 버퍼에 남아 새 화면에서는 접근할 수 없다. retainScreenChannel()은 Retain scope가 유지되는 동안 같은 채널을 돌려주므로, effect가 다시 시작됐을 때 기존 버퍼의 메시지를 이어서 받을 수 있다.

Retain은 수집 코루틴을 살려 두는 장치가 아니다. ScreenChannel 인스턴스와 내부 버퍼를 유지할 뿐, 수집 코루틴은 컴포지션에 맞춰 취소되고 다시 시작된다. 프로세스 종료 후 메시지를 복원하는 영속 큐도 아니며, 백 스택에서 엔트리가 제거되어 Retain scope가 끝나면 함께 폐기된다.

이 사례에서 얻은 결론

DroidKaigi 2026 앱은 "Retain으로 ViewModel을 어떻게 복제할까?"보다 "화면의 각 요소에 어떤 수명이 필요한가?"라는 질문에서 출발한 구조에 가깝다.

이 사례를 보고 나니 Retain Presenter를 설계할 때 반드시 Presenter 클래스 전체를 유지할 필요는 없다는 점이 더 분명해졌다. 선택지는 적어도 두 가지다.

  1. 클래스 기반 Presenter를 retain하고 스코프와 정리 시점을 직접 관리한다.
  2. Presenter를 Composable 함수로 두고 화면 graph, 로컬 상태, 이벤트 채널처럼 실제로 보존해야 할 값만 retain한다.

두 번째 방식은 Compose의 효과 수명과 데이터 로딩 계층을 적극적으로 활용할 수 있는 프로젝트에서 특히 매력적이다. 반면 기존 ViewModel 구조에서 점진적으로 이전하거나 순수 Kotlin Presenter가 필요하다면 첫 번째 방식이 더 현실적일 수 있다.

무엇보다 이 프로젝트는 Retain이 ViewModel의 일대일 대체재라기보다, 화면을 이루는 값마다 적절한 수명을 부여하는 도구라는 점을 잘 보여준다. 공식 권장안은 아니지만 Navigation3 + Retain + Composable Presenter를 실제 규모의 앱에서 어떻게 조합할 수 있는지 보여주는 참고 사례로는 충분히 가치가 있다.

참고 자료

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글