iOS

[Swift] Coordinator Pattern 기본!! With RayWanderlich Tutorial!

Duno 2022. 5. 19. 20:13

Coordinator가 무엇일까?

Coordinator

A type to coordinate with the view controller.

coordinate는 배열하다, 조정하다 정도의 의미를 가지고 있다. Coordinator는 뷰 컨트롤러의 움직임을 조정하기 위한 타입이라고 하는데, 무슨 역할을 하는 것일까?

Coordinator Pattern

스위프트에서 코디네이터에 관한 글들을 읽어 보면 아래와 같은 문제를 해결하기 위해 제시된 패턴이라는 정보를 찾아볼 수 있다.

  1. Massive View Controller의 문제점
  2. 기존 화면전환 방식에 존재하던 VC간의 수평적인 관계 또는 강한 결합의 문제점

코디네이터는 위의 두 가지 문제점을 해결하기 위해 화면전환을 관리하는 독립적인 클래스를 이용하자는 아이디어에서 나왔다고 한다!

MVC 패턴에서는 뷰 객체를 생성하고, 띄워주는 순서로 화면 전환을 구현했다. 간단한 프로젝트에서는 잘 작동하지만 프로젝트 규모가 커질수록 다양한 방식의 화면전환 플로우가 요구될 가능성이 높아진다.

아래는 이러한 문제점을 지닌 코드의 예시이다.

private func dismissToHomeVC() {
    presentingViewController?.presentingViewController?.dismiss(animated: true)
}

private func dismissJoinCodeToHomeVC() {
    presentingViewController?.presentingViewController?.presentingViewController?.dismiss(animated: true)
    NotificationCenter.default.post(name: .appearFloatingButton, object: nil)
}

@IBAction func goToFeedVC(_ sender: Any) {
    guard let presentingVC = self.presentingViewController?.presentingViewController as? UITabBarController else { return }
    guard let naviVC = presentingVC.viewControllers?[1] as? UINavigationController else { return }
    
    presentingVC.dismiss(animated: false) {
        naviVC.popViewController(animated: false)
        presentingVC.selectedIndex = 0
    }
}

@IBAction func goToFeedVC(_ sender: Any) {
    guard let presentingVC = self.presentingViewController?.presentingViewController as? UITabBarController else { return }
    guard let naviVC = presentingVC.viewControllers?[1] as? UINavigationController else { return }
    
    presentingVC.dismiss(animated: false) {
        naviVC.popViewController(animated: false)
        presentingVC.selectedIndex = 0
    }
}

Coordinator를 공부할 이유

뷰가 뷰를 불러오고, 이전 뷰가 해제될 수도 있으며 navigationController나 TabBarController까지 관여시키는 방식은 객체지향 프로그래밍에 있어서 규범에 맞지 않는 방식이라고 할 수 있다. 뷰 컨트롤러들이 종속적으로 연결되어 있기에 위와 같은 문제점들이 생기는 것이다.

따라서 이러한 ViewController들을 보다 높은 수준에서 관리하여 안정적으로 플로우를 관리하기 위해 Coordinator라는 개념을 도입한다. ViewController보다 한 층 위의 레이어에서 종속된 ViewController들을 관리하기 때문에 ViewController 간의 강한 결합을 줄여줄 수 있다.

또한 화면 전환에 관한 비즈니스 로직을 모두 coordinator에서 관리하여 Massive View Controller 문제를 해결할 수 있고, coordinator도 화면전환에 특화된 class라는 점에서 화면전환 기능의 유지보수가 필요할 때 편의성을 높여준다. 동시에 View Controller에서도 View의 역할에만 집중할 수 있게 된다.

마지막으로 동일한 로직의 화면 전환에 대해서 수정할 때의 코스트도 줄어들며, 클린 아키텍쳐를 표방할 경우 repisotry, ViewModel, Service에 대한 의존성 주입 또한 함께 관리할 수 있다.


RayWanderlich의 Tutorial을 보면서 이해해보기!

