반응형

TCA(The Composable Architecture) 사용 가이드(v1.23.1)

 

1. 설치 방법 (SPM)


Xcode 패키지 추가

  1. Xcode 메뉴에서 File → Add Package Dependencies… 선택
  2. 아래 URL 입력
    text https://github.com/pointfreeco/swift-composable-architecture
  3. 버전 규칙 설정
    • Up to Next Major Version
    • 1.23.1 이상, 2.0.0 미만으로 지정 (예: from: "1.23.1")

Package.swift 직접 사용하는 경우 예시:

// swift-tools-version: 5.10

import PackageDescription

let package = Package(
  name: "MyApp",
  platforms: [
    .iOS(.v17),
  ],
  dependencies: [
    .package(
      url: "https://github.com/pointfreeco/swift-composable-architecture",
      from: "1.23.1"
    )
  ],
  targets: [
    .target(
      name: "MyApp",
      dependencies: [
        .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
      ]
    )
  ]
)

 

2. 기본 개념 정리

TCA 1.23.1에서도 핵심 개념은 동일합니다.

  • State : 화면/도메인 상태
  • Action : 사용자/시스템 이벤트
  • Reducer : 상태 + 액션 → 새로운 상태 + Effect
  • Store : 상태를 보관하고, 액션을 받아 리듀서를 구동하는 객체
  • Effect : 비동기 작업, API 호출, 타이머 등 사이드 이펙트 표현
  • Dependency : 외부 의존성(클라이언트, 환경 객체)을 주입하는 메커니즘

1.23 이후 버전에서는 다음 매크로 기반 API 사용이 기본입니다.

  • @Reducer
  • @ObservableState
  • StoreOf<Feature>
  • BindingReducer
  • @Dependency / DependencyValues

 

3. 최소 예제: CounterFeature


3.1 리듀서 정의

import ComposableArchitecture

@Reducer
struct CounterFeature {
  @ObservableState
  struct State: Equatable {
    var count = 0
  }

  enum Action: Equatable {
    case incrementButtonTapped
    case decrementButtonTapped
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementButtonTapped:
        state.count += 1
        return .none

      case .decrementButtonTapped:
        state.count -= 1
        return .none
      }
    }
  }
}

3.2 Store 생성

let counterStore = Store(initialState: CounterFeature.State()) {
  CounterFeature()
}

 

4. SwiftUI 연동 (@Bindable / @ObservableState)

TCA 1.23.1에서는 SwiftUI와의 결합을 위해 @ObservableState + @Bindable 조합 사용이 기본 패턴입니다.

struct CounterView: View {
  @Bindable var store: StoreOf<CounterFeature>

  var body: some View {
    VStack(spacing: 16) {
      Text("Count: \(store.count)")
        .font(.largeTitle)

      HStack(spacing: 24) {
        Button("-") {
          store.send(.decrementButtonTapped)
        }

        Button("+") {
          store.send(.incrementButtonTapped)
        }
      }
    }
    .padding()
  }
}

루트 진입점 예시:

@main
struct MyApp: App {
  let store = Store(initialState: CounterFeature.State()) {
    CounterFeature()
  }

  var body: some Scene {
    WindowGroup {
      CounterView(store: store)
    }
  }
}

 

5. 상태 바인딩과 BindingReducer

@ObservableState와 @Bindable를 사용하면 SwiftUI의 양방향 바인딩을 TCA 액션으로 자동 변환할 수 있습니다.

폼/토글/텍스트필드가 많은 화면에서 특히 유용합니다.

5.1 State / Action

@Reducer
struct ProfileFeature {
  @ObservableState
  struct State: Equatable {
    var name: String = ""
    var isMarketingOn: Bool = false
  }

  enum Action: BindableAction, Equatable {
    case binding(BindingAction<State>)
    case saveButtonTapped
  }

  var body: some ReducerOf<Self> {
    BindingReducer()   // binding 액션 처리

    Reduce { state, action in
      switch action {
      case .binding:
        // name / isMarketingOn 변경은 BindingReducer가 처리
        return .none

      case .saveButtonTapped:
        // 저장 로직 (Effect.run 등)
        return .none
      }
    }
  }
}

5.2 View

struct ProfileView: View {
  @Bindable var store: StoreOf<ProfileFeature>

  var body: some View {
    Form {
      TextField("이름", text: $store.name)
      Toggle("마케팅 정보 수신 동의", isOn: $store.isMarketingOn)

      Button("저장") {
        store.send(.saveButtonTapped)
      }
    }
  }
}

BindingReducer()가 binding 액션을 자동으로 처리하므로, 별도의 액션 매핑 코드가 크게 줄어듭니다.

 

6. Effect.run 기반 비동기 처리

