다시 - Composable Architecture 설계 변경

다시 - Composable Architecture 설계 변경



개인 광고 영역

Composable Architecture에 대한 세 편의 글에 이어 이번 글을 작성하게 되었다. 이번에는 전반적인 설계가 변경되어 사실상 새로운 아키텍처라고 볼 수 있다.

가장 큰 변화는 무엇인가?

  • 싱글턴으로 활용하던 ActionViewModel에 한정하여 사용하도록 변경했다.
  • Router를 새로 추가했으며, Action과 독립적으로 동작하도록 개선했다.

이 글에서는

  • Action 활용법 개선 방안을 알아본다.
  • 이전 설계에서 발생했던 문제점들을 짚어본다.

설명에 포함된 샘플 화면

이 글의 설명은 다음 샘플 프로젝트를 기반으로 한다.

TComposableArchitecture - GitHub - link

sample_01

Action은 그대로 사용하지만, 범위는 제한한다.

Action 자체는 이전과 동일하게 사용한다. 하지만 기존에는 싱글턴으로 어디서든 접근하고 활용할 수 있었던 점을, 이제는 Compose와 ViewModel 간의 1:1 관계로 한정했다.

sample_02

이로 인해 ActionViewModel이라는 BaseViewModel이 추가되었다. 이 ViewModel은 Action을 활용하기 위한 핵심 동작 코드를 포함하고 있다. 특히, flowAction을 처리하는 코드가 이곳에 위치한다.

ActionViewModel - link

abstract class ActionViewModel<ACTION : Action>(
    private val flowActionStream: FlowActionStream,
    actionClass: KClass<ACTION>,
) : ViewModel() {

    @Inject
    internal lateinit var actionSender: ActionSender

    @VisibleForTesting
    var flowActionJob: Job? = null

    @VisibleForTesting
    val flowAction by lazy {
        flowActionStream.flowAction()
            .filterIsInstance(actionClass)
            .onEach {
                handleAction(action = it) // Action을 처리하기 위함
            }
    }

    internal fun loadAction() {
        cancelAction()

        flowActionJob = flowAction
            .launchIn(viewModelScope)
    }

    /**
     * ViewModel created only call once.
     */
    open fun onCreated() {}

    internal fun cancelAction() {
        if (flowActionJob?.isActive == true) {
            flowActionJob?.cancel()
            flowActionJob = null
        }
    }

    fun nextAction(action: ACTION) {
        flowActionStream.nextAction(action)
    }

    abstract suspend fun handleAction(action: ACTION)
}

Base ViewModel은 어디까지나 사용 편의성을 위한 것이므로, internal 키워드를 통해 불필요한 외부 접근을 최소한으로 제한한다.

ActionViewModel 활용 예시

SettingsViewModel - link에서 전체 코드를 확인할 수 있다. 여기서는 UiState를 통해 화면을 갱신하고, Action을 통해 이벤트를 업데이트하는 과정을 적용한다.

관련 코드는 다음과 같습니다.

@HiltViewModel
internal class SettingsViewModel @Inject constructor(
    flowActionStream: FlowActionStream,
) : ActionViewModel<SettingsAction>(flowActionStream, SettingsAction::class) {

    // UI의 상태를 업데이트합니다.
    private val _settingsUiState = MutableStateFlow(SettingsUiState.Default)
    val settingsUiState = _settingsUiState.asStateFlow()

    override suspend fun handleAction(action: SettingsAction) {
        when (action) {
            is SettingsAction.ThemeModeChange -> { // action으로 들어온 ThemeModeChange 이벤트를 처리합니다.
                _settingsUiState.update {
                    it.copy(
                        mode = action.mode,
                    )
                }
            }
        }
    }
}

UI에서의 사용 방법

개발하다 보면 활용성을 높이기 위한 방식으로 접근할 수밖에 없다. 이는 새로운 방식에 익숙해지는 시간과 노력이 필요하다는 의미이기도 한다.

하지만 기존 방식보다는 훨씬 쉽게 접근하면서도 사이드 이펙트 발생을 최소화하는 형태로 접근한다.

@Composable
internal fun InternalSettingsScreen(
    settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
    ActionSenderCompositionLocalProvider(settingsViewModel) { // 필수로 필요
        val settingsUiState by settingsViewModel.settingsUiState.collectAsStateWithLifecycle()

        InternalSettingsScreen(
            // Your screen paramater
        )
    }
}

