Nav3 Result 전달 방식에 대한 고찰

easyhooon·2026년 3월 6일

Navigation

목록 보기
8/8
post-thumbnail

서두

목록화면 -> 상세화면 -> 수정화면 -> 수정완료 -> 상세화면 복귀 상황에서,
상세 조회 API 호출을 통해 수정된 데이터에 대한 갱신을 구현하는게 다소 비효율적이란 생각이 들었다.

수정 화면내에서 수정 성공시 이미 서버에 반영된 데이터를 갖고 있기 때문이다!

따라서 이번 글에선 Navigation3 을 사용할 때, 화면 간 결과를 어떻게 전달할 수 있는지에 대해 알아보도록 하겠다.

본론

구현에 앞서 구글링을 통해 검색을 먼저 해봤는데, 공식 문서를 통해 이미 구현 방법을 제안하고 있었다!

공식 문서의 방식

ResultEventBus (이벤트 기반)

공식 문서에서 제안하는 방식Channel + Flow 기반의 EventBus 방식이다.

EventBus 는 발행자(sender)와 구독자(receiver)가 서로를 직접 참조하지 않고 버스를 통해 데이터를 주고받는 패턴이다. 결과를 전송하면 해당 결과를 구독 중인 모든 수신자에게 전달된다.

버스(Bus): 컴퓨터 아키텍처에서 여러 컴포넌트가 공유하는 통신 경로를 의미, 실제 버스처럼 여러 정류장(구독자)이 하나의 노선(채널)을 공유하는 것에서 유래

/**
 * [ResultEventBus]를 컴포지션 트리에 제공하기 위한 CompositionLocal 홀더.
 *
 * 사용 예시:
 * ```
 * val resultBus = remember { ResultEventBus() }
 * CompositionLocalProvider(LocalResultEventBus provides resultBus) {
 *     // 하위 컴포저블에서 LocalResultEventBus.current로 접근 가능
 * }
 * ```
 */
object LocalResultEventBus {
    private val LocalResultEventBus: ProvidableCompositionLocal<ResultEventBus?> =
        compositionLocalOf { null }

    /**
     * 현재 컴포지션에서 [ResultEventBus]를 가져온다.
     * [CompositionLocalProvider]로 제공된 적 없으면 예외를 던진다.
     */
    val current: ResultEventBus
        @Composable
        get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided")

    /**
     * [ResultEventBus]를 컴포지션에 제공한다.
     * `CompositionLocalProvider(LocalResultEventBus provides resultBus)` 형태로 사용.
     */
    infix fun provides(
        bus: ResultEventBus
    ): ProvidedValue<ResultEventBus?> {
        return LocalResultEventBus.provides(bus)
    }
}
/**
 * 화면 간 결과를 이벤트 기반으로 전달하는 버스.
 *
 * 내부적으로 key별 [Channel]을 관리한다.
 * 결과를 보내면 해당 key를 구독 중인 모든 수신자에게 전달된다.
 *
 * - 결과 전송: [sendResult]
 * - 결과 수신: [getResultFlow]로 Flow를 collect ([ResultEffect] 사용 권장)
 * - key 기본값: 타입의 클래스명 (T::class.toString())
 */
class ResultEventBus {

    /**
     * key(결과 타입명)와 Channel을 매핑하는 Map.
     * `mutableStateMapOf`를 사용하므로 Map 변경 시 Compose가 재구독한다.
     * → [ResultEffect]의 `LaunchedEffect`가 새 Channel을 감지해 재실행된다.
     */
    val channelMap = mutableStateMapOf<String, Channel<Any?>>()

    /**
     * 주어진 key에 해당하는 Channel을 Flow로 변환해 반환한다.
     * Channel이 없으면 null 반환 (아직 sendResult가 호출되지 않은 상태).
     *
     * @param resultKey 결과를 식별하는 key. 기본값은 T의 클래스명.
     */
    inline fun <reified T> getResultFlow(resultKey: String = T::class.toString()) =
        channelMap[resultKey]?.receiveAsFlow()

    /**
     * 결과를 해당 key의 Channel로 전송한다.
     * Channel이 없으면 새로 생성 후 전송한다.
     *
     * BUFFERED + SUSPEND: 버퍼가 가득 차면 send를 일시 중단(데이터 유실 없음).
     * trySend: suspend 없이 즉시 전송 시도 (버퍼 여유 있으면 성공).
     *
     * @param resultKey 결과를 식별하는 key. 기본값은 T의 클래스명.
     * @param result 전달할 결과값.
     */
    inline fun <reified T> sendResult(resultKey: String = T::class.toString(), result: T) {
        if (!channelMap.contains(resultKey)) {
            channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND)
        }
        channelMap[resultKey]?.trySend(result)
    }

    /**
     * 해당 key의 Channel을 Map에서 제거한다.
     * 더 이상 결과를 수신할 필요가 없을 때 호출해 메모리를 정리한다.
     *
     * @param resultKey 제거할 key. 기본값은 T의 클래스명.
     */
    inline fun <reified T> removeResult(resultKey: String = T::class.toString()) {
        channelMap.remove(resultKey)
    }
}

