Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.

Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.



개인 광고 영역

이전 글에서 Composable Architecutre를 소개하는 내용을 담아보았는데, 몇 가지 문제점을 발견하여 이를 개선한 내용을 다시 정리하는 글이다.

크게 2가지 문제점을 확인하였다.

  • ViewModel 내 Reducer 처리 후 자동 next
  • Action 스트림 처리를 위한 싱글턴 활용 시 Lifecycle 문제

이 2가지 문제점을 해결하기 위해 코드를 어떻게 수정했는지, 그리고 더 나은 방법은 없을지 고민한 과정을 정리해본다.

이 글에서는

  • 기존 아키텍처의 구조적 문제점을 파악한다.
  • 문제 해결 과정과 더 나은 구조에 대한 고민을 공유한다.
  • 기본적인 내용을 담지 않고있어 앞선 글을 참고하면 좋다.

Action이란?

View와 ViewModel 사이의 통신을 어떻게 더 간결하게 할 수 있을까? Jetpack Compose 환경에서는 CompositionLocal - link을 활용하는 방법이 있다. 저는 이 방식을 응용하여 Composable 함수 어디서든 이벤트 처리를 쉽게 호출할 수 있도록 Action이라는 개념을 만들었다.

Flow를 기반으로 한 Action을 사용한 이유를 설명하기 위해, 먼저 일반적인 View-ViewModel 간 통신 방식의 예시 코드를 살펴보자.

Composable 함수에서 ViewModel 인스턴스를 파라미터로 직접 전달받아 사용하는 것이 일반적이다. 하지만 이 방식은 Composable 함수의 깊이가 깊어지거나 개수가 많아질수록 ViewModel을 어디까지 전달해야 할지 고민이 필요하며, 구조가 복잡해지면 자연스럽게 보일러플레이트 코드가 늘어나는 단점이 있다.

ViewModel을 직접 사용한 함수 호출

@Composable
fun SomeScreen(someViewModel: SomeViewModel) {
  Button(onClick = { someViewModel.doSomething() })
  Button(onClick = { someViewModel.doSomethingTwo() })
  Button(onClick = { someViewModel.doSomethingThree() })
}

class SomeViewModel : ViewModel() {
  fun doSomething() { /* ... */ }
  fun doSomethingTwo() { /* ... */ }
  fun doSomethingThree() { /* ... */ }
}

ViewModel 함수를 sealed interface로 통합하여 호출하는 경우

sealed interface SomeAction {
    data object ActionOne : SomeAction
    data object ActionTwo : SomeAction
    data class ActionThree(val item: Any) : SomeAction
}

@Composable
fun SomeScreen(someViewModel: SomeViewModel) {
  val item = remember { /* ... */ } // 예시 데이터
  Button(onClick = { someViewModel.dispatch(SomeAction.ActionOne) })
  Button(onClick = { someViewModel.dispatch(SomeAction.ActionTwo) })
  Button(onClick = { someViewModel.dispatch(SomeAction.ActionThree(item)) })
}

class SomeViewModel : ViewModel() {
  fun dispatch(action: SomeAction) {
    when (action) {
      is SomeAction.ActionOne -> { /* ... */ }
      is SomeAction.ActionTwo -> { /* ... */ }
      is SomeAction.ActionThree -> { /* ... */ }
    }
  }
}

제가 활용하는 방식 (CompositionLocal 활용)

위 방식들은 ViewModel을 계속 전달하거나, onClick: () -> Unit 같은 콜백을 계속 만들어 전달해야 하는 번거로움이 있다. 특히 콜백 방식은 이벤트 통합 과정에서 N개의 고차 함수(Higher-Order function)가 만들어질 수 있다.

그래서 Compose에서 제공하는 Locally scoped - link을 활용하여 Action 객체에 쉽게 접근하는 방법을 사용하고 있습니다. (자세한 활용법은 이전 글 컴포즈에 사용할 Composable Architecture 설명을 참고해주세요.)