구글과 깃헙을 통해 Coordinator에 대해 공부하다 보니 Coordinator는 개발자에 따라서 구현 방식이 매우 다르다고 느꼈고, 가장 초기의 기본적인 형태를 가진 Coordinator를 확인해보는 것이 도움이 될 것이라 생각했다. RayWanderlich의 Tutorial에서 좋은 샘플 프로젝트를 제공하고 있었고, 이를 공부하면서 정리해보았다.

Coordinator Tutorial for iOS: Getting Started

 

Coordinator Tutorial for iOS: Getting Started

In this Coordinator tutorial you’ll convert an iOS app from using the MVC pattern to the Coordinator pattern and examine the pros and cons of Coordinators.

www.raywenderlich.com

<aside> 🐣 If you were to reuse KanjiDetailViewController , the code would quickly get out of hand, because this method would need to grow into a giant switch statement so it knew which view controller to push next. This is why the logic shouldn’t be inside of another view controller — it creates a strong connection between view controllers, making them have more responsibility than they should.

</aside>

뷰컨트롤러를 재사용하려는 입장에서, 로직이 다른 뷰컨트롤러에 존재한다면 매우 복잡한 분기처리를 거쳐서 push를 해야 한다. 만약 로직이 해당 뷰컨의 외부, 즉 다른 뷰컨에 존재한다면 이는 그 뷰컨트롤러들이 해야할 일보다 더 많은 책임을 그 뷰컨트롤러들에게 부여하고 있는것이다.

먼저 실제로 공부를 해보니, 뷰컨트롤러의 재사용에 있어서 서로 간에 강하게 결합된 뷰컨트롤러의 경우 재사용에 있어서 많은 문제점이 발생했다. 복잡한 case문을 통해서 다양한 로직을 처리해주는 것이 어렵게 생각되었고, 이러한 문제를 해결하고 기능을 전담할 Coordinator의 필요성을 다시 한 번 느끼게 되었다.

코디네이터 패턴의 근본적인 아이디어는 application의 flow만 전담하는 새로운 entity(coordinator)를 만드는 것이다. 거기에서 더 구체적으로 들어가면 아래와 같은 특성을 가지고 있다.

  1. 각각의 코디네이터들은 부모, 자식 코디네이터를 가지고 있다.
  2. 본인은 부모 코디네이터의 정보를 전혀 모르지만, 자식 코디네이터를 시작시킬 수 있는 능력을 가진다.

코디네이터는 부모-자식 관계의 tree 구조를 이용해 코디네이터들 간의 관계를 형성하고 있고, 각 코디네이터는 뷰컨트롤러들을 서로 독립적이고 분리된 상태로 만들어 준다. 코디네이터가 뷰컨트롤러를 create, present, dimiss할 수 있도록 만들 수 있다는 점이 뷰컨트롤러들이 UIView들을 관리하는 것과 비슷하게 느껴졌다.

쉽게 말해서 뷰컨트롤러 안의 UIView들이 서로의 관계를 모르는 것처럼, 뷰컨트롤러들끼리도 서로의 관계를 알 필요가 없어지는 대신 이 관계를 코디네이터가 알고 있는 것이다.

코디네이터 프로토콜

코디네이터의 가장 기본적인 구조를 형성하는 것은 코디네이터 프로토콜이다. 구글링을 하면 개발자 또는 프로젝트마다 아래의 start 메서드 말고도 다양한 메서드와 프로퍼티를 정의하고 있는 것을 볼 수 있지만, 코디네이터 패턴의 가장 기본적인 프로토콜 형태는 start() 메서드이다.

protocol Coordinator {
  func start()
}

본격적으로 코드를 살펴보기에 앞서 샘플 프로젝트의 구조를 살펴보자!

샘플 프로젝트 구조 살펴보기

뷰 1 : KanjiListVC

뷰 2 : KanJiDetailVC

뷰 3 : KanJiListVC(재사용)

우선 첫 번째 메인화면은 ApplicationCoordinator와 AllKanjiListCoordinator를 start하면 나오는 뷰이다.

