CS/디자인패턴

[디자인패턴] Coordinator(코디네이터) 패턴 with iOS

유정주 2023. 3. 25. 00:10
반응형

서론

iOS에서 ViewController의 역할은 비대합니다.

그래서 여러 아키텍처, 디자인 패턴이 나오며 ViewController의 역할을 가볍게 하고 있습니다.

예를 들어, MVVM은 ViewModel 개념을 이용해 ViewController에서 비즈니스 로직을 분리합니다.

 

이번에 알아볼 Coordinator 패턴은 ViewController의 화면 전환 역할을 분리합니다.

Coordinator 패턴이 무엇인지 알아보고 간단한 화면 전환 앱 예시까지 알아보겠습니다.

 

(+

요즘 문장 스타일을 바꿔보려고 하고 있습니다.

읽기 쉽게 작성하는 일이 참 어렵네요..

많은 조언 부탁드립니다.)

 

Coordinator 패턴 필요성

Coordinator 패턴은 2015년에 https://khanlou.com/2015/01/the-coordinator/ 글로 처음 소개되었습니다.

(정말 오래된 디자인 패턴이라 놀랐습니다.)

Coordinator 패턴을 처음 제시한 Khanlou는 

ViewController의 Flow 로직과 비즈니스 로직이 얽혀있는 점을 문제로 제시했습니다.

 

저의 프로젝트 코드를 예를 들겠습니다.

final class HomeViewController: UIViewController {
	...
    
    @objc private func clickedInfoButton() {
        let infoVC = InfoViewController()
        present(infoVC, animated: true)
    }
    
    ...
    
    func goSummarizeVC(_ summarizeResult: SummarizeResult) {
        let summarizeVC = SummarizeResultViewController()
        navigationController?.pushViewController(summarizeVC, animated: true)
    }
    
    ...
    
}

HomeVC는 InfoVC와 SummarizeResultVC로 이동할 수 있습니다.

여기서 문제는 HomeVC 안에서 InfoVC와 SummarizeResultVC가 직접 생성되고 직접 이동시키고 있다는 점입니다.

이는 곧 HomeVC는 두 VC가 없으면 제대로 기능 수행이 안 된다는 의미가 됩니다.

 

만약 앱이 확장된다면 HomeVC의 결합도는 더 높아질거고, 재활용도는 점점 더 낮아지겠죠.

(SOLID의 S인 단일 책임 원칙과 점점 멀어진다는 의미)

 

장점 1

Coordinator 패턴은 위같은 Flow 로직을 Coordinator 객체에게 위임하여

ViewController의 역할을 줄여줄 수 있습니다.

따라서 ViewController는 View의 역할에 충실할 수 있고, (SOLID의 S: 단일 책임 원칙에 충실해짐)

재활용성은 높아지고 의존도는 낮아지게 됩니다.

 

장점 2

또 한가지 장점은 앱의 Flow 로직을 제어하고 파악하는데 용이합니다.

지금처럼 Flow 로직이 VC 이곳저곳에 작성되어 있다면

최악의 경우 프로젝트의 모든 VC를 확인해서 앱의 Flow를 확인해야 합니다.

 

하지만 Coordinator 패턴을 도입한다면 해당 객체만 확인하면 되니 앱의 흐름을 쉽게 확인할 수 있습니다.

스토리보드의 장점 중 하나가 앱의 흐름을 쉽게 파악할 수 있다는건데,

그 장점을 코드로만 이루어진 프로젝트에서도 어느정도 누릴 수 있다는 말이 됩니다.

 

Coordinator 예제

이제 iOS에서 Coordinator 패턴을 구현하는 방법을 알아보겠습니다.

예제의 전체 코드는 

https://github.com/jeongju9216/SwiftPractice/tree/main/Practice-Coordinator-Pattern에서 확인할 수 있습니다.

FirstViewController에서 버튼을 누르면 SecondViewController를 push하는 아주 간단한 예제입니다.

 

실제 앱은 훨씬 복잡한 시나리오로 동작하고,

Coordinator 패턴은 개발자마다 구현하는 방법이 조금씩 다르기 때문에

여러 예제 코드를 보고 상황에 맞게 응용해서 사용하시면 적절하겠습니다.

 

Coordinator 프로토콜

모든 Coordinator 객체가 준수해야하는 프로토콜을 정의합니다.

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }

    func start()
}

childCoordinators는 자기 자신의 하위 Coordinator를 관리하는 배열입니다.

