TCA의 구성을 하나 씩 살펴보겠습니다.
앱의 상태 "State"
State는 Reducer의 현재 상태를 갖는 구조체입니다.
쉽게 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터를 소지합니다.
예를 들어 카운트를 증, 감 시키는 어플리케이션을 가정합니다
그럼 카운트를 표시하기위해 State는 다음과같이 count데이터를 소지하고 있을 것 입니다.
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
}
...
}
여기서 State는 Equatable인데요, 그 이유는 다음과 같습니다.
SwiftUI는 View의 상태가 변경되었을 때 해당 view를 자동으로 업데이트 합니다.
즉 현재 상태와 이 전 상태를 비교하여 불 필요한 렌더링을 방지하는데, Equatable을 적용하여 변화를 빠짐없이 체크하기 위해 Equatable한 Struct를 활용합니다(테스트와도 관련 있다고 합니다).
상태 변화를 일으키는 모든 동작 "Action"
Action은 디바이스와 User Interaction을 받아오기 위한 타입입니다.
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
}
enum Action: Equatable {
case decrementButtonTapped
case incrementButtonTapped
}
...
}
카운터 앱이면 숫자를 증가, 감소 시킬 수 있어야 할 것 입니다.
이 외에도 알림창을 닫거나, API의 Request를 받거나, 타이머를 작동시키거나 하는 복잡한 행동들도 Action에 추가할 수 있습니다.
변경을 처리한다 "Reducer"
Reducer는 App의 현재 State를 주어진 Action을 바탕으로 어떻게 다음 상태로 바꿀 것인지 묘사하고 어떤 결과(Effect)가 존재한다면 현재 상태(State)를 함수형으로, 가독성 좋게 작성하기 위해 고안되었습니다.
실제로 Action을 처리하게됩니다.
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
}
enum Action: Equatable {
case decrementButtonTapped
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
}
}
}
}
View에서 Action을 취하면 Action은 Reducer를 통해 State를 변화시킵니다.
Effect
1. Action에 따른 결과 Effect
- Reducer의 Action이 반환하는 타입 입니다.
- Action을 거친 모든 결과물입니다.
- 예상과 다른 결과물을 Side Effect라고 합니다.
- 외부 시스템과 상호작용 하는 작업을 나타냅니다. 이를 통해 앱의 State가 변경됩니다.
- State를 직접 변경하는 Action과 달리 비동기 작업을 수행하고 결과를 Action으로 변환하여 State에 반영하게 됩니다.
그래서 Effect는 특정 Action을 실행한 후 결과에 따라 새로운 Action을 생성하고 이를 통해 State를 업데이트 합니다.
TCA에서의 Effect는 다음과 같은 기능을 수행합니다.
- 비동기 작업 관리: 네트워크 요청, 데이터 로딩, 파일 다운로드 등등..
- Side Effect 분리: 함수형 프로그래밍 원칙에 따라 Side Effect를 배제합니다. Effect와 SideEffect를 다루는 부분을 명확히 분리하여 코드의 가독성을 높이고 추론력을 높이고 테스트와 디버깅에 용이해집니다.
- 취소 및 에러 핸들링: 비동기 작업의 성공, 실패 및 중단을 관리하는데 사용됩니다. 예를 들어 네트워크 요청중에 발생한 오류를 적절히 처리하고 State를 적절히 업데이트 합니다.
- 순서 보장: TCA의 Effect는 순차적으로 실행되며 그 순서가 보장됩니다.
순수함수적인 Effect
함수형 프로그래밍에서 순수함수는 아주 중요한 내용입니다.
*순수함수: 주어진 입력에 대해 항상 동일한 출력을 return하며 외부 상태를 변경하지 않고 Side Effect가 없는 함수를 의미합니다.
그렇기 때문에 순수함수로는 SideEffect나 비동기작업을 처리할 수 없습니다.
Effect는 직접 state를 변경하진 않습니다. 하지만 TCA에서 Effect는 사이드 이펙트와 같은 비동기 작업도 순수함수 방식으로 처리할 수 있도록 설계되었습니다.
예시 코드를 보겠습니다.
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
var fact: String?
var isLoadingFact = false
}
enum Action: Equatable {
case decrementButtonTapped
case incrementButtonTapped
case factResponse(String)
case getFactButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case .getFactButtonTapped:
state.fact = nil
state.isLoadingFact = true
// [count = state.count] 구문은 클로저 내부에서 state.count를 count로 사용하겠다는 의미입니다.
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "url 주소가 들어갈 장소/\(count)")!
)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoadingFact = false
return .none
}
}
}
}
.run, send등은 뒤에서 더 설명하겠습니다. 일단 구조만 보자면 .getFactButtonTapped 액션이 발생하면 외부 통신과 연결된 프로퍼티를 설정하고 외부 통신을 진행한 후 factResponse Action을 send하게 됩니다. 만약 외부 통신의 결과로 Error가 발생하여 Side Effect가 발생하게 되면(디코딩 과정 등) 이 또한 .showErrorToast 같은 액션을 만들어서 처리할 수 있을 것입니다.
Effect와 Combine(Rx)의 상관관계
Rx, Combine의 개념에는 Publisher(Observable)와 Subscriber가 있습니다.
Publisher: 데이터를 생성하고 전달하는 역할을 합니다. 이들은 비동기적으로 여러 값들을 시간에 따라 방출할 수 있으며, 이벤트나 데이터 스트림의 소스 역할을 합니다
Subscriber: Publisher 로부터 방출되는 데이터나 이벤트를 구독하고 받아 처리합니다. 데이터가 방출될 때마다 특정 액션을 취할 수 있도록 합니다.
Effect (TCA): TCA에서는 Effect를 사용하여 앱의 사이드 이펙트를 관리합니다. 이는 네트워크 요청, 타이머, 외부 데이터 피드 등과 같은 비동기 작업을 캡슐화합니다. Effect는 Combine의 Publisher와 유사하게, 작업의 결과를 시간에 따라 방출할 수 있습니다.
.run (Effect의 메서드): Effect 내에서 비동기 작업을 시작하고, 그 결과를 앱의 상태 변화로 연결하는 데 사용됩니다. 이는 Combine에서의 Subscriber가 Publisher를 구독하는 것과 유사한 역할을 하며, RxSwift에서의 Subscription 과정과 비교될 수 있습니다.
Combine과 TCA에서는 데이터나 이벤트의 흐름을 정의하는 "소스(Publisher/Effect)"와 이를 받아 처리하는 "리스너(Subscriber/.run)"의 관계를 통해 비동기 작업을 관리합니다.
RxSwift를 사용해본 경험이 있다면, Observable이 데이터를 방출하고 Observer가 이를 구독하는 방식을 이미 이해하고 계실 것입니다. TCA의 Effect와 Combine의 Publisher는 Observable과 유사한 역할을 하며, 데이터 흐름을 관리하고 구독하는 방식에서도 Observer와 Subscriber의 개념을 찾아볼 수 있습니다.
Effect와 Action
Effect와 Action은 각각 다른 역할을 담당합니다.
Action은 사용자에게서 발생하는 이벤트를 캡슐화하며 이는 State 변화를 일으키는데 사용됩니다. 예를 들어 버튼 클릭. 텍스트 입력, 타이머 완료, 네트워크 응답 등의 이벤트가 Action이 됩니다.
반면 Effect는 비동기 작업을 처리하고 결과를 다시 Action으로 반환하는 역할을 수행합니다.
Effect는 외부와의 상호작용을 위해 사용되며 해당 작업이 완료 ==> 적절한 Action을 실행 ==> State를 업데이트
또한 TCA의 Effect는 Swift의 Combine 프레임워크를 기반으로 작성되었습니다. 비동기 작업을 처리하고 Combine의 Publisher 및 Operator를 활용하여 작업을 조합하고 변환할 수 있습니다.
주요 메서드
.none
Effect는 반환해야 하는데 아무 동작도 취하지 않을 때 사용합니다.
예를 들어 액션에 대해 아무런 비동기 처리도 필요하지 않을 때 사용됩니다.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
....
}
}
.send
- 파라미터로 Action을 받는 메서드입니다.
- 특정 Action 이후 즉시 추가적인 동기 액션이 필요할 때 사용됩니다.
- 주로 자식 컴포넌트에서 부모 컴포넌트로 데이터를 전달할 때 사용됩니다.
- 액션을 전달하는 동시에 애니메이션을 지정할 수 있습니다.
예를 들어 카운터 앱에서 최대치에 도달한 경우 사용자에게 최대치 도달 알림을 전달해야 하는 경우 예를 들어보겠습니다.
struct CounterState: Equatable {
var count: Int = 0
var isMaxReached: Bool = false
}
enum CounterAction: Equatable {
case incrementButtonTapped
case maxCountReached
}
let counterReducer = Reducer<CounterState, CounterAction, Void> { state, action, _ in
switch action {
case .incrementButtonTapped:
state.count += 1
if state.count == 10 {
return .send(.maxCountReached)
}
return .none
case .maxCountReached:
state.isMaxReached = true
return .none
}
}
.run
- 비동기 작업을 래핑하는 메서드입니다.
- 인자로 비동기 클로저를 받아서 실행합니다.
- 클로저 내부에서 .send를 사용해 액션을 시스템에 전달 할 수 있습니다.
//.run method에 들어가는 operation 인자
operation: @escaping @Sendable (_ send: Send<Action>) async throws -> Void
struct CounterFeature: Reducer {
...
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .getFactButtonTapped:
state.fact = nil
state.isLoadingFact = true
// [count = state.count] 구문은 클로저 내부에서 state.count를 count로 사용하겠다는 의미입니다.
return .run { [count = state.count] send in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "url 주소가 들어갈 장소/\(count)")!
)
let fact = String(decoding: data, as: UTF8.self)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.fact = fact
state.isLoadingFact = false
return .none
}
}
}
}
.cancellable(id:), cancle(id:)
- Effect를 취소할 수 있게 만들어주는 메서드입니다.
- id는 Effect를 식별하는 값 입니다. 문자열 등 해쉬가 되는 값이라면 OK입니다.
- cancelInFlight라는 Bool타입의 인자가 있는데 Default는 false입니다. true라면 같은 id로 실행중인 Effect를 취소할 수 있습니다.
- 필요한 타이밍에 cancle한다면 이미 진행중인 Effect를 즉시 취소 후 취소된 결과를 전달합니다.
struct CounterFeature: Reducer {
struct State: Equatable {
var count = 0
var isTimerOn = false
}
enum Action: Equatable {
case togggleTimerButtonTapped
case timerTicked
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .togggleTimerButtonTapped:
state.isTimerOn.toggle()
if state.isTimerOn {
return .run { send in
while true {
try await Task.sleep(for: .seconds(1))
await send(.timerTicked)
}
}
.cancellable(id: "timer")
} else {
return .cancel(id: "timer")
}
case .timerTicked:
state.count += 1
return .none
}
}
}
}
.merge, .concateate
merge
- 여러개의 Effect들을 동시에 실행 시키는 기능입니다.
- 여러개의 이펙트를 인자로 받아서 결과를 병합합니다.
- 순서가 보장되지 않습니다.
concatenate
- merge와 같지만 순서를 부여합니다.
struct CounterFeature: Reducer {
struct State: Equatable {}
enum Action: Equatable {
case incrementButtonTapped
case decrementButtonTapped
case factResponse(String)
case getFactButtonTapped
// 숫자를 1 증가시키고 그 번호에 해당하는 사실을 가져오는 기능 추가
case increaseAndGetFactButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .increaseAndGetFactButtonTapped:
return .concatenate(
.send(.incrementButtonTapped),
.send(.getFactButtonTapped))
}
}
}
}