다음 화면은 첫 번째 뷰에서 셀을 눌러 pushViewController를 통해서 띄운 화면이다. 딜리게이트 패턴을 통해 AllKanjiListCoordinator가 메서드의 실행을 대리하여 KanJiDetailCoordinator를 생성하고, KanjiDetailCoordinator의 start 메서드를 실행하면 두 번째 뷰가 나오게 된다.

마지막 뷰는 두 번째 뷰에서 마찬가지로 딜리게이트를 통해 KanJiDetailCoordinator가 push를 통해 띄워주는 화면이다.

Application Coordinator

import UIKit

class ApplicationCoordinator: Coordinator {
  let kanjiStorage: KanjiStorage //  1
  let window: UIWindow  // 2
  let rootViewController: UINavigationController  // 3
  
  init(window: UIWindow) { //4
    self.window = window
    kanjiStorage = KanjiStorage()
    rootViewController = UINavigationController()
    rootViewController.navigationBar.prefersLargeTitles = true
    
    // Code below is for testing purposes   // 5
    let emptyViewController = UIViewController()
    emptyViewController.view.backgroundColor = .cyan
    rootViewController.pushViewController(emptyViewController, animated: false)
  }
  
  func start() {  // 6
    window.rootViewController = rootViewController
    window.makeKeyAndVisible()
  }
}

ApplicationCoordinator는 app의 window에 보이는 presentation을 세팅한다. 이는 생성자의 파라미터로 전달되는 window를 받아와서 이루어진다.

rootViewController에는 먼저 내비게이션 컨트롤러를 넣어준다.

일단 여기서는 childcoordinator를 가지고 있지 않기 때문에, 이 코드는 단순히 presentation이 잘 세팅되어 있는지를 테스트할 수 있게 한다. 빈 뷰컨트롤러를 띄워주는 식으로 테스트를 해보자!

start() 메서드는 모든 일들이 시작되는 메서드이다. window는 자기가 가진 rootViewController와 함께 화면에 보여지기 시작한다.

그래서 이렇게 만든 appcoordinator는 SceneDelegate에서 사용해준다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  
  var window: UIWindow?
  
  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let windowScene = scene as? UIWindowScene {
      let window = UIWindow(windowScene: windowScene)
      self.window = window
      
      let coordinator = ApplicationCoordinator(window: window)
      coordinator.start()
    }
  } 

}
  • AppDelegate를 사용한다면
  • import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private var applicationCoordinator: ApplicationCoordinator? // 1 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) let applicationCoordinator = ApplicationCoordinator(window: window) // 2 self.window = window self.applicationCoordinator = applicationCoordinator applicationCoordinator.start() // 3 return true } }

앱이 시작될 때 만들어진 window 객체를 appcoordinator 객체에 전달하여 앱코디네이터를 생성하고, 코디네이터와 윈도우를 AppDelegate에 선언된 변수에 넣어준다.

이후 코디네이터의 start 메서드를 호출하여 루트뷰컨트롤러를 바꿔주고 window를 띄우게 된다.

여기까지 하면, 아까 test용도로 만든 emptyViewController만 화면에 보여지게 된다.

테스트를 성공했다면, 이제는 childCoordinator를 만들어서 실제 앱의 flow와 같이 구현해보자.

import UIKit

class AllKanjiListCoordinator: Coordinator {
  private let presenter: UINavigationController  // 1
  private let allKanjiList: [Kanji]  // 2
  private var kanjiListViewController: KanjiListViewController? // 3
  private let kanjiStorage: KanjiStorage // 4

  init(presenter: UINavigationController, kanjiStorage: KanjiStorage) {
    self.presenter = presenter
    self.kanjiStorage = kanjiStorage
    allKanjiList = kanjiStorage.allKanji()  // 5
  }

  func start() {
    let kanjiListViewController = KanjiListViewController(nibName: nil, bundle: nil) // 6
    kanjiListViewController.title = "Kanji list"
    kanjiListViewController.kanjiList = allKanjiList
    presenter.pushViewController(kanjiListViewController, animated: true)  // 7

    self.kanjiListViewController = kanjiListViewController
  }
}