이를 통해 child Coordinator가 할당 해제되는 것을 방지하고, 꼭 필요한 범위의 VC만 알 수 있도록 도와줍니다.

 

navigationController는 화면을 present, push하는 역할이고, (선택)

start는 실제로 화면을 보여주는 메서드입니다.

 

이제 본격적으로 Coordinator 프로토콜을 사용합니다.

 

SceneDelegate

iOS의 Flow는 SceneDelegate에서 rootViewController를 표시부터 시작됩니다.

이를 위해 Info.plist와 프로젝트 세팅에서 Main Storyboard를 삭제해 주세요.

스토리보드를 사용하지 말라는 의미가 아니라, 코드로 rootViewController를 설정할 수 있도록 하는 것입니다.

 

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    var appCoordinator: AppCoordinator?

    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
        
        appCoordinator = AppCoordinator(window: window)
        appCoordinator?.start()
    }

	...   
}

SceneDelegate에서는 가장 상위의 Coordinator 객체인 AppCoordinator를 생성합니다.

이름 그대로 앱 Flow 흐름의 최상위에 존재하는 Coordinator 입니다.

이번 예시에서는 rootViewController를 생성하고 화면에 표시하는 역할을 합니다.

 

참고로 여기서 appCoordinator를 SceneDelegate의 클래스 변수로 선언했는데요.

이 위치에 선언을 한 이유가 있습니다.

scene 메서드 안에서 생성을 하면 start 메서드 이후 appCoordinator 인스턴스의 참조 카운팅이 감소되서

이후 나올 delegate 사용에 문제가 발생합니다.

주의해서 예제를 따라해 주세요.

 

AppCoordinator

위에서 설명한 AppCoordinator입니다.

final class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator]
    var navigationController: UINavigationController
    let window: UIWindow
    
    init(window: UIWindow) {
        self.window = window
        self.navigationController = UINavigationController()
        self.childCoordinators = []
    }
    
    func start() {
        window.rootViewController = navigationController
        
        showFirstViewController()
        
        window.makeKeyAndVisible()
    }
    
    private func showFirstViewController() {
        let coordinator = FirstCoordinator(navigationController: navigationController)
        childCoordinators.append(coordinator)
        coordinator.start()
    }
}

Coordinator 프로토콜을 채택했기 때문에 그와 관련된 프로퍼티와 메서드를 구현했습니다.

 

AppCoordinator의 가장 큰 역할은 첫 화면을 보여주는 것이므로

start 메서드에서 첫 화면인 FirstViewController의 Coordinator를 생성하고 FirstCoordinator의 start 메서드를 수행합니다.

그 결과 화면에 FirstViewController가 나타나겠죠.

 

rootViewController는 pop을 하지 않기 때문에 굳이 child를 해제하는 메서드를 구현하지 않았습니다.

 

 

FirstViewController & FirstCoordinator

첫 화면으로 나오는 FirstViewController입니다.

protocol FirstViewControllerDelegate: AnyObject {
    func navigateToSecond()
}

final class FirstViewController: UIViewController {

    weak var delegate: FirstViewControllerDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        print("delegate is nil? \(delegate == nil)")
    }
    
    @IBAction func clickedPushSecondButton(_ sender: UIButton) {
        print("clickedPushSecondButton: \(delegate == nil)")
        delegate?.navigateToSecond()
    }
}

ViewController에서 바로 Coordinator를 생성하는게 아니라,

delegate를 활용한다면 보다 유연한 구조를 가질 수 있습니다.

 

버튼 하나에 시나리오가 여러 개인 앱이라면 ViewController에서 화면 전환 분기처리를 하는게 아니라,

Coordinator에서 분기처리를 하면 되기 때문입니다.

Flow에 관한 분기 처리이므로 의미상으로도 Coordinator에서 진행하는게 적절합니다.

 

그럼 이제 FirstCoordinator를 봅시다.

final class FirstCoordinator: Coordinator {
    
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    
    //ViewModel 필요시 init으로 전달
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        print("FirstCoordinator start")
        let vc = FirstViewController.instantiate
        vc.delegate = self
        navigationController.pushViewController(vc, animated: true)
    }
    
    func finished(child: Coordinator) {
        print("finished: \(child)")
        childCoordinators = childCoordinators.filter { $0 !== child }
    }
    
    private func showSecondViewController() {
        print("showSecondViewController")
        let child = SecondCoordinator(navigationController: navigationController)
        child.parentCoordinator = self
        childCoordinators.append(child)
        child.start()
    }
}

