이번 프로젝트에서 SwiftUI + TCA 를 도입하여 개발 시작부터 앱 배포까지 진행을 해 보았다. 이를 통해 얻은 노하우와 팁 등을 정리하고자 TCA 시리즈를 준비해 보았다.
이번 게시글에는 TCA 의 구성요소, 의미, 그리고 간단한 구현 예제를 작성하고자 한다.
TCA, The Swift Composable Architecture 의 The 는 관사이니 제외하면 CA 만 남는다. 즉, Swift 를 이용한 Composable Architecture 라는 뜻이 된다.
Composable Architecture 에 대한 아이디어는 많은 정보를 찾을 수는 없었다. 아마도 아래의 내용에 대한 것을 포함한다면 Composable Architecture 라고 부르는 것으로 판단된다.
이를 통해 저는 TCA 의 가장 큰 장점으로 다음의 요소를 꼽고 싶다.
TCA 는 비즈니스 로직을 작게 분류하여 여러 개의 비즈니스 로직으로 만드는 데 특화된 아키텍처이다
TCA 의 기본 흐름도이다. 우선 구성요소들에 대한 설명은 아래와 같다.
Clean Architecture 의 상세한 설명은 (잘 모르기도 하고...) 아래의 그림으로 대체한다. (https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
가장 겉 껍질을 통해 계속 의존성이 전달되어야 함을 강조하는 그림이다.
클린 아키텍처 관점에서 TCA 는 어떻게 해석할 수 있을까?
TCA 의 View 와 Store 는 UI, Presenter, Controller 를 뜻한다. Dependency 는 UseCase 를 뜻한다.
다른 의미로는 Use Case, Entity 에 의한 여러 도메인들은 직접 정의해야 한다는 뜻이다. TCA 는 거기까진 책임지지 않는다.
View 는 명실상부 View 이다. 한 가지 책임이라 하면 (사실 iOS 의 핵심이지 않나 싶다) 유저 이벤트를 받는다는 것이다.
그럼 Store 는 어째서 ViewModel 일까? Store 가 State, Action 을 저장하고 있다는 것을 생각하면 ViewModel 의 다음 책임을 똑같이 수행하고 있다고 할 수 있다.
이렇게 본다면 View 로 사용자에게 보여줄 뷰를 그리고, Store 로 내부 로직을 정의하면 된다는 사실을 쉽게 알 수 있을 것이다.
Dependency 는 미리 정의된 DependencyKey, DependencyValue 를 이용해 어느 Store 에든 주입할 수 있는 객체이다. (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/dependencymanagement/)
UseCase, Entity 는 외부 환경과 소통하기 위해 정의하는 객체이다. 이를 통해 뷰에서 사용할 모델의 Raw Data 를 받아올 수도 있으며, ViewModel 에서 만든 데이터를 외부에 전달해야 할 수도 있다.
즉, mutable 할 필요가 없다. 실제 Dependency 도 Read-Only 이다. 아래는 DependencyKey 소스코드에 포함된 예제 코드이다. 유저를 불러오고 저장하는 클라이언트 객체이며, 수정될 이유가 없는 코드이다.
struct UserClient {
var fetchUser: (User.ID) async throws -> User
var saveUser: (User) async throws -> Void
}
extension UserClient: DependencyKey {
static let liveValue = Self(
fetchUser: { /* Make request to fetch user */ },
saveUser: { /* Make request to save user */ }
)
}
extension DependencyValues {
var userClient: UserClient {
get { self[UserClient.self] }
set { self[UserClient.self] = newValue }
}
}
자세한 사용법은 나중에 후술하도록 하겠다.
사실 이 부분은 개인적인 의견이 너무 많이 (사실 앞에도 그랬다) 포함되어 있다. TCA 는 자체적인 테스트 케이스 클래스를 포함하고 있는데 Unit/Integration-Test 이 모두 가능하다. 그렇게 때문에 TCA 에서 사용하는 테스트 클래스만으로 테스트를 수행해도 괜찮다.
그럼 Unit/Integration-Test 를 얼마나, 어디까지 수행해야 할 것인가? SUT(System under test) 를 어디까지 정의할 것인지가 중요한 것 같다. 그래서 필자는 SUT 를 Store 로 정의하고 테스트할 것을 추천한다. 그리고 Integration-Test 는 지양할 것을 추천한다.
Store 에는 뷰의 State 가 저장되어 있으며, 뷰가 수행해야 할 Action 도 정의되어 있다. 사실 애플리케이션에서 버그가 발생한다면 Store 부터 보게 될 것이다. 그렇기 때문에 Store 가 유일한 SUT 이다.
그리고 Integration-Test 를 추천하지 않는 이유는 Integration-Test 가 타겟팅하는 테스트 타겟은 Store 가 아니라고 생각하기 때문이다. Integration-Test 는 Dependency 에서 테스트해야 한다.
아마 TCA 개발팀도 같은 생각이었는지 TestStore 객체를 통해 테스트를 수행하는 것을 권장하고 있다. (https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#testing)
@MainActor
func testFeature() async {
// 기존 Store 초기화 방법
// Store(initialState: Feature.State(), reducer: { Feature() })
let store = TestStore(initialState: Feature.State()) {
Feature()
}
// 동기화 된 객체 변경 - Unit-Test 가능.
// send 를 통해 Action 전달. trailing closure 에는 Action 수행 후 변경될 State 와 같도록 mutable 한 State 를 조작.
await store.send(.incrementButtonTapped) { $0.count = 1 }
await store.send(.decrementButtonTapped) { $0.count = 0 }
// 비동기 Action 수행 - Integration-Test 가능
// send 를 통해 Action 전달. receive 에서 전달되는 KeyPath 는 State 의 KeyPath 이다.
// 위와 같이 예상되는 State 의 상태로 직접 변경해주는 것으로 테스트를 수행한다.
await store.send(.numberFactButtonTapped)
await store.receive(\.numberFactResponse) { $0.numberFactAlert = false }
}
참고로 여기서 Unit-Test, Integration-Test 는 각각 다음의 차이점을 갖고 정의했다.
아래는 간단한 계산기 앱을 구축해 볼 것이다. SwiftUI + TCA 를 사용할 것이며, 소스코드는 Repository_URL 에서 확인 가능하다.
개인적으로 아키텍처를 통해 정확히 책임을 분리하는 앱을 구현하고 싶었다.
struct CalculatorView: View {
typealias Feat = CalculatorFeature
let store: StoreOf<Feat>
var body: some View {
// 1
WithViewStore(store, observe: {$0}) { vs in
VStack {
HStack {
// 2
ForEachStore(
store.scope(state: \.textFields, action: Feat.Action.fromTextField)
) { textFieldStore in
CommonTextField(store: textFieldStore)
}
// 3
Picker(
Feat.Operator.addition.rawValue,
selection: vs.binding(get: \.operator, send: { .setOperator($0) })
) {
ForEach(Feat.Operator.allCases, id: \.self) { op in
Text(op.rawValue)
.lineLimit(1)
}
}
.foregroundStyle(.secondary)
.pickerStyle(.wheel)
.buttonStyle(BorderedButtonStyle())
.minimumScaleFactor(0.2)
}
.padding(.horizontal, 30)
.padding(.bottom, 50)
// 4
Button("Calculate!", systemImage: "rectangle.portrait.and.arrow.forward.fill") {
vs.send(.calculateButtonClicked)
}
.foregroundStyle(.primary)
.buttonStyle(BorderedButtonStyle())
}
// 5
.onAppear(perform: { vs.send(.refresh) })
.navigationDestination(isPresented: .constant(vs.result != nil)) {
ResultMessageView(result: vs.result ?? 0)
}
}
.navigationTitle("Calculator")
}
}
struct CommonTextField: View {
typealias Feat = CommonTextFieldFeature
let store: StoreOf<Feat>
var body: some View {
WithViewStore(store, observe: {$0}) { vs in
TextField(
text: vs.binding(get: \.text, send: { .setString($0) }),
prompt: Text(vs.prompt ?? "")
) {
EmptyView()
}
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.border(.secondary)
}
}
}
struct CalculatorFeature: Reducer {
typealias UseCase = ResultUseCase
typealias Operator = ResultUseCase.Operator
@Dependency(\.resultUseCase) var useCase: UseCase
struct State: Equatable {
var textFields = IdentifiedArrayOf<CommonTextFieldFeature.State>(uniqueElements: [.init(), .init()])
var `operator`: Operator = .addition
var result: Int?
var localError: UseCase.UseCaseError?
}
enum Action {
case refresh
case setOperator(Operator)
case setLocalError(UseCase.UseCaseError)
case setResult(Int)
case calculateButtonClicked
case fromTextField(CommonTextFieldFeature.State.ID, CommonTextFieldFeature.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .refresh:
state.result = nil
state.localError = nil
return .none
case .setOperator(let `operator`):
state.operator = `operator`
return .none
case .setLocalError(let error):
state.localError = error
return .none
case .setResult(let result):
state.result = result
return .none
case .calculateButtonClicked:
return .run { [lh = state.textFields[0].text, rh = state.textFields[1].text, op = state.operator] send in
let fetch = await useCase.getResult(lh, rh, op: op)
switch fetch {
case .success(let result):
await send(.setResult(result))
case .failure(let error):
await send(.setLocalError(error))
}
} catch: { error, send in
if let error = error as? UseCase.UseCaseError {
await send(.setLocalError(error))
}
}
default:
return .none
}
}
.forEach(\.textFields, action: /Action.fromTextField) {
CommonTextFieldFeature()
}
}
}
class ResultUseCase {
private let isTestable: Bool
private var cache: (lh: Int?, rh: Int?)
init(isTestable: Bool = false) {
self.isTestable = isTestable
}
enum Operator: String, CaseIterable {
case addition // +
case subtraction // -
case multiplication // *
case division // ÷
}
enum UseCaseError: Error {
case divideWithZero
case twoNumberZero
case sameInput
case undefinedNumbers
}
func getResult(_ lh: Int, _ rh: Int, op: Operator) async -> Result<Int, UseCaseError> {
await withCheckedContinuation { continuation in
self.calculate(lh: lh, rh: rh, op: op) {
continuation.resume(returning: $0)
}
}
}
func getResult(_ lh: String, _ rh: String, op: Operator) async -> Result<Int, UseCaseError> {
await withCheckedContinuation { continuation in
guard let lh = Int(lh), let rh = Int(rh) else {
continuation.resume(returning: .failure(UseCaseError.undefinedNumbers))
return
}
self.calculate(lh: lh, rh: rh, op: op) {
continuation.resume(returning: $0)
}
}
}
private func calculate(lh: Int, rh: Int, op: Operator, completionHandler: @escaping (Result<Int, UseCaseError>) -> Void) {
if cache.lh == lh && cache.rh == rh {
completionHandler(Result.failure(UseCaseError.sameInput))
return
}
if lh == 0, rh == 0 {
completionHandler(Result.failure(UseCaseError.twoNumberZero))
return
}
let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))
DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
guard (op == .division && rh == 0) == false else {
completionHandler(Result.failure(UseCaseError.divideWithZero))
return
}
self.cache = (lh, rh)
completionHandler(Result.success(lh.calculate(op, operand: rh)))
}
}
}
extension ResultUseCase: DependencyKey {
static var liveValue: ResultUseCase = .init()
static var testValue: ResultUseCase = .init(isTestable: true)
}
extension DependencyValues {
var resultUseCase: ResultUseCase {
get { self[ResultUseCase.self] }
set { self[ResultUseCase.self] = newValue }
}
}
private extension Int {
func calculate(
_ `operator`: ResultUseCase.Operator,
operand: Int
) -> Int {
switch `operator` {
case .addition:
return self + operand
case .subtraction:
return self - operand
case .multiplication:
return self * operand
case .division:
return self / operand
}
}
}
위의 로직은 ViewModel 역할을 하는 Store 에서 계산과 관련된 역할을 모두 가져왔다고 보면 된다. TCA 와 관련하여 주목할 사항은 위의 1,2 라고 생각한다.
final class TCACalculatorTests: XCTestCase {
@MainActor
func testExample() async throws {
let store = TestStore(initialState: CalculatorFeature.State()) {
CalculatorFeature()
}
await store.send(.refresh)
await store.send(.setOperator(.multiplication)) { state in
state.operator = .multiplication
}
await store.send(.setResult(5)) { state in
state.result = 5
}
}
@MainActor
func testDivisionError() async throws {
let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
.init(),
.init(text: "0")
]))) {
CalculatorFeature()
}
await store.send(.calculateButtonClicked)
await store.receive(/CalculatorFeature.Action.setLocalError) { state in
state.localError = .undefinedNumbers
}
}
@MainActor
func testFailed() async throws {
let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
.init(text: "2"),
.init(text: "0")
]))) {
CalculatorFeature()
}
await store.send(.setOperator(.division)) { state in
state.operator = .division
}
await store.send(.calculateButtonClicked).finish()
await store.receive(/CalculatorFeature.Action.setLocalError) { state in
state.localError = .divideWithZero
}
}
}
Unit Test 를 진행중이다. 원래는 비동기 테스트를 진행하지 않도록 하는 것이 원칙이나, 할 수만 있다면 하는 방법을 고민해봐야 할 것이다. 하나의 테스트로 여러 개 테스트할 수 있다면 좋은 거 아닐까?
하지만 UseCase 테스트를 빼먹으면 안된다고 생각한다. 내부에 정확한 테스트 자체는 Store 만 진행한다면 테스트 코드에 드는 노력도 너무 낭비될 것이다.
extension ResultUseCase: DependencyKey {
static var liveValue: ResultUseCase = .init()
static var testValue: ResultUseCase = .init(isTestable: true)
}
Dependency 로 추가될 값 중 test 에서 쓰일 의존성은 testValue 이다. 여기서 사용된 isTestable 프로퍼티는 아래와 같이 실행 타이밍을 지워준다.
let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))
DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
// [execute logic]
}
Integration-Test 는 UseCase 만 따로 진행해주면 대부분의 코드를 테스트할 수 있을 것이다.
func useCaseTests() async throws {
typealias E = ResultUseCase.UseCaseError
let useCase = ResultUseCase()
if case let .success(result) = await useCase.getResult(2, 0, op: .addition) {
XCTAssert(result > 0)
}
if case let .failure(error) = await useCase.getResult("2", "0", op: .multiplication) {
XCTAssertEqual(error, E.sameInput)
}
if case let .failure(error) = await useCase.getResult(0, 0, op: .addition) {
XCTAssertEqual(error, E.twoNumberZero)
}
if case let .failure(error) = await useCase.getResult("NotNum", "0", op: .addition) {
XCTAssertEqual(error, E.undefinedNumbers)
}
if case let .success(result) = await useCase.getResult("2", "4", op: .multiplication) {
XCTAssert(result == 8)
}
if case let .failure(error) = await useCase.getResult("2", "0", op: .division) {
XCTAssertEqual(error, E.divideWithZero)
}
}
전체 코드를 볼 수 있나요?