// Action 정의 (예시)
sealed interface MyScreenAction : CaAction { // CaAction은 마커 인터페이스 역할
    data object ButtonClick : MyScreenAction
    data class TextTyped(val text: String) : MyScreenAction
    data class SwitchChanged(val isOn: Boolean) : MyScreenAction
}

// Composable View
@Composable
fun SomeScreen() {
  // CompositionLocal을 통해 ActionDispatcher 획득
  val actionDispatcher = LocalActionDispatcher.current
  var textState by remember { mutableStateOf("") }
  var switchState by remember { mutableStateOf(false) }

  Column {
      Button(onClick = { actionDispatcher.dispatch(MyScreenAction.ButtonClick) }) {
          Text("Click Me")
      }
      TextField(
          value = textState,
          onValueChange = {
              textState = it
              actionDispatcher.dispatch(MyScreenAction.TextTyped(it))
          }
      )
      Switch(
          checked = switchState,
          onCheckedChange = {
              switchState = it
              actionDispatcher.dispatch(MyScreenAction.SwitchChanged(it))
          }
      )
  }
}

// ViewModel
class SomeViewModel(
    private val flowCaActionStream: FlowCaActionStream // Action 스트림 주입
) : CaViewModel<MyScreenAction>(flowCaActionStream, MyScreenAction::class) { // 수신할 Action 타입 지정

    // CaViewModel 내부에서 flowAction을 통해 MyScreenAction 타입의 이벤트만 필터링하여 수신
    // reducer 메소드에서 각 Action 처리 로직 구현
    override suspend fun reducer(action: MyScreenAction) {
        when (action) {
            is MyScreenAction.ButtonClick -> {
                // 버튼 클릭 처리 로직
                Log.d("SomeViewModel", "Button Clicked")
            }
            is MyScreenAction.TextTyped -> {
                // 텍스트 입력 처리 로직
                Log.d("SomeViewModel", "Text Typed: ${action.text}")
            }
            is MyScreenAction.SwitchChanged -> {
                // 스위치 변경 처리 로직
                Log.d("SomeViewModel", "Switch Changed: ${action.isOn}")
            }
        }
    }
}


사용성 측면에서의 개선

필요한 Action을 LocalAction.current를 통해 어디서든 호출할 수 있으므로, ViewModel 인스턴스를 계속해서 하위 Composable로 전달할 필요가 없어 개발 편의성이 향상될 수 있다.

CompositionLocal에 적절한 기본값이나 테스트용 구현체를 제공하면 Preview 동작에도 문제가 없다. 다만, Preview에서 특정 UI 요소의 상태 변화나 인터랙션을 테스트하려면, 선언형 UI의 원칙에 따라 Stateless - link한 Composable을 만들고 상태와 이벤트를 외부에서 주입하는 것이 좋다.

// Stateless Composable 예시
@Composable
fun SomeContent(
    text: String,
    isSwitchOn: Boolean,
    onButtonClick: () -> Unit,
    onTextTyped: (String) -> Unit,
    onSwitchChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier // Modifier 추가 권장
) {
    Column(modifier = modifier) {
        Button(onClick = onButtonClick) { /* ... */ }
        TextField(value = text, onValueChange = onTextTyped)
        Switch(checked = isSwitchOn, onCheckedChange = onSwitchChange)
    }
}

// Statefull Composable (ViewModel과 연결)
@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModel()) { // Hilt 등 DI 활용 예시
    val actionDispatcher = LocalActionDispatcher.current // Action 발송기
    // ViewModel로부터 상태를 구독하거나, 필요한 상태를 여기서 관리
    val textState by viewModel.textState.collectAsState() // 예시 StateFlow
    val switchState by viewModel.switchState.collectAsState() // 예시 StateFlow

    SomeContent(
        text = textState,
        isSwitchOn = switchState,
        onButtonClick = { actionDispatcher.dispatch(MyScreenAction.ButtonClick) },
        onTextTyped = { actionDispatcher.dispatch(MyScreenAction.TextTyped(it)) },
        onSwitchChange = { actionDispatcher.dispatch(MyScreenAction.SwitchChanged(it)) }
    )
}

