[Design Pattern] 내가 보려고 정리하는 Swift 디자인 패턴 (7) - 코디네이터 패턴(Coordinator Pattern)

2024. 7. 4. 18:34Architecture, Design Pattern

1️⃣ 코디네이터 패턴 (Coordinator Pattern) 왜 (Why?) 쓰는 거야?

예전 MVC 글에서도 다룬 적이 있지만, 기본적으로 iOS 개발에서 사용되는 아키텍처의 대표적인 문제는 Massive ViewController, 즉 View와 Controller를 합쳐서 사용하는 ViewController의 책임과 역할이 너무 많다는 것이었다.

👇🏻 MVC Pattern 글을 리마인드하고 싶으면, 아래 글을 확인할 것!

 

[Architecture Pattern] 내가 보려고 정리하는 Swift 아키텍처 패턴 (1) - MVC 패턴 (Model-View-Controller)

💡 글을 시작하기 전에, 아키텍처 패턴 (Architecture Pattern)이 무엇인지 설명하고 넘어가자면!아키텍처 패턴은 애플리케이션에서 필요한 주요 부분을 각각 분리하여, 역할을 명확하게 구분하는데

mini-min-dev.tistory.com

Coordinator Pattern은 ViewController의 많은 역할 중에서 "화면 전환"에 대한 책임을 Coordinator가 가져오는 것을 목표로 한다.

나에게는 "왜 많은 책임 중에서 화면 전환에 대한 부분을 분리하는 패턴이 등장하게 된 것일까?"라는 질문이 머릿속에 떠올랐는데, 기존 뷰 컨트롤러가 가지고 있던 화면 전환 코드는 어떤 문제를 가지고 있었는지, 코디네이터 패턴은 어떤 것을 목표로 두고 있는 것인지 같이 공감하며 글을 써내려가보겠다.

💡 뷰 컨트롤러 입장에서 바라보는 단일 책임 원칙 (SRP: Single Responsibility Principle)

Swift는 객체 지향 프로그래밍(OOP) 언어에 속하고, 그에 따라 5가지의 원칙(SOLID)이 존재한다고 예전 글에서 설명한 적이 있었다. 

단순히, 위에서는 "ViewController는 무겁다 = 그러니까 ViewController의 역할을 나눠야 한다"의 논리로 이어졌다면,
단일 책임 원칙의 입장에서 다시 봤을 때 ViewController는 본연의 View와 Controller의 책임만을 가질 수 있도록, 다시 말해 기존 ViewController가 화면을 표시하고 View-Model 간의 상호작용 책임만을 가질 수 있도록 화면전환 코드를 분리하게 된 것이다.

💡 기존 화면 전환 코드의 문제 (중복되는 코드와 뷰컨트롤러 간 높은 결합도)

기존 화면 전환 코드는 보통 아래와 같이 작성될 것이다.
아래 코드는 길고 복잡한 것은 아니지만, 복잡한 화면을 가진 앱의 경우 아래와 같은 문제를 떠올릴 수 있게 된다.

  • 동일한 ViewControllerB로 화면전환하는 코드가 해당 ViewControllerA 외에 다른 VC에서도 사용하는 경우 -> 같은 코드를 중복해서 매번 작성해야 하는 문제!
  • ViewControllerA는 ViewControllerB에 의존적 -> 뷰 컨트롤러 간 서로 종속적인 관계를 갖게 되는 문제! + 매번 VC 인스턴스 생성
  • 부모인 NavigationController를 자식인 ViewController의 입장에서 control 하게 된다는 문제
class ViewControllerA: UIViewController {
    func ButtonTapped() {
        let vcB = ViewControllerB()
        navigationController?.pushViewController(vcB, animated: true)
    }
}


결국은 너무도 당연하게 사용했던 기존 ViewController 화면 전환 로직에 있어 생각보다 많은 문제가 있었고, 이런 문제점들을 개선하기 위해 등장한 것이 지금부터 설명하게 될 Coordinator Pattern이라고 사용 이유를 알고 있으면 좋겠다.

흔히 MVC-C나 MVVM-C와 같이 뒤에 붙는 C가 화면 전환 책임을 이 Coordinator로 분리한 구조다.

 

2️⃣ 코디네이터 패턴 (Coordinator Pattern) 기본 개념

아래 그림을 살펴보면, 코디네이터 패턴 (Coordinator Pattern)이 어떤 구조를 갖게 되는지 직관적으로 이해할 수 있다.

