아티클 목록으로 가기

SnapshotFlowManager: Compose가 여러 스냅샷 플로우 간에 관찰 인프라를 공유하는 방법

skydovesJaewoong Eum (skydoves)||11분 소요

SnapshotFlowManager: Compose가 여러 스냅샷 플로우 간에 관찰 인프라를 공유하는 방법

Compose의 snapshotFlow는 스냅샷 상태 읽기를 Kotlin Flow로 변환합니다. snapshotFlow를 호출할 때마다 스냅샷 시스템에 자체 apply observer를 등록하고, 새로운 방출(emission)을 트리거해야 하는 상태 변경을 감시합니다. 앱에서 수십 개의 스냅샷 플로우를 사용하면, 각 플로우가 독립적으로 apply 알림을 등록하고 처리하게 됩니다. 이는 N개의 플로우가 N개의 observer를 생성하며, 각 observer가 동일한 변경 객체 집합을 순회하면서 자신이 감시하는 상태가 변경되었는지 확인해야 한다는 뜻입니다. 새로 도입된 SnapshotFlowManager는 여러 플로우 간에 단일 apply observer를 공유함으로써, 관찰 계층에서 발생하는 중복 처리를 줄여 줍니다.

이 글에서는 snapshotFlow의 내부 동작 원리, 플로우별 apply observer가 오버헤드를 발생시키는 이유, SnapshotFlowManager가 여러 플로우 간에 관찰 인프라를 공유하는 방식, 단일 구독에서 다중 구독 백킹(backing)으로의 자동 승격(auto-promotion) 메커니즘, 그리고 다수의 상태를 관찰하는 ViewModel에서의 실용적인 활용 패턴을 살펴보겠습니다.

근본적인 문제: 플로우별 관찰 오버헤드

다수의 스냅샷 상태를 관찰하는 ViewModel을 생각해 보겠습니다.

class DashboardViewModel : ViewModel() {
    private val userState = mutableStateOf(User.empty())
    private val notifications = mutableStateOf(emptyList<Notification>())
    private val settings = mutableStateOf(Settings.default())
    private val networkStatus = mutableStateOf(NetworkStatus.Connected)

    val userFlow = snapshotFlow { userState.value }
    val notificationsFlow = snapshotFlow { notifications.value }
    val settingsFlow = snapshotFlow { settings.value }
    val networkFlow = snapshotFlow { networkStatus.value }
}

snapshotFlow 호출은 자체적으로 내부 SnapshotFlowManager를 생성하며, 각 매니저는 Snapshot.registerApplyObserver를 통해 자신만의 apply observer를 등록합니다. 애플리케이션 어디에서든 스냅샷이 적용(apply)될 때마다 네 개의 observer가 모두 실행됩니다. 각 observer는 변경된 객체 전체 집합을 전달받아, 자신이 감시 중인 상태가 그 집합에 포함되어 있는지 확인해야 합니다. 네 개 정도라면 충분히 감당할 수 있지만, 복잡한 화면에서 서로 다른 상태를 관찰하는 플로우가 20개, 30개로 늘어나면, 매번 스냅샷이 적용될 때마다 독립된 observer들이 실행되는 데 따른 오버헤드가 체감할 수 있는 수준이 됩니다.

핵심은 이러한 플로우가 대부분 동일한 스레드(메인 스레드 또는 단일 코루틴 디스패처)에서 수집된다는 점입니다. 독립적인 관찰 인프라가 필요하지 않습니다. SnapshotFlowManager는 여러 플로우가 단일 apply observer를 공유하여 모든 플로우의 알림을 한꺼번에 처리할 수 있게 함으로써 이 문제를 해결합니다.

snapshotFlow의 내부 동작 원리

매니저를 살펴보기 전에, 먼저 snapshotFlow가 내부적으로 어떻게 동작하는지 이해하는 것이 도움이 됩니다. 내부 구현은 snapshotFlowImpl에 위치하며, 단순한 루프를 기반으로 콜드 Flow를 구축합니다. 이 플로우는 스냅샷을 생성하고, 그 안에서 사용자 블록을 실행하여 어떤 상태 객체가 읽혔는지 기록한 뒤, 결과를 방출합니다. 그런 다음 해당 상태 객체 중 하나가 변경되었다는 알림을 기다렸다가, 같은 과정을 반복합니다.

snapshotFlowImpl의 코드를 살펴보면, 매니저를 확인(resolve)하고 변경 알림을 위한 conflated 채널을 설정하는 콜드 플로우를 생성하는 것을 확인할 수 있습니다.

private fun <T> snapshotFlowImpl(
    externalManager: SnapshotFlowManager?,
    block: () -> T
): Flow<T> = flow {
    val manager = externalManager ?: SnapshotFlowManager()
    val needToRerunBlock = Channel<Unit>(1)

    try {
        var lastValue = manager.runAndWatch(needToRerunBlock, block)
        emit(lastValue)

최초의 runAndWatch 호출은 읽기 전용 스냅샷 내에서 블록을 실행하고, 어떤 상태 객체가 읽혔는지 기록한 뒤, 첫 번째 결과를 방출합니다. 이후 플로우는 변경 알림을 기다리는 루프에 진입합니다.

        while (true) {
            needToRerunBlock.receive()
            val newValue = manager.runAndWatch(needToRerunBlock, block)
            if (newValue != lastValue) {
                lastValue = newValue
                emit(newValue)
            }
        }
    } finally {
        manager.reportSnapshotFlowCancellation(needToRerunBlock)
        if (externalManager == null) {
            manager.dispose()
        }
    }
}

플로우는 다음 단계를 거쳐 진행됩니다.

  1. 매니저 확인(resolution): 외부 매니저가 제공되지 않으면 새로 생성합니다. 매니저 인자 없이 snapshotFlow { ... }를 호출할 때의 기본 동작입니다.
  2. 최초 방출: runAndWatch가 읽기 전용 스냅샷 내에서 블록을 실행하고, 읽힌 상태 객체를 기록하며, 해당 객체가 변경될 때 알림을 받도록 needToRerunBlock 채널을 구독합니다.
  3. 변경 루프: 플로우는 needToRerunBlock.receive()에서 일시 중단됩니다. 감시 중인 상태 객체가 변경되면 apply observer가 이 채널에 Unit을 전송하여 플로우를 깨웁니다. 이후 블록을 다시 실행하고, 새로운 의존성 집합을 기록하며, 결과가 이전 방출 값과 다를 때만 방출합니다.
  4. 정리(cleanup): 취소 시 플로우는 자신의 채널을 매니저에 보고하여 구독을 정리할 수 있게 합니다. 매니저가 내부적으로 생성된 것이라면 함께 해제(dispose)됩니다.

finally 블록의 if (externalManager == null) 검사에 주목하세요. 매니저가 외부에서 제공된 경우, 다른 플로우가 아직 사용 중일 수 있으므로 플로우가 매니저를 해제하지 않습니다. 바로 이 점 덕분에 공유 매니저를 사용할 수 있습니다.

표준 snapshotFlow: 내부 매니저 생성

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기