구현

수신 측에서는 ResultEffect라는 Composable을 통해 Flow를 collect한다.

내부적으로 LaunchedEffect를 사용하며, key로 channelMap[resultKey]를 함께 사용하는 것이 포인트다.
sendResult 호출로 Channel 이 생성되는 시점에 자동으로 재구독하므로,
ResultEffectsendResult보다 먼저 실행되더라도 타이밍 이슈가 없다.

/**
 * 화면 간 결과를 수신하기 위한 Composable Effect.
 *
 * @param resultEventBus 결과를 가져올 [ResultEventBus]. 기본값은 [LocalResultEventBus.current].
 * @param resultKey 이 Effect와 연결할 key. 기본값은 T의 클래스명.
 * @param onResult 결과를 수신했을 때 호출되는 콜백.
 *
 * 사용 예시:
 * ```
 * // 수신 측 (Home 화면) - sendResult가 호출되면 자동으로 람다 실행
 * ResultEffect<Person>(resultBus) { person ->
 *     viewModel.person = person
 * }
 *
 * // 전송 측 (Form 화면) - 결과를 전송하고 화면을 닫는다
 * resultBus.sendResult<Person>(result = person)
 * backStack.removeLastOrNull()
 * ```
 */
@Composable
inline fun <reified T> ResultEffect(
    resultEventBus: ResultEventBus = LocalResultEventBus.current,
    resultKey: String = T::class.toString(),
    crossinline onResult: suspend (T) -> Unit
) {
    LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) {
        resultEventBus.getResultFlow<T>(resultKey)?.collect { result ->
            onResult.invoke(result as T)
        }
    }
}

전달 흐름

  1. 수신 측에서 ResultEffect를 통해 특정 타입의 결과를 구독한다.
  2. 전송 측에서 sendResult() 호출 시 channelMap에 새 Channel이 생성된다.
  3. mutableStateMapOf가 변경을 감지해 LaunchedEffect가 재실행되며 자동으로 재구독한다.
  4. onResult 람다가 실행되어 결과를 처리한다. (예: ViewModel에서 UI 상태 갱신)

전체 코드는 Nav3-recipes 레포지토리 에서 확인할 수 있으며, 직접 빌드해 동작을 검증해볼 수도 있다.

이 방식의 특징

  • Channel을 사용하므로 결과가 여러 번 전달될 수 있는 이벤트 스트림에 적합
  • CompositionLocal로 주입하므로 어느 화면에서든 접근 가능
  • 구현 복잡도가 상대적으로 높다

하지만 이 방식, 단건 결과 전달에는 과하지 않을까?

단순히 "단건 수정 결과를 이전 화면에 넘기는" 용도라면 Channel, Flow, CompositionLocal까지 도입하는 건 코드량과 구현 복잡도 측면에서 필요 이상이라고 생각했다.

채택한 방식

ResultStore (Map 기반)

해당 영상을 참고해서 단순한 Map 기반의 ResultStore 를 구현했다.

다만, 원본은 Compose Multiplatform 기반이라 Hilt를 사용하지 않으며, rememberSaveableSaver를 통해 프로세스 종료 후 복원까지 지원한다. 또한 resultStore를 Route에 직접 파라미터로 전달하는 방식이다.
본 구현에서는 Hilt의 @ActivityRetainedScoped로 생명주기를 관리하고, Route는 콜백만 받도록 해 ResultStore에 대한 직접 의존을 제거했다.

/**
 * 화면 간 결과 전달을 위한 저장소.
 * [Navigator]의 멤버로 선언되어 @ActivityRetainedScoped 생명주기를 따른다.
 *
 * @see <a href="https://youtu.be/cGofLfEBKJo">Navigation 3 - Navigating Back With a Result</a>
 */
class ResultStore {
    private val results = mutableMapOf<Any, Any?>()

    /** key에 해당하는 결과를 반환한다. 없거나 타입 불일치 시 null. */
    @Suppress("UNCHECKED_CAST")
    fun <T> getResult(key: Any): T? = results[key] as? T

    /** 결과를 저장한다. 동일 key로 재저장 시 덮어쓴다. */
    fun <T> setResult(key: Any, value: T) {
        results[key] = value
    }

