Coordinator Pattern을 한 마디로 정의하면 화면 전환에 대한 코드를 다른 객체에 위임하여 별도로 관리할 수 있도록 하는 디자인 패턴입니다. 그렇다면 이것을 “왜” 사용하는 걸까요?
우리의 ViewController는 할 일이 매우 많습니다. 복잡한 UI를 구성하는 UI일수록 더욱 그렇습니다. 이외에도 데이터와 UI 바인딩, 라이프 사이클 관리, 또 MVC 패턴을 사용한다면 그 크기가 더욱 방대해질 것입니다. 이렇게 하나의 ViewController에서 많은 역할을 수행하면 정작 해야할 UI 관리에 집중을 하지 않게 될 수 있습니다.
Coordinator Pattern의 장점
그래서 등장한 것이 Coordinator Pattern디자인 패턴입니다. 우리는 ViewController에서 화면 전환에 대한 코드를 분리함으로서 다음과 같은 이점을 얻을 수 있습니다.
- 단일 책임 원칙
- 재사용성과 테스트 가능성
- 유지보수성
이 세 가지 장점은 사실 모두 연관되어 있습니다. 먼저 일차원적으로 ViewController에 화면 전환 코드를 분리함으로서 단일 책임 원칙(Single Responsibility Principle)을 어느정도 지킬 수 있게 됩니다. 나중에 설명할 Coordinator 객체들이 화면 전환 로직에 집중하면서 단일 책임 원칙을 준수할 수 있습니다. 동시에 ViewController도 기존의 화면 전환 로직이 제외되어 UI 관리에만 집중할 수 있게 됩니다.
Coordinator Pattern을 사용하지 않으면 무의식적으로 같은 화면 전환의 코드를 반복해서 사용할 수도 있습니다. 예를 들어 A라는 화면에 진입할 수 있는 통로가 여러 곳이 있다면, 그럴 때마다 ViewController의 객체를 만들어서 NavigationStack에 계속해서 넣어줘야합니다. 이런 반복된 작업이 나중에 가면 큰 부담으로 다가올 수 있습니다.
하지만 Coordinator가 이런 작업을 수행해준다면, 우리는 해당 화면에서 Coordinator 객체를 갖고 있는 것만으로도 반복적인 코드를 작성할 필요가 없게 됩니다. 우리는 Coordinator의 어떤 동작을 호출해주는 것만으로도 충분합니다. 이렇게 하면 개발자들은 각 객체가 하는 역할이 명확해져서 앱이 방대해져도 유지보수를 하기 용이합니다. 동시에 테스트하기에도 좋아지죠.
Coordinator 구현
그렇다면 Coordinator는 어떤식으로 구현하면 좋을까요? 위에서 살펴본 큰 틀에서만 벗어나지 않는다면 어떤 식으로든 구현할 수 있습니다. 하지만 이번에는 개발자들에게 보편적인 Coordinator 구현 방식으로 살펴볼까 합니다.
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get set }
var childCoordinators: [any Coordinator] { get set }
var parentCoordinator: Coordinator? { get set }
func start()
func start(childCoordinator: some Coordinator)
func finish(childCoordinator: some Coordinator)
}
먼저 각 Coordinator는 Coordinator라는 프로토콜을 준수해야 합니다. 모든 Coordinator가 일관되게 앱에서 관리해야 하기 때문입니다. 기본적으로 세 가지의 변수와 메서드가 존재합니다. 여기서 특히 주의깊게 봐야할 것은 Coordinator들은 트리 구조를 갖는다는 것입니다. 이것은 NavigationController가 화면들을 Stack의 자료구조로 관리되고 있기 때문입니다.
Coordinator의 트리 구조
하나의 화면 당 하나의 Coordinator를 가지는 것이 일반적입니다. 화면 전환이 이루어지면서 Navigation Stack이 쌓이게 됩니다. 그래서 다른 화면으로 전환되어도 이전 Coordinator에 대한 메모리를 해제 하지 않고 가지고 있어야합니다. 왜냐하면 Stack에 화면이 쌓인다는 것은 아직 화면에 대한 인스턴스 메모리가 남아있다는 뜻이기 떄문이죠.
즉 Navigation Stack이 추가되거나 삭제될 때, Coordinator도 같은 방식으로 관리를 해줘야 일관성있고 독립적으로 존재할 수 있게 됩니다. 그 관리 방법 중 하나가 바로 트리 구조입니다.
extension Coordinator {
func start(childCoordinator: some Coordinator) {
self.childCoordinators.append(childCoordinator)
childCoordinator.parentCoordinator = self
childCoordinator.start()
}
func finish(childCoordinator: some Coordinator) {
childCoordinators = childCoordinators.filter { $0 !== childCoordinator }
}
}
한 가지 편의성을 위해 Coordinator 프로토콜에 extension으로 start(childCoordinator:)와 finish(childCoordinator:)메서드의 기본 구현 목록을 추가합니다. 왜냐하면 자식, 부모 Coordinator를 참조하는 것들이 반복적인 과정일 수 밖에 없기 때문입니다.
특정 화면으로 이동할 때, 트리 구조를 유지하기 위해 Coordinator를 계속해서 append해줘야합니다. 그리고 다음 Coordinator의 부모 Coordinator가 누구인지도 알아야하죠. 마지막으로 자식 Coordinator의 start()를 호출하면, 올바른 트리 구조를 만들고 다음 화면으로 성공적으로 이동할 수 있게 됩니다.
finish(childCoordinator:)의 기본 구현은 보여지고 있는 화면이 pop처리가 될 때 불려지는 메서드입니다. 즉 현재 자식 Coordinator들에게서 자기 자신의 Coordinator를 filter처리하여 삭제하는 것입니다.
Root Coordinator 구현
class BaseCoordinator: NSObject, Coordinator {
let disposeBag = DisposeBag()
var childCoordinators: [any Coordinator] = []
var navigationController: UINavigationController
var parentCoordinator: Coordinator?
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
fatalError("start() method must be implemented")
}
}
Coordinator 프로토콜을 ‘직접적’으로 모두 채택할 필요는 없습니다. 이렇게 BaseCoordinator에게 Coordinator 프로토콜을 채택하면 상속 개념으로 확장성 있게 사용이 가능합니다. 이번 구현 방식에서는 ReactorKit을 함께 사용했기 때문에 disposeBag이 있는 것을 확인할 수 있습니다.(신경쓰지 않아도 됩니다!)
당연히 start(childCoordinator:) 메서드와 finish(childCoordinator:)는 보이지 않습니다. 이것은 Coordinator 프로토콜에 extension으로 기본 값으로 구현했기 떄문입니다.
final class AppCoordinator: BaseCoordinator {
override func start() {
// == first launch check ==
let coordinator: Coordinator
if FTUXStorage().wasLaunchedBefore {
coordinator = HomeCoordinator(navigationController: self.navigationController)
} else {
coordinator = OnboardingCoordinator(navigationController: self.navigationController)
}
start(childCoordinator: coordinator)
}
}
먼저 가장 최상위에 존재하는 AppCoordinator라는 것을 구현해줄 것입니다. 이 AppCoordinator는 앱이 실행과 동시에 생성되어야 하니, SceneDelegate에서 소유하고 있는 것이 바람직합니다. start()를 통해 앱이 최초의 실행 됐는지 아닌지에 따라서 어떤 Coordinator가 생성되는지 결정됩니다. 만약 HomeCoordinator라고 한다면, start(childCoordinator:)를 통해 HomeCoordinator가 자식 Coordinator에 추가되고, 부모 Coordinator는 AppCoordinator가 됩니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coordinator: AppCoordinator?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let scene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: scene)
self.window = window
let navVC = UINavigationController()
self.coordinator = AppCoordinator(navigationController: navVC)
self.coordinator?.start()
window.rootViewController = navVC
window.makeKeyAndVisible()
}
}
Coordinator간의 소통
Coordinator에 대한 소통 방식도 개발자가 고려해야할 것 중 하나입니다. 일방적으로 NavigationStack에 쌓이는 화면 전환이라면 상관없지만, 사용자의 이벤트에 따라서 특정 스택에 있는 화면을 제거하고 다른 화면으로 이동해야할 경우가 있습니다. 즉 사용자에 이벤트가 부모 Coordinator가 감지하고 비즈니스 로직을 수행해야 한다는 의미입니다.
이것에 대한 방법은 여러 가지입니다. 대표적으로는 Delegate Pattern이 그 중 하나입니다. 하지만 Delegate Pattern은 넘어가야할 계층이 많으면 많을 수록 그것을 전달하기 힘들어집니다.(Cell → ViewController → ViewModel → Coordinator 같이..) 이것에 대한 자세한 구현 방법은 Zedd님이 잘 설명 해주셨습니다. https://zeddios.medium.com/coordinator-pattern-bf4a1bc46930
또 하나는 비동기 프로그래밍을 이용한 소통 방식이 있습니다. 여기서 비동기 프로그래밍이란 RxSwift나 Combine을 뜻합니다. 이 방법은 기존 고전적인 방법보다는 훨씬 수월합니다.
응용
이제 기본적인 작업은 모두 끝났습니다. 이 Coordinator들을 어디서 어떻게 관리할지는 프로젝트 성격에 따라서 많이 달라지게 됩니다. 이 Coordinator Pattern과 쓰이는 아키텍처는 가장 보편적인 MVVM입니다. MVVM - C라고도 불리는 이 아키텍처는 규모가 큰 프로젝트도 유지보수성 있게 구현할 수 있게 됩니다.
즉 ViewModel까지 View이외에도 Coordinator까지 통제하게 됩니다. 이렇게 ViewModel에서 이벤트를 방출하면 ViewModel이 특정 화면에서 허브 같은 역할을 수행할 수 있습니다. 다음 포스트에서는 ReactorKit을 이용한 MVVM과 Coordinator를 이용하여 어떻게 구현할 수 있을지 살펴보겠습니다.