해당 블로그 글은 구버전에서 작성된 소개글입니다. TCA 1.0 버전은 이 링크를 통해 학습하실 수 있습니다. 제가 직접 집필하였으며, 무료입니다.
이번에 출시한 앱 SeoulSalam에 적용된 아키텍쳐 프레임워크 TCA에 대해 공부한 내용을 공유해보도록 하겠습니다.
TCA를 개발한 사람들의 공식 사이트이자, 이번에 공부하고 코드를 짤 때 제일 많이 방문한 사이트이다.
이 글을 읽고 관심이 생긴 사람들은 위의 사이트에서 더 자세한 내용을 확인하고 공부하길 바란다. 이 글은 그저 나의 TCA 입문기로서, 공식 사이트에 비하면 아주 제한적이며 필자가 이해한 내용에 기반하여 다루게 될 것이다.
TCA는 단방향 아키텍쳐로서 기존의 단방향 아키텍쳐인 Redux, Flux, 그리고 이것의 swift 버전인 ReactorKit과 비슷하다.
잠깐, 단방향 아키텍쳐는 뭐고, 또 swift 언어로 작성된 ReactorKit이 있는데 왜 TCA가 이렇게 핫한거에요?
라는 질문에 대답하는 것이 이번 글의 주제가 될것이다 :)
이 블로그가 항상 그랬다시피, 학술적이고 정교한 내용이 아닌, 필자가 스스로가 이해하기 쉬운, 다소 직관적인 설명으로만 이루어져 있음을 다시 한 번 인지해주시길 바랍니다.
1. 단방향 아키텍쳐
1.1. 단방향 아키텍쳐는 무엇이고 왜 생겼을까?
위의 이미지는 필자가 공부할때 필자 스스로 이해하기 쉬우라고 새로 그린 TCA의 도식이다ㅎㅎ...
다른 도식들을 찾아봤을때 뭔가 '단방향'인게 직관적으로 이해도 안가고 그래서 내 멋대로 그려봤는데 이건 다른 사람에게 보기 쉬울까 우려가 된다..ㅎㅎ;;
MVC의 도식과 비교하여 보면 위의 TCA 도식은 쉽게 말해서 '화살표가 겹치는 부분'이 없다는 것을 느낄 수 있을 것이다!
즉, 기존의 구조는 State가 여러 곳에서 영향을 받게 되며, 그렇게 되면 작업들(비동기 포함)은 꼬이게 되고 결과적으로 보이게 되는 State가 예측불능하게 된다. 이는 기존의 구조대로 앱을 만들게 되면 필연적으로 만나게 되는 현상이고, 나름 큰 규모의 기업들도 가지고 있는 문제다. (필자의 Facebook과 DIscord 등의 알림, 남은 메세지 등이 이상하게 보이는 버그도 아마 이러한 현상일 것이라고 예측해본다)
반면 TCA와 같은 단방향 아키텍쳐들은 말그대로 결과적으로 View에 보이게되는 State에 전달되는 변경사항들이 한 방향으로만 수정된다. 그리고 필자 개인적으로는 상태 관리 중심으로 코드를 짜게 되므로 머리속에 State의 흐름만 인지한 채로 코드를 짜게 되니까, 앱이 복잡해져도 코드도 굉장히 정리가 잘 되어있고 나름 수월하게 작업을 진행할 수 있었다.(유지보수성이 좋을 것 같다)
1.2. ReactorKit 있다면서! 근데 왜 TCA를 쓰나요?
기본적으로 ReactorKit과 TCA는 모두 단방향 아키텍쳐 패턴으로, ReactorKit은 UIKit과 함께 쓸 때 많이 쓰고, SwiftUI에게는 TCA가 더욱 잘 어울린다고 생각한다.
그 이유로는,
- SwiftUI에서는 ReactorKit에서 사용하는 Reactor 클래스를 사용할 필요도 없고, 더 복잡해진다고 한다.
- 반대로 TCA를 UIKit에서 사용할 수 있다고 하지만, README.md에서는 fold되어있는 상태이다.
- ReactorKit은 RxSwift+MVVM이 기반이 되는 패턴이다. 따라서 RxSwift와 ReactorKit 두가지 외부 라이브러리를 같이 사용하는 것이 주된다.
이것은 필자 100% 개인적인 생각이지만,애플 순정 프레임워크를 선호하기 때문에 SwiftUI + TCA가 필자에겐 더욱 매력적으로 느껴진다. 그리고 TCA 공식 블로그에서도 컴파일러의 안정성을 더욱 챙겼다는 이야기가 많이 나온다.
2. TCA 입문
그렇다면 예시와 함께 위의 도식에 나와있는 구성요소들을 설명하고, 이해하기 쉽게 알아보자.
import ComposableArchitecture
// MARK: - Reducer
struct Feature: ReducerProtocol {
// MARK: - State
struct State: Equatable {
var count = 0
var numberFactAlert: String?
}
// MARK: - Action
enum Action: Equatable {
case factAlertDismissed, decrementButtonTapped, incrementButtonTapped, numberFactButtonTapped
case numberFactResponse(TaskResult<String>)
}
// MARK: - Reduce
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 .numberFactButtonTapped:
return .task { [count = state.count] in
await .numberFactResponse(
TaskResult {
String(
decoding: try await URLSession.shared.data(from URL("..")!).0,
as: UTF8.self
)
}
)
}
case .numberFactResponse(.success(let fact)): state.numberFactAlert = fact
return .none
case .numberFactResponse(.failure): state.numberFactAlert = "Colud not load a number fact :("
return .none
}
}
}
출처 : https://github.com/pointfreeco/swift-composable-architecture
위 코드는 TCA의 Github에서 가져온 코드이다.
ReducerProtocol을 따르는 Feature 구조체 자체가 store라고 생각해도 무방할 것 같다.
따라서, 위 도식에서 Store이라고 쓰여있는 네모칸 안에 있는 요소들 중 State, Action, Reducer이 모두 담겨져 있는 것을 볼 수 있다.
코드를 보면, state들이 정의되어 있고 그것이 reduce라는 함수 안에서 변화가 일어나고 있는 것을 알 수 있다.
따라서 위 코드와 연결된 View에서는 숫자를 늘리고 줄이는 Action 혹은 FactAlert를 보내는 Action을 보낼 수 있을 것이다.
그렇게 보내진 Action은 Reducer로 가서 해당 작업을 수행하게 되고, 예를들어 그 작업이 decrementButtonTapped라면 count라는 state가 -1이 되는 것을 알 수 있다.
근데 Envrionment하고 Effect는 어디갔나요?
// State
struct CalendarState: Equatable {
var timing: PrayerTime? = nil
var isLoading: Bool = false
}
// Action
enum CalendarAction: Equatable {
case fetchItem(_ yearAndMonth: String, _ day: Int)
case fetchItemResponse(Result<PrayerTime, CalendarClient.Failure>)
}
// Environment
struct CalendarEnvironment {
var calendarClient: CalendarClient
var mainQueue: AnySchedulerOf<DispatchQueue>
}
// Reducer
let calendarReducer = Reducer<CalendarState, CalendarAction, CalendarEnvironment> {
state, action, environment in
switch action {
case .fetchItem(let yearAndMonth, let day):
enum FetchItem {}
state.isLoading = true
return environment.calendarClient
.fetchCalendarItem(yearAndMonth, day)
.catchToEffect(CalendarAction.fetchItemResponse)
case .fetchItemResponse(.success(let timing)):
state.timing = timing
state.isLoading = false
return Effect.none
case .fetchItemResponse(.failure):
state.timing = nil
state.isLoading = false
return Effect.none
}
}
이번에 출시하는 앱에서, 무슬림들의 기도시간을 불러와주는 api를 사용하여 시간들을 뿌려주는 View의 Store이라고 보면 될 것같다.
위 코드는 TCA의 공식코드와는 다르게 Effect와 Environment가 있는 것을 볼 수 있다. 왜 이러한 차이가 생길까?
나의 코드는 timing이라는 state 변경을 위해 api와 통신을 할 필요성이 있으며, 그렇게 된다면 통신을 하는 client와 의존성이 생긴다. 따라서 Environment는 구조체에 var calendarClient: CalendarClient 를 보면 알 수 있듯 외부에 만들어놓은 client라는 통신 구조체와 의존성이 생긴 것이다.
따라서 TCA공식 코드와는 다르게 reducer 부분에 Effect.none 으로 반환이 되는 이유는 Environment 안에 있는 Client에서 결과를
Effect<PrayerTime, Failure>로 반환하기 때문이다.(해당코드는 생략되어있는 점 양해부탁드립니다)
그렇게 반환된 Effect는 .success와 .failure로 response의 결과에 따라 처리가 달라진다. success에서는 원하는 결과가 나왔을 경우이므로 timing이라는 state에 통신해서 받아온 결과를 업데이트해주고, .failure에서는 통신에 무언가 잘못이 있을 경우이므로 timing이라는 state에 nil을 넣어 view에서 통신에 실패했음을, 에러가 있음을 알려주도록 처리해줄 수 있도록 하였다.
이렇게 TCA 기초 정리를 가장한 필자의 TCA 입문기에 대해서 다루어 보았다. :)
TCA는 데이터 흐름이 명확하여 상태관리가 쉽고, 그 상태 변화를 처리하는 모든 코드가 한 곳에 있기에 디버깅에 좋다. 따라서 TCA는 유닛테스트 작성에 좋다고 하는데, 그래서 다음엔 TCA 코드의 테스트 코드 작성기에 대해도 다루어 보려고 한다.
'iOS' 카테고리의 다른 글
Swift Async - Await, Combine 원만한 합의 부탁드리겠습니다.(3 - 完) (0) | 2023.05.10 |
---|---|
WWDC2023 Swift Student Challenge에서 accepted 했으나 수상실패한 경험 회고 (0) | 2023.05.10 |
fastlane을 통한 CI/CD 자동화 (0) | 2023.05.09 |
Xcode 패키지 무한로딩 오류 (0) | 2023.05.07 |
아랍어 Localization 추가 시 발생하는 아주 의외의 문제 (0) | 2023.05.07 |