observe { }로 UIKit에 연결하고, FAQ로 쓰지 말아야 할 때를 정리한다.1부 목표 · 29분
- SyncUps의 기능 트리를 그리고, 각 연결에 쓰인 도구(Scope · forEach · @Presents · @Shared · @Dependency)를 해당 Day와 짝짓는다.
- 기능을
Core/ UI / 의존성 인터페이스 / live 구현 모듈로 나누고, 어느 쪽이 어느 쪽을 import해도 되는지 정한다. - UIKit 뷰 컨트롤러에서
observe { }·@UIBindable·present(item:)·NavigationStackController로 같은 스토어를 그린다. - "이 화면에 TCA를 쓸까?"를 FAQ 기준으로 판단하고, 흔한 반론이 지금 버전에서도 맞는지 가려낸다.
1. 앱 전체 지도 — SyncUps를 위에서 내려다보기 🔥 핵심
SyncUps 예제는 공식 튜토리얼 Building SyncUps의 완성본이다. 기능 파일 6개(AppFeature, SyncUpsList, SyncUpForm, SyncUpDetail, RecordMeeting, Meeting)와 의존성 2개(SpeechRecognizer, OpenSettings)로 되어 있다. 코스에서 배운 도구가 거의 다 들어 있어서 지도로 쓰기 좋다.
지도에서 Day 찾기
| SyncUps에서 | 쓰인 도구 | 배운 곳 |
|---|---|---|
| AppFeature가 목록 화면을 항상 품음 | Scope(_:action:) | Day 5 |
| 상세 → 기록 화면 push, 뒤로 가기 | StackState · forEach(\.path) · @Reducer enum Path | Day 11 |
| 추가·편집 시트, 삭제 알럿 | @Presents · @Reducer enum Destination · AlertState | Day 10 |
| 편집 폼의 텍스트 필드 | BindableAction · BindingReducer | Day 4 |
| 회의 타이머, 음성 인식 스트림 | .run · continuousClock · speechClient | Day 3 · Day 7 |
| 목록·상세·기록이 같은 SyncUp을 봄, 앱 재시작 후에도 유지 | @Shared(.syncUps) + .fileStorage | Day 12 |
| 상세가 "회의 시작"을 부모에게 알림 | delegate 액션 | Day 5 |
| 기능별 테스트 파일 5개 (SyncUpsTests) | TestStore | Day 8 · 9 |
루트 리듀서의 뼈대는 짧다. 자식 기능을 먼저 골격만 둔다.
// SyncUps의 자식 기능들 — 여기서는 골격만 남겼다 (원본: Examples/SyncUps)
@Reducer
struct SyncUpsList {
@ObservableState
struct State: Equatable { var titles: [String] = [] }
enum Action { case addSyncUpButtonTapped }
var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}
@Reducer
struct SyncUpDetail {
@ObservableState
struct State: Equatable { var title = "" }
enum Action {
case delegate(Delegate)
case startMeetingButtonTapped
@CasePathable
enum Delegate { case startMeeting(title: String) } // 원본은 Shared<SyncUp>을 넘긴다
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .delegate:
return .none
case .startMeetingButtonTapped:
return .send(.delegate(.startMeeting(title: state.title))) // "부모야, 회의 시작해 줘"
}
}
}
}
@Reducer
struct RecordMeeting {
@ObservableState
struct State: Equatable { var title: String }
enum Action { case endMeetingButtonTapped }
var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}@Reducer
struct AppFeature {
@Reducer(state: .equatable) // TestStore로 테스트하려면 State가 Equatable이어야 한다
enum Path { // push될 수 있는 화면 목록 (Day 11)
case detail(SyncUpDetail)
case record(RecordMeeting)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
var syncUpsList = SyncUpsList.State() // 루트 화면은 항상 존재
}
enum Action {
case path(StackActionOf<Path>)
case syncUpsList(SyncUpsList.Action)
}
var body: some ReducerOf<Self> {
Scope(\.syncUpsList, action: \.syncUpsList) { // Day 5
SyncUpsList()
}
Reduce { state, action in
switch action {
// 상세 화면의 delegate 액션을 가로채 기록 화면을 push
case let .path(.element(_, .detail(.delegate(.startMeeting(title))))):
state.path.append(.record(RecordMeeting.State(title: title)))
return .none
case .path, .syncUpsList:
return .none
}
}
.forEach(\.path, action: \.path) // 스택의 각 화면에 자식 리듀서 연결
}
}루트가 하는 일은 조립과 화면 간 연결뿐이다. 회의 로직은 RecordMeeting에, 편집 로직은 SyncUpForm에 있다. 상세 화면은 기록 화면을 모른다. .delegate(.startMeeting)를 보내면 스택을 가진 부모가 push한다. 그래서 상세 기능은 기록 기능 없이 컴파일된다. 2절의 모듈화가 이 성질을 이용한다.
struct AppView: View {
@Bindable var store: StoreOf<AppFeature>
var body: some View {
NavigationStack(path: $store.scope(\.path, action: \.path)) {
Text("SyncUps 목록") // 원본: SyncUpsListView(store: …)
} destination: { store in
switch store.case {
case let .detail(store): Text(store.title)
case let .record(store): Text("회의 중: \(store.title)")
}
}
}
}
@main
struct SyncUpsApp: App {
// 앱 전체에 스토어는 하나. static으로 둬서 Preview가 앱 진입점을 다시 만들 때 섞이지 않게 한다
static let store = Store(initialState: AppFeature.State()) {
AppFeature()
._printChanges() // 모든 액션과 상태 diff를 콘솔에
}
var body: some Scene {
WindowGroup {
if isTesting {
EmptyView() // 테스트 중엔 앱 코드를 띄우지 않는다
} else {
AppView(store: Self.store)
}
}
}
}| 같은 앱을 SwiftUI(@Observable)로 | TCA로 |
|---|---|
| 화면마다 모델 클래스. 부모 모델이 자식 모델 인스턴스를 들고 있음 | 화면마다 리듀서. 부모 State가 자식 State 값을 들고 있음 |
상세 → 기록 이동: 상세 모델에 클로저(onStartMeeting)를 주입해 부모가 받음 | delegate 액션을 부모 리듀서가 패턴 매칭으로 받음 |
| "앱이 지금 어떤 상태인가"를 보려면 모델 그래프를 따라가야 함 | AppFeature.State 값 하나가 전부. _printChanges()가 액션마다 diff를 찍음 |
| 통합 테스트: 모델 여러 개를 직접 엮어 호출 | 루트 TestStore 하나로 push·delegate·공유 상태까지 단언(그래서 위 코드는 @Reducer(state: .equatable) enum Path + State: Equatable로 두었다 — 없으면 TestStore를 만들 수 없다) |
큰 앱의 TCA 구조는 "루트 하나 + 자식은 세 가지 방식으로 매달림"이다. 항상 있음 → Scope, 잠깐 뜸 → @Presents + ifLet, 쌓임 → StackState + forEach. 목록 행이 여럿이면 여기에 IdentifiedArray + forEach(Day 6)가 붙는다. 새 화면을 만나면 넷 중 어디에 해당하는지부터 정한다.
2. 기능 모듈화 — 패키지로 경계 긋기
기능이 30개를 넘으면 앱 타깃 하나로는 빌드가 느리고, Preview 하나 보려고 전체를 컴파일한다. 공식 예제 TicTacToe의 README는 목표를 이렇게 적는다. 기능마다 모듈을 따로 두고, 의존을 최소로 해서 앱 전체를 빌드하지 않고도 기능 하나를 컴파일·실행한다. 로직은 *Core 모듈에 두고 UI 모듈과 분리해서 SwiftUI 앱과 UIKit 앱이 같은 Core를 공유한다.
| 모듈 (TicTacToe 실제 이름) | 들어 있는 것 | 의존하는 것 |
|---|---|---|
AuthenticationClient | 의존성 인터페이스(@DependencyClient 구조체, testValue) | swift-dependencies만 |
AuthenticationClientLive | liveValue — 실제 구현 | AuthenticationClient |
LoginCore · TwoFactorCore | 리듀서(State · Action · body) | ComposableArchitecture, 인터페이스, 자식 Core |
LoginSwiftUI · LoginUIKit | 뷰 / 뷰 컨트롤러 | LoginCore, 자식 UI 모듈 |
LoginCoreTests | TestStore 테스트 | LoginCore만 |
| 앱 타깃 | 진입점, live 조립 | App UI 모듈 + AuthenticationClientLive |
의존성: 인터페이스와 live를 다른 모듈에
swift-dependencies 문서 Separating interface and implementation의 방법이다. 인터페이스 모듈에서는 TestDependencyKey만 채택하고, live 모듈에서 같은 타입에 DependencyKey를 추가로 채택해 liveValue를 준다. previewValue도 인터페이스 모듈에 둬야 한다(같은 문서의 Note).
// ── Module: AuthenticationClient (인터페이스) ──
@DependencyClient
public struct AuthenticationClient: Sendable {
public var login: @Sendable (_ email: String, _ password: String) async throws -> String
}
extension AuthenticationClient: TestDependencyKey {
public static let testValue = Self() // 테스트에서 안 덮어쓰고 부르면 실패
}
extension DependencyValues {
public var authenticationClient: AuthenticationClient {
get { self[AuthenticationClient.self] }
set { self[AuthenticationClient.self] = newValue }
}
}
// ── Module: AuthenticationClientLive (실제 구현) ──
extension AuthenticationClient: DependencyKey {
public static let liveValue = Self(
login: { email, password in
try await Task.sleep(for: .seconds(1)) // 실제로는 네트워크·서드파티 SDK
return "token-for-\(email)"
}
)
}
// ── Module: LoginCore ── 다른 모듈에서 쓰려면 타입·init·body가 public
@Reducer
public struct Login {
@ObservableState
public struct State: Equatable {
public var email = ""
public var isLoading = false
public init(email: String = "") { self.email = email }
}
public enum Action {
case loginButtonTapped
case loginResponse(Result<String, any Error>)
}
@Dependency(\.authenticationClient) var authenticationClient // 인터페이스만 안다
public init() {}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .loginButtonTapped:
state.isLoading = true
return .run { [email = state.email] send in
await send(.loginResponse(Result {
try await authenticationClient.login(email: email, password: "password")
}))
}
case .loginResponse:
state.isLoading = false
return .none
}
}
}
}Core 모듈의 리듀서는 접근 제어(public)만 늘었지 구조는 그대로다. LoginCore는 AuthenticationClientLive를 import하지 않는다. 테스트는 testValue를 덮어쓰고, 앱 타깃이 live를 링크하면 실행 시 liveValue가 쓰인다.
① public init 필요: 다른 모듈에서 Login.State()를 만들려면 public init을 직접 써야 한다. State에 @Shared가 있으면 init 작성 규칙이 따로 있다(SharingState · Initialization rules, Day 12 2부). ② 테스트 호스트 함정이 사라짐: 앱 타깃에서 테스트를 돌리면 앱 진입점이 같이 실행돼 의존성 오류가 난다(Day 9). TestingTCA는 프레임워크·SPM 라이브러리 테스트에서는 이 문제가 없다며 "모듈화할 또 하나의 이유"라고 적는다.
내비게이션 방식이 모듈 경계를 정한다
What is navigation?은 트리와 스택의 장단점을 모듈화 관점으로도 비교한다.
트리 기반 (@Presents, Day 10) | 스택 기반 (StackState, Day 11) | |
|---|---|---|
| 모듈 결합 | 상세가 편집을 품으므로 상세를 빌드하려면 편집도 빌드 | 스택의 각 기능은 서로 몰라서 모듈끼리 의존이 없음 |
| 기능 모듈 단독 Preview | 상세 Preview에서 편집 화면까지 동작 | 상세 Preview에서 push 버튼은 동작 안 함(목적지를 모름) |
| 통합 테스트 | 상세+편집 통합을 기능 모듈 안에서 테스트 | 통합은 스택을 가진 루트(앱)에서만 테스트 |
SyncUps가 둘을 섞은 이유가 여기 있다. 짧고 닫힌 흐름(편집 시트, 알럿)은 트리로 묶고, 화면 간 이동은 스택으로 풀었다. NavigationLink(state:)는 Path.State를 알아야 해서 모든 기능을 같이 빌드하게 만든다. 그래서 StackBasedNavigation은 대안으로 버튼 → 자식 액션(위의 delegate) 방식을 보여 준다.
3. UIKit과 함께 — observe { } 와 UIKit 내비게이션
회사 앱은 대부분 UIKit 화면이 섞여 있다. TCA의 UIKit Integration 문서는 "SwiftUI를 염두에 두고 설계했지만 UIKit 연동 도구도 있다"고 적는다. 도구의 정의는 대부분 형제 라이브러리 swift-navigation의 UIKitNavigation에 있고 TCA가 다시 내보낸다. TCA의 UIKit/ 폴더에는 파일이 두 개(AlertStateUIKit.swift, NavigationStackControllerUIKit.swift)뿐이다(소스 기준).
| SwiftUI 뷰에서 (Day 2~11) | UIKit 뷰 컨트롤러에서 |
|---|---|
body가 읽은 상태 자동 추적 | observe { } 클로저가 읽은 상태 자동 추적 |
@Bindable var store | @UIBindable var store |
TextField(text: $store.text) | UITextField(text: $store.code) |
.sheet(item: $store.scope(…)) | present(item: $store.scope(…)) { … } |
.alert($store.scope(…)) | present(item:) + UIAlertController(store:) |
NavigationStack(path: $store.scope(…)) | NavigationStackController 서브클래스 |
@Reducer
struct Counter {
@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
}
}
}
}
final class CounterViewController: UIViewController {
private let store: StoreOf<Counter>
init(store: StoreOf<Counter>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
let countLabel = UILabel()
let incrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
self?.store.send(.incrementButtonTapped) // 입력: 액션을 보낸다
})
incrementButton.setTitle("+", for: .normal)
view.addSubview(UIStackView(arrangedSubviews: [countLabel, incrementButton]))
observe { [weak self] in // 출력: 읽은 상태가 바뀌면 다시 실행
guard let self else { return }
countLabel.text = "\(store.count)"
}
}
}withObservationTracking이고, 변경 후 재실행은 DispatchQueue.main.async로 예약된다(swift-navigation 소스 기준).observe 문서 주석의 사용 규칙은 세 가지다. ① 보통 viewDidLoad에서 한 번 부르고 라벨 여러 개를 한 클로저에서 갱신한다. 관련 없는 필드가 바뀌어 label.text를 다시 대입해도 성능 문제는 아니다. ② reloadData()처럼 무거운 일은 별도 observe로 떼어 낸다. ③ 반환된 ObserveToken으로 취소할 수 있다(예: viewWillAppear에서 시작, viewWillDisappear에서 cancel()).
// 1.7 이전 UIKit 관찰 — Combine 구독을 필드마다 손으로 관리했다
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
store.publisher.count
.sink { [weak self] in self?.countLabel.text = "\($0)" }
.store(in: &cancellables)
}@Reducer
struct CodeEntry {
@ObservableState
struct State: Equatable {
@Presents var alert: AlertState<Action.Alert>?
var code = ""
var isRequestInFlight = false
}
enum Action: BindableAction {
case alert(PresentationAction<Alert>)
case binding(BindingAction<State>)
case submitButtonTapped
enum Alert: Equatable {}
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .submitButtonTapped where state.code.count < 4:
state.alert = AlertState { TextState("코드는 4자리 이상입니다") }
return .none
case .submitButtonTapped:
state.isRequestInFlight = true
return .none
case .alert, .binding:
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
final class CodeEntryViewController: UIViewController {
@UIBindable var store: StoreOf<CodeEntry> // SwiftUI의 @Bindable 자리
init(store: StoreOf<CodeEntry>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
let codeField = UITextField(text: $store.code) // 양방향 바인딩 (Day 4의 $store.text)
let submitButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
self?.store.send(.submitButtonTapped)
})
let spinner = UIActivityIndicatorView(style: .medium)
view.addSubview(UIStackView(arrangedSubviews: [codeField, submitButton, spinner]))
observe { [weak self] in
guard let self else { return }
spinner.isHidden = !store.isRequestInFlight
submitButton.isEnabled = !store.isRequestInFlight
}
// 상태가 nil이 아니게 되면 띄우고, 닫히면 nil로 되돌린다 (Day 10의 .alert($store.scope…))
present(item: $store.scope(\.alert, action: \.alert)) { store in
UIAlertController(store: store)
}
}
}스택 내비게이션은 UINavigationController 대신 NavigationStackController를 서브클래싱한다. 1절의 AppFeature를 그대로 쓴다. SwiftUI판 NavigationStack(path:root:destination:)과 모양이 거의 같다.
final class AppController: NavigationStackController {
private var store: StoreOf<AppFeature>!
convenience init(store: StoreOf<AppFeature>) {
@UIBindable var store = store
self.init(path: $store.scope(\.path, action: \.path)) {
UIHostingController(rootView: Text("SyncUps 목록")) // 루트는 SwiftUI여도 된다
} destination: { store in
switch store.case {
case let .detail(store):
UIHostingController(rootView: Text(store.title))
case let .record(store):
UIHostingController(rootView: Text("회의 중: \(store.title)"))
}
}
self.store = store
}
}위 코드처럼 TCA로 만든 SwiftUI 뷰를 UIHostingController(rootView: FeatureView(store: …))로 감싸면 기존 UIKit 내비게이션에 그대로 끼울 수 있다(SwiftUI 코스 Day 6). 점진 도입은 보통 이 방향이 쉽다. 새 화면을 TCA+SwiftUI로 만들어 호스팅하고, 기존 VC는 그대로 둔다.
4. 언제 TCA를 쓰지 말까 — FAQ 쟁점 정리
"We also don't think TCA really shines when building simple “reader” apps that mostly load JSON from the network and display it. … In general it can be fine to start a project with vanilla SwiftUI … and then transition to TCA later if there is a need for any of its powers." — TCA 문서 · FAQ
JSON을 받아 보여 주기만 하는 "리더" 앱에서는 TCA의 장점이 잘 드러나지 않는다는 뜻이다. 순수 SwiftUI로 시작해 필요할 때 TCA로 옮겨도 된다고 한다. 같은 절은 Swift·SwiftUI를 처음 배우는 단계에서는 권하지 않는다고도 적는다. TCA는 SwiftUI를 대체하지 않고 SwiftUI 위에 얹는 도구다.
판단 기준
흔한 반론과 FAQ의 답
| 반론 | FAQ의 답 (1.26 기준) |
|---|---|
| 보일러플레이트가 많다 | 대부분 옛 ViewStore 시절 이야기다. 1.7에서 폐기됐고 지금은 store.count를 직접 읽는다(2부). |
| Action enum은 쓸데없는 일이다 | 액션이 데이터라서 다른 리듀서가 가로채 로직을 덧댈 수 있고(isowords 온보딩 예), _printChanges()·signpost 같은 도구와 exhaustive 테스트가 가능하다. |
| 앱 상태 전체가 거대한 값 하나라 느리다 | 띄우지 않은 화면은 옵셔널이라 nil이다. 뷰는 읽은 상태만 관찰한다. 내비게이션 도구가 표시 지점마다 힙 할당 copy-on-write 래퍼를 넣어서 스택 오버플로도 실제로는 드물다. |
| 액션 핑퐁이 심하다 | 중간에 상태를 바꿀 필요가 없으면 비동기 작업 여러 개를 이펙트 하나에 넣는다(아래 코드). |
| 값 타입이라 상태를 공유 못 한다 | 1.10부터 @Shared가 있고, 공유 상태도 exhaustive 테스트가 된다(Day 12). |
struct User: Equatable, Sendable { var id: Int }
struct Movie: Equatable, Sendable { var title: String }
@DependencyClient
struct MovieClient {
var fetchCurrentUser: @Sendable () async throws -> User
var fetchMovies: @Sendable (_ userID: Int) async throws -> [Movie]
}
extension MovieClient: TestDependencyKey { static let testValue = Self() }
extension DependencyValues {
var movieClient: MovieClient {
get { self[MovieClient.self] }
set { self[MovieClient.self] = newValue }
}
}
@Reducer
struct Movies {
@ObservableState
struct State: Equatable { var movies: [Movie] = [] }
enum Action {
case refreshButtonTapped
case moviesResponse([Movie])
}
@Dependency(\.movieClient) var movieClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .refreshButtonTapped:
return .run { send in
// 사이에 상태를 바꿀 일이 없으면 한 이펙트 안에서 이어서 await
let user = try await movieClient.fetchCurrentUser()
let movies = try await movieClient.fetchMovies(userID: user.id)
await send(.moviesResponse(movies))
}
case let .moviesResponse(movies):
state.movies = movies
return .none
}
}
}
}전면 교체로 시작하면 학습 비용과 이전 비용을 한꺼번에 낸다. 복잡한 화면 하나(예: 결제 폼)를 TCA로 만들어 UIHostingController로 끼우고, TestStore로 효과를 확인한 뒤 넓힌다. 단순 조회 화면까지 억지로 옮기지 않는다.
✅ 복습 · 5분
SyncUps에서 상세 화면(SyncUpDetail)은 기록 화면(RecordMeeting)을 직접 push하지 않는다. 어떻게 넘어가고, 그 덕분에 무엇이 가능한가?
.delegate(.startMeeting)을 보내기만 하고, 스택을 가진 AppFeature가 .path(.element(_, .detail(.delegate(…))))를 패턴 매칭해 state.path.append(.record(…))를 한다. 상세 기능이 기록 기능을 몰라서 서로 다른 모듈에 두고 따로 컴파일할 수 있다.의존성 인터페이스 모듈과 live 모듈을 나눌 때, TestDependencyKey와 DependencyKey는 각각 어느 모듈에서 채택하나? 앱 타깃 말고 live 모듈을 import하는 곳이 있어야 하나?
TestDependencyKey(testValue, 필요하면 previewValue)를, live 모듈에서 DependencyKey(liveValue)를 채택한다. live 모듈은 앱 타깃만 import하면 된다. Core·테스트는 인터페이스만 보고 빌드된다.🔮 코드 예측: 3절의 CounterViewController에서 화면이 뜬 뒤 + 버튼을 두 번 누르면 observe 클로저는 모두 몇 번 실행되나? State에 isLoading이 추가되고 다른 액션이 그것만 바꾼다면 클로저가 다시 실행되나?
count가 바뀔 때마다 1번씩 2번. isLoading만 바뀌면 다시 실행되지 않는다. 클로저가 지난 실행에서 읽은 필드(count)만 추적되기 때문이다. swift-navigation 소스에서 observe는 withObservationTracking { apply() } onChange: { … }로 매번 추적을 새로 등록한다.🔁 지난 Day 복습 (Day 13): 여러 액션이 같은 상태 변경·이펙트를 공유해야 할 때, Performance 아티클은 "공유용 액션을 보내기" 대신 무엇을 권하나? 이유는?
inout State를 받고 Effect를 반환) 각 case에서 호출한다. 액션 전송은 앱의 여러 계층을 거치며 각 계층 리듀서가 가로챌 수 있어서 메서드 호출보다 무겁다. 테스트에서도 공유용 액션을 매번 receive로 단언해야 한다.회사 코드에서 WithViewStore, IfLetStore, @PresentationState를 만나면? 2부는 옛 TCA 코드를 현행 API로 읽고 옮기는 대응표를 만들고, 14일 전체를 지도 한 장으로 정리한다.