SwiftUI 침하하 앱 개발기
@main & App protocolSwiftUI 앱의 진입점.App 프로토콜을 채택하고 @main을 붙이면 된다.
WindowGroup이 root scene 역할을 하며, 그 안에 RootView를 넣는다.
@main
struct ChimhahaApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
SwiftUI로 프로젝트를 만들면, UIKit에선 기본으로 생성되던 AppDelegate, SceneDelegate가 생성되지 않는다! (두둥)
UIKit에선 AppDelegate + SceneDelegate로 앱 생명주기를 관리했는데, SwiftUI에서는 App 프로토콜이 그 역할을 다 흡수해서 기본적으로 없다고 한다.
냅다 대응을 해보자면 다음과 같다고 하는데,
| UIKit | SwiftUI |
|---|---|
| AppDelegate | App (Protocol) |
| SceneDelegate | WindowGroup |
| application(_:didFinishLaunchingWithOptions:) | init() |
푸시 알림, 딥링크 처리 등 AppDelegate가 꼭 필요한 경우에는 다음과 같이 붙일 수도 있다고 한다.
@main
struct ChimhahaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup { ... }
}
}
아직까지는 UIKit 구현과 크게 다를 것이 없었다.
Combine 이라는걸 공부해보고 싶긴 했는데... TCA 1.0부터 공식적으로 Swift Concurrency로 전환했다고 한다.
기존에 Networkmanager로 쓰던 것을 ApiClient이라는 이름으로 대체하고, async/await을 활용해서 구성했다. 크게 다를 건 없어서 여기는 패스
Moya와 비슷하게 사용해보기 위해 Endpoint enum을 만들었다. 각 API 경로를 case로 관리해서 오타를 방지하고, URL 생성 로직을 한 곳에 모아둘 수 있다. 이 프로젝트에서는 학습 목적으로 Moya 없이 URLSession을 직접 다룬다.
enum Endpoint {
case posts
case post(id: String)
case comments(postId: String)
var url: URL? { ... }
}
async throws 기반 테스트는 expectation 없이 훨씬 간결하게 쓸 수 있다.
func test_fetch_success_decodesPosts() async throws {
URLProtocolStub.stub(data: json, statusCode: 200)
let posts: [Post] = try await client.fetch(.posts)
XCTAssertEqual(posts.first?.title, "Hello")
}
실제 네트워크 없이 URLSession을 테스트하는 방법으로 URLProtocol을 서브클래싱해서 요청을 가로채는 방식을 썼다. URLSessionConfiguration.ephemeral을 쓰면 캐시와 쿠키가 없어서 테스트 간 격리가 보장된다.
TCA를 앱에 처음 연결하는 단계로, 파일은 세 개뿐이지만 TCA의 핵심 흐름이 다 담겨 있다.
TCA에서 앱은 하나의 상태 트리로 관리되고, AppReducer가 그 트리의 최상단이다.
지금은 탭 선택 상태만 갖고 있지만, 나중에 각 탭의 Reducer(HomeReducer, SearchReducer 등)가 여기에 자식으로 붙는다고 한다.
UIKit으로 비유하면 루트 코디네이터 역할을 하는 AppCoordinator에 해당하는 것 같음!
@Reducer
struct AppReducer {
@ObservableState
struct State: Equatable {
var selectedTab: AppTab = .home
}
enum Action {
case tabSelected(AppTab)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .tabSelected(tab):
state.selectedTab = tab
return .none
}
}
}
}
✏️ @ObservableState
@ObservableState를 붙이면 TCA가 상태 변화를 자동으로 View에 전달한다. UIKit에서 Observable을 구독하는 그런 느낌인듯
AppReducer의 Store를 받아서 탭바 UI를 구성한다. View는 Store에서 상태를 읽고, 액션만 보낸다. 직접 상태를 바꾸지 않는다.
struct AppView: View {
@Bindable var store: StoreOf<AppReducer>
var body: some View {
TabView(selection: $store.selectedTab.sending(\.tabSelected)) {
Tab("홈", systemImage: "house", value: AppTab.home) {
Text("홈")
}
// ...
}
.tint(Color("chimPrimary"))
}
}
✏️ Store
: TCA에서 상태(State), 액션(Action), 리듀서(Reducer)를 하나로 묶은 객체!@Bindable var store: StoreOf<AppReducer>UIKit이랑 비유하면 ViewModel이랑 비슷한 역할이라고 한다.
✏️ @Bindable
@Bindable은 Observable 객체의 프로퍼티를$바인딩으로 쓸 수 있게 해준다.
UIKit에선 bind를 수동으로 구현하여 직접 동기화해야 했지만...viewModel.selectedTab.bind(to: tabBarController.rx.selectedIndex) tabBarController.rx.selectedIndex.bind(to: viewModel.selectedTab)SwiftUI에선 @Bindable 객체에 $만 붙이면 양방향 자동 동기화된다.
TabView(selection: $store.selectedTab) { ... }
✏️ \.tabSelected
TabView는 바인딩($) 을 요구하는데, TCA에서는 View가 직접 상태를 바꾸지 못한다.
상태를 바꾸기 위해서는 반드시 Action을 통해야 하는데,$store.selectedTab.sending(\.tabSelected)이렇게 하면 TabView가 값을 직접 바꾸는 대신,
.sending이 Reducer로 보내도록 중간에서 가로채서tabSelectedAction으로 변환한다.👉🏻 TabView의 바인딩 요구는 맞춰주면서, 실제 동작은 액션으로 우회하는 어댑터!
@main 진입점에서 Store를 딱 한 번 만들어서 루트 View에 넘긴다.
@main
struct ChimhahaApp: App {
var body: some Scene {
WindowGroup {
AppView(store: Store(initialState: AppReducer.State()) {
AppReducer()
})
}
}
}
Store(initialState:reducer:) — 앱의 초기 상태와 Reducer를 묶어서 Store를 만든다.
이 Store가 앱이 살아있는 동안 전체 상태를 들고 있게 된다.

여기까지의 수확!
@Reducer 매크로 오류Type 'AppReducer' does not conform to protocol 'Reducer'
환경
Xcode 26
Swift 6.2
TCA 1.24.1

아까 위에서 봤던 AppReducer.swift 코드인데,
이 정석적이고 간단한 코드에서 자꾸 Reducer protocol에 맞지 않다고 오류가 나는 것이었다 ㅜㅜ

이건 공식문서에 있는 튜토리얼 코드인데,
이거랑 진짜... 똑같은데 오류가 나는 것이었음..............
결국 구글링하다가 원인을 찾을 수 있었는데,
https://github.com/pointfreeco/swift-composable-architecture/discussions/3714
Swift 6.2(Xcode 26)에서 Default Actor Isolation = MainActor 설정이 이번에 새로 도입됐는데, 이게 TCA의 @Reducer 매크로와 충돌한다는 것이다.

이렇게 되어 있어서 모든 코드가 기본적으로 @MainActor에서 실행되는데,
@Reducer 매크로가 내부적으로 생성하는 extension AppReducer: Reducer 코드에서 Reducer 프로토콜 요구사항(nonisolated)과 MainActor 격리가 충돌하여 컴파일 에러가 발생한다.

해서 이것을 None으로 바꿔주면 에러가 사라진다.
TCA maintainer Comment:
"the default main actor isolation features of Swift 6.2 are really not ready for primetime."
— GitHub Issue