이 방식(Stateless/Statefull 분리 및 CompositionLocal 활용)의 단점은 다음과 같다.

  • ViewModel에서 모든 UI 인터랙션을 하나의 reducer 함수로 통합 관리하기보다, 각 상태 업데이트 로직과 이벤트 발송 로직이 분리될 수 있다. (이는 관점에 따라 장점일 수도 있다)
  • 새로운 Action 이벤트를 추가할 때, ViewModel의 reducer에서도 해당 이벤트를 처리하는 로직을 추가해야 한다. (이는 sealed interface 사용 시 컴파일 타임에 강제될 수 있어 오히려 장점이 될 수 있다.)

하지만 장점은 다음과 같습니다.

  • 각 UI 요소의 이벤트 발송 책임이 명확해진다.
  • Stateless Composable은 재사용 및 테스트가 용이하다.
  • CompositionLocal을 통해 이벤트 발송 인터페이스 접근이 간편하다.


정리하면

해결하고 싶었던 지점

  • View와 ViewModel 간의 이벤트 전달을 왜 항상 ViewModel 인스턴스를 통해 viewModel.someFunction() 형태로 직접 호출해야 할까?
  • Composable 함수의 깊이가 깊어질 때 ViewModel 인스턴스나 콜백 함수를 계속 전달해야 하는 번거로움을 줄일 수 없을까?

그래서 도입한 Action(CompositionLocal)

이벤트 처리를 위한 Action 인터페이스와 이를 쉽게 발송(dispatch)할 수 있는 ActionDispatcher를 CompositionLocal로 제공하여, Composable 함수 내에서 발생하는 보일러플레이트를 줄이고자 했다.

하지만 여전히 해결해야 할 문제가 있는데

  • 이벤트를 보내는 쪽(View)과 받는 쪽(ViewModel)에서 정확히 어떤 Action 타입을 사용하고 처리할지 명확히 약속이 필요하다. 만약 서로 다른 타입을 사용하거나 누락하면 이벤트가 유실되어 동작하지 않는 치명적인 문제가 발생할 수 있다.

이 문제를 해결하고 개발 과정에서 실수를 줄이기 위해 sealed interface를 사용하여 Action을 정의하는 방식을 채택했다. sealed interface를 사용하면 ViewModel의 reducer에서 when 식으로 처리할 때 모든 하위 타입을 강제로 구현해야 하므로, 이벤트 누락 가능성을 컴파일 시점에 방지할 수 있다. 이는 UI 동작 관련 테스트 케이스를 일부 줄여줄 수 있는 장점도 있다.


그래서 발견한 문제

Swift-composable architecture - 링크를 참고하여 아키텍처를 구상하다 보니, Reducer가 특정 액션을 처리한 후 다음 액션을 연쇄적으로 발생시키는 구조를 발견했다.

Swift Composable Architecture의 코드 예시를 보면, Reduce 클로저 내에서 .send나 다른 이펙트(Effect)를 반환하여 다음 동작을 유발할 수 있다. (아래 코드는 TCA의 이전 버전 구문일 수 있으며, 현재는 @Reducer 매크로 등을 사용한다.)

// TCA 예시 (개념 설명용)
Reduce { state, action in
  switch action {
  case .buttonTapped:
    state.isLoading = true
    // 이펙트를 반환하여 비동기 작업 후 다른 액션(.dataLoaded)을 발생시킴
    return .run { send in
      let data = try await apiClient.fetchData()
      await send(.dataLoaded(data))
    }
  case let .dataLoaded(data):
    state.isLoading = false
    state.data = data
    return .none // 추가 액션 없음
  // ...
  }
}

