
본 글은 How to implement flow coordinator pattern (Pavle Pesic)를 한국어로 번역하여 옮긴 글입니다.
제가 iOS 개발에 대한 글을 쓰기 시작할 무렵, 잘 다뤄지지 않은 주제의 글을 작성하고 싶었습니다. 그래서 custom transitions using coordinator pattern과 how to implement delegation pattern using MVVM and coordinators에 대해 글을 작성했습니다.
이 글들에 대한 가장 큰 불만 중 하나는 제가 코디네이터 패턴(coordinator pattern)이 무엇인지 설명하지 않았다는 것이었습니다. 이 주제를 다룬 훌륭한 글들이 많습니다. 예를 들어, Andrey Panov의 Coordinator Essential Tutorial, Krzysztof Zabtocki의 lecture about Good iOS Application Architecture, Denis Walsh의 Flow Coordinator in iOS가 있습니다. 그래서 모두가 이 내용을 이미 익숙하리라 생각했지만, 제 생각이 틀렸네요.
내비게이션에 대해 이야기할 때, 일반적으로 우리는 아래 코드를 생각합니다. 게다가, 이것은 좋은 시나리오라는 것을 명심하세요. if 구문, 전환 애니메이션(animating transtions)과 뷰 컨트롤러 간 데이터 전달이 없기 때문입니다.
@IBAction func goToB() {
let bViewController = UIStoryboard.first.instantiateViewController(withIdentifier: "BViewController") as! BViewController
bViewController.viewModel = BViewModel()
self.navigationController?.pushViewController(bViewController, aniamted: true)
}
@IBAction func profile() {
let profileViewController = UIStoryboard.first.instantiateViewController(withIdentifier: "ProfileViewController") as! ProfileViewController
profileViewController.viewModel = ProfileViewModel()
self.navigationController.pushViewController(profileViewController, animated: true)
}
이러한 접근법의 문제가 무엇일까요?
뷰 컨트롤러에 코드가 너무 많음
모든 뷰 컨트롤러가 다른 뷰 컨트롤러를 알고 있음
흐름(flow)을 변경하기 어려움
코드를 재사용하기 어려움
테스트하기 어려움
코디네이터는 앱 내비게이션 흐름을 다루고, 뷰 컨트롤러와 뷰 모델을 구성하는 객체입니다. 뷰 컨트롤러는 코디네이터에 대해 아무것도 알지 못합니다. 뷰 컨트롤러는 내비게이션이 발생할 때 코디네이터에게 이를 알리는 인터페이스만 노출합니다.
로그인, 등록, 비밀번호 변경과 몇 가지 다른 컨트롤러를 포함하는 앱을 만들어보겠습니다. 내비게이션 흐름에는 코디네이터 패턴을 사용하고, 결과를 분석해보겠습니다.
우리 앱에 어떤 코디네이터가 필요한지 살펴보겠습니다.
Application coordinator - 모든 앱은 이 코디네이터가 필요합니다. 애플리케이션 코디네이터는 내비게이션 흐름을 시작하고, 새호운 하위 코디네이터를 만듭니다.
Auth coordinator
Change password coordinator
Main corrordinator - 사용자가 로그인할 때 앱의 흐름 처리
Profile coordinator - 사용자 프로필을 변경할 때 앱의 흐름 처리