    /** 저장된 결과를 제거한다. 소비 후 반드시 호출해 중복 소비를 방지한다. */
    fun removeResult(key: Any) {
        results.remove(key)
    }
}

ResultStoreNavigator의 멤버로 선언한다.
Navigator@ActivityRetainedScoped이므로 별도 스코프 없이 동일한 생명주기를 자연스럽게 공유한다.

@ActivityRetainedScoped
class Navigator @Inject constructor(
    val state: NavigationState,
) {
    val resultStore = ResultStore()

    private var topLevelBackStack: TopLevelBackStack? = null
    private var topLevelBaseIndex: Int = -1

    val currentTopLevelKey: NavKey?
        get() = topLevelBackStack?.topLevelKey

    fun navigate(key: NavKey) {
        val topLevel = topLevelBackStack
        if (topLevel != null) {
            topLevel.add(key)
            syncTopLevelEntries()
        } else {
            state.backStack.add(key)
        }
    }
    
    // ...
}

구현

EntryProvider에서 navigator.resultStore를 통해 콜백을 연결한다.
Route는 ResultStore를 직접 알 필요 없이 콜백만 받으면 되므로, Route의 의존성이 깔끔하게 유지된다.


// key는 같은 파일 내 최상위 private const로 선언해 외부에 노출되지 않도록 관리한다.
private const val EDIT_RECORD_RESULT_KEY = "edit_record_result"

@Module
@InstallIn(ActivityRetainedComponent::class)
object LedgerEntryProviderModule {

    @IntoSet
    @Provides
    fun provideLedgerEntry(
        navigator: Navigator,
    ): EntryProviderScope<NavKey>.() -> Unit = {
        
        // ...

        entry<LedgerNavKey.RecordDetail> { key ->
            RecordDetailRoute(
                entry = key.entry,
                navigateBack = { navigator.goBack() },
                navigateToEditRecord = { entry ->
                    navigator.navigateToEditRecord(key.tripId, entry)
                },
                onConsumeEditResult = {
                    // 결과를 가져온 뒤 즉시 제거 (중복 소비 방지)
                    navigator.resultStore.getResult<LedgerEntry>(EDIT_RECORD_RESULT_KEY)?.also {
                        navigator.resultStore.removeResult(EDIT_RECORD_RESULT_KEY)
                    }
                },
            )
        }

        entry<LedgerNavKey.EditRecord> { key ->
            EditRecordRoute(
                tripId = key.tripId,
                entry = key.entry,
                navigateBack = { navigator.goBack() },
                onRecordUpdated = { updatedEntry ->
                    // 수정된 데이터를 저장하고 이전 화면으로 복귀
                    navigator.resultStore.setResult(EDIT_RECORD_RESULT_KEY, updatedEntry)
                    navigator.goBack()
                },
            )
        }
    }
}

수신 측인 RecordDetailRoute에서는 LaunchedEffect(Unit)을 통해 결과를 소비한다.
LaunchedEffect(Unit)은 해당 컴포저블이 컴포지션에 진입하는 시점에 실행되므로,
EditRecord에서 goBack()RecordDetail로 복귀하는 타이밍에 자연스럽게 결과를 가져올 수 있다.

@Composable
fun RecordDetailRoute(
    entry: LedgerEntry,
    onConsumeEditResult: () -> LedgerEntry?,
    ...
) {
    // key로 Unit을 사용하므로 컴포저블이 컴포지션에 처음 진입할 때 단 한 번만 실행된다.
    // EditRecord에서 goBack() 호출 시 RecordDetail이 컴포지션에 재진입하는 시점을 자연스럽게 트리거로 활용한다.
    // 리컴포지션이 발생해도 재실행되지 않으므로 중복 소비 걱정이 없다.
    LaunchedEffect(Unit) {
        onConsumeEditResult()?.let { viewModel.refreshEntry(it) }
    }
    ...
}

// ViewModel
fun refreshEntry(updatedEntry: LedgerEntry) {
    currentEntry = updatedEntry
    uiState.update {
        it.copy(
            name = updatedEntry.name,
            amount = updatedEntry.amount,
            currencyCode = updatedEntry.currencyCode,
            method = updatedEntry.method,
            category = updatedEntry.category,
        )
    }
}

전달 흐름

  1. EditRecord에서 수정 완료 시 setResult() 호출 + goBack()
  2. RecordDetail이 컴포지션에 재진입하며 LaunchedEffect(Unit) 실행
  3. onConsumeEditResult()getResult() + removeResult()
  4. viewModel.refreshEntry()로 UI 갱신