이러한 ‘액션 후 연쇄 액션’ 개념을 안드로이드에서 Flow와 제가 만든 Action 시스템으로 구현해보고자 했다. 하지만 여기서 두 가지 주요 문제가 발생했다.

  1. 자동 nextAction 호출로 인한 문제:
    • ViewModel의 reducer 함수가 반환하는 값을 기반으로 시스템이 자동으로 다음 액션(nextAction)을 발생시키도록 설계했더니, 개발자가 이 동작 방식을 정확히 이해하고 사용해야 하는 부담이 늘었다. 알아야 할 규칙이 많아진 것.
    • 결정적으로, nextAction을 잘못 지정하거나 reducer 로직에 실수가 있으면 무한 루프에 빠질 위험이 있었다. 디버깅이 불가능한 것은 아니지만, 코드 설계상 예측 가능한 문제가 쉽게 발생할 수 있는 구조였다.
  2. 싱글턴 Action 스트림과 Lifecycle 동기화 문제:
    • 앱 전체에서 단 하나의 Action 스트림(FlowCaActionStream)을 싱글턴으로 사용하다 보니, 새로운 Activity가 실행되거나 Composable Navigation 라이브러리(like Navigation-Compose)를 통해 화면이 전환될 때 문제가 발생했다.
    • 예를 들어, Activity A와 Activity B가 있고 각각 여러 Composable 화면(Screen)을 가지고 있다고 가정해 보자. 사용자가 Activity B에 있더라도, 백그라운드의 Activity A에 속한 ViewModel들이 여전히 싱글턴 Action 스트림을 구독하고 있을 수 있다. 만약 특정 Action이 Activity B에서 발생했는데, Activity A의 ViewModel도 해당 Action 타입에 대해 필터링 로직(filterIsInstance)을 가지고 있다면, 의도치 않게 Activity A의 ViewModel에서도 해당 Action이 처리될 수 있다. (물론 reducer 로직 내에서 현재 화면 상태 등을 체크하여 방어할 수는 있겠지만, 근본적으로 불필요한 구독 및 처리 시도가 발생한다.)
    • 이는 특히 Alert, Toast, Router(화면 전환)와 같이 앱 전역적으로 영향을 줄 수 있는 Side Effect 처리 시 동기화 문제를 일으킬 수 있다. ViewModel의 생명주기(viewModelScope)는 일반적으로 Composable의 생명주기보다 길기 때문에 이 문제가 더 두드러진다.

이 두 가지 문제를 어떻게 해결했는지 구체적으로 설명하겠다.


문제점 1 - 무한 루프 가능성 해결하기

무한 루프 발생 가능성을 제거하기 위해 기존 CaViewModel의 flowAction 처리 방식에서 문제의 소지가 있는 부분을 수정했다.

기존 코드 (문제 발생 가능성 있음):

abstract class CaViewModel<ACTION : CaAction, SIDE_EFFECT : CaSideEffect>(
    private val flowCaActionStream: FlowCaActionStream, // 'private' 추가 (캡슐화)
    actionClass: KClass<ACTION>,
) : ViewModel() {

    @VisibleForTesting
    val flowAction by lazy(LazyThreadSafetyMode.NONE) {
        flowCaActionStream.flowAction()
            .filterIsInstance(actionClass) // 1. 해당 ViewModel이 처리할 Action만 필터링
            .map { action -> // 2. reducer를 호출하고, 그 결과를 반환 (문제의 소지)
                reducer(action = action) // reducer가 다음 Action을 반환한다고 가정
            }
            .onEach { nextActionToDispatch -> // 3. map에서 반환된 다음 Action을 자동으로 전파 (문제!)
                flowCaActionStream.nextAction(nextActionToDispatch) // 무한 루프 가능 지점
            }
            // .launchIn(viewModelScope) // 실제로는 여기서 launch 되어야 함
    }

    // reducer 함수가 다음 Action을 반환하는 형태였다고 가정
    abstract suspend fun reducer(action: ACTION): CaAction? // 예시: 반환 타입이 다음 Action
}

수정된 코드:

