Coordinator 패턴

Dophi·2023년 7월 27일

개발 기술

목록 보기
2/12

소개글

요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

Coordinator (코디네이터) 패턴이란

iOS 코디네이터 패턴에 대해 검색해보면 주로 UIKit에서 구현한 자료들이 많이 나옵니다. 다만 저희 프로젝트는 SwiftUI로 구현을 하고 있어서 해당 자료들을 사용할 수 없었는데, 다행히도 NavigationStack을 사용하는 방식도 존재한다는 것을 알게 됐고 그렇게 구현을 했습니다.

우선 코디네이터 패턴을 제 스스로 간단하게 정리를 하자면 아래와 같습니다. 특징 같은 경우는 일반적인 특징이라기 보다는 제가 구현한 코디네이터 패턴의 특징입니다.

개념

  • 코디네이터라는 것이 화면 전환에 대한 로직을 모두 관리해줌

쓰는 이유

  • ViewController에서 이동해야할 다음 객체를 직접적으로 생성하거나 알 필요가 없음
  • 화면 전환과 관련된 로직이 변하더라도 오직 코디네이터에서만 수정하면 됨

특징

  • iOS 16.0부터 사용 가능한 NavigationStack 도입
  • Path 를 공유해야하기 때문에 오직 한개의 코디네이터 객체만 생성
  • 코디네이터는 App 영역에 존재함
  • Presentation 영역의 ViewModel이 코디네이터에 접근 가능하도록 프로토콜 사용

코드

코드와 함께 어떻게 구현했는지 설명드리겠습니다. 참고로 개발환경은 아래와 같습니다.

클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine

AppScene

우선은 이동 가능한 앱의 화면들을 AppScene 이라는 enum 값으로 정의해둡니다. AppScene은 Presentation 영역에 존재하며, 추후 ViewModel이 오직 이 enum 값만 가지고도 이동할 화면을 선택할 수 있게 할 것입니다.


// Presentation 영역 - AppScene
public enum AppScene: Hashable {
    case loginScene, profileSettingScene
    case rootTabScene
    case learningHomeScene, problemListScene, solvingProblemListScene, favoriteProblemListScene, problemDetailScene(id: Int)
    case pedigreeListScene
    case myPageScene
}

Coordinator

실제 코디네이터 구현체는 아래와 같습니다.

원리를 간단하게 설명드리자면

  • NavigationPath 라는 스택 자료구조가 존재합니다.
  • push, pop, popToRoot 함수를 통해 스택에 enum 값을 넣거나 빼낼 수 있습니다.
  • NavigationPath가 변하면 buildScene을 실행해서 실제 뷰를 생성해내고 보여줍니다.

injector 관련 코드들은 Swinject의 코드들인데, 이것에 대해서는 다음 포스팅에서 설명드리겠습니다. 지금은 아래 두 코드가 똑같은 의미를 가진다는 것만 알아두면 될 것 같습니다.

injector?.resolve(LearningHomeView.self)
LearningHomeView(...)

특이한 점은 CoordinatorProtocol 이란 것을 채택하고 있다는 점인데, 이것에 대해서는 밑에서 설명드리겠습니다.

import SwiftUI
import Presentation
import Domain

// App 영역 - Coordinator
public class Coordinator: ObservableObject, CoordinatorProtocol {
    @Published public var path: NavigationPath // 앱 전반에 걸쳐 공유되야 하는 변수
    private let initialScene: AppScene
    var injector: Injector?
    
    public init(_ initialScene: AppScene) {
        self.initialScene = initialScene
        self.path = NavigationPath()
    }
    
    // 앱을 켤 때 처음 나타나는 뷰를 정함
    public func buildInitialScene() -> some View {
        return buildScene(scene: initialScene)
    }
    
    // 원하는 화면으로 이동하기
    public func push(_ scene: AppScene) {
        path.append(scene)
    }
    
    // 뒤로가기
    public func pop() {
        path.removeLast()
    }
    
    // Root 화면으로 뒤로가기
    public func popToRoot() {
        path.removeLast(path.count)
    }
    
    // 이동할 화면을 생성함
    @ViewBuilder
    public func buildScene(scene: AppScene) -> some View {
        switch scene {
        case .learningHomeScene:
            injector?.resolve(LearningHomeView.self)
        case .problemDetailScene(let id):
            injector?.resolve(ProblemDetailView.self, argument: id)
            ...
        }
    }
}

App

이 부분은 코디네이터 객체를 생성하고 사용하는 부분입니다. NavigationPath를 사용하기 위해서는 NavigationStack으로 앱의 화면을 감싸줘야하며, navigationDestination을 정의해야 NavigationStack의 변화를 감지하고 새로운 뷰를 생성해낼 수 있습니다.

import SwiftUI
import Data
import Presentation
import Domain
import Swinject

// App 영역 - App (앱이 시작되는 부분)
@main
struct LitoApp: App {
    private let injector: Injector
    @ObservedObject private var coordinator: Coordinator
    
    init() {
    	...
        coordinator = Coordinator(.loginScene)
        coordinator.injector = injector
        ...
    }
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $coordinator.path) {
                coordinator.buildInitialScene()
                    .navigationDestination(for: AppScene.self) { scene in
                    	// NavigationPath의 변화를 감지하고 실행
                        coordinator.buildScene(scene: scene)
                    }
            }
        }
    }
}

ViewModel

실제 ViewModel에서 사용할 때는 단순히 push를 통해 View를 전환할 수 있습니다.

// Presentation 영역 - ViewModel
public class ProfileSettingViewModel: ObservableObject {
    
	var coordinator: CoordinatorProtocol
    
    public init(coordinator: CoordinatorProtocol) {
        self.coordinator = coordinator
    }
    
    func moveToLearningHomeView() {
        coordinator.push(.learningHomeView)
    }
}

Coordinator Protocol

다만 여기서 한가지 의문이 들 수 있는 점이 있습니다. Presentation 영역은 App 영역을 알지 못합니다. 하지만 Presentation 영역의 ViewModel이 App 영역의 코디네이터를 가져다 쓰고 있습니다. 이렇게 할 수 있는 이유는 바로 Protocol을 사용하기 때문입니다.

Presentation 영역에 CoordinatorProtocol을 미리 정의해놓고, App 영역의 실제 코디네이터는 이 프로토콜을 따르게 한 뒤, DI를 통해 코디네이터를 ViewModel에 주입해준 것입니다.

IoC 원칙을 얘기할 때는 보통 개발자는 기능의 구현에만 집중하고 프레임워크가 기능이 실행되는 시점을 결정하는 것이라고 합니다. 하지만 저는 추가적으로 이런 것도 IoC라고 생각합니다. App 영역에서는 코디네이터의 구현에만 집중하고, Presentation 영역에서 코디네이터가 실행되는 시점을 결정하는 것, 즉 구현과 제어가 분리된 것이 IoC라고 개인적으로 판단하고 있습니다.

// Presentation 영역 - CoordinatorProtocol
public protocol CoordinatorProtocol {
    func push(_ scene: AppScene)
    func pop()
    func popToRoot()
}

마무리

이상으로 제가 사용해본 코디네이터 패턴을 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

0개의 댓글