TCA 1.23.x 라인에서는 비동기 작업을 표현할 때 Effect.run 패턴을 사용합니다.

6.1 액션 정의 예시

enum Action: Equatable {
  case onAppear
  case response(TaskResult<String>)
}

6.2 리듀서에서 Effect.run 사용

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .onAppear:
      return .run { send in
        do {
          // 비동기 작업 (예: 네트워크 호출)
          let value = try await someAsyncAPI()
          await send(.response(.success(value)))
        } catch {
          await send(.response(.failure(error)))
        }
      }

    case let .response(.success(value)):
      state.message = value
      return .none

    case .response(.failure):
      state.message = "에러가 발생했습니다."
      return .none
    }
  }
}

여기서 send는 액션을 다시 시스템으로 전달하는 타입으로, 1.23.1 문서에서 Effect.run과 함께 설명됩니다.

 

7. 의존성 관리 (@Dependency / DependencyValues)

1.23.1에서도 @Dependency + DependencyValues를 사용해 의존성을 주입합니다.

7.1 의존성 정의

import ComposableArchitecture

struct UserClient {
  var fetch: @Sendable () async throws -> [User]
}

extension UserClient: DependencyKey {
  static let liveValue = Self(
    fetch: {
      // 실제 네트워크 구현
      try await Task.sleep(nanoseconds: 300_000_000)
      return [User(id: 1, name: "Kka7")]
    }
  )
}

extension DependencyValues {
  var userClient: UserClient {
    get { self[UserClient.self] }
    set { self[UserClient.self] = newValue }
  }
}

7.2 리듀서에서 사용

@Reducer
struct UserListFeature {
  @ObservableState
  struct State: Equatable {
    var users: [User] = []
    var isLoading = false
  }

  enum Action: Equatable {
    case onAppear
    case usersResponse(TaskResult<[User]>)
  }

  @Dependency(\.userClient) var userClient

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        state.isLoading = true
        return .run { send in
          await send(
            .usersResponse(
              TaskResult {
                try await userClient.fetch()
              }
            )
          )
        }

      case let .usersResponse(.success(users)):
        state.isLoading = false
        state.users = users
        return .none

      case .usersResponse(.failure):
        state.isLoading = false
        // 에러 상태 업데이트
        return .none
      }
    }
  }
}

테스트 시에는 store.withDependencies를 이용해 목 클라이언트를 쉽게 주입할 수 있습니다.

 

8. Feature 구성 및 Scope

1.23.1에서는 Scope(state: \\State.child, action: \\Action.child) 형태의 키 패스 기반 API를 사용합니다(이전 /action.child는 deprecated).

8.1 Parent / Child 구조 예시

@Reducer
struct ChildFeature {
  @ObservableState
  struct State: Equatable {
    var text = ""
  }

  enum Action: Equatable {
    case textChanged(String)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .textChanged(text):
        state.text = text
        return .none
      }
    }
  }
}

@Reducer
struct ParentFeature {
  @ObservableState
  struct State: Equatable {
    var child = ChildFeature.State()
  }

  enum Action: Equatable {
    case child(ChildFeature.Action)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \\State.child, action: \\Action.child) {
      ChildFeature()
    }

    Reduce { state, action in
      switch action {
      case .child:
        return .none
      }
    }
  }
}

SwiftUI에서의 사용 예:

struct ParentView: View {
  @Bindable var store: StoreOf<ParentFeature>

  var body: some View {
    ChildView(
      store: store.scope(state: \\.child, action: \\.child)
    )
  }
}

 

9. Stack 기반 네비게이션 (NavigationStack 연동)

TCA 1.23.1 문서에서는 Stack-based navigation을 공식적으로 다룹니다. NavigationStack의 path를 TCA 상태로 관리하는 패턴입니다.

9.1 Stack 상태 정의

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var path = StackState<DetailFeature.State>()
    var home = HomeFeature.State()
  }

  enum Action: Equatable {
    case home(HomeFeature.Action)
    case path(StackAction<DetailFeature.State, DetailFeature.Action>)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \\State.home, action: \\Action.home) {
      HomeFeature()
    }

    Reduce { state, action in
      switch action {
      case .home(.itemTapped(let item)):
        state.path.append(.init(item: item))
        return .none

      case .path:
        return .none
      }
    }
    .forEach(\\State.path, action: \\Action.path) {
      DetailFeature()
    }
  }
}

9.2 View에서 NavigationStack 사용

struct AppView: View {
  @Bindable var store: StoreOf<AppFeature>

  var body: some View {
    NavigationStack(path: $store.path) {
      HomeView(
        store: store.scope(state: \\.home, action: \\.home)
      )
      .navigationDestination(
        for: DetailFeature.State.self
      ) { detailState in
        if let detailStore = store.scope(
          state: \\.path[id: detailState.id],
          action: \\.path[id: detailState.id]
        ) {
          DetailView(store: detailStore)
        }
      }
    }
  }
}