abstract class CaViewModel<CA_ACTION : CaAction>(
    private val flowCaActionStream: FlowCaActionStream,
    actionClass: KClass<CA_ACTION>,
) : ViewModel() {

    // Action 처리를 위한 Flow (자동 nextAction 로직 제거)
    @VisibleForTesting
    internal val actionProcessingFlow by lazy(LazyThreadSafetyMode.NONE) { // 'internal'로 변경하고 이름 명확화
        flowCaActionStream.flowAction()
            .filterIsInstance(actionClass) // 1. 처리할 Action 필터링
            .onEach { action -> // 2. map 대신 onEach 사용. 각 Action에 대해 reducer만 실행 (반환값 사용 안 함)
                reducer(action = action)
            }
            // 3. 자동 nextAction 전파 로직 제거됨
    }

    // reducer 함수는 이제 Side Effect 처리나 상태 변경에만 집중 (반환값 없음)
    abstract suspend fun reducer(action: CA_ACTION)

    // 다음 Action을 명시적으로 전파하고 싶을 때 호출하는 함수 추가
    protected fun nextAction(action: CaAction) { // 'protected'로 변경하여 자식 클래스에서만 사용하도록 제한
        flowCaActionStream.nextAction(action)
    }

    // 실제 Flow 구독 시작/취소는 별도 관리 (아래 Lifecycle 해결 부분 참조)
    @VisibleForTesting
    var actionProcessingJob: Job? = null
}

수정 내용 요약:

  • map -> onEach 변경: reducer 함수가 다음 Action을 반환하고 이를 map 연산자가 받아 downstream로 흘려보내는 구조를 제거했다. 대신 onEach를 사용하여 각 Action에 대해 reducer 함수를 실행만 하도록 변경다. 이로써 reducer의 반환값과 관계없이 자동 nextAction 호출 가능성이 사라졌다.
  • 명시적 nextAction 함수 추가: 연쇄적인 Action 전파가 필요한 경우, 개발자가 reducer 함수 내에서 직접 nextAction(action) 함수를 호출하도록 변경했다. 이는 시스템에 의한 암묵적인 동작 대신, 개발자의 명확한 의도에 따라 다음 Action이 발생하도록 하여 코드의 예측 가능성을 높인다.

이제 reducer 내에서 다음과 같이 명시적으로 다음 Action을 지정해야 한다.

override suspend fun reducer(action: MyScreenAction) {
    when (action) {
        is MyScreenAction.ButtonClick -> {
            // 예시: 버튼 클릭 후 특정 조건 만족 시 Alert 표시 Action 전파
            if (shouldShowAlert()) {
                nextAction(CommonUiAction.ShowAlert("버튼 클릭됨!")) // 명시적으로 nextAction 호출
            }
        }
        // ... 다른 Action 처리
    }
}

이 변경으로 시스템적인 무한 루프 발생 가능성은 제거되었고, 코드의 흐름이 더 명확해졌다.


문제점 2 - Lifecycle 문제 해결하기

싱글턴 Action 스트림(FlowCaActionStream) 사용 시 발생하는 Lifecycle 동기화 문제를 해결하기 위해, Composable의 Lifecycle에 맞춰 ViewModel의 Action 스트림 구독을 시작하고 중지하는 방식을 도입했다.

문제 상황: Activity A와 B가 있을 때, Activity B가 화면에 보이는 동안에도 백그라운드의 Activity A에 있는 ViewModel이 Action 스트림을 계속 구독하고 있으면, Activity B에서 발생한 Action이 Activity A의 ViewModel에게도 전달될 수 있다. (물론 filterIsInstance로 타입 필터링은 되지만, 같은 타입의 Action을 여러 화면에서 사용한다면 문제가 된다.)

sample_01


해결 방안: Lifecycle에 따른 구독 제어

Composable의 Lifecycle 상태(특히 ON_RESUME, ON_PAUSE)에 맞춰 ViewModel 내 Action 스트림(actionProcessingFlow)의 구독(Job)을 시작하고 취소하는 방법을 사용합니다. 이를 위해 DisposableEffectLocalLifecycleOwner를 활용하는 Helper Composable 함수를 만들었습니다.

