최근에 TCA에 관련되어 자료조사할 일이 생겨서 TCA 공식 문서와 공식 업데이트, 개발 방향에 대해 공부한 것을 정리해보려고 한다.
TCA
- pointfree에서 Brandon Williams와 Stephen Ceils가 만들어낸 아키텍처
TCA는 The Composable Architecture의 약자로서, 구성, 테스트 및 인체 공학적으로 개발자가 일관적으로 이해하여 애플리케이션을 구축할 수 있도록 도와주는 라이브러리이다.
SwiftUI, UIKit 등 모든 Apple 플랫폼(iOS, macOS, tvOS 및 watchOS)에서 사용할 수 있다.
The Composable Architecture can be used to power applications built in many frameworks, but it was designed with SwiftUI in mind, and comes with many powerful tools to integrate into your SwiftUI applications.
swift-composable-architecture
위 글에서 볼 수 있듯이 TCA는 SwiftUI를 염두에 두고 디자인되었으며 강력한 힘을 발휘한다.
TCA의 특징
1. 상태 관리
- 간단한 값 유형을 사용하여 애플리케이션의 상태를 관리하고, 한 화면이 다른 화면에서 즉시 관찰될 수 있도록 상태를 공유할 수 있다.
2. 구성
- 큰 기능을 작은 모듈로 분리하여 자체 모듈로 사용하거나 작은 모듈을 다시 합쳐 큰 기능을 구성하기위해 다시 합성할 수 있다.
3. 사이드 이펙트
사이드 이펙트(Side Effect): 실행 도중 어떠한 객체를 접근해서 의도치 않은 변화가 발생하는 행위를 말한다.
- 어플리케이션 바깥세상(외부 API 등)과 접촉하는 작업을 테스트할 수 있고 이해하기 쉽게 작성하는 방법을 제공한다. 이로 발생되는 사이드 이펙트를 분석하기에 용이하다.
4. 테스팅
- 아키텍처에 포함된 기능만을 테스트하는 것이 아니라 많은 부분으로 구성된 기능에 대해서도 테스트를 작성할 수 있다. 종단 테스트(end-to-end)를 작성하여 사이드 이펙트가 애플리케이션에 어떤 영향을 미치는지 확인할 수 있고 이것으로 작성된 비즈니스 로직이 개발자가 기대하는 방식으로 실행되는지 확인할 수 있다.
5. 인체 공학
- 가능한 적은 개념과 구성으로 간단한 API를 작성하여 위의 모든 것들을 수행할 수 있다.
- TCA는 개발자가 작은 구성들을 합하여 이해에 직관적인 성능 좋은 코드를 작성할 수 있도록 하며, 유지 관리와 테스트를 더 쉽게 해준다는 점에서 인체 공학적이라는 뜻은 사용했다고 볼 수 있다
TCA의 작동 방식
출처: https://www.youtube.com/watch?v=SfFDj6qT-xg
- 상태(State): 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터에 대한 설명을 나타내는 타입
- 행동(Action): 사용자가 하는 행동이나 Notification 등 어플리케이션에서 생길 수 있는 모든 행동을 나타내는 타입
- 환경(Environment): API 클라이언트나 애널리틱스 클라이언트와 같이 어플리케이션이 필요로 하는 의존성(Dependency)을 가지고 있는 타입
- 리듀서(Reducer): 어떤 행동(Action)이 주어졌을 때 지금 상태(State)를 다음 상태로 변화시키는 방법을 가지고 있는 함수
- 리듀서는 실행할 수 있는 이펙트(Effect, 예시: API 리퀘스트)를 반환해야 하며, 보통은 Effect 값을 반환
- 스토어(Store): 실제로 기능이 작동하는 공간(State, Reducer, Environment가 포함됨)
- 우리는 사용자 행동(Action)을 보내서 스토어(Store)는 리듀서(Reducer)와 이펙트(Effect)를 실행할 수 있고, 스토어(Store)에서 일어나는 상태(State) 변화를 관측(observe)해서 UI를 업데이트할 수 있음
과정
- TCA 작동의 시작점을 View라고 하자. View에서 사용자는 버튼 입력과 같은 행위로 Action을 발생시킬 수 있다.
- Action은 Reducer로 들어간다. Reducer는 Action이 들어왔을 때, 현재의 State(상태)를 다른 상태로 변화시키는 함수를 가진다.
- Reducer 함수가 실행되면 두 가지 경우로 나뉘는데, 그냥 바로 State를 변화시키거나 Environment를 실행시킨다. 도식 그림에서는 Reducer에서 Environment와 Effect가 나뉘는데, 사실 Environment와 상호작용을 한 결과 값을 Effect로 반환하여 Action에 들어가는 것이다.
- State로 바로 들어간 경우: 이 경우는 앱 내에서 간단한 로직처리를 한 상황이라고 할 수 있다. 예시로 특정 State + 1을 하는 경우, Text String을 반영하는 경우가 있다. 이 과정은 외부 API(Environment)와 상호작용할 필요가 없기 때문에 바로 Reducer → State가 가능하다.
- Environment로 진행한 경우: 이 경우는 외부 API와 통신해야하는 경우이다. 이러한 앱은 해당 API 클라이언트에 의존성(Dependency)을 갖게 되고, 이러한 의존성을 가지고 있는 타입을 Environment 라고 한다. 이제 Environment와 상호작용한 결과 값을 Effect 타입으로 반환한다. 이 Effect 결과가 다시 Action으로 들어간다. Effect는 개발자가 의도한 결과 값이나 실패한 결과 값을 가진다.
- Effect의 결과 값을 가지고 TCA 과정은 다시 Action 단계로 진행되고 Reducer 함수를 실행하여 해당 Effect 결과 값에 따른 State를 변화시킨다. 예시로, 의도한 결과 값인 경우 비즈니스 로직에 따른 State를 변화시키고, 의도치 않은 결과 값인 경우(에러) Alert 상태 값을 변화시켜 사용자에게 의도치 않은 결과 값이라는 것을 알릴 수 있겠다.
- 변화된 State 값으로 비즈니스 로직을 수행하거나 수행 결과 값을 View에 반영한다.
다시한번 정리하면, Environment는 외부 API와 통신한 결과 값을 다시 Action 단계를 거쳐 Reducer 단계에서 검토하여 결과를 적절한 State 값에 반영한다고 할 수 있다.
TCA 작동 예시 - 출처
예제
화면에 숫자와 이 숫자를 증가할 수 있는 + 버튼, 감소할 수 있는 - 버튼, 더 다양한 행동을 위해 탭 하면 API 호출을 해서 숫자에 관한 무작위 사실을 알림창으로 보여주는 버튼을 갖는 View를 구현한다.
위 예제를 구현하기 위해 우리는 ReducerProtocol을 준수하며 TCA의 구성 도메인을 정의할 것이다.
TCA는 State, Action, Reducer, Environment, View, Store를 기본 구성으로 갖고, View 하나당 한 개의 Store(State, Action, Reducer, Environment?)를 갖는다.
Environment에만 물음표가 붙은 이유
TCA 개념과 작동방식을 설명할 때 Environment를 다른 구성요소들과 동일하게 강조했다.
하지만 이번 게시물에서 TCA 예제를 학습할 때 Environment를 객체로 정의하거나 메소드로 정의해서 사용하지 않아 의아할 수 있을 것이다.
왜 Environment만 다른 구성 요소와 사용 방법이 다를까?
사실 공식 문서에 사용되는 표기법은 아니지만, 이해를 돕기 위해서 Environment는 Store 내에서 필수로 할당할 필요가 없다는 의미로 필자가 물음표를 사용했다.
자세한 설명은 본 게시물을 모두 이해한 상태로 다음 포스팅을 참고해주기 바란다.
Store: Store는 State, Action, Reducer, Environment?가 정의된 집합체이다.
import ComposableArchitecture
struct Feature: ReducerProtocol {
}
State: 화면의 숫자를 Count 할 정수 타입 프로퍼티와 알림창을 통해 보여줄 숫자의 String 타입 프로퍼티를 State로 정의한다. (알림창이 뜰 필요가 없는 상황에선 nil 값을 넣어야 하니 옵셔널로 정의)
struct State: Equatable {
var count = 0
var numberFactAlert: String?
}
Action: 증가 버튼이나 감소 버튼을 누르는 행동, 알림창을 닫거나 의존성을 갖는 API request 결과를 받았을 때 수행하는 행동처럼 다양한 행동들이 있다.
- 통상 Action을 정의할 때 enum으로 Action을 정의한다.
enum Action: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(String)
}
Reducer: Reducer는 현재 상태(State)를 변화시켜서 다음 상태로 만드는 방법에 대한 설명과 어떤 이펙트(Effect)가 실행돼야하는지에 대한 설명이 필요하다. 만약 어떠한 이펙트도 실행이 필요하지 않은 경우엔 .none을 반환한다.
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .factAlertDismissed:
state.numberFactAlert = nil
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case .numberFactButtonTapped:
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)/trivia")!
)
await send(
.numberFactResponse(String(decoding: data, as: UTF8.self))
)
}
case let .numberFactResponse(fact):
state.numberFactAlert = fact
return .none
}
}
reducer 함수에서 의존성(Environment) 기능의 함수를 확인할 수 있는데, case .numberFactButtonTapped: 의 내용이 그렇다.
그동안의 설명에서 TCA의 의존성은 Environment라고 했는데, reducer 함수에서 의존성 기능은 있지만 Environment가 Struct나 Class, Enum의 객체 형태로 구현되어있지 않다.
이 예제의 경우, 의존성 기능이 단 한개(외부 API로부터 랜덤 숫자 하나를 가져오는)인 간단한 로직이기 때문에 Environment 객체를 정의해서 사용하지 않았고, reducer 함수에 단지 의존성 기능의 함수 코드를 이어붙이기 하여 구현했다.
이에 대한 자세한 설명은 위에서 언급한대로 다음 포스트를 확인해주기 바란다.
Store 정리: 최기에 작성한 Store 틀에 State, Action, Reducer를 포함한 결과이다.
import ComposableArchitecture
struct Feature: ReducerProtocol {
struct State: Equatable {
var count = 0
var numberFactAlert: String?
}
enum Action: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(String)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .factAlertDismissed:
state.numberFactAlert = nil
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case .numberFactButtonTapped:
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)/trivia")!
)
await send(
.numberFactResponse(String(decoding: data, as: UTF8.self))
)
}
case let .numberFactResponse(fact):
state.numberFactAlert = fact
return .none
}
}
}
View: 우리가 구현하고자 하는 작동 View를 정의한다.
StoreOf<Feature>가 있으면 모든 상태 변화를 관측하고 UI를 다시 그릴 수 있으며, 사용자 행동을 보내서 상태를 변화할 수도 있다. .alert 수정자가 요구하는 대로 Identifiable을 따르는 구조체를 정의하여 여기에 numberFactAlert을 맵핑한 결과를 전달한다.
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack {
HStack {
Button("−") { viewStore.send(.decrementButtonTapped) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.incrementButtonTapped) }
}
Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
}
.alert(
item: viewStore.binding(
get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
send: .factAlertDismissed
),
content: { Alert(title: Text($0.title)) }
)
}
}
}
struct FactAlert: Identifiable {
var title: String
var id: String { self.title }
}
App 진입점은 다음처럼 사용할 수 있다.
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
}
앱 진입점에서 View의 store를 초기화할 때, 초기상태 값으로 미리 구조체로 정의한 특정 도메인의 Store로 상태값을 할당했다.
이로서 화면을 보여주기 위한 작업이 모두 끝났다. 이렇게 여러 단계를 통해 기능을 만드는 것은 순수하게 SwiftUI로 만드는 것보단 확실히 복잡해보이긴하지만, 그만큼 이점이 있다.
이 과정은 도메인 로직의 결과가 단순히 관측 가능한 객체나 UI 컴포넌트에 상태 값을 전달하는 것뿐만 아니라, 상태 변경을 적용하는 것에 일관된 태도를 갖는다는 거에 의의를 가진다.
즉, 상태 변경에 대한 신뢰성을 갖게 된다. 또한 사이드 이펙트를 간결하게 표현하는 방법도 제공한다. 그리고 추가적인 작업 없이 이펙트가 포함된 로직을 바로 테스트할 수도 있다.
정리
TCA의 가장 큰 특징은 단방향 아키텍쳐라는 점이다. 이로서 얻을 수 있는 확실한 장점은 상태 관리의 신뢰성이다.
MVC의 경우, View와 Controller가 서로 양방향으로 작동하고 있다. 따라서 View에 정보 전달을 하는 역할군이 Model과 Controller 두 개가 동시에 하고 잇다.
만약 도메인 기능이 복잡하게 얽혀있다면 데이터의 상태흐름을 따라가기 어려워 개발자는 휴먼 에러가 발생하면 유지보수에 어려움이 생긴다.
TCA처럼 단방향 아키텍쳐인 경우 State의 흐름상태를 유추하기 쉽기 때문에 유지보수성이 높아질 것이다.
TCA는 기능의 재사용성을 높이기 때문에 애플리케이션을 확장하는데 큰 도움이 될 것으로 보인다.
또한 아키텍처에서 의존성에 대한 사이드 이펙트 관리에 강력한 기능을 제공한다. 이를 통해 쉽게 테스트하여 의존성이 전체 애플리케이션에 미치는 영향과 버그를 쉽게 파악할 수 있다.
하지만 단점도 존재한다.
예제를 통해서 알 수 있듯이, 기본 SwiftUI 기능만을 사용해서도 쉽게 구현이 가능한 작은 규모의 앱에 TCA를 적용하면 구조 및 관리에 필요한 일부 코드와 구성이 복잡해질 수도 있을 것 같다.
따라서 프로젝트의 규모와 요구 사항에 따라서 아키텍처를 선택해야지 무조건적인 TCA 도입은 지양해야겠다.
출처
https://green1229.tistory.com/325
https://gist.github.com/pilgwon/ea05e2207ab68bdd1f49dff97b293b17
'Swift' 카테고리의 다른 글
애플 Text엔진 TextKit (2) | 2024.09.08 |
---|---|
TCA - Binding (0) | 2023.08.17 |
TCA - Environment는 어디갔나? (0) | 2023.07.10 |
Swift 네트워크 추상화 - URL 처리방법 (0) | 2023.05.09 |
객체지향 생활체조 (0) | 2023.05.05 |