Coordinator Pattern 구조의 핵심사항이자 기존 코드에서 변경되는 사항은 아래 크게 세 가지라고 보면 된다.

  1. 화면 전환은 ViewController가 아닌, Coordinator가 담당한다. (각 ViewController는 서로가 서로를 알지 못한다.)
  2. 앱의 메인 흐름을 담당하는 AppCoordinator를 SceneDelegate 아래에 둔다.
  3. 앱의 주요 기능 또는 흐름의 단위별로 하위 Coordinator들을 만든다. (한 ViewController 당 하나의 Coordinator를 가져야 한다는 의미가 아니다! 보통 하나의 Coordinator가 관련된 ViewController 여러 개를 관리하게 된다.)

왼쪽이 코디네이터 적용 전 구조와 화면 전환 흐름, 오른쪽이 코디네이터 적용 후의 구조이다.

 

3️⃣ 코디네이터 패턴 (Coordinator Pattern) 코드로는 어떻게 쓰는 건데?

실제로 코드로는 어떻게 사용하는지 예시를 들어서 설명해 보도록 하겠다.

아래 예시 코드는 Zedd님의 Coordinator Pattern 글을 참고해 작성했으며,
앱 실행 시 로그인 여부에 따라 LoginVC 또는 HomeVC로 시작하는 화면 전환과 LoginVC의 버튼을 눌러 로그인을 하게 되면 HomeVC로 넘어가는 화면 전환을 구현한 내용이다. 하나씩 코드를 살펴보자.

1. 공통으로 사용할 Coordinator 프로토콜 생성

우선, 코디네이터를 생성할 때마다 공통으로 사용될 부분을 프로토콜로 만들어줬다.
AnyObject를 상속받아 클래스 타입만 해당 프로토콜을 채택할 수 있도록 했는데, 이는 클래스의 인스턴스가 참조 타입 (Reference Type)인 것을 고려해 특정 ViewController를 비교하거나, 약한 참조 (weak) 설정으로 메모리 누수를 방지하는 것을 가능하도록 했기 때문이다.

  • childCoordinators : 자신의 하위 코디네이터를 관리하는 데 사용되는 배열
  • start() : 해당 코디네이터가 관리하는 첫 번째 화면을 표시할 때 사용되는 메서드 (ex. NavigationController를 시작하는 상황)
protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    func start()
}

2. 앱의 메인 흐름을 담당하는 AppCoordinator 생성

앱의 메인 흐름을 담당하는 AppCoordinator의 메인 역할은 결국 로그인 여부에 따라 초기 화면(LoginVC vs HomeVC)을 설정해 주는 것이라고 볼 수 있다.
해당 부분은 AppCoordinator의 초기값으로 들어온 isLoggedIn Bool값에 의해 설정되는 start() 메서드가 해당되겠다.

  • showHomeViewController() : HomeCoordinator를 생성하고, 해당 Coordinator를 AppCoordinator가 관리하기 위해 하위 배열에 추가, Home 화면을 띄우기 위한 초기 메서드 호출
  • showLoginViewController() : LoginCoordinator를 생성하고, 해당 Coordinator를 AppCoordinator가 관리하기 위해 하위 배열에 추가, Login 화면을 띄우기 위한 초기 메서드 호출
import UIKit

class AppCoordinator: Coordinator {
    
    var childCoordinators: [Coordinator] = []
    
    private let navigationController: UINavigationController
    private var isLoggedIn: Bool
    
    init(navigationController: UINavigationController, isLoggedIn: Bool) {
        self.navigationController = navigationController
        self.isLoggedIn = isLoggedIn
    }
    
    func start() {
        if isLoggedIn {
            showHomeViewController()
        } else {
            showLoginViewController()
        }
    }
    
    private func showHomeViewController() {
        let homeCoordinator = HomeCoordinator(navigationController: navigationController)
        childCoordinators.append(homeCoordinator)
        homeCoordinator.start()
    }
    
    private func showLoginViewController() {
        let loginCoordinator = LoginCoordinator(navigationController: navigationController)
        childCoordinators.append(loginCoordinator)
        loginCoordinator.start()
    }
    
}

3. SceneDelegate 설정

위에서 설정해 둔 AppCoordinator를 가지고 초기 화면을 세팅(start)해준다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        let window = UIWindow(windowScene: windowScene)
        self.window = window
        
        let isLoggedIn = false	// 임의 설정
        let navigationController = UINavigationController()

        self.window?.rootViewController = navigationController
        let coordinator = AppCoordinator(navigationController: navigationController,
                                         isLoggedIn: isLoggedIn)
        coordinator.start()
        
        self.window?.makeKeyAndVisible()
    }
    ...
 }

4. LoginViewController를 관리하는 LoginCoordinator, HomeViewController를 관리하는 HomeCoordinator 생성

appDelegate와 마찬가지로 하위 코디네이터를 관리할 childCoordinators 배열과 첫 화면을 표시하는 start 메서드에 ViewController를 추가해서 연관을 지어주고 세부 코드를 작성해 줬다.
여기까지 작성한다면, 기본적으로 SceneDelegate에서 isLogged Bool 값에 따라 초기 화면을 전환하는 것은 구현 가능하다.