@Composable
fun LaunchedLifecycleViewModel(
    viewModel: CaViewModel<*> // 라이프사이클 관리가 필요한 ViewModel
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner, viewModel) { // lifecycleOwner와 viewModel이 키
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> {
                    // 화면이 활성화되면 Action 스트림 구독 시작
                    viewModel.startActionProcessing()
                }
                Lifecycle.Event.ON_PAUSE -> {
                    // 화면이 비활성화되면 Action 스트림 구독 취소
                    viewModel.cancelActionProcessing()
                }
                // ON_DESTROY는 DisposableEffect의 onDispose에서 처리되거나
                // ViewModel의 onCleared에서 처리될 수 있음
                else -> { /* Do nothing for other events */ }
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        // Composable이 Composition에서 제거될 때(onDispose) Observer 제거
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
            // 필요하다면 여기서도 cancelActionProcessing() 호출 고려
            // viewModel.cancelActionProcessing()
        }
    }
}

그리고 CaViewModelAction 스트림 구독을 시작하고 취소하는 함수를 추가한다. (internal 접근 제한자를 사용하여 모듈 외부에서의 직접 호출을 방지한다.)

abstract class CaViewModel<CA_ACTION : CaAction>(
    // ... (이전 코드와 동일)
) : ViewModel() {

    // ... (actionProcessingFlow, reducer, nextAction 등) ...

    @VisibleForTesting
    var actionProcessingJob: Job? = null // 구독 상태를 관리하는 Job

    // Action 스트림 구독 시작 (ON_RESUME 시 호출됨)
    internal fun startActionProcessing() {
        // 이미 실행 중이라면 중복 실행 방지
        if (actionProcessingJob?.isActive == true) return

        // 기존 Job이 있다면 취소 (혹시 모를 상황 대비)
        cancelActionProcessing()

        // actionProcessingFlow를 viewModelScope에서 구독 시작
        actionProcessingJob = actionProcessingFlow
            .launchIn(viewModelScope)
    }

    // Action 스트림 구독 취소 (ON_PAUSE 시 호출됨)
    internal fun cancelActionProcessing() {
        actionProcessingJob?.cancel()
        actionProcessingJob = null
    }

    // ViewModel이 파괴될 때(onCleared) 확실하게 Job 취소
    override fun onCleared() {
        super.onCleared()
        cancelActionProcessing()
    }
}

위 코드를 활용하는 방법은?

각 화면의 최상위 Composable에서 ObserveLifecycle 함수를 호출해주어야 한다.

@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModel()) {
    // ViewModel의 Action 구독 라이프사이클 관리
    LaunchedLifecycleViewModel(viewModel = viewModel)

    // --- 실제 UI ---
    // val state by viewModel.uiState.collectAsState()
    // SomeContent(...)
    // ---
}

개선 아이디어: 매번 ObserveLifecycle(viewModel)를 호출하는 것이 번거롭다면, ViewModel 인스턴스를 얻을 때 자동으로 이 로직을 포함시키는 확장 함수나 위임(delegate)을 고려해볼 수 있다. 예를 들어:

@Composable
fun Some(viewModel: ViewModel = hiltViewModel().Activate()) {
  // Your view
}

또는 hilt를 직접 확장한다면 아래와 같다.

// 개념적인 아이디어 (구현 필요)
@Composable
inline fun <reified VM : CaViewModel<*>> hiltViewModelWithLifecycle(): VM {
    val viewModel: VM = hiltViewModel()
    LaunchedLifecycleViewModel(viewModel = viewModel)
    return viewModel
}

// 사용 예시
@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModelWithLifecycle()) {
    // 이제 LaunchedLifecycleViewModel() 호출 불필요
    // ... UI ...
}

(Gemini 주의: hiltViewModelWithLifecycle 같은 함수는 Composable 함수 내에서 다른 Composable 함수(LaunchedLifecycleViewModel)를 호출하는 방식이므로, ViewModel 생성 로직과 Lifecycle 관찰 로직을 분리하는 것이 더 좋을 수 있다. 또는 별도의 진입점 Composable에서 처리하는 방식도 고려할 수 있다.)

