@Observable 모델 하나에 상태를 모으고, 뷰는 그 모델을 읽었다. 화면이 하나일 때는 이 방식이 가장 짧다. 그런데 기능이 늘면 "이 값을 누가 바꿨지?"를 찾으려고 버튼 클로저, .task, 모델 메서드 안의 Task를 전부 뒤지게 된다. TCA는 상태를 바꾸는 입구를 하나로 좁히고, 일어난 일을 전부 데이터(Action)로 남기는 라이브러리다. 오늘은 코드를 많이 쓰기보다 TCA가 무엇을 대신 해 주고 그 대가가 무엇인지 지도를 그린다.오늘의 목표 · 29분
@Observable모델 코드를 보고 상태 변경 경로·부수효과 위치·테스트·조합의 네 가지 문제를 짚는다.- TCA의 5요소(State·Action·Reducer·Store·Effect)를
@Observable모델의 어느 부분과 대응되는지 표로 옮긴다. - 버튼 한 번이 Action → Reducer → State → View → (Effect → Action)으로 도는 단방향 흐름을 단계별로 따라간다.
- 보일러플레이트·학습 곡선·액션 전송 비용을 알고, "이 프로젝트에 TCA를 쓸까?"를 결정 기준으로 판단한다.
1. @Observable 모델이 커질 때 생기는 문제 🔥 핵심
먼저 SwiftUI 코스에서 배운 방식으로 검색 화면을 만든다. 문제는 코드가 틀려서가 아니라 규칙이 없어서 생긴다. 아래 코드는 전부 컴파일되고 잘 동작한다.
import SwiftUI
@Observable
final class SearchModel {
var query = ""
var results: [String] = []
var isLoading = false
func search() async {
isLoading = true
// 네트워크를 모델 안에서 직접 부른다
let url = URL(string: "https://example.com/search?q=\(query)")!
let data = (try? await URLSession.shared.data(from: url).0) ?? Data()
results = String(decoding: data, as: UTF8.self)
.split(separator: "\n").map(String.init)
isLoading = false
}
}import SwiftUI
struct SearchScreen: View {
@State private var model = SearchModel()
var body: some View {
List(model.results, id: \.self) { Text($0) }
.searchable(text: $model.query) // ① 바인딩이 바꾼다
.onChange(of: model.query) {
if model.query.isEmpty { model.results = [] } // ② onChange가 바꾼다
}
.task { await model.search() } // ③ 메서드가 바꾼다
.toolbar {
Button("지우기") {
model.query = "" // ④ 버튼 클로저가 바꾼다
model.results = []
}
}
}
}results를 바꾸는 곳이 세 군데(②·③·④), query를 바꾸는 곳이 두 군데(①·④)다. 화면이 10개로 늘면 이런 경로가 수십 개가 된다. 이 방식에서 반복해서 부딪히는 문제는 네 가지다.
| 문제 | @Observable에서 흔한 모습 | TCA의 답 |
|---|---|---|
| 상태 변경 경로 분산 | 뷰 클로저·onChange·모델 메서드·Task가 각자 프로퍼티를 바꾼다 | 상태는 Reducer 안에서만 바뀐다. 뷰는 Action을 보내기만 한다 |
| 부수효과 위치 | 모델 메서드 안에 URLSession이 섞인다. 규칙은 팀 컨벤션에 맡긴다 | Reducer는 부수효과를 Effect 값으로 반환하고, 실행은 Store가 한다 |
| 테스트 | 메서드가 실제 네트워크를 부르면 결과를 예측할 수 없다. 주입 방식은 직접 설계 | TestStore가 Action마다 상태 변화와 이펙트가 돌려준 Action을 전부 단언하게 한다 |
| 조합 | 부모 모델이 자식 모델을 들고, 자식 이벤트를 받는 방법(클로저·델리게이트)을 매번 정한다 | 자식의 State·Action을 부모에 넣고 Scope로 붙인다(Day 5) |
테스트 쪽 문제는 코드로 보면 분명하다. 아래 테스트는 컴파일되지만 결과가 네트워크 상태에 따라 달라진다.
import Testing
@MainActor
@Test func searchLoadsResults() async {
let model = SearchModel()
model.query = "swift"
await model.search() // 진짜 URLSession 호출
#expect(model.results.isEmpty == false) // 서버·네트워크에 따라 성공/실패
#expect(model.isLoading == false) // 중간에 true였는지는 확인할 방법이 없다
}@Observable로도 네트워크를 프로토콜로 주입하고, 상태 변경을 메서드로만 하도록 팀 규칙을 세우면 이 문제를 풀 수 있다. TCA가 하는 일은 그 규칙을 라이브러리 구조로 강제하고, 테스트·디버깅 도구를 함께 주는 것이다. 규칙을 스스로 설계·유지할지, 라이브러리 규칙을 따를지의 선택이다.
2. TCA의 5요소 지도 🔥 핵심
TCA 문서 Getting started는 기능 하나를 State·Action·Reducer·Store 네 타입으로 모델링하라고 한다. 여기에 Reducer가 돌려주는 Effect를 더하면 5요소가 된다.
"Reducer: A function that describes how to evolve the current state of the app to the next state given an action. The reducer is also responsible for returning any effects that should be run, such as API requests, which can be done by returning an Effect value." — TCA 문서 · Getting started
풀이: Reducer는 "지금 상태 + 들어온 Action → 다음 상태"를 계산하는 함수다. API 요청 같은 바깥 일은 직접 실행하지 않고 Effect 값으로 돌려준다.
struct. @ObservableState를 붙인다.enum.@Reducer 타입의 body.store.count로 읽고 store.send(...)로 알린다. 뷰 자체는 SwiftUI 그대로다.문서의 예제를 그대로 옮긴다. 숫자를 올리고 내리고, 버튼을 누르면 그 숫자에 대한 재미있는 사실을 서버에서 받아 온다.
import ComposableArchitecture
@Reducer
struct Feature {
@ObservableState
struct State: Equatable {
var count = 0
var numberFact: String?
}
enum Action {
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(String) // 이펙트가 돌려주는 "보이지 않는 사건"
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none // 할 바깥 일 없음
case .incrementButtonTapped:
state.count += 1
return .none
case .numberFactButtonTapped:
return .run { [count = state.count] send in // 바깥 일을 "값"으로 반환
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://number-trivia.com/\(count)/trivia")!
)
await send(.numberFactResponse(String(decoding: data, as: UTF8.self)))
}
case let .numberFactResponse(fact):
state.numberFact = fact
return .none
}
}
}
}import ComposableArchitecture
import SwiftUI
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
Form {
Section {
Text("\(store.count)")
Button("Decrement") { store.send(.decrementButtonTapped) }
Button("Increment") { store.send(.incrementButtonTapped) }
}
Section {
Button("Number fact") { store.send(.numberFactButtonTapped) }
}
if let fact = store.numberFact {
Text(fact)
}
}
}
}이 5요소를 SwiftUI 코스에서 쓰던 @Observable 모델에 대응시키면 이렇다. TCA가 새로 만든 개념이 아니라, 모델 안에 섞여 있던 역할을 타입으로 떼어 낸 것에 가깝다.
| TCA | SwiftUI(@Observable)에서 같은 역할 | 달라지는 점 |
|---|---|---|
State (struct) | 모델의 저장 프로퍼티 | 클래스가 아니라 값 타입. 복사·비교(Equatable)가 쉽다 |
Action (enum) | 모델 메서드 호출, 뷰 클로저의 직접 대입 | 호출이 아니라 데이터. 기록·가로채기·단언이 가능 |
Reducer | 메서드 본문들 | 모든 변경 로직이 switch action 한 곳에 |
Effect | 메서드 안의 await, Task { } | Reducer가 실행하지 않고 반환. 결과는 Action으로 복귀 |
Store | @State private var model = Model() 인스턴스 | 뷰가 쥐는 객체. 읽기는 자유, 쓰기는 send로만 |
3. 단방향 흐름 — 버튼 한 번을 끝까지 따라가기
"Number fact" 버튼을 누른 순간부터 사실이 화면에 뜰 때까지를 따라간다. 화살표는 한 방향으로만 돈다. 뷰도 Effect도 State를 직접 바꾸지 않는다.
이 규칙은 컴파일러가 지킨다. 뷰에서 store.count는 읽을 수만 있다. Store의 상태 접근 subscript에 get만 있기 때문이다(소스 · Store+Observation.swift).
struct BadFeatureView: View {
let store: StoreOf<Feature>
var body: some View {
Button("+") {
store.count += 1 // error: left side of mutating operator isn't mutable
}
}
}import SwiftUI
@Observable final class CounterModel {
var count = 0
}
struct ObservableCounterButton: View {
let model: CounterModel
var body: some View {
Button("+") { model.count += 1 } // 어디서든 대입 가능
}
}
struct TCACounterButton: View {
let store: StoreOf<Feature>
var body: some View {
Button("+") { store.send(.incrementButtonTapped) } // "무슨 일이 있었나"만 알린다
}
}Action이 데이터라서 공짜로 얻는 것
FAQ는 "Action enum을 따로 유지하는 건 낭비 아니냐"는 질문에 세 가지로 답한다(TCA 문서 · FAQ).
- 가로채기: 부모 Reducer가 자식의 Action을 보고 로직을 덧붙인다. isowords의 온보딩이 게임 기능의
.submitButtonTapped를 가로채 다음 단계로 넘어가는 예가 FAQ에 있다. 자세한 방법은 Day 5. - 디버깅 도구:
._printChanges()는 들어온 모든 Action과 그때의 State 차이(diff)를 콘솔에 찍는다. - 빈틈없는 테스트:
TestStore로 Action을 보내고 State가 어떻게 바뀌었는지 단계마다 단언한다.
import ComposableArchitecture
import SwiftUI
@main
struct NumberFactApp: App {
static let store = Store(initialState: Feature.State()) {
Feature()
._printChanges() // DEBUG 빌드에서 Action과 State diff 출력
}
var body: some Scene {
WindowGroup {
FeatureView(store: Self.store)
}
}
}import ComposableArchitecture
import Testing
@MainActor
@Test func incrementThenDecrement() async {
let store = TestStore(initialState: Feature.State()) {
Feature()
}
await store.send(.incrementButtonTapped) {
$0.count = 1 // "이렇게 바뀌어야 한다"를 적는다. 틀리면 테스트 실패
}
await store.send(.decrementButtonTapped) {
$0.count = 0
}
}4. 대가 — 보일러플레이트·학습 곡선·성능 주의
같은 카운터를 두 방식으로 쓰면 줄 수 차이가 바로 보인다.
import SwiftUI
@Observable final class TallyModel {
var count = 0
func increment() { count += 1 }
}import ComposableArchitecture
@Reducer
struct TallyFeature {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case incrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
}
}
}
}FAQ의 입장은 "기능 하나로는 줄이 더 들지만, 테스트를 쓰거나 기능을 조합하기 시작하면 오히려 덜 든다"이다. 또 하나 자주 나오는 불만이 액션 핑퐁이다. 비동기 작업 사이사이에 State를 바꿔야 하면 Action을 여러 번 왕복해야 한다. 사이에 State 변경이 없다면 Effect 하나 안에서 await를 이어 붙이면 된다.
import ComposableArchitecture
struct User: Equatable, Sendable { var id: Int }
@Reducer
struct MoviesFeature {
@ObservableState
struct State: Equatable {
var isLoading = false
var movies: [String] = []
}
enum Action {
case refreshButtonTapped
case moviesResponse([String])
}
let fetchUser: @Sendable () async -> User // 의존성은 Day 7에서 제대로
let fetchMovies: @Sendable (Int) async -> [String]
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .refreshButtonTapped:
state.isLoading = true
return .run { send in
let user = await fetchUser() // 두 요청을 한 Effect에서 연달아
let movies = await fetchMovies(user.id)
await send(.moviesResponse(movies)) // Action 왕복은 한 번
}
case let .moviesResponse(movies):
state.isLoading = false
state.movies = movies
return .none
}
}
}
}| 대가 | 실제 크기 | 완화책 |
|---|---|---|
| 보일러플레이트 | 기능마다 State·Action·body. 매크로가 상당 부분을 대신 쓴다(Day 2) | 옛 ViewStore 시절보다 크게 줄었다(FAQ). 테스트·조합에서 코드가 오히려 준다 |
| 학습 곡선 | Reducer·Effect·Scope·의존성·TestStore 등 개념이 많다 | SwiftUI 기본기가 전제. FAQ: Swift/SwiftUI를 처음 배울 때는 권하지 않음 |
| 액션 전송 비용 | Action은 여러 계층의 Reducer를 통과한다. 메서드 호출보다 무겁다 | 로직 공유는 Action 대신 메서드로(Performance, Day 13) |
| 서드파티 의존 | 앱 구조 전체가 한 라이브러리에 묶인다 | FAQ: 팀이 논의해 정할 일. 블로그 팁을 이어 붙이는 것보다 위험이 크지 않다는 입장 |
"It's definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components." — TCA 문서 · Getting started
풀이: 순수 SwiftUI보다 단계가 많다는 것을 문서도 인정한다. 대신 상태 변경을 observable 객체와 UI 클로저에 흩어 놓지 않고 한 가지 방식으로 하게 된다. 1절에서 본 문제가 바로 그 "흩어진 로직"이다.
5. 언제 쓰나 — 결정 기준
FAQ는 모든 앱에 TCA를 쓰라고 하지 않는다. 두 가지를 명시적으로 권하지 않는다. Swift·SwiftUI를 처음 배우는 단계, 그리고 JSON을 받아 보여 주기만 하는 "리더" 앱이다.
"In general it can be fine to start a project with vanilla SwiftUI (with a concentration on concise domain modeling), and then transition to TCA later if there is a need for any of its powers." — TCA 문서 · FAQ
풀이: 순수 SwiftUI로 시작하되 도메인 모델을 간결하게 설계해 두고, TCA의 기능이 필요해지면 그때 옮겨도 된다.
흔한 오해 세 가지
- "TCA를 쓰면 SwiftUI를 덜 써도 된다" — 반대다. FAQ: "TCA is not a substitute or replacement for SwiftUI, but rather is meant to be paired with SwiftUI."
sheet(item:),NavigationStack,@Bindable등을 그대로 쓴다. - "앱 전체 상태를 한 덩어리로 들고 있어서 느리다" — 표시되지 않은 화면의 State는 옵셔널이라
nil이다. 뷰는@Observable처럼 읽은 필드만 관찰해 다시 그린다(FAQ). - "함수형 프로그래밍을 알아야 한다" — FAQ는 TCA가 스스로를 함수형 라이브러리라 부른 적이 없다고 한다. 값 타입을 쓰고 부수효과를 로직과 분리한다는 두 원칙만 알면 된다.
✅ 복습 · 5분
Reducer는 API 요청을 직접 실행하지 않는다. 대신 무엇을 하고, 그 결과는 어떻게 State에 반영되나?
Effect 값(.run { send in … })으로 반환만 한다. 실행은 Store가 한다. 요청이 끝나면 Effect가 send(.numberFactResponse(fact))로 새 Action을 보내고, Reducer가 그 Action을 받아 state.numberFact = fact로 바꾼다. State를 바꾸는 곳은 끝까지 Reducer 하나다.FAQ가 TCA를 권하지 않는 두 경우는? 그런 프로젝트는 어떻게 시작하라고 하나?
struct PeekView: View {
let store: StoreOf<Feature>
var body: some View {
Button("두 번") {
store.send(.incrementButtonTapped)
store.send(.incrementButtonTapped)
print(store.count)
}
}
}🔮 코드 예측: 처음 count가 0일 때 위 버튼을 한 번 누르면 콘솔에 무엇이 찍히나? (Feature는 2절의 것)
2가 찍힌다. store.send는 Reducer를 그 자리에서 동기로 실행하고, 돌아오기 전에 새 State를 저장한다(소스 기준: RootCore._send가 reducer._reduce(into:action:)를 호출하고 defer에서 self.state = currentState). 그래서 다음 줄에서 읽는 store.count는 이미 2다. 비동기로 늦게 반영되는 것은 .run 같은 Effect가 나중에 보내는 Action뿐이다.🔁 SwiftUI 코스 복습: @Observable 모델에서 뷰가 다시 그려지는 기준은? TCA의 뷰도 같은 기준을 따르나?
body가 실행되는 동안 읽은 프로퍼티만 의존성이 된다(SwiftUI 코스 Day 5). TCA도 같다. @ObservableState를 붙인 State는 store.count를 읽을 때 관찰 등록기에 "읽었다"를 기록하므로, numberFact만 바뀌면 count만 읽은 뷰는 다시 그려지지 않는다. FAQ: "views re-compute the minimal number of times based off of what state is accessed in the view, just as it does in vanilla SwiftUI with the @Observable macro."🛠 미니 과제 (선택 · 10분)
Xcode 프로젝트에 TCA 패키지(https://github.com/pointfreeco/swift-composable-architecture)를 추가했다고 가정한다. 2절의 Feature와 FeatureView를 파일 하나에 붙여 넣고 아래를 한다.
- 요구 1:
#Preview에서 Store를 만들 때 Reducer에._printChanges()를 붙인다. - 요구 2:
resetButtonTappedAction을 추가해count를 0으로,numberFact를nil로 되돌린다. 뷰에 "Reset" 버튼을 단다. - 요구 3: 뷰 코드 어디에도
store.count = …같은 대입이 없어야 한다.
확인 방법: Preview를 실행하고 +, +, Reset을 누른다. Xcode 콘솔의 received action: 아래에 - _count: 2 / + _count: 0 줄이 찍히면 성공이다(필드 이름 앞 _는 @ObservableState가 만든 저장 프로퍼티 이름이다).
정답 코드 펼치기
정답은 2절 Feature를 줄인 새 버전이다 — 감소·숫자 사실 액션은 빼고 resetButtonTapped를 더했다.
import ComposableArchitecture
import SwiftUI
@Reducer
struct ResettableFeature {
@ObservableState
struct State: Equatable {
var count = 0
var numberFact: String?
}
enum Action {
case incrementButtonTapped
case resetButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .resetButtonTapped:
state.count = 0
state.numberFact = nil
return .none
}
}
}
}
struct ResettableView: View {
let store: StoreOf<ResettableFeature>
var body: some View {
Form {
Text("\(store.count)")
Button("Increment") { store.send(.incrementButtonTapped) }
Button("Reset") { store.send(.resetButtonTapped) }
}
}
}
#Preview {
ResettableView(
store: Store(initialState: ResettableFeature.State()) {
ResettableFeature()
._printChanges()
}
)
}