extension FirstCoordinator: FirstViewControllerDelegate {
    func navigateToSecond() {
        print("navigateToSecond")
        showSecondViewController()
    }
}

FirstCoordinator에는 FirstViewController로 이동하는 코드와 SecondViewController로 이동하는 코드가 있습니다.

위에서 delegate로 위임한 동작을 FirstCoordinator에서 구현하고 있습니다.

 

showSecondViewController 메서드에서 Second의 parentCoordinator를 FirstCoordinator로 설정합니다.

FirstVC에서 SecondVC로 이동하면 SecondVC의 부모는 FirstVC가 되기 때문입니다.

이 parentCoordinator는 SecondVC가 삭제될 때 finished 메서드를 호출하는 역할을 합니다.

그래야 화면이 사라지는 동시에 Coordinator 객체도 없앨 수 있습니다.

 

SecondViewController & SecondCoordinator

다음으로 SecondViewController와 SecondCoordinator 입니다.

 

SecondViewController부터 보겠습니다.

protocol SecondViewControllerDelegate: AnyObject {
    func didFinish()
}

final class SecondViewController: UIViewController {
    
    weak var delegate: SecondViewControllerDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        delegate?.didFinish()
    }
}

SecondViewController는 화면에서 사라질 때 delegate의 didFinish 메서드를 수행합니다.

이곳에서 delegate를 사용한 이유도 FirstVC와 동일합니다.

화면이 사라질 때 분기되는 Flow 흐름이 있다면 Coordinator에서 delegate를 채택하여 구현하면 됩니다.

 

다음은 SecondCoordinator 입니다.

final class SecondCoordinator: Coordinator {
    
    var childCoordinators: [Coordinator] = []
    weak var parentCoordinator: FirstCoordinator?
    var navigationController: UINavigationController
    
    //ViewModel 필요시 init으로 전달
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        print("SecondCoordinator start")
        let vc = SecondViewController.instantiate
        vc.delegate = self
        navigationController.pushViewController(vc, animated: true)
    }
}

extension SecondCoordinator: SecondViewControllerDelegate {
    func didFinish() {
        print("didFinish")
        parentCoordinator?.finished(child: self)
    }
}

SecondCoordinator는 간단하죠?

start에서는 SecondVC로 이동하고,

didFinish에서는 parent에게 사라진다는 것을 알립니다.

위에서 본 코드 일부를 가져와서 다시 보면,

func finished(child: Coordinator) {
    print("finished: \(child)")
    childCoordinators = childCoordinators.filter { $0 !== child }
}

filter와 !== 연산자를 이용해서 파라미터로 전달된 child coordinator 객체와 동일한 객체를 삭제합니다.

이를 통해 VC가 화면에서 사라짐과 동시에 childCoordinator도 삭제될 수 있습니다.

 

결과 확인

FirstVC 이동 -> 버튼 클릭 -> SecondVC 이동 -> SecondVC 삭제의 흐름입니다.

지금까지의 설명대로 흘러가는 것을 볼 수 있습니다.

 

TabBarController를 사용한다면 탭 하나당 하나의 Coordinator를 생성하여 적용하면 됩니다.

기타 세부사항은 프로젝트 상황에 맞게 수정하여 적용하시면 되겠습니다.

 

마무리

오늘은 Coordinator 패턴에 대해 알아보았습니다.

ViewController에서 Flow 로직을 분리할 수 있다는 점이 매력적입니다.

 

다만, 프로젝트의 Flow 로직이 간단하며 확장의 가능성이 적을 때는 과할 수 있습니다.

이번 예제에서도 FirstVC에서 2~3줄의 코드만으로 동일한 기능을 구현할 수 있습니다.

하지만 Coordinator 패턴을 적용해서 여러 파일로 분할하고 코드도 몇 십 배가 되었죠.

 

프로젝트 상황에 맞게, 팀 상황에 맞게, 인력에 맞게 적절히 판단하여 적용하면

매우 유용한 기술이지 않을까 싶습니다.

 

감사합니다!

 

참고

https://www.kodeco.com/158-coordinator-tutorial-for-ios-getting-started#toc-anchor-005

https://zeddios.medium.com/coordinator-pattern-bf4a1bc46930

https://khanlou.com/2015/01/the-coordinator/


잘못된 점이 있다면 댓글로 알려주시면 감사하겠습니다.

감사합니다.

 

 

반응형