ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI 멀티 플렛폼 Navigation 아키텍쳐 설계 (Coordinator 야 저리 가라)
    Apple🍎/SwiftUI 2025. 4. 8. 17:23

    UIKit에서 Coordinator 패턴이 등장한 배경

    UIKit 개발에서 Coordinator 패턴은 앱의 화면 전환 흐름을 관리하는 문제를 해결하기 위해 등장했습니다. 이 패턴이 필요했던 주요 이유들은 다음과 같습니다.

    1. 뷰 컨트롤러의 과도한 책임 분산

    UIKit의 기본 구조에서는 화면 전환 로직이 주로 뷰 컨트롤러 내부에 위치했습니다.

    class ProfileViewController: UIViewController {
        func showSettings() {
            let settingsVC = SettingsViewController()
            settingsVC.user = self.user
            self.navigationController?.pushViewController(settingsVC, animated: true)
        }
        
        func showEditProfile() {
            let editVC = EditProfileViewController()
            editVC.delegate = self
            editVC.user = self.user
            self.present(editVC, animated: true)
        }
    }
    

    이 접근법은 다음과 같은 문제를 야기했습니다.

    • 단일 책임 원칙 위반: 뷰 컨트롤러가 UI 표시와 함께 화면 전환까지 처리
    • 재사용성 저하: 화면 전환 로직이 뷰 컨트롤러에 강하게 결합
    • 테스트 어려움: 화면 전환 로직 분리가 어려워 단위 테스트 복잡성 증가

    2. 뷰 컨트롤러 간 의존성 연결

    뷰 컨트롤러 간에 데이터를 전달하기 위해서는 직접적인 참조가 필요했습니다.

    // 화면 A에서 화면 B로 전환할 때 데이터 전달
    let detailVC = DetailViewController()
    detailVC.item = selectedItem
    detailVC.delegate = self
    navigationController?.pushViewController(detailVC, animated: true)
    

    이러한 접근법은 다음과 같은 문제를 가져옵니다. 

    • 강한 결합: 뷰 컨트롤러 A가 뷰 컨트롤러 B의 구현 세부사항을 알아야 함
    • 의존성 그래프 복잡화: 뷰 컨트롤러 간의 복잡한 의존 관계 형성
    • 유지보수 어려움: 하나의 화면 변경이 여러 화면에 영향 미칠 수 있음

    3. 복잡한 앱 흐름 관리

    여러 화면이 있는 앱에서는 화면 흐름을 일관되게 관리하기 어려웠습니다.

    • 흐름 일관성 유지: 여러 경로에서 동일한 화면으로 이동할 때 일관된 설정 필요
    • 조건부 흐름: 로그인 상태, 권한 등에 따라 다른 화면으로 이동하는 로직 복잡성
    • 딥링크 처리: 앱의 특정 화면으로 직접 이동하는 외부 링크 처리 어려움

    4. 생명주기 관리 복잡성

    UIKit의 뷰 컨트롤러 생명주기는 다음과 같은 관리 문제를 야기했습니다.

    • 메모리 관리: 화면 전환 시 메모리 누수 가능성
    • 상태 보존 및 복원: 앱 종료/재시작 시 화면 상태 복원 어려움 (컨트롤러러에 화면 상태가 묶여 있어서)
    • 화면 스택 관리: 복잡한 네비게이션 스택 조작(여러 화면 건너뛰기, 특정 화면으로 돌아가기 등)

    Coordinator 패턴이 UIKit에서 제공한 해결책

    Coordinator 패턴은 앞서 Controller 내부에 화면 전환 로직을 포함하면서 발생했던 문제들을 다음과 같이 해결했습니다.

    protocol Coordinator: AnyObject {
        var childCoordinators: [Coordinator] { get set }
        func start()
    }
    
    class MainCoordinator: Coordinator {
        var childCoordinators: [Coordinator] = []
        var navigationController: UINavigationController
        
        init(navigationController: UINavigationController) {
            self.navigationController = navigationController
        }
        
        func start() {
            let homeVC = HomeViewController()
            homeVC.coordinator = self
            navigationController.pushViewController(homeVC, animated: false)
        }
        
        func showDetail(for item: Item) {
            let detailVC = DetailViewController(item: item)
            detailVC.coordinator = self
            navigationController.pushViewController(detailVC, animated: true)
        }
        
        func showSettings() {
            let settingsCoordinator = SettingsCoordinator(navigationController: navigationController)
            childCoordinators.append(settingsCoordinator)
            settingsCoordinator.parentCoordinator = self
            settingsCoordinator.start()
        }
        
        func childDidFinish(_ child: Coordinator) {
            for (index, coordinator) in childCoordinators.enumerated() {
                if coordinator === child {
                    childCoordinators.remove(at: index)
                    break
                }
            }
        }
    }
    

    Coordinator 패턴의 주요 이점

    1. 화면 전환 로직 중앙화: 화면 간 이동 로직이 뷰 컨트롤러에서 분리
    2. 의존성 주입 단순화: Coordinator가 뷰 컨트롤러 생성 및 설정을 담당
    3. 계층적 흐름 관리: 부모-자식 Coordinator 관계를 통한 복잡한 흐름 관리
    4. 뷰 컨트롤러 재사용성 향상: 화면 전환 로직이 없어 다른 컨텍스트에서 재사용 용이
    5. 테스트 용이성: 네비게이션 로직과 UI 로직 분리로 단위 테스트 개선

    SwiftUI에서 Coordinator 패턴이 적합하지 않은 이유

    SwiftUI는 근본적으로 UIKit과 다른 패러다임을 가지고 있어, 전통적인 Coordinator 패턴이 잘 맞지 않습니다.

    1. 선언적 UI 패러다임의 차이

    UIKit의 명령형(imperative) 접근법과 SwiftUI의 선언적(declarative) 접근법 사이의 근본적 차이

    UIKit (명령형): 화면 전환 방법을 구체적으로 명시 ( 컨트롤러를 생성해서 -> navigationController에 push 해라)

    // 명령적으로 화면 전환 수행
    navigationController.pushViewController(detailVC, animated: true)
    

    SwiftUI (선언적): NavigationLink와 같은 컴포넌트로 전환해야할 화면만 명시하면 SwiftUI가 알아서 화면 전환 

    // 네비게이션 링크를 선언하고 상태에 따라 UI가 자동으로 업데이트
    NavigationLink(value: selectedItem) {
        Text("상세 보기")
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
    

    SwiftUI에서는

    • UI는 상태의 함수로 표현됨 (UI = f(state))
    • 명시적인 명령 대신 상태 변경을 통해 간접적으로 UI 업데이트
    • 네비게이션도 상태 변화에 따른 자연스러운 UI 변화로 처리

    2. 화면 생명주기 개념의 변화

    UIKit과 SwiftUI의 생명주기 관리 방식이 근본적으로 다릅니다.

    UIKit

    • 뷰 컨트롤러 생명주기 메서드: viewDidLoad, viewWillAppear, viewDidDisappear 등
    • 화면 전환 시 명시적인 생명주기 이벤트 처리 필요

    SwiftUI

    • 뷰는 값 타입이며 상태 변화에 따라 지속적으로 재구성됨
    • .onAppear, .onDisappear 같은 간단한 콜백만 제공
    • 뷰의 생성과 소멸이 프레임워크에 의해 자동 관리됨

    Coordinator의 주요 책임 중 하나인 뷰 컨트롤러 생명주기 관리가 SwiftUI에서는 크게 간소화됩니다.

    3. 환경(Environment)을 통한 의존성 주입

    SwiftUI는 환경 객체를 통한 강력한 의존성 주입 메커니즘을 제공합니다.

    // 앱 전체에 의존성 주입
    @main
    struct MyApp: App {
        let container = DependencyContainer()
        
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(container)
            }
        }
    }
    
    // 환경에서 의존성 사용
    struct DetailView: View {
        @EnvironmentObject var container: DependencyContainer
        
        var body: some View {
            // container 사용
        }
    }
    

    이 방식을 통해 

    • Coordinator를 통한 명시적 의존성 주입 필요성 감소
    • 환경을 통한 간접적이고 유연한 의존성 접근 제공
    • 뷰의 계층 구조를 따라 자동으로 의존성 전파

    4. 데이터 흐름 패러다임의 차이

    UIKit과 SwiftUI의 데이터 흐름 모델이 근본적으로 다릅니다.

    UIKit

    • 주로 델리게이트 패턴, 클로저, 노티피케이션을 통한 데이터 전달
    • 화면 간 데이터 공유를 위한 명시적 참조 필요

    SwiftUI

    • 단방향 데이터 흐름 (Single Source of Truth)
    • 공유 상태를 위한 @StateObject, @EnvironmentObject
    • 상태 변화에 UI가 자동 반응하는 반응형 프로그래밍 모델

    Coordinator가 관리하던 화면 간 데이터 전달이 SwiftUI에서는 상태 객체 공유로 간단하게 해결됩니다.

    5. NavigationStack과 같은 선언적 네비게이션 도구

    SwiftUI는 iOS 16부터 강력한 네비게이션 프리미티브를 제공합니다.

    struct ContentView: View {
        @State private var path = NavigationPath()
        
        var body: some View {
            NavigationStack(path: $path) {
                List {
                    // 다양한 유형의 네비게이션 대상 지원
                    NavigationLink(value: "문자열 값") {
                        Text("문자열 화면으로")
                    }
                    NavigationLink(value: 42) {
                        Text("숫자 화면으로")
                    }
                    NavigationLink(value: User(name: "Kim")) {
                        Text("사용자 화면으로")
                    }
                    
                    Button("프로그래매틱 네비게이션") {
                        // 코드로 네비게이션 경로 조작
                        path.append("프로그래매틱 추가됨")
                    }
                    
                    Button("홈으로 돌아가기") {
                        path = NavigationPath()
                    }
                }
                // 타입별 목적지 정의
                .navigationDestination(for: String.self) { string in
                    Text("문자열 화면: \(string)")
                }
                .navigationDestination(for: Int.self) { number in
                    Text("숫자 화면: \(number)")
                }
                .navigationDestination(for: User.self) { user in
                    UserDetailView(user: user)
                }
            }
        }
    }
    

    이 접근법을 통해 

    • 타입 안전한 네비게이션 제공
    • 프로그래매틱 네비게이션 조작 지원
    • 복잡한 네비게이션 스택 상태 관리 간소화

    SwiftUI의 선언적 특징을 반영한 Navigation 방법 (멀티 플렛폼) 

    1. 앱 진입 지점

    앱의 흐름은 DoneDoneApp.swift에서 시작합니다.

    @main
    struct MultiApp: App {
        private let dependencyContainer = DependencyContainer()
    
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(dependencyContainer)
                    .environment(\.managedObjectContext, dependencyContainer.persistenceController.viewContext)
            }
        }
    }

    이 진입점에서 다음과 같은 중요한 작업이 이루어집니다.

    • DependencyContainer 인스턴스 생성
    • ContentView를 루트 뷰로 설정
    • 의존성 컨테이너와 CoreData 컨텍스트를 환경 객체로 주입

    2. 플랫폼 감지 및 네비게이션 결정

    ContentView는 디바이스 유형을 감지하여 적절한 네비게이션 패턴을 선택합니다.

    struct ContentView: View {
        @State private var selection: AppScreen? = .home
        @Environment(\.prefersTabNavigation) private var prefersTabNavigation
        @EnvironmentObject private var dependencyContainer: DependencyContainer
        
        var body: some View {
            if prefersTabNavigation {
                // iPhone 또는 컴팩트 환경에서는 탭 사용
                AppTabView(selection: $selection)
            } else {
                // iPad, Mac 등에서는 사이드바 사용
                NavigationSplitView {
                    AppSidebarList(selection: $selection)
                } detail: {
                    AppDetailColumn(screen: selection)
                }
            }
        }
    }
    

    핵심 메커니즘

    • @Environment(\.prefersTabNavigation): 현재 환경이 탭 네비게이션을 선호하는지 감지
    • @State private var selection: AppScreen?: 현재 선택된 화면 상태 추적
    • 조건부 렌더링으로 디바이스에 맞는 네비게이션 UI 선택

    3. 디바이스별 네비게이션 구조

    a. 모바일 폰/컴팩트 디바이스 (AppTabView)

    struct AppTabView: View {
        @Binding var selection: AppScreen?
        @EnvironmentObject private var container: DependencyContainer
        
        var body: some View {
            TabView(selection: $selection) {
                ForEach(AppScreen.allCases) { screen in
                    screen.destination(using: container)
                        .tag(screen as AppScreen?)
                        .tabItem { screen.label }
                }
            }
        }
    }
    

    이 모드에서는

    • 하단 탭 바를 통해 주요 화면 간 전환
    • 각 탭은 독립적인 NavigationStack을 가짐

    b. 태블릿/데스크톱 디바이스 (NavigationSplitView)

    NavigationSplitView {
        AppSidebarList(selection: $selection)
    } detail: {
        AppDetailColumn(screen: selection)
    }
    
    struct AppSidebarList: View {
        @Binding var selection: AppScreen?
        
        var body: some View {
            List(AppScreen.allCases, selection: $selection) { screen in
                NavigationLink(value: screen) {
                    screen.label
                }
            }
        }
    }
    
    struct AppDetailColumn: View {
        var screen: AppScreen?
        @EnvironmentObject private var container: DependencyContainer
        
        var body: some View {
            Group {
                if let screen {
                    screen.destination(using: container)
                } else {
                  
                }
            }
            #if os(macOS)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background()
            #endif
        }
    }

    이 모드에서는

    • 좌측 사이드바를 통해 주요 화면 탐색
    • 우측에 선택된 화면의 상세 내용 표시
    • 분할 뷰를 통한 마스터-디테일 패턴 구현

    4. 각 화면의 내부 네비게이션 스택

    각 주요 화면(Home, Statistics, Settings)은 자체 NavigationStack을 가지고 있어 독립적인 내부 네비게이션을 관리합니다.

    struct HomeNavigationStack: View {
        @EnvironmentObject private var container: DependencyContainer
        @StateObject var viewModel: HomeViewModel
        @State private var path = NavigationPath()
        @State private var presentedSheet: SheetType?
        
        enum SheetType: Identifiable {
            case newActivity
            
            var id: String {
                switch self {
                case .newActivity: return "newActivity"
                }
            }
        }
        
        var body: some View {
            NavigationStack(path: $path) {
                VStack {
                    Text("Home")
                }
            }
            .navigationDestination(for: String.self) { route in
                // 다양한 목적지 화면들
            }
            .sheet(item: $presentedSheet) { sheetType in
                // 모달 시트 화면들
            }
        }
    }
    

    각 스택에서는

    • NavigationPath를 사용한 화면 이동 히스토리 관리
    • .navigationDestination을 통한 화면 간 이동 정의
    • .sheet를 통한 모달 표시 정의

     

    댓글

Designed by Tistory.