presenter를 UINavigationController 타입으로 선언해 뒀다. 이는 start 메서드에서 presenter.pushViewController라는 형식으로 사용하기 위한 것이다. 이 presenter는 파라미터로 들어오기 때문에 UINavigationController 타입이면 무엇이든 들어가서 그 역할을 수행할 수 있다.

이 코디네이터를 생성할 때 실제로 그 역할을 해줄 presenter를 초기화해주면, 뷰컨트롤러와 코디네이터 입장에서는 이전에 올 뷰컨트롤러가 무엇이건 간에 자신의 동일한 역할을 수행할 수 있다. 이런 측면에서 뷰컨트롤러 간의 강한 결합을 끊어준다고 말하는 것이다.

또한 kanJiList도 의존성 주입 방식을 사용하고 있기 때문에, 뷰컨트롤러를 재사용하기 위해 새로운 리스트가 필요한 경우에 제한 없이 새로운 리스트를 넣어줄 수 있다는 장점이 있다.

start() 메서드에서는 원하는 뷰컨트롤러를 띄운 다음에, 본인이 참조하는 실제 객체가 무엇인지 참조해주고 있다. self.kanjiListViewController = kanjiListViewController 이러한 작업을 해줘야, 다시 kanjiListViewController가 가진 기능을 이용할 수 있다.

allKanJiListCoordinator를 만들었으니, 아래와 같이 appcoordinator도 수정해줘야 한다. emptyViewController를 띄우던 것을 코디네이터를 등록하는 식으로 바꾸었고, start메서드에서도 자식 코디네이터를 start해주고 있다.

import UIKit

class ApplicationCoordinator: Coordinator {
  let kanjiStorage: KanjiStorage //  1
  let window: UIWindow  // 2
  let rootViewController: UINavigationController  // 3
  let allKanjiListCoordinator: AllKanjiListCoordinator
  
  init(window: UIWindow) {
    self.window = window
    kanjiStorage = KanjiStorage()
    rootViewController = UINavigationController()
    rootViewController.navigationBar.prefersLargeTitles = true  // 4
    
    allKanjiListCoordinator = AllKanjiListCoordinator(presenter: rootViewController, kanjiStorage: kanjiStorage)
  }
  
  func start() {  // 6
    window.rootViewController = rootViewController
    allKanjiListCoordinator.start()
    window.makeKeyAndVisible()
  }
}

화면전환과 관련된 Delegate 구현해주기

kanjiListViewController가 가진 tableView의 셀을 탭하면 화면전환을 해줘야 한다.

화면전환은 coordinator에게 전담시키기로 했으니, 뷰컨트롤러의 화면전환을 대신 시키기 위해서는 delegate 패턴을 사용해야 할 것이다.

그래서 start() 메서드를 아래와 같이 수정할 수 있다.

func start() {
  let kanjiListViewController = KanjiListViewController(nibName: nil, bundle: nil) // 6
  kanjiListViewController.delegate = self
  kanjiListViewController.title = "Kanji list"
  kanjiListViewController.kanjiList = allKanjiList
  presenter.pushViewController(kanjiListViewController, animated: true)  // 7
  
  self.kanjiListViewController = kanjiListViewController
}

바뀐 부분은 딱 하나인데, 뷰컨트롤러의 delegate를 코디네이터 자신으로 만들어주고 있다. 이렇게 하면 테이블뷰 셀을 눌렀을 때 생기는 이벤트를 코디네이터가 대신 처리하도록 만들 수 있다.

아래는 뷰컨트롤러의 셀이 선택되면, 코디네이터의 메서드를 실행해주고 있는 모습이다.

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  let kanji = kanjiList[indexPath.row]
  delegate?.kanjiListViewControllerDidSelectKanji(kanji)
  tableView.deselectRow(at: indexPath, animated: true)
}

아래와 같이 코디네이터를 익스텐션해서 딜리게이트 메서드도 구현해준다.

class AllKanjiListCoordinator: Coordinator {

}

