[SwiftUI] TCA(The Composable Architecture) 아키텍쳐 알아보기 ( 1편 - TCA, ReducerProtocol)
최근 SwiftUI로 새로운 프로젝트를 진행할 일이 생겨 SwiftUI의 아키텍쳐 패러다임에 대해 공부했고, 프로젝트에 적용중입니다. 링크에 SwiftUI의 MVVM에 대한 견해가 담긴 글이 있습니다. UIKit에서는 MVVM을 많이 이용했지만, SwiftUI의 단방향 Data Flow와 선언형 UI가 주는 장점을 이용하기 위해 TCA를 채택했습니다.
각설하고, SwiftUI의 아키텍쳐 중의 하나인 Pointfreeco의 TCA(the composable architecture)에 대해 정리해 보겠습니다.
본문은 TCA API Document와 TCA Readme 한국어 번역을 참고하여 작성했음을 알려드립니다.
TCA(The Composable Architecture)란?
- TCA는 일관되고 이해할 수 있는 방식으로 어플리케이션을 만들기 위해 탄생한 라이브러리입니다.
- Composition / Testing / Ergonomics(인체공학)를 염두에 두고 설계되었습니다.
- 상태 관리를 기반으로 한 단방향 데이터 구조를 가집니다.
Application 개발에서 일관된 방식은 코드의 흐름을 유추 가능하게하고, 객체 및 모듈 설계에 대한 고민을 줄여줍니다. TCA는 일관된 개발 방식을 제공함으로써 생산 및 유지보수성을 높여줍니다.
일관된 방식으로 Composable하게 구현되기 때문에, 작은 feature들을 Composition(합성)하고, 이를 다시 분해(scope 이용)하여 적재적소에 활용할 수 있도록 합니다.
다른 아키텍쳐와의 비교(ReSwift, MVVM)
1. TCA는 Unidirectional Data Flow를 채택했습니다.
- 이는 React의 Redux를 Swift로 구현한 ReSwift와 비슷한 구조를 가집니다.
- MVVM의 양방향 Data Flow와 대조됩니다.
- 단방향 데이터 흐름은 어플리케이션의 이벤트에 반응한 데이터의 가공 과정을 예상가능하게 만들어줍니다. 단방향 데이터 흐름에 대한 좋은 글이 있습니다.
2. @State로 관리되는 Store가 Source Of Truth의 역할을 합니다.
- MVVM(양방향 바인딩을 사용한)과 비교하여 산재된 데이터로 인해 신뢰성이 떨어지는 문제가 생기지 않습니다.
TCA가 제공하는 5가지 Story(철학, 가치, ... 등)
TCA는 개발자가 Application을 개발하며 마주칠 수 있는 5가지 Story에 대한 해결책을 제공합니다.
1. 상태 관리
- 간단한 값 타입으로 어플리케이션의 상태를 관리합니다.
- APP 전반적으로 상태를 공유하여 각 화면에서 일어나는 변화를 통합할 수 있습니다.
2. 합성
- 기능을 여러 개의 독립된 모듈로 추출하고, 다시 합쳐서 거대한 기능을 구현합니다.
- 아키텍쳐에서 책임의 분리를 분명하고 편리하게 해 줍니다.
3. 사이드 이펙트
- 어플리케이션의 바깥 세상과 접촉하는 작업을 testable하고 이해하기 쉽게 작성하는 방법을 제공합니다.
- 예를 들어 서버 통신과 같은 과정을 예상 가능한 흐름으로 쉽게 작성하게 해 줍니다.
4. 테스팅
- 아키텍쳐 내부의 기능 테스트를 수행할 수 있습니다.
- 여러 파트로 구성된 기능의 통합 테스트를 수행할 수 있습니다.
- 사이드 이펙트가 어플리케이션에 끼치는 영향을 전체적으로 테스트 할 수 있습니다.
5. 인체 공학
- 이러한 기능들을 가능한 한 적은 API로 구현합니다.
TCA의 구성요소 별 역할
TCA는 위의 Story별 해결책을 제공하기 위해 아래와 같은 구조로 설계되었습니다. Action, Store, State, Reducer, Environment가 핵심 멤버들이라는 것을 확인할 수 있습니다.
1. 상태(State)
- 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터에 대한 설명을 나타내는 타입입니다.
- View는 이 상태 변화를 감지하여 값과 범위(scope)에 따라 View를 다시 그립니다.
- View가 State를 변화시키는 방법은 Action을 통하는 것으로 한정됩니다.
2. 행동(Action)
- 사용자가 하는 행동이나 노티피케이션 등 어플리케이션에서 생길 수 있는 모든 행동을 나타내는 타입입니다.
- reducer의 State를 변화시키거나, 바깥 세상과 소통할 수 있는 Side EffectTask를 촉발시킬 수 있습니다.
3. 환경(Environment)
- API 클라이언트나 애널리틱스 클라이언트와 같이 어플리케이션이 필요로 하는 의존성(Dependency)을 가지고 있는 타입입니다.
- 최신 버전(0.5.0)에서 ReducerProtocol을 사용할 경우, Reducer 내부 필드로 선언하면 됩니다.
- @Dependency를 통해 Dependencies 라이브러리로 정의한 의존성을 사용할 수 있습니다.
4.리듀서(Reducer)
- 어떤 행동(Action)이 주어졌을 때 지금 상태(State)를 다음 상태로 변화시키는 방법을 가지고 있는 함수입니다.
- 실행할 수 있는 이펙트(Effect, 예시: API 리퀘스트)를 반환해야 하며, 보통은 Effect 값을 반환합니다.
- id값을 사용하여 Effect를 cancel 가능합니다.
5. 스토어(Store)
- 실제로 기능이 작동하는 공간입니다.
- 사용자 행동(Action)을 보내면, 스토어(Store)는 리듀서(Reducer)와 이펙트(Effect)를 실행합니다.
- View는 스토어(Store)에서 일어나는 상태(State) 변화를 관측(observe)해서 UI를 업데이트합니다.
Reducer Protocol을 사용하여 Store 구현하기(링크)
ReducerProtocol은 주어진 action에 따라서, 어플리케이션이 현재 state에서 다음 state로 갈 때 어떻게 evolve해야하는지에 대해 묘사하는 프로토콜입니다. 만약 있다면 필요한 Side Effect가 있다면 store에 의해 실행되어야 하는 EffectTask도 정의할 수 있습니다.
특정 Feature의 도메인, 로직, 행동을 나타내기 위해서 이 프로토콜을 사용할 수 있습니다.
아래는 예시입니다.
import Foundation
import ComposableArchitecture
struct Feature: ReducerProtocol {
struct State: Equatable {
var count = 0
var alert: AlertState<Action>? = nil
}
enum Action {
case incrementButtonTapped
case startTimer
case timerTick
case alertDismissed
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .incrementButtonTapped:
state.count += 1
if state.count == 2 {
state.alert = AlertState {
.init("타이머를 시작하시겠습니까?")
} actions: {
ButtonState.default(.init("시작하기"), action: .send(.startTimer))
}
}
return .none
case .startTimer:
return .run { send in
while true {
try await Task.sleep(for: .seconds(1))
await send(.timerTick)
}
}
case .alertDismissed:
state.alert = nil
return .none
case .timerTick:
state.count += 1
return .none
}
}
}
- View의 원천이 될 State, 사용자의 행동 또는 Effect에 의해 촉발될 수 있는 action을 정의합니다.
- reduce method 에서는 action의 case에 따라 원하는 State의 변화와 새로운 Effect를 반환합니다. action에서 필요한 Effect가 있는 경우 .startTimerButtonTapped에서 .run 메서드를 통해 Moder Concurrency로 Effect를 반환하고 있는 것을 볼 수 있습니다. 또한 timerTick이라는 action도 촉발시킵니다.
- reduce method가 아닌 body: ReducerProtocol을 구현할 수 있습니다. Reduce 메서드를 이용하면 새로운 Reducer를 선언할 필요 없이 로직을 내부에 추가할 수 있습니다. body는 @ReducerBuilder 어트리뷰트를 가지기 때문에 다른 Reducer들과 결합이 가능합니다.
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
// extra logic
}
Activity()
Profile()
Settings()
}
마지막으로, 실제 View에서 사용하기 위해서 다음과 같이 할 수 있습니다.
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack {
Button {
viewStore.send(.incrementButtonTapped)
} label: {
Text("PlusButton")
}
Text("count: \(viewStore.count)")
}
.alert(
store.scope(state: \.alert),
dismiss: .alertDismissed
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
store: Store(
initialState: Feature.State(),
reducer: Feature()
)
)
}
}
StoreOf<ReducerProtocol> 을 이용해서 Store의 Type을 받아왔고, View 프로토콜을 채택하는 WithViewStore로 store를 파라미터로 가지는 클로저를 받아올 수 있습니다. action을 trigger하고 싶다면 .send를 이용하고, store의 scope를 제한하고 싶다면 .scope 메서드를 이용할 수 있습니다.
다음과 같이 동작합니다.
이번 편에서는 TCA의 정의, 장점, 속성에 대해 알아보았습니다. 또한 ReducerProtocol을 채택한 Reducer를 구현하여, View의 Store에서 사용해 보았습니다.
다음 편에서는 Reducer의 합성에 대해 알아보겠습니다.