[SwiftUI] 침하하 앱을 만들어보자 Step 1, 2, 3 + 트러블슈팅

팔랑이·2026년 2월 26일

iOS/Swift

목록 보기
86/89
post-thumbnail

SwiftUI 침하하 앱 개발기


Step 1. Project Setup

@main & App protocol

SwiftUI 앱의 진입점.App 프로토콜을 채택하고 @main을 붙이면 된다.
WindowGroup이 root scene 역할을 하며, 그 안에 RootView를 넣는다.

@main
struct ChimhahaApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

AppDelegate, SceneDelegate가 없다

SwiftUI로 프로젝트를 만들면, UIKit에선 기본으로 생성되던 AppDelegate, SceneDelegate가 생성되지 않는다! (두둥)
UIKit에선 AppDelegate + SceneDelegate로 앱 생명주기를 관리했는데, SwiftUI에서는 App 프로토콜이 그 역할을 다 흡수해서 기본적으로 없다고 한다.

냅다 대응을 해보자면 다음과 같다고 하는데,

UIKitSwiftUI
AppDelegateApp (Protocol)
SceneDelegateWindowGroup
application(_:didFinishLaunchingWithOptions:)init()

푸시 알림, 딥링크 처리 등 AppDelegate가 꼭 필요한 경우에는 다음과 같이 붙일 수도 있다고 한다.

  @main
  struct ChimhahaApp: App {
      @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

      var body: some Scene {
          WindowGroup { ... }
      }
  }

Step 2-1. Domain Layer - Entity

아직까지는 UIKit 구현과 크게 다를 것이 없었다.

Step 2-2. Network Layer

Combine 이라는걸 공부해보고 싶긴 했는데... TCA 1.0부터 공식적으로 Swift Concurrency로 전환했다고 한다.
기존에 Networkmanager로 쓰던 것을 ApiClient이라는 이름으로 대체하고, async/await을 활용해서 구성했다. 크게 다를 건 없어서 여기는 패스

Endpoint enum

Moya와 비슷하게 사용해보기 위해 Endpoint enum을 만들었다. 각 API 경로를 case로 관리해서 오타를 방지하고, URL 생성 로직을 한 곳에 모아둘 수 있다. 이 프로젝트에서는 학습 목적으로 Moya 없이 URLSession을 직접 다룬다.

enum Endpoint {
    case posts
    case post(id: String)
    case comments(postId: String)

    var url: URL? { ... }
}

URLProtocol 스텁 테스트

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을 쓰면 캐시와 쿠키가 없어서 테스트 간 격리가 보장된다.


Step 3 - rootTabBar 만들기

TCA를 앱에 처음 연결하는 단계로, 파일은 세 개뿐이지만 TCA의 핵심 흐름이 다 담겨 있다.

AppReducer.swift — 앱 전체의 루트 Reducer

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을 구독하는 그런 느낌인듯

AppView.swift — 앱 전체의 루트 View

AppReducerStore를 받아서 탭바 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
@BindableObservable 객체의 프로퍼티를 $ 바인딩으로 쓸 수 있게 해준다.
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로 보내도록 중간에서 가로채서 tabSelected Action으로 변환한다.

👉🏻 TabView의 바인딩 요구는 맞춰주면서, 실제 동작은 액션으로 우회하는 어댑터!

ChimhahaApp.swift — Store 생성 및 주입

@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가 앱이 살아있는 동안 전체 상태를 들고 있게 된다.

여기까지의 수확!


트러블슈팅

TCA @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

profile
정체되지 않는 성장

0개의 댓글