이 스키마를 보면, 우리는 코디네이터가 다른 코디네이터를 생성하고 추가하거나 제거하며, 뷰 컨트롤러를 생성하고 앱 내에서 내비게이션을 처리하는 방법을 알아야 한다는 걸 볼 수 있습니다. 이 로직을 구현하는 데는 정해진 방법이 없습니다. 제 접근 방식은 Andrey Panov의 방법을 따르고 있다는 것을 보게 될 것입니다.
코디네이터를 생성하기 위해 코디네이터 팩토리(coordinator factory)를 사용합니다.
protocol CoordinatorFactoryProtocol {
func makeAuthCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactory) -> AuthCoordinator
func makeMainCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactory) -> MainCoordinator
}
final class CoordinatorFactory: CoordinatorFactoryProtocol {
// MARK: - CoordinatorFactoryProtocol
func makeAuthCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactoryProtocol) -> AuthCoordinator {
let coordinator = AuthCoordinator(router: router, coordinatorFactory: coordinatorFactory, viewControllerFactory: viewControllerFactory)
return coordinator
func makeMainCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactoryProtocol) -> MainCoordinator {
let coordinator = MainCoordinator(router: router, coordinatorFactory: coordinatorFactory, viewControllerFactory: viewControllerFactory)
return coordinator
}
}
베이스 코디네이터(base coordinator)에는 코디네이터를 추가하거나 제거하는 로직이 포함되어 있습니다. 하위 코디네이터(child coordinator) 배열은 활성 상태의 코디네이터를 저장하며, 이 배열은 하위 코디네이터를 강하게 참조합니다. 그렇지 않으면 메모리에서 해제될 수 있습니다.
앱 스키마를 자세히 살펴보세요. 사용자가 프로필 화면(ProfileVC)에 있다면, 프로필 코디네이터는 메인 코디네이터의 하위 코디네이터 변수에 할당됩니다. 그와 동시에, 메인 코디네이터는 애플리케이션 코디네이터의 하위 코디네이터 변수에 할당됩니다. 프로필 화면에서 A 화면(A VC)로 되돌아간다면, 메인 코디네이터는 하위 코디네이터 배열에서 프로필 코디네이터를 제거하고, 더 이상 이를 강하게 참조하지 않을 것입니다.
protocol Coordinator: class {
func start()
func start(with option: DeepLinkOption?)
}
class BaseCoordinator: Coordinator {
// MARK: - Vars & Lets
var childCoordinators = [Coordinator]()
// MARK: - Public methods
func addDependency(_ coordinator: Coordinator) {
for element in childCoordinators {
if element === coordinator { return }
}
childCoordinators.append(coordinator)
}
func removeDependency(_ coordinator: Coordinator?) {
guard let childCoordinators.isEmpty == false, let coordinator = coordinator else { return }
for (index, element) in childCoordinators.enumerated() {
if element === coordinator {
childCoordinators.remove(at: index)
break
}
}
}
// MARK: - Coordinator
func start() {
start(with: nil)
}
func start(with option: DeepLinkOption?) {
}
}
뷰 컨트롤러를 생성하기 위해 뷰 컨트롤러 팩토리(view controller factory)를 사용합니다. 이 클래스는 굉장히 단순합니다. 팩토리 패턴을 사용해 뷰 컨트롤러를 생성합니다.
import UIKit
class ViewControlerFactory {
func instantiateAViewController() -> AViewController {
let aVC = UIStoryboard.first.instantiateViewController(withIdentifier: "AViewController") as! AViewController
aVC.viewModel = AViewModel()
return aVC
}
func instantiateBViewController(delegate: BViewControllerDelegate) -> BViewController {
let bVC = UIStoryboard.first.instantiateViewController(withIdentifier: "BViewController") as! BViewController
bVC.viewModel = BViewModl()
bVC.delegate = delegate
return bVC
}
}
라우팅(routing)의 경우, 라우터(router) 객체를 사용합니다. 라우터 객체는 내비게이션 컨트롤러에 대한 참조를 가지고 있기 때문에, 표시(present), 푸시(push)하거나 해제(dismiss)나 뷰 컨트롤러를 팝(pop)할 수 있습니다. 또한, 커스텀 전환도 가능합니다.
이제 코디네이터 객체가 필요한 모든 것을 갖췄으니, 한번 사용해봅시다.
앱 델리게이트에서 애플리케이션 코디네이터를 초기화하고 최상위 뷰 컨트롤러를 설정하세요. 애플리케이션 코디네이터는 LaunchInstructor 객체를 사용하여 인증 흐름으로 갈지, 메인 흐름으로 갈지 결정합니다. 이는 단지 개념일 뿐이므로, 구현은 하지 않겠습니다. 그러나, 개념은 LaunchInstructor가 인증 토큰을 사용할 수 있는지 확인하고, 사용이 가능하다면 등록이 완료되었다고 판단하는 것입니다.
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var rootViewController: UINavigationController {
return self.window!.rootViewController as! UINavigationController
}
private lazy var applicationCoordinator: Coordinator = ApplicationCoordinator(router: Router(rootController: self.rootController), coordinatorFactory: CoordinatorFactory())
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
return true
}
}
final class ApplicationCoordinator: BaseCoordinator {
// MARK: - Vars & Lets
private let coordinatorFactory: CoordinatorFactoryProtocol
private let router: RouterProtocol
private var launchInstructor = LaunchInstructor.configure()
private let viewControllerFactory: ViewControllerFactory = ViewControllerFactory()
// MARK: - Coordinator
override func start(with option: DeepLinkOption?) {
if option != nil {
} else {
switch launchInstructor {
case .auth: runAFlow()
case .main: runMainFlow()
}
}
}
// MARK: - Private methods
private func runAFlow() {
let coordinator = self.coordinatorFactory.makeAuthCoordinatorBox(router: self.router, coordinatorFactory: self.coordinatorFactory, viewControllerFactory: self.viewControllerFactory)
coordinator.finishFlow = { [unowned self, unowned coordinator] in
self.removeDependency(coordinator)
self.lanchInstructor = LaunchInstructor.configure()
self.start()
}
self.addDependency(coordinator)
coordinator.start()
}
private func runMainFlow() {
let coordinator = self.coordinatorFactory.makeMainCoordinatorBox(router. self.router, coordinatorFactory: self.coordinatorFactory, viewControllerFactory: ViewControllerFactory())
coordinator.finishFlow = { [unowned self, unowned coordinator] in
self.removeDependency(coordinator)
self.launchInstructor = LaunchInstructor.configure()
self.start()
}
self.addDependency(coordinator)
coordinator.start()
}
// MARK: - Init
init(router: Router, coordinatorFactory: CoordinatorFactory) {
self.router = router
self.coordinatorFactory = coordinatorFactory
}
}
앱을 시작하면 로그인이나 회원가입을 할 수 있습니다. 로그인 과정을 완료하면 애플리케이션 코디네이터는 인증 코디네이터를 제거하고, 메인 코디네이터를 시작합니다.
지금까지 우리가 얻은 것은 무엇인가요?
내비게이션 로직이 분리되었기 때문에 뷰 컨트롤러의 코드가 간결해졌습니다.
뷰 컨트롤러는 다른 뷰 컨트롤러를 알지 못합니다.
스키마에서 볼 수 있듯이, 비밀번호 변경 코디네이터는 두 곳에 있습니다. 어떤 플로우를 재사용하고 싶다면, 선택한 코디네이터의 새로운 인스턴스를 생성하면 모든 것이 준비됩니다.
final class AuthCoordinator: BaseCoordinator, CoordinatorFinishOutput {
private func showForgotPassword(module: LoginViewController) {
let coordinator = self.coordinatorFactory.makeChangePasswordCooridnatorBox(router: self.router, viewControllerFactory: self.viewControllerFactory)
coordinator.finishFlow = { [unowned self, unowned coordinator] in
self.removeDependency(coordinator)
self.router.popToModule(module: module, animated: true)
}
self.addDependency(coordinator)
coordinator.start()
}
}
final class ProfileCoordinator: BaseCoordinator, CoordinatorFinishOutput {
private func showForgetPassword(module: ProfileViewController) {
let coordinator = self.coordinatorFactory.makeChangePasswordCoordinatorBox(router: self.router, viewControllerFactory: self.viewControllerFactory)
coordinator.finishFlow = { [unowned self, weak module, unowned coordinator] in
self.removeDependency(coordinator)
self.router.popToModule(module: module , animated: true)
}
self.addDependency(coordinator)
coordinator.start()
}
}
프로덕트 매니저가 다가와서 작은 변경 하나만요. 그저 워크쓰루(walk-through)와 딥링크만 추가하면 돼요.라고 말한 적이 몇 번이나 있나요? 제게 물어보면, 너무 많았습니다. 운이 좋게도 이제 이러한 전환은 훨씬 덜 고통스러울 겁니다.
워크쓰루를 추가하려면 새로운 코디네이터를 생성하고 LaunchInstructor 객체를 변경하면 됩니다. 앱 델리게이트나 뷰 컨트롤러에는 어떤 변경을 가할 필요가 없습니다.
final class ApplicationCoordinator: BaseCoordinator {
// MARK: - Coordinator
override func start(with option: DeepLinkOption?) {
if option != nil {
} else {
switch launchInstructor {
case .onboarding: runOnboardingFlow()
case .auth: runAFlow()
case .main: runMainFlow()
}
}
}
// MARK: - Private methods
private func runOnboardingFlow() {
let coordinator = self.coordinatorFactory.makeWalkthroughCoordinatorBox(router: self.router, viewControllerFactory: ViewControllerFactory())
coordinator.finishFlow = { [unowned self, unowned coordinator] in
self.removeDependency(coordinator)
self.launchInstructor = LaunchInstructor.configure()
self.start()
}
self.addDependency(coordinator)
coordinator.start()
}
}
프로젝트에 딥링크와 푸시 알림을 추가하는 건 매우 고통스러울 수 있습니다. 그러나, 플로우 코디네이터는 이를 위한 해결책도 가지고 있습니다. 코디네이터를 시작할 때, startWithOption 메서드를 사용하거나 기존 메서드를 재사용하세요.
override func start(with option: DeepLinkOption?) {
if let option = option {
switch option {
case .onboarding: runOnboardingFlow()
case .signUp: runAuthFlow()
default: childCoordinators.forEach { coordinator in
coordinator.start(with: option)
}
}
} else {
switch instructor {
case .onboarding: runOnboardingFlow()
case .auth: runAuthFlow()
case .main: runMainFlow()
}
}
}
이제 뷰 컨트롤러와 뷰 모델이 더 깔끔해지고, 재사용성이 높아졌으며, 의존성이 크게 줄었습니다. 그래서 테스트를 작성하기가 더 쉬워졌습니다. 하지만, 새로운 주제가 될 수 있기 때문에 이 글에서는 테스트를 다루지 않겠습니다.
플로우 코디네이터를 사용하여 뷰 컨트롤러에서 내비게이션 로직을 제거했습니다. 코드가 더 깔끔해지고, 재사용성이 높아졌으며, 변경에 더 유연해지고 테스트하기 쉬워졌습니다. 처음에는 작성해야 할 코드가 너무 많아 보일 수 있습니다. 그러나 BaseCoordinator, Router, LaunchInstructor와 같은 코드는 다른 프로젝트에서도 재사용할 수 있기 때문에, 처음 한 번만 작업량이 많을 뿐입니다. 몇몇 사람들은 너무 복잡하다고 말할 수 있지만, 저는 그렇게 생각하지 않습니다. 심지어 그 말이 맞더라도, 한번 시도해보세요. 그 이점은 놓치기에는 너무 아깝습니다. 아래는 예제 코드입니다.
How to implement delegation pattern using MVVM and coordinators
Krzysztof Zabtocki's lecture about Good iOS Application Archtiecture
읽어주셔서 감사합니다:)
이 글이 유용하셨고 비슷한 주제를 더 보고 싶으시다면, 박수를 보내주시고, 공유와 팔로우를 해주세요. 댓글도 남겨주세요.