요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
iOS 코디네이터 패턴에 대해 검색해보면 주로 UIKit에서 구현한 자료들이 많이 나옵니다. 다만 저희 프로젝트는 SwiftUI로 구현을 하고 있어서 해당 자료들을 사용할 수 없었는데, 다행히도 NavigationStack을 사용하는 방식도 존재한다는 것을 알게 됐고 그렇게 구현을 했습니다.
우선 코디네이터 패턴을 제 스스로 간단하게 정리를 하자면 아래와 같습니다. 특징 같은 경우는 일반적인 특징이라기 보다는 제가 구현한 코디네이터 패턴의 특징입니다.
코드와 함께 어떻게 구현했는지 설명드리겠습니다. 참고로 개발환경은 아래와 같습니다.
클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine
우선은 이동 가능한 앱의 화면들을 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
}
실제 코디네이터 구현체는 아래와 같습니다.
원리를 간단하게 설명드리자면
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)
...
}
}
}
이 부분은 코디네이터 객체를 생성하고 사용하는 부분입니다. 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에서 사용할 때는 단순히 push를 통해 View를 전환할 수 있습니다.
// Presentation 영역 - ViewModel
public class ProfileSettingViewModel: ObservableObject {
var coordinator: CoordinatorProtocol
public init(coordinator: CoordinatorProtocol) {
self.coordinator = coordinator
}
func moveToLearningHomeView() {
coordinator.push(.learningHomeView)
}
}
다만 여기서 한가지 의문이 들 수 있는 점이 있습니다. 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()
}
이상으로 제가 사용해본 코디네이터 패턴을 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