ActionSenderCompositionLocalProvider를 추가해야 합니다. 기존에는 싱글턴이었기 때문에 Activity에서 이를 처리했지만, 이제는 ViewModel이 필요한 시점에 즉시 추가할 수 있습니다.

만약 Action을 활용하지 않겠다면 직접 구현하여 사용할 수 있지만, Lifecycle만 사용하겠다면 LaunchedLifecycleActionViewModel을 호출해야 합니다.

이 코드에서는 CompositionLocalProvider를 활용하여 범위가 지정된 ActionSender를 사용하기 위해 ActionSenderCompositionLocalProvider를 활용합니다.

ActionViewModel에 대한 Unit Test

Unit Test를 통해 로직 테스트를 진행할 수 있다. 물론 ActionSenderCompositionLocalProvider가 적용되지 않았다는 점은 직접 UI를 눌러보며 테스트하거나, UI Test를 진행해야 한다.

여기서는 Unit Test 코드를 살펴보겠다.

위 샘플 코드에서 한 가지 시나리오를 기반으로 Unit Test가 가능하다.

  • View에서 Theme 옵션 중 하나를 선택한다.
  • Repository를 통해 로컬 데이터 업데이트를 처리한다. (이 코드에는 포함되어 있지 않는다.)
  • 업데이트가 성공하면 UI를 갱신한다.

여기서는 Mockito, Turbine, Coroutines Test를 활용하여 Unit Test를 진행한다.

internal class SettingsViewModelTest {

    private val flowActionStream = mock<FlowActionStream>()

    private val viewModel = SettingsViewModel(
        flowActionStream = flowActionStream,
    )

    @Test
    fun `test ThemeModeChange`() = runTest {
        // UI에서 DARK 테마 변경 버튼을 눌렀다고 가정합니다.
        val mockItem = SettingsAction.ThemeModeChange(mode = SettingsUiState.Mode.DARK)
        // flowAction에서 이를 전달해줍니다.
        whenever(flowActionStream.flowAction()).thenReturn(flowOf(mockItem))

        viewModel.flowAction
            .test { // 구독 및 테스트 시작
                // awaitItem()을 통해 눌린 정보를 확인합니다.
                Assert.assertEquals(
                    SettingsAction.ThemeModeChange(mode = SettingsUiState.Mode.DARK),
                    awaitItem()
                )

                // UiState 갱신을 확인합니다.
                Assert.assertEquals(
                    SettingsUiState(
                        mode = SettingsUiState.Mode.DARK,
                    ),
                    viewModel.settingsUiState.value
                )

                // Mockito를 활용하였으므로 flowAction 이벤트가 들어왔는지 확인합니다.
                verify(flowActionStream).flowAction()

                cancelAndIgnoreRemainingEvents()
            }
    }
}

설계 방향

원래의 설계는 이 Action을 활용하여 앱 전체에 하나의 Event Bus를 만들 생각이었다. 하지만 사용하다 보니 단점이 더 많아 이 부분을 제거하고, View와 ViewModel 간의 1:1 관계로 제약했다.

싱글턴을 제한하기 위해 두 번째 글을 작성했지만, 결국 그 부분도 설계상의 미스가 있었기에, 완전히 새롭게 1:1로 한정한 Action으로 개선했다.

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

Next

이로 인해 전역 Action을 통해 처리할 수 있었던 Alert/Toast, Router 시스템도 새로 만들어야 했다.

달라진 점이라면 이제 정말 필요할 때 Action과 무관하게 단독으로 사용할 수 있게 되었다는 점이다.

  • Router: 액티비티, 내비게이션 간 화면 이동과 처리를 담당하며, Navigator를 활용해 화면 이동이 가능하다.
    • 이전에는 액션을 통해 이를 활용하던 부분은 모두 제거되어 코드에 반영되어 있다. 이 내용은 다음 글에서 자세히 다룰 예정이다.
  • Alert/Toast: Action을 통해 이벤트를 전달하고 전달받는 처리를 하려고 했으나, Action 개선으로 단독 시스템으로 만들어질 예정이다. (샘플이라고 생각하는 것이 맞다.)

기반 코드가 변경되었으니, 완전히 새로운 형태의 아키텍처라고 보는 것이 더 적절할 것이다.

Router 부분에서 싱글 액티비티를 활용하는 부분은 드로이드 나이츠 코드에 반영되어 있다.

droidknights 2025 - Router

작성 글 이어보기



About Taehwan

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

Comments