Jetpack Compose에서 화면 간 결과 전달하기

aufcl4858·2025년 2월 28일
post-thumbnail

안드로이드 앱 개발 패러다임이 Multi-Activity에서 Single-Activity로 변화함에 따라, 화면 간 데이터 전달 방식도 함께 변화하고 있습니다. 특히 Jetpack Compose와 Navigation Component를 사용하면서 화면 간 이동이 더욱 간편해졌지만, 이전에 startActivityForResult()로 쉽게 처리하던 화면 간 결과 전달 패턴을 대체할 솔루션이 필요해졌습니다.

기존 방식의 한계

Single-Activity 아키텍처에서는 전통적인 startActivityForResult()onActivityResult() 패턴을 사용할 수 없게 되었습니다. 대신 다음과 같은 방법들이 주로 사용되었습니다:

  1. SavedStateHandle: Destination 간 데이터를 공유하기 위해 Navigation의 SavedStateHandle을 사용
  2. SharedViewModel: 여러 화면에서 접근 가능한 ViewModel을 통해 데이터 공유
  3. Flow, LiveData 등의 Observable 패턴: 데이터 변화를 구독하여 처리

하지만 이러한 방식들은 몇 가지 단점이 있습니다:

  • 코드가 복잡해지고 관리가 어려워짐
  • 일회성 이벤트 처리에 적합하지 않음
  • 화면 간 결합도가 높아짐
  • 데이터 생명주기 관리가 어려움

이러한 문제를 해결하기 위해, ActivityResult 패턴과 유사한 방식으로 Jetpack Compose의 Navigation Component에서 화면 간 결과를 전달할 수 있는 확장 함수를 구현해 보았습니다.

// 콜백을 관리하는 Registry
object NavCallbackRegistry {
    private val callbackMap = mutableMapOf<String, Any>()

    fun <T> registerCallback(key: String, callback: NavResultCallback<T>) {
        callbackMap[key] = callback
    }

    @Suppress("UNCHECKED_CAST")
    fun <T> getCallback(key: String): NavResultCallback<T>? {
        val callBack = callbackMap[key] as? NavResultCallback<T>
        unregisterCallback(key)
        return callBack
    }

    private fun unregisterCallback(key: String) {
        callbackMap.remove(key)
    }
}

typealias NavResultCallback<T> = (T) -> Unit

private fun generateNavResultCallbackKey(route: String?): String {
    return "NavResultCallbackKey_$route"
}

fun <T> NavController.setNavResultCallback(callback: NavResultCallback<T>) {
    val currentRouteId = currentBackStackEntry?.destination?.route
    val key = generateNavResultCallbackKey(currentRouteId)
    NavCallbackRegistry.registerCallback(key, callback)
}

fun <T> NavController.getNavResultCallback(): NavResultCallback<T>? {
    val previousRouteId = previousBackStackEntry?.destination?.route
    return NavCallbackRegistry.getCallback(generateNavResultCallbackKey(previousRouteId))
}

fun <T> NavController.popBackStackWithResult(result: T) {
    val callback = getNavResultCallback<T>()
    if (popBackStack()) {
        callback?.invoke(result)
    }
}

fun <T> NavController.navigateForResult(
    route: Any,
    navResultCallback: NavResultCallback<T>,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    setNavResultCallback(navResultCallback)
    navigate(route, navOptions, navigatorExtras)
}

fun <T> NavController.navigateForResult(
    route: Any,
    navResultCallback: NavResultCallback<T>,
    builder: NavOptionsBuilder.() -> Unit
) {
    setNavResultCallback(navResultCallback)
    navigate(route, builder)
}

코드 상세 설명

1. NavCallbackRegistry

콜백을 저장하고 관리하는 싱글톤 객체입니다.

object NavCallbackRegistry {
    private val callbackMap = mutableMapOf<String, Any>()
    
    // 콜백 등록
    fun <T> registerCallback(key: String, callback: NavResultCallback<T>) {
        callbackMap[key] = callback
    }
    
    // 콜백 가져오기 (가져온 후 삭제)
    @Suppress("UNCHECKED_CAST")
    fun <T> getCallback(key: String): NavResultCallback<T>? {
        val callBack = callbackMap[key] as? NavResultCallback<T>
        unregisterCallback(key)
        return callBack
    }
    
    // 콜백 삭제
    private fun unregisterCallback(key: String) {
        callbackMap.remove(key)
    }
}
  • callbackMap: 키-값 쌍으로 콜백을 저장하는 맵입니다.
  • registerCallback(): 특정 키로 콜백을 등록합니다.
  • getCallback(): 키에 해당하는 콜백을 가져오고 바로 맵에서 제거합니다. 일회성 사용을 보장합니다.
  • unregisterCallback(): 키에 해당하는 콜백을 맵에서 제거합니다.

2. 키 생성 함수

private fun generateNavResultCallbackKey(route: String?): String {
    return "NavResultCallbackKey_$route"
}

이 함수는 화면의 경로(route)를 기반으로 고유한 키를 생성합니다. 이 키는 콜백을 저장하고 검색하는 데 사용됩니다.

3. NavController 확장 함수

setNavResultCallback

fun <T> NavController.setNavResultCallback(callback: NavResultCallback<T>) {
    val currentRouteId = currentBackStackEntry?.destination?.route
    val key = generateNavResultCallbackKey(currentRouteId)
    NavCallbackRegistry.registerCallback(key, callback)
}