이 방식의 특징

  • 단순한 Map 기반으로 구현 복잡도가 낮다.
  • 결과를 소비한 뒤 즉시 제거하므로 중복 소비 걱정이 없다.
  • Navigator 멤버로 선언해 별도 스코프 없이 @ActivityRetainedScoped 생명주기를 자연스럽게 공유한다.

CompositionLocal vs DI 라이브러리

ResultEventBusCompositionLocalProvider를 통해 의존성을 주입받는 반면,
ResultStore는 Hilt의 @ActivityRetainedScoped로 관리되는 Navigator의 멤버로 DI 라이브러리를 통해 주입된다.

CompositionLocalDI 프레임워크 (Hilt)
범위컴포지션 트리 (UI 레이어)앱/Activity/ViewModel 스코프
접근 방법LocalXxx.current@Inject / hiltViewModel()
생명주기컴포저블이 컴포지션에 있는 동안스코프에 따라 명확히 관리
적합한 대상UI 의존성 (테마, 로케일 등)비즈니스 로직, Repository 등

CompositionLocal은 UI 트리 안에서만 유효한 암묵적 의존성 전달 방식으로, Hilt 같은 DI 프레임워크 없이도 사용할 수 있다는 장점이 있다.

다만 컴포지션 트리 밖에서는 접근할 수 없고, 파라미터로 명시적으로 전달하지 않는 특성상 어디서 값이 변경되는지 추적하기 어려워, 남용하면 데이터 흐름 파악이 힘들어질 수 있다.

쓰라고 만들어 놓은 거니까 써도 되는데, 적당히 써라 ㅇㅇ

결론

ResultEventBusResultStore
구현 복잡도높음낮음
결과 전달 횟수다회 가능1회 (소비 후 제거)
적합한 상황이벤트 스트림, 다:11:1 단건 결과 전달
의존성CompositionLocalNavigator 멤버 (@ActivityRetainedScoped)

1:1 화면 간 단건 결과 전달에는 ResultStore 방식을 사용해도 요구사항을 충분히 만족할 수 있고, 더 단순하다고 생각한다.

앱 전반에 걸쳐 여러 화면에서 결과 전달이 반복된다면 ResultEventBus 방식이 더 적합할 수 있다.

다만, ResultStore 처럼 key-value 기반으로도 여러 화면의 결과를 관리할 수 있고, 각 화면마다 key만 다르게 선언하면 되므로 굳이 이벤트 버스가 필요한 상황이 아니라면 ResultStore로 해결 가능하다.

목록 갱신처럼 여러 항목을 다뤄야 하는 경우엔 API 재호출이 더 명확하고 안전하다. 로컬에서 직접 리스트 항목을 찾아 교체하는 방식은 서버 실제 데이터와 불일치가 생길 여지가 있다.

P.S

사실 이 글을 쓰게 된 계기가 하나 더 있다.

요즘 AI 도구의 도움을 받아 코드를 작성하다 보니, Navigation3 처럼 새로 등장한 기술들을 직접 손으로 짜고 이해하기보다 AI가 만들어준 코드를 그대로 쓰는 경우가 늘었다.

따라서 코드는 동작하지만 '내가 이걸 정말 이해하고 있는가?' 라는 물음에 자신 있게 답하기가 어려웠다.

그래서 의도적으로 블로그를 쓰면서 복습하고, 내 것으로 만들려고 한다.
AI 시대일수록 "왜 이 방식인가" 를 설명할 수 있는 개발자가 되어야 한다고 생각하기 때문이다.

돌아보면 예전엔 더 유려하고 멋진(?) 방식을 선호했던 것 같다.

하지만 이번처럼 단순한 방식을 의도적으로 선택하면서, 단순함이 AI 시대 이전에도 이후에도 코드를 작성할 때 가장 먼저 고려해야 할 덕목이라는 걸 다시 한번 느꼈다.

reference)
https://youtu.be/37Q5yQaT7_I?si=kIeQlhIL7_dgQ-4T
https://github.com/philipplackner/Nav3Guide/tree/5-navigating-back-with-result
https://developer.android.com/guide/navigation/navigation-3/recipes/results-event
https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/event
https://speakerdeck.com/l2hyunwoo/seoneonhyeong-uieseoyi-sangtaegwanri?slide=21

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

5개의 댓글

comment-user-thumbnail
2026년 3월 12일

잘 읽었습니다. 질문이 있는데요, Navigation3를 안써봐서 그런데 그냥 NavBackStackEntry savedStateHandle에 저장하면 안되는건가요? 번들 직렬화를 회피하는 용도로 이렇게 구현하는건가요?

4개의 답글