import UIKit

// LoginCoordinator
class LoginCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    
    private let navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let loginViewController = LoginViewController()
        navigationController.pushViewController(loginViewController, animated: true)
    }
}

// HomeCoordinator
class HomeCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    
    private let navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let homeViewController = HomeViewController()
        navigationController.pushViewController(homeViewController, animated: true)
    }
}

5. LoginViewController에서 Login Button을 사용자가 눌렀을 때, Coordinator로 전달할 수 있도록 구현

말 그대로 LoginVC에서 버튼을 누르는 경우는 로그인에서 홈 뷰컨으로 초기 화면을 다시 변경해야 하는 경우일 것이다.
LoginViewController 버튼 클릭 -> LoginCoordinator 로그인 부분 메서드 -> AddCoordinator에서 HomeCoordinator로 변경하는 순이다.
지금 아래 코드는 LoginViewController 버튼 클릭 -> LoginCoordinator 로그인 부분 메서드를 호출하는 과정을 구현한 것이다.

이때 LoginViewController에서는 Coordinator가 AnyObject로 제약을 둔 부분이 있기에 약한 참조(weak var)로 불러올 수 있었던 것을 다시 확인할 수 있다. 높은 결합도를 가졌던 기존 화면 전환 코드와 차이가 나는 것이 바로 이 부분에 해당한다.

import UIKit

final class LoginViewController: UIViewController {
        
    weak var coordinator: LoginCoordinator?
    
    private let loginButton = UIButton()
        
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loginButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
    }
}

// MARK: - Private Extensions

private extension LoginViewController {
    @objc
    func buttonTapped() {
        coordinator?.login()
    }
    
}

6. LoginCoordinator와 AppCoordinate 연결

여기서는 Delegate Pattern을 사용해서 LoginCoordinator로 들어온 login() 부분을 AppCoordinator와 연관이 되도록 구현했다.
login 메서드 구현부에는 LoginCoordinatorDelegate의 didLoggedIn() 메서드를 호출 (해당 메서드는 AppCoordinate에서 구현할 부분이다!)

protocol LoginCoordinatorDelegate {
    func didLoggedIn(_ coordinator: LoginCoordinator)
}

class LoginCoordinator: Coordinator {
	...
    var delegate: LoginCoordinatorDelegate?
    
    func start() {
        let loginViewController = LoginViewController()
        loginViewController.coordinator = self
        navigationController.pushViewController(loginViewController, animated: true)
    }
    
    func login() {
        delegate?.didLoggedIn(self)
    }
    
    ...
}

7. AppCoordinator 부분에 최종 Login시 화면 변경 부분 구현

  • loginCoordinator.delegate = self : LoginCoordinator가 갖고 있는 LoginCoordinatorDelegate를 AppCoordinator에서 구현하겠다는 의미 (Why? Login과 Home의 화면 전환은 앱의 큰 흐름인 AppCoordinator에서 담당하고 있었으므로!)
  • childCoordinators = childCoordinators.filter { $0 !== coordinator) : AppCoordinator가 관리하고 있는 Coordinator 배열에서 LoginCoordinator를 제거한다는 의미. (Why? 더 이상 Login 관련 화면이 아니라 Home 관련 화면으로 화면 로직이 넘어갔기 때문!)
import UIKit

class AppCoordinator: Coordinator {
    ...
    
    private func showLoginViewController() {
        let loginCoordinator = LoginCoordinator(navigationController: navigationController)
        loginCoordinator.delegate = self
		...
    }
    
}

extension AppCoordinator: LoginCoordinatorDelegate {
    func didLoggedIn(_ coordinator: LoginCoordinator) {
        self.childCoordinators = childCoordinators.filter { $0 !== coordinator }
        showHomeViewController()
    }
}

최종 구현!

 

4️⃣ 코디네이터 패턴 (Coordinator Pattern) 실제 프로젝트에 적용해 보기!

여기까지가 로그인에 따른 화면 전환 부분을 Coordinator Pattern으로 변경해 본 코드이다.

위에서는 NavigationController에서 이루어지는 push만을 구현했었는데, 실제 프로젝트에는 pop이나 present-dismiss 화면전환, 그리고 탭바, 커스텀 내비게이션, 바텀시트 등 다양한 화면전환이 이루어지기 때문에 각 부분에 있어 어떻게 구현하는지는 더욱 공부해봐야 할 것 같다.

나중에는 프로젝트 화면전환 코드를 리팩토링하는 과정을 담아 별도의 글로 아래에 추가해 보도록 하겠다! 커밍쑨~!