고려사항:

  • 이 방식은 ViewModel이 간접적으로 UI Lifecycle을 인지하게 되는 것 아니냐는 비판이 있을 수 있다. 하지만 start/cancelActionProcessing 함수 호출 시점은 ViewModel 외부(LaunchedLifecycleViewModel)에서 결정되므로, ViewModel 자체는 Lifecycle 객체를 직접 참조하지 않는다.
  • ON_RESUME / ON_PAUSE 이벤트마다 Job을 취소하고 다시 시작하는 오버헤드가 발생할 수 있다. 하지만 이는 화면이 실제로 보이지 않을 때 불필요한 Action 처리를 방지하는 효과적인 방법 중 하나이다.


해결했을까?

위 변경 사항들을 통해 초기 설계에서 발견된 두 가지 주요 문제점을 해결할 수 있었다.

  • 무한 루프 발생 가능성: reducer 처리 후 자동 nextAction 전파 로직을 제거하고, 명시적인 nextAction 함수 호출 방식으로 변경하여 해결했다.
  • 싱글턴 Action 스트림의 Lifecycle 문제: Composable의 Lifecycle에 맞춰 ViewModel의 Action 스트림 구독을 제어하는 LaunchedLifecycleViewModel Helper Composable을 도입하여, 비활성 화면에서의 불필요한 Action 처리 가능성을 해결했다.

하지만 항상 더 나은 방법이 있을 수 있다.


구독하는 더 좋은 방법은 없을까?

ON_RESUME/ON_PAUSE 마다 launchIn으로 Job을 생성하고 취소하는 방식 대신, Flow의 stateIn 연산자를 활용하는 방법을 고려해볼 수 있다.

stateIn 연산자는 FlowStateFlow로 변환하며, 구독자(collector)의 유무에 따라 업스트림 Flow의 실행을 제어할 수 있는 SharingStarted 정책을 제공한다. 예를 들어 SharingStarted.WhileSubscribed() 정책을 사용하면, StateFlow를 구독하는 Composable이 화면에 보이는 동안(collectAsState 등으로 구독 중)에만 업스트림 Flow(actionProcessingFlow)가 활성화되고, 화면에서 사라지면 자동으로 구독이 중단(cancel)될 수 있다.

이러한 접근 방식은 Slack에서 만든 Circuit - link 아키텍처와 유사한 방향으로 흘러갈 수 있다. Circuit에서는 Presenter가 UI State를 Flow로 노출하고, UI 이벤트는 Sink를 통해 Presenter로 전달됩니다. Presenter 내부 로직의 실행 여부는 최종적으로 UI State Flow의 구독 여부에 따라 결정될 수 있다.

skydoves(재웅 님)가 작성한 Loading Initial Data in LaunchedEffect vs. ViewModel - link 글에서도 LaunchedEffect 내에서의 데이터 로딩과 ViewModel의 stateIn을 활용한 데이터 로딩 방식을 비교하며 유사한 아이디어를 엿볼 수 있다.

결국 상태(State)를 중심으로 데이터 흐름을 관리하고, 그 상태의 구독 생명주기에 맞춰 연관된 로직(Action 처리 등)의 실행 여부를 제어하는 방식이 더 Compose 친화적이고 효율적일 수 있다.

저는 현재 설계에서 상태 관리와 이벤트 처리를 분리하고 싶어 이 방식(Circuit이나 stateIn을 전면적으로 활용하는 방식)까지 적용하지는 않았지만, 궁극적으로는 이와 유사한 형태로 발전할 수 있지 않을까 생각하고 있다.


Next

이번 글에서는 기존 Composable Architecture 설계에서 발견된 2가지 문제점을 파악하고 이를 해결하기 위해 어떤 고민과 선택을 했는지 정리해보았다.

다음 글에서는 이 아키텍처를 활용하여 구현한 구체적인 기능들을 다룰 예정이다.

  • 코드에는 이미 있지만 Alert/Toast를 포함하고있다.
  • Router(액티비티, 네비게이션)를 처리하기 위한 부분도 포함하고있다.

이제 다음 글에서 이 두개를 정리할 계획이다.


작성 글 이어보기



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments