컴포즈에 사용할 Composable Architecutre 설명(리엑트?)

컴포즈에 사용할 Composable Architecutre 설명(리엑트?)



개인 광고 영역

이 글은 최근 유행하는 MVI (Model-View-Intent) 패턴과는 다른, 리액트의 Reducer, UiState, Effect, Action 개념을 포함하는 T Composable Architecture를 소개하는 글의 첫 번째 편입니다.

T는 글쓴이 이름의 첫 글자이며, 나머지는 Compose와 Architecture를 의미합니다.

Compose에서는 MVI가 가장 적합하다는 의견이 많지만, MVI를 소개하는 cycle.js.org에서는 다음과 같이 설명합니다.

cycle.js.org - Model-View-Intent - 링크

Model-View-Intent (MVI) is reactive, functional, and follows the core idea in MVC. It is reactive because Intent observes the User, Model observes the Intent, View observes the Model, and the User observes the View. It is functional because each of these components is expressed as a referentially transparent function over streams. It follows the original MVC purpose because View and Intent bridge the gap between the user and the digital model, each in one direction.

위를 번역하면 아래와 같은데,

모델-뷰-인텐트(MVI)는 반응적이고 기능적이며 MVC의 핵심 아이디어를 따릅니다. 인텐트가 사용자를 관찰하고, 모델이 인텐트를 관찰하고, 뷰가 모델을 관찰하고, 사용자가 뷰를 관찰하기 때문에 반응형입니다. 이러한 각 구성 요소는 스트림을 통해 참조적으로 투명한 함수로 표현되므로 기능적입니다. 뷰와 인텐트는 각각 한 방향으로 사용자와 디지털 모델 사이의 간극을 메우기 때문에 원래의 MVC 목적에 부합합니다.

핵심은 reactive, functional, MVC의 융합입니다. 지금 우리가 사용하는 MVI 패턴과는 다른 부분이 있는데, reactive와 functional은 같고 MVVM을 포함해서 UDF(단방향 데이터 흐름)가 더 적합하다고 보는 것이 좋아보인다.

이 글에서는

  • 필자가 생각하는 Composable 구조는?

아키텍처 이해는?

아키텍처를 이해하는 데 필요한 부분은 UDF(Unidirectional Data Flow, 단방향 데이터 흐름)를 통해 설명하는 것이 가장 좋다. 여기서 실질적인 데이터 흐름을 관찰하고 능동적으로 변화하는지 여부가 리액트의 핵심 개념이다.

데이터 흐름(Data flow)을 이해해 보는 데 있어 필요한 것은? 짝퉁 개발자처럼 논하기

일반적인 Android 아키텍처에서의 데이터 흐름을 표현하면 아래와 같다.

sample_01

현재는 UiState를 포함하는데, 이 또한 리액트의 개념 중 하나이다.


데이터 흐름 표현한 코드

위 도식에 대한 코드를 작성하며 설명해보겠다

Repository 부분

그림과 반대로 Repository 코드부터 작성해야 어느 부분이 구독을 일으켜 반응적으로 업데이트되는지 이해할 수 있다.

class SomeRepository(private val someApi: SomeApi) {

  suspend fun loadData(): DataEntity =
    someApi.load()
}

ViewModel 부분

이 부분에서는 호출이 일어나고, 코루틴 blocking으로 응답을 처리한다. 응답 결과를 반응적으로 받는 것이 아니라 즉시 호출하고 즉시 반환받는다는 점이 중요하다.

class SomeViewModel(private val someRepository: SomeRepository) {

  private val _uiState = MutableStateFlow(SomeUiState())
  val uiState = _uiState.asStateFlow()

  fun load() = viewModelScope.launch {
    val data = someRepository.loadData() // 여기까진 일반적인 호출
    // 여기부터 반응형을 위한 stateFlow 관리
    _uiState.value = data.convert() // convert 후 _uiState에 갱신
  }
}

ViewModel에 Effect를 추가하여 reducer 처럼 사용한다면

현재는 반응형 코드 형태로 StateFlow를 사용하고 있다. 여기에 MVI의 Effect를 추가하면 아래와 같이 변경해야 한다.

sealed interface Effect {
  data object Load : Effect
}

Effect를 받아서 처리하도록 수정하면:

class SomeViewModel(private val someRepository: SomeRepository) {

  private val _uiState = MutableStateFlow(SomeUiState())
  val uiState = _uiState.asStateFlow()

  fun reduce(effect: Effect) = viewModelScope.launch {
    when (effect) {
      is Effect.Load -> {
        val data = someRepository.loadData() // 여기까진 일반적인 호출
        // 여기부터 반응형을 위한 stateFlow 관리
        _uiState.value = data.convert() // convert 후 _uiState에 갱신
      }
    }
  }
}

이 코드는 간단하게 작성되었고, 사용하는 MVI 라이브러리에 따라 다를 수 있다. 마지막에 UiState를 반환하고 BaseReducer로 전달하는 작업을 하는 라이브러리도 있지만, 여기서는 생략했다.

View 부분

마지막으로 View 부분이다.

@Composable
fun ContentView(
  someViewModel: SomeViewModel = viewModels(),
) {
  val uiState by someViewModel.uiState.collectAsStateWithLifecycle()

  Column {
    Button(
      onClick = {
        someViewModel.reduce(Effect.Load), // 새로운 데이터 업데이트
      }
    ) {
      Text(
        text = "Button",
      )
    }

    Text(
      text = uiState.text, // UiState에 따라 갱신
    )
  }
}

이 코드를 도식화한 부분이 위에서 본 그림에 해당한다.

sample_01


MVI인가 안드로이드 아키텍처인가?

제 생각에는 크게 보면 MVI와 Android 아키텍처는 크게 다르지 않다고 생각한다.

Android 아키텍처도 리액트의 UiState 개념을 포함하고 있으며, 이미 오래전부터 RxJava, LiveData, Flow 같은 리액트 개념을 가져와서 사용하고 있었다. 동적으로 데이터 흐름을 받고, 이걸 업데이트하는 부분을 구독 형태로 사용했죠.

그럼 남는 것은 무엇일까? 좀 더 쉽게 쓸 수 있는 부분을 추가하면 어떨까?

그전에 ViewModel에 이벤트를 전달할 때 어떻게 하는지 살펴보는 것이 좋을 것 같다.


Action

  • 내용 수정

Action은 View에서 ViewModel로 이벤트를 전달하는 데 사용되는 개념이다. MVI 패턴에서는 Intent가 Action과 유사한 역할을 한다.

이후 데이터 흐름은 Action을 받고, ViewModel은 Action에 따라 UiState를 업데이트할 수 있다. View는 업데이트된 UiState를 사용하여 화면을 그린다.

반대로 ViewModel에서 발생한 이벤트를 Effect를 통해 View에 전달하는데 사용한다. Effect는 Toast 메시지 표시, 화면 이동 등과 같은 일회성 이벤트를 나타낸다.

그럼 @Composable 함수에서 onClick을 처리하는 방법에 대해 크게 3가지로 나눠 설명해 본다.

1. Higher-order function을 파라미터로 받아서 호출하는 방식

제일 흔하게 쓰는 방법 중 하나이다. 이 방식을 버릴 수는 없다. Action을 지정할 수는 없으니까 공통 컴포넌트를 만들거나 재사용해야 하는 Component를 만들 때 이 방식은 필수이다.

@Composable
fun SomeItem(
  onClick: () -> Unit,
) {
  Surface(
    onClick = onClick,
  ) {
    // 생략
  }
}

하지만 이 방식은 ViewModel이 있는 곳까지 끌어올려 체이닝시켜야 한다는 단점이 있다. 그렇다고 안 할 수도 없고, ViewModel을 모든 곳에 주입할 수도 없다.

2. ViewModel 같은 호출 함수를 직접 파라미터로 전달해서 호출하는 방법

1번 방법에서 Screen을 만나면 ViewModel을 쓸 수 있는데, 이 부분에서 Higher-order function을 실질적인 함수 호출할 수 있다.

@Composable
fun SomeScreen(
  viewModel: SomeViewModel = viewModels(),
) {
  Surface(
    onClick = {
      viewModel.some()
    },
  ) {
    // 생략
  }
}

여기서 주의할 점은 Stateless와 Stateful로 함수를 잘 구분해서 작성해야 Preview에 좋다는 것인데, 무작정 Stateful로 작성하면 Preview가 어려워진다.

Stateful versus stateless - 공식 문서 링크

3-1. UiState에 Higher-order 정의를 넘겨주고 호출하는 방식

Effect를 모른다면 이 방식도 선택할 수 있을것 같다. 어떤 함수를 불러야 할지 모른다면.

data class SomeUiState(
  val onClick: () -> Unit,
)

Item을 사용하면 viewModel을 알지 않더라도 바로 불러줄 수 있는 장점이 생긴다.

@Composable
fun SomeView(
  someUiState: SomeUiState,
) {
  Surface(
    onClick = {
      someUiState.onClick()
    },
  ) {
    // 생략
  }
}

3-2. UiState에 Action(Effect) 정보를 넘겨주고 호출하는 방식

이번엔 Effect를 정의하고, 이를 활용한다면 어떨까?

sealed interface Effect {
  data object Some : Effect
}

data class SomeUiState(
  val effect: Effect,
)

이 방식에서는 결국 viewModel을 알아야 호출할 수 있다는 점이다.

@Composable
fun SomeScreen(
  someUiState: SomeUiState,
  viewModel: SomeViewModel = viewModels(),
) {
  Surface(
    onClick = {
      viewModel.onEffect(someUiState.effect)
    },
  ) {
    // 생략
  }
}


이 같은 방식은

위에서 나열한 4가지 방식은 필자가 생각나는 대로 적은 것이다.

1, 2, 3-2 방식은 viewModel을 결국 @Composable에서 알아야 한다는 단점이 생기고, 여기 까지 끌고 오기 위해선 Higher-order function을 계속 넘겨줘야 한다는 부분도 생긴다.

결국 Higher-order function을 계속 넘겨주는 것도 나쁜 건 아니지만 많아지면 관리할 부분이 많다는 점이 생긴다.

그렇다고 3-1번의 방식을 선택하는 것은 좋은 선택지는 아니라고 보는데, 고민이 필요하지만 개발 편의성에서는 최고의 선택일 듯 하다.


T Composable Architecture

필자가 작업한 T Composable Architecture는 과거에 작성한 글에서도 이야기했던 부분에 해당한다.

이 글에서도 이미 소개했던 방식을 Compose 전용으로 라이브러리화하여 배포했다.

여기선 용어도 수정하였고, 형태도 변경시켰다.

  • Action : Effect, MVI에서 말하는 Intent와 동일할 수 있다. view에서는 Action을 발생 시킨다.
  • reducer : ViewModel에서 View에서 발생한 Action을 받아 처리하는 부분이다. MVI 코드에서는 Intent를 reducer로 처리하는 것과 동일하다.

나머지는 코드 부분에 대한 부분이고, 이 글에서 알아야 할 부분은 위와 같이 2가지이다.


T Composable Architecture 활용하기

활용하기 전에 전체 sequence를 그려보았는데, flow 1과 flow 2로 나눠질 수 있다. Repository에서 이벤트 처리 후 UiState를 갱신할 수도 있고, SideEffect를 처리 할 수도 있다.

sample_02

전체 샘플 코드는 아래 링크를 참고하길

Sample code - link

디펜던시 정의

implementation("tech.thdev:composable-architecture-system:latestversion")

Action 정의

Action을 정의하는데, CaAction을 상속받아 구현한다.

sealed interface Action : CaAction {
    
    data object Task : Action // Use object for simple actions
    
    data object LoadData : Action // Use object for simple actions
}

SideEffect 정의

여기서는 SideEffect란 용어를 사용하였다. CaSideEffect를 상속 받아 구현한다.

sealed interface SideEffect : CaSideEffect {
    
    data object ShowToast : SideEffect // Use object for simple side effects
}

ViewModel 정의

Hilt를 사용하여 아래와 같이 사용할 ViewModel을 지정한다.

여기서는 reducer()를 상속받아 정의한다.

@HiltViewModel
class MainViewModel @Inject constructor(
    flowCaActionStream: FlowCaActionStream,
) : CaViewModel<Action, SideEffect>(flowCaActionStream, Action::class) {

  private val _uiState = MutableStateFlow(UiState())
  val state = _uiState.asStateFlow()

  override suspend fun reducer(action: Action): CaAction =
      when (action) {
          is Action.Task -> {
            _uiState.value = UiState(showPlaceholder = true)
            Action.LoadData // next event
          }

          is Action.LoadData -> {
              val loadEnd = // load network
              _uiState.value = UiState(text = loadEnd)

              sendSideEffect(SideEffect.ShowToast)

              CaActionNone // Or return another action
          }
          // ... other actions
      }
}

Activity에서 사용하는 경우

마지막으로 Activity에서는 아래와 같이 동작시켜줘야 한다.

@AndroidEntryPoint
class MainActivity : CaActionActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    @Composable
    override fun ContentView() {
        TComposableArchitectureTheme {
            val action = LocalActionOwner.current

            Column {
              Button(
                onClick = action.send(Action.LoadData),
              ) {
                Text(
                  text = "OnClick",
                )
              }

              Text(
                text = uiState.text,
              )
            }

            LaunchedEffect(Unit) {
                mainViewModel.loadAction() // Required: Load actions
                mainViewModel.action(Action.Task) // Option task
            }

            mainViewModel.sideEffect.collectAsEvent { // Optional: Handle side effects
                when (it) {
                    SideEffect.ShowToast -> {
                        Toast.makeText(this@MainActivity, "message", Toast.LENGTH_SHORT).show()
                    }
                    // ... other side effects
                }
            }
        }
    }
}

Composable에서 활용하는 경우

만약 Composable에서 이를 활용한다고 하면

@Composable
fun SomeScreen(
  mainViewModel: MainViewModel = viewModels(),
) {
  val uiState by mainViewModel.uiState.collectAsStateWithLifecycle()

  SomeScreen(
    uiState = uiState,
  )

  LaunchedEffect(Unit) {
      mainViewModel.loadAction() // Required: Load actions
      mainViewModel.action(Action.Task) // Option task
  }
}

@Composable
fun SomeScreen(
  uiState: UiState,
) {
  val action = LocalActionOwner.current

  Column {
    Button(
      onClick = action.send(Action.LoadData),
    ) {
      Text(
        text = "OnClick",
      )
    }

    Text(
      text = uiState.text,
    )
  }
}

구현할 부분에서는 loadAction을 호출을 LaunchEffect()에 따라 생성하도록 강제하였다.


LocalActionOwner의 활용은?

이 코드는 크게 Action을 처리하기 위한 내부 구현체가 하나 있다. 나머진 일반적인 MVVM + 리엑트, UiState 처리와 동일하다.

LocalActionOwner.current를 사용하였을때의 장점은 무엇일까?

  • viewModel, Higher-order function을 파라메터로 매번 넘길 필요없이 사용할 위치에서 즉시 LocalActionOwner.current를 사용하여 액션을 전달할 수 있다.

이 하나만으로도 개발에서 귀찮은 보일러플레이트 코드를 줄일 수 있을 거라고 생각한다.

다행히도 LocalActionOwner.current를 포함해도 Preview에 영향 받지 않는다는 점이다.


Action에서 Next 활용은?

그리고 앞서 언급하였듯 reducer에서는 next Action을 정의하도록 구현하였다.

  • filter하여 현재 Screen에서 발생한 이벤트만을 감지할 수 있다.
    • 내가 가진 화면에서의 Action 만을 처리한다. 만약 필요하면 상위에서 정의한 Action을 넘겨야 한다.
      • 보통 그럴일이 잘 없긴하다.
  • ViewModel에서 이벤트를 처리하고나서도 다른 액션을 또 처리해야할 때가 있다. A 액션이 끝나면 B 액션을 처리해야 하는 경우가 꼭 생긴다. 이런 부분을 어떻게 할까하다가 TCA에서 활용하는 방식으로 리턴 시켰다.
    • 이 부분은 MVI에서 UiState를 리턴시키는것과는 매우 다른 부분이다.

리턴 시킨 Aciton이 다음 이벤트 어떤 걸 처리할지를 지정할 수 있도록 하였는데, 이런 장점으로 인해 A가 끝나면 B로 이벤트를 전달하여 처리할 수 있다.

함수 다시 부르면 되는 것도 있겠지만 Stream 상에서의 다음 이벤트를 또 이어주기가 쉽진 않아 이 방식으로 구현하도록 해두었다.


주요 코드

주요 코드는 다음과 같다.

InternalCaAction@Singleton으로 작성했다는 점이 특징이다. 앱 내에 단 하나만 존재할 수 있다.

SharedFlow를 활용하여 여러 view에서 동일한 이벤트를 하나로 통합했지만, 어디에서나 접근 가능하다는 단점이 있다. 반대로 어디서든 보낼 수 있다는 장점이 있다.

@Singleton
class InternalCaAction @Inject constructor() : FlowCaActionStream, CaActionSender {

    private val flowCaAction = MutableSharedFlow<CaAction>(
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )

    override fun flowAction(): Flow<CaAction> =
        flowCaAction.asSharedFlow()
            .filter { it != CaActionNone }

    override fun send(action: CaAction) {
        flowCaAction.tryEmit(action)
    }

    override fun nextAction(action: CaAction) {
        send(action)
    }
}

이 문제를 해결하기 위해 CaViewModel에 필터링 코드를 추가했다.

abstract class CaViewModel<ACTION : CaAction, SIDE_EFFECT : CaSideEffect>(
    flowCaActionStream: FlowCaActionStream,
    actionClass: KClass<ACTION>,
) : ViewModel() {

    @VisibleForTesting
    val flowAction by lazy(LazyThreadSafetyMode.NONE) {
        flowCaActionStream.flowAction()
            .filterIsInstance(actionClass) // filter
            .map {
                reducer(action = it)
            }
            .onEach {
                flowCaActionStream.nextAction(it)
            }
    }
}

이제 작성한 screen을 벗어나는 일은 발생하지 않는다.


Compose에서 활용하기 위한 중요 코드

액티비티 기준으로 매번 갱신할 이유가 없어 staticCompositionLocalOf를 사용하였다. 영향 범위에 대한 글은 이전에 웹뷰 작성하면서 언급했기에 여기서는 언급하지 않는다.

Compose Navigation - WebView Recomposition 해결? - 링크

object LocalCaActionSenderOwner {

    private val LocalComposition = staticCompositionLocalOf<CaActionSender?> { null }

    val current: CaActionSender?
        @Composable
        get() = LocalComposition.current

    infix fun provides(registerOwner: CaActionSender): ProvidedValue<CaActionSender?> =
        LocalComposition provides registerOwner
}

provides를 시켜두고, 이를 지속해서 활용하는 형태로 만들었다.

CompositionLocalProvider(
    LocalCaActionSenderOwner provides caActionSender,
) {
    ContentView()
}

사용할 때는

val action = LocalActionOwner.current

Button(
  onClick = action.send(Action.Some)
) {}

이로 인해 파라미터 전달 이유도 사라지고, 필요한 부분에 LocalActionOwner.current를 호출하여 사용할 수 있다.


다음 작업은

다음 작업은 Alert/Toast/Snackbar를 Action을 활용해 처리하는 방법을 소개할 예정이다. UI 커스터마이징이 가능하도록 설계가 필요하며, Action으로 처리할지 별도로 분리할지 결정해야 한다.

이 부분에 대한 고민은 다음 글에서 다루도록 하겠다.


마무리

Slack의 Circuit - 링크을 사용하는 것도 좋은 방법 중 하나이지만, 직접 구현한 이유는 어렵지 않다고 생각했기 때문이다.

데이터 흐름이 중요한 부분이라 어려울 부분은 없다고 생각하지만, 개념만 가지고 만든 부분이라 정확하지 않을 수 있다.

이전에 작성한 Swift-composable architecture - 링크에서 영감을 받아 만든 라이브러리라고 생각하는 것이 편할 것 같다.

iOS TCA 살펴보기 - 링크



About Taehwan

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

Comments