현재 화면의 route를 기반으로 키를 생성하고, 해당 키로 콜백을 등록합니다. 제네릭 타입 T는 결과값의 타입을 나타냅니다.

getNavResultCallback

fun <T> NavController.getNavResultCallback(): NavResultCallback<T>? {
    val previousRouteId = previousBackStackEntry?.destination?.route
    return NavCallbackRegistry.getCallback(generateNavResultCallbackKey(previousRouteId))
}

이전 화면(호출한 화면)의 route를 기반으로 등록된 콜백을 가져옵니다. 이 함수는 결과를 반환할 때 사용됩니다.

popBackStackWithResult

fun <T> NavController.popBackStackWithResult(result: T) {
    val callback = getNavResultCallback<T>()
    if (popBackStack()) {
        callback?.invoke(result)
    }
}

현재 화면에서 이전 화면으로 돌아가면서 결과값을 전달합니다:
1. 이전 화면에 등록된 콜백을 가져옵니다.
2. 백 스택에서 현재 화면을 제거합니다.
3. 콜백이 있다면 결과값과 함께 호출합니다.

fun <T> NavController.navigateForResult(
    route: Any,
    navResultCallback: NavResultCallback<T>,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    setNavResultCallback(navResultCallback)
    navigate(route, navOptions, navigatorExtras)
}

결과를 기대하며 다른 화면으로 이동하는 함수입니다:
1. 콜백을 등록합니다.
2. 지정된 route로 이동합니다.

NavOptions와 navigatorExtras를 추가로 지정할 수 있는 오버로드 버전입니다.

fun <T> NavController.navigateForResult(
    route: Any,
    navResultCallback: NavResultCallback<T>,
    builder: NavOptionsBuilder.() -> Unit
) {
    setNavResultCallback(navResultCallback)
    navigate(route, builder)
}

NavOptionsBuilder를 사용하여 네비게이션 옵션을 구성할 수 있는 오버로드 버전입니다.

사용 예시

화면 A에서 화면 B로 이동하고 결과 받기

// 화면 A
@Composable
fun ScreenA(navController: NavController) {
    Button(onClick = {
        navController.navigateForResult<String>(
            route = "screen_b",
            navResultCallback = { result ->
                // 결과 처리
                Log.d("ScreenA", "Received result: $result")
            }
        )
    }) {
        Text("Go to Screen B")
    }
}

// 화면 B
@Composable
fun ScreenB(navController: NavController) {
    Button(onClick = {
        // 화면 A로 돌아가면서 결과 전달
        navController.popBackStackWithResult("Result from Screen B")
    }) {
        Text("Return with result")
    }
}

데이터 객체 결과 전달 예시

// 결과 데이터 클래스
data class FormResult(val name: String, val email: String)

// 화면 A
@Composable
fun ProfileScreen(navController: NavController) {
    var userName by remember { mutableStateOf("") }
    var userEmail by remember { mutableStateOf("") }

    Button(onClick = {
        navController.navigateForResult<FormResult>(
            route = "edit_profile_screen",
            navResultCallback = { result ->
                userName = result.name
                userEmail = result.email
            }
        )
    }) {
        Text("Edit Profile")
    }
    
    // 프로필 정보 표시...
}

// 화면 B
@Composable
fun EditProfileScreen(navController: NavController) {
    var name by remember { mutableStateOf("") }
    var email by remember { mutableStateOf("") }
    
    // 폼 필드...
    
    Button(onClick = {
        val result = FormResult(name, email)
        navController.popBackStackWithResult(result)
    }) {
        Text("Save")
    }
}

장점

  1. 간결한 API: 기존의 startActivityForResult()와 유사한 패턴으로 사용할 수 있어 직관적입니다.
  2. 타입 안전성: 제네릭을 사용하여 타입 안전성을 보장합니다.
  3. 낮은 결합도: 화면 간 결합도를 낮추고 독립적인 구현이 가능합니다.
  4. 일회성 데이터 전달: 일회성 이벤트에 적합한 방식으로, 데이터 공유 시 발생할 수 있는 부작용을 방지합니다.
  5. 기존 Navigation Component와 호환성: 기존 Navigation 시스템과 완벽하게 호환됩니다.

주의사항

  1. 메모리 누수 방지: getCallback() 함수가 콜백을 검색한 후 바로 제거하여 메모리 누수를 방지합니다.
  2. 프로세스 종료 복원: 이 구현은 프로세스 종료 시 콜백이 보존되지 않습니다. 필요하다면 추가적인 저장 메커니즘을 구현해야 합니다.

결론

Jetpack Compose와 Navigation Component를 사용하는 Single-Activity 앱에서, navigateForResult 확장 함수는 화면 간 결과 전달 패턴을 간결하고 타입 안전하게 구현할 수 있는 방법을 제공합니다. 이 접근 방식은 코드의 가독성을 높이고, 화면 간 결합도를 낮추며, 간결한 API를 통해 개발자의 생산성을 향상시킵니다.

이 패턴을 활용하면 복잡한 데이터 공유 메커니즘 없이도 화면 간 결과 전달을 효과적으로 처리할 수 있으며, 기존의 ActivityResult 패턴에 익숙한 개발자들도 쉽게 적응할 수 있습니다.

profile
데브누누

0개의 댓글