// MARK: - KanjiListViewControllerDelegate
extension AllKanjiListCoordinator: KanjiListViewControllerDelegate {
  func kanjiListViewControllerDidSelectKanji(_ selectedKanji: Kanji) {
    let kanjiDetailCoordinator = KanjiDetailCoordinator(presenter: presenter, kanji: selectedKanji, kanjiStorage: kanjiStorage)
    kanjiDetailCoordinator.start()
    
    self.kanjiDetailCoordinator = kanjiDetailCoordinator
  }
}

익스텐션의 코드를 살펴보자!

여기서는 화면전환을 위해 새로운 코디네이터를 만들고, 의존성 주입하는 모습을 확인할 수 있다.

새로운 뷰컨트롤러를 불러올 것이기 때문에 그 뷰컨트롤러를 관리해줄 새로운 코디네이터를 만들어주고 있으며, 부모 코디네이터인 AllKanjiListCoordinator가 가진 presenter, kanji, kanjiStorage가 그대로 전달된다.

이후 start 메서드를 실행해주면 화면이 전환될 것이고, self.kanjiDetailCoordinator = kanjiDetailCoordinator와 같이 자식 코디네이터를 등록해주고 있다.

그런데 여기서, 자식 코디네이터를 등록해줘야 하는 이유는 무엇일까?

자식 코디네이터로 등록해준 kanJiDetailCoordinator를 한번 살펴보자.

import UIKit

class KanjiDetailCoordinator: Coordinator {
  private let presenter: UINavigationController  // 1
  private var kanjiDetailViewController: KanjiDetailViewController? // 2
  private var wordKanjiListViewController: KanjiListViewController? // 3
  private let kanjiStorage: KanjiStorage  // 4
  private let kanji: Kanji  // 5
  
  init(presenter: UINavigationController,
       kanji: Kanji,
       kanjiStorage: KanjiStorage) {
    
    self.kanji = kanji
    self.presenter = presenter
    self.kanjiStorage = kanjiStorage  // 6
  }
  
  func start() {
    let kanjiDetailViewController = KanjiDetailViewController(nibName: nil, bundle: nil)
    kanjiDetailViewController.delegate = self
    kanjiDetailViewController.title = "Kanji details"
    kanjiDetailViewController.selectedKanji = kanji    // 7
    
    presenter.pushViewController(kanjiDetailViewController, animated: true)
    self.kanjiDetailViewController = kanjiDetailViewController  // 8
  }
}

// MARK: - KanjiDetailViewControllerDelegate
extension KanjiDetailCoordinator: KanjiDetailViewControllerDelegate {
  func kanjiDetailViewControllerDidSelectWord(_ word: String) {
    let wordKanjiListViewController = KanjiListViewController(nibName: nil, bundle: nil)
    wordKanjiListViewController.cellAccessoryType = .none
    let kanjiForWord = kanjiStorage.kanjiForWord(word)
    wordKanjiListViewController.kanjiList = kanjiForWord
    wordKanjiListViewController.title = word
    
    presenter.pushViewController(wordKanjiListViewController, animated: true)
  }
}

간단히 말하면 메모리 해제의 문제이다!

start 메서드를 보면, 생성자에서 부모 코디네이터로부터 전달받은 presenter를 사용하고 있다. 만약에 부모 코디네이터인 AllKanJiListCoordinator의 딜리게이트 메서드 안에서 새로운 kanjiListCoordinator를 생성하고, 단순히 start만 해주면 그 메서드의 코드블록 안에서 생성된 객체는 메서드의 스코프를 벗어나면 사라져버리게 된다.

따라서 위의 kanjiDetailViewControllerDidSelectWord 메서드를 실행하려고 보니, delegate를 채택해 줬던 KanjiDetailCoordinator가 메모리에서 사라진 것이다. 그러면 delegate를 실행해줄 coordinator가 존재하지 않으니 위의 화면전환이 실행이 되지 않는다.

결국, 이 객체의 참조를 보장해주기 위해, self.kanjiDetailCoordinator = kanjiDetailCoordinator 와 같은 방식으로 메모리상에 코디네이터를 유지해주는 것이라고 할 수 있다.