실제 구현에서는 StackState / StackAction / pathView 관련 유틸을 함께 사용해 더 간결하게 구성할 수 있습니다.

 

10. 시트, 팝오버, 풀스크린 커버 프레젠테이션

TCA 1.x에서는 SwiftUI의 시트/팝오버를 Store 기반으로 다루기 위한 여러 헬퍼가 제공됩니다.

1.23.1 시점에는 일부 구 API가 deprecated 상태이므로, 가능하면 SwiftUI 표준 API와 ifLet, CaseLet 등을 조합하는 패턴을 사용하는 것이 안전합니다.

10.1 State에 optional 하위 도메인 두기

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var sheet: SheetFeature.State?
  }

  enum Action: Equatable {
    case presentSheetButtonTapped
    case sheet(SheetFeature.Action)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \\State.sheet, action: \\Action.sheet) {
      SheetFeature()
    }

    Reduce { state, action in
      switch action {
      case .presentSheetButtonTapped:
        state.sheet = .init()
        return .none

      case .sheet(.dismiss):
        state.sheet = nil
        return .none

      case .sheet:
        return .none
      }
    }
  }
}

10.2 View에서 sheet 사용

struct AppView: View {
  @Bindable var store: StoreOf<AppFeature>

  var body: some View {
    Button("시트 열기") {
      store.send(.presentSheetButtonTapped)
    }
    .sheet(
      item: $store.sheet
    ) { sheetStore in
      SheetView(store: sheetStore)
    }
  }
}

여기서 SheetFeature.State가 Identifiable을 만족하도록 구현하면 item: 기반 시트를 쉽게 구성할 수 있습니다.

 

11. TestStore를 이용한 테스트

TCA 1.23.1에서도 TestStore가 핵심 테스트 유틸입니다.

import XCTest
import ComposableArchitecture

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testIncrementDecrement() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.incrementButtonTapped) {
      $0.count = 1
    }

    await store.send(.decrementButtonTapped) {
      $0.count = 0
    }
  }
}

비동기 Effect 테스트 시에는 receive/finish 등을 사용해 순서를 검증합니다.

 

12. 마이그레이션 시 주의 사항 (1.23.1 기준)

이전 버전(0.x, 1.0 초반대)에서 1.23.1로 올릴 때 대표적으로 확인해야 할 항목입니다.

  1. ReducerProtocol → @Reducer 매크로로 전환
  2. State에 Equatable 수동 구현 대신, @ObservableState + 자동 합성 활용
  3. /Action.child 스타일 CasePath 기반 스코프 코드는 모두
    • Scope(state: \\State.child, action: \\Action.child)형태로 변경
  4. EffectPublisher, 옛 유틸 메서드(catchToEffect, fireAndForget, timer 등) 중심 코드 →
    • Effect.run / Effect.publisher 기반으로 리팩터링
  5. Environment 구조체 패턴 →
    • @Dependency / DependencyValues 기반 의존성 주입으로 변경
  6. SwiftUI에서 WithViewStore 남용 →
    • @Bindable var store: StoreOf<Feature> 기반 View로 전환

문서의 Migrating to 1.6, Migrating to 1.9 등 마이그레이션 가이드를 함께 참고하면 세부적인 변경 이력을 따라가기 좋습니다.

 

13. 실제 프로젝트 적용 순서 예시

  1. 새 모듈/프로젝트에서는
    • 처음부터 @Reducer, @ObservableState, @Dependency 패턴으로 시작
  2. 기존 TCA 0.x/1.x 프로젝트 업그레이드 시
    1. 의존성 버전을 1.23.1로 올린다.
    2. 가장 작은 Feature부터 ReducerProtocol → @Reducer로 변환
    3. SwiftUI View를 WithViewStore  @Bindable 기반으로 전환
    4. Effect 관련 API를 Effect.run 기반으로 정리
    5. 네비게이션/프레젠테이션을 Stack 기반 / optional state 기반으로 정리
  3. 테스트 정비
    • TestStore 기반 유닛 테스트를 추가하면서 리팩터링 안전망 확보

 

14. 마무리

TCA 1.23.1은

  • 매크로 기반 API 정착
  • SwiftUI, UIKit 모두를 아우르는 네비게이션/프레젠테이션 도구
  • 공유 상태, 의존성 관리, 테스트 인프라 강화

를 특징으로 합니다.

새 프로젝트에서는 1.23.1 스타일을 기본값으로 두고, 기존 프로젝트는 점진적인 마이그레이션 전략으로 올리는 것이 유지보수 측면에서 가장 안전한 접근입니다.

 

 

반응형
Posted by 까칠코더
,