TCA(The Composable Architecture) 사용 가이드(v1.23.1)
1. 설치 방법 (SPM)
Xcode 패키지 추가
- Xcode 메뉴에서 File → Add Package Dependencies… 선택
- 아래 URL 입력
text https://github.com/pointfreeco/swift-composable-architecture - 버전 규칙 설정
- 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로 올릴 때 대표적으로 확인해야 할 항목입니다.
- ReducerProtocol → @Reducer 매크로로 전환
- State에 Equatable 수동 구현 대신, @ObservableState + 자동 합성 활용
- /Action.child 스타일 CasePath 기반 스코프 코드는 모두
- Scope(state: \\State.child, action: \\Action.child)형태로 변경
- EffectPublisher, 옛 유틸 메서드(catchToEffect, fireAndForget, timer 등) 중심 코드 →
- Effect.run / Effect.publisher 기반으로 리팩터링
- Environment 구조체 패턴 →
- @Dependency / DependencyValues 기반 의존성 주입으로 변경
- SwiftUI에서 WithViewStore 남용 →
- @Bindable var store: StoreOf<Feature> 기반 View로 전환
문서의 Migrating to 1.6, Migrating to 1.9 등 마이그레이션 가이드를 함께 참고하면 세부적인 변경 이력을 따라가기 좋습니다.
13. 실제 프로젝트 적용 순서 예시
- 새 모듈/프로젝트에서는
- 처음부터 @Reducer, @ObservableState, @Dependency 패턴으로 시작
- 기존 TCA 0.x/1.x 프로젝트 업그레이드 시
- 의존성 버전을 1.23.1로 올린다.
- 가장 작은 Feature부터 ReducerProtocol → @Reducer로 변환
- SwiftUI View를 WithViewStore → @Bindable 기반으로 전환
- Effect 관련 API를 Effect.run 기반으로 정리
- 네비게이션/프레젠테이션을 Stack 기반 / optional state 기반으로 정리
- 테스트 정비
- TestStore 기반 유닛 테스트를 추가하면서 리팩터링 안전망 확보
14. 마무리
TCA 1.23.1은
- 매크로 기반 API 정착
- SwiftUI, UIKit 모두를 아우르는 네비게이션/프레젠테이션 도구
- 공유 상태, 의존성 관리, 테스트 인프라 강화
를 특징으로 합니다.
새 프로젝트에서는 1.23.1 스타일을 기본값으로 두고, 기존 프로젝트는 점진적인 마이그레이션 전략으로 올리는 것이 유지보수 측면에서 가장 안전한 접근입니다.
'Dev Study > iOS' 카테고리의 다른 글
| iOS 개발자가 많이 하는 실수 - guard를 잘못 사용(Early Exit Misuse) (0) | 2025.12.04 |
|---|---|
| iOS 개발자가 많이 하는 실수 - Optional Binding 중첩(if let 지옥) (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - 옵셔널을 강제 언래핑(!)하기 (0) | 2025.12.04 |
| iOS 색상 토큰(Role-based Color Tokens) 역할 설명 가이드 (0) | 2025.12.04 |
| 테스트 작성 (Unit Test + Snapshot Test) (0) | 2025.11.14 |
| 앱 시작 속도 개선(App Launch Optimization) (0) | 2025.11.14 |
| Transition / Animation 최적화 (0) | 2025.11.14 |
| AutoLayout — Hugging / Compression Resistance 이해하기 (0) | 2025.11.14 |

