[iOS] Coordinator Pattern 알아보기

Youth·2023년 10월 3일
1

TIL

목록 보기
13/20
post-custom-banner

연휴의 마지막날 100번째 포스팅으로 찾아온 킴스캐슬입니다!

포스팅을 하려고 들어오니까 99개의 글이 올라와있네요

100번째 포스팅을 개발자로의 회고 정도로 남겨볼까도 생각했는데 지금 공부하고 있는 coordinator pattern에 대해서 포스팅하는게 낫겠다는 생각이 들어서 생각을 조금 바꿨습니다

coordinator는 처음 듣는 개념이었다거나 한번도 써보지 못했던 디자인패턴은 아니었습니다. 아카데미에있을때 당시 팀원 한명이 coordinator패턴을 도입하고 싶다고 해서 관련 코드를 몇줄 짰던 기억은 있는데 왜 써야했으며 작동방식도 모르고 그냥 이렇게 쓰면된다해서 썼었던 기억이 나네요...

그 이후로 몇번이고 coordinator패턴을 공부하고 적용해보려 했으나 번번히 실패했던 기억밖에 없었습니다

제 기준으로 코디네이터패턴을 익히기가 힘들었던 이유 중에 가장 컷던건 정형화 되어있지 않은 방식 이었습니다. 이게 무슨소리냐면 아무래도 디자인패턴이기때문에 사람마다 방식이 아주 조금씩 달랐던거죠...그래서 공부를 하는 입장에서 어떤걸 선택해서 학습해야겠다라는 판단을 내리기가 어려웠습니다

그래서 열심히 구글링을 하고 거의 모든 코디네이터에 대한 글을 보면서 제 머릿속에 약간이라도 그려지는 코디네이터 패턴의 구현과 가장 비슷한 예제를 가지고 설명하는 글과 예시코드를 찾게 되어서 해당 글과 예제를 바탕으로 한번 진행해보도록 하겠습니다

본 포스팅은 아래 링크의 블로그의 예제와 설명을 바탕으로 쓰여진 글입니다
코디네이터 패턴 예제 블로그 글


Coordinator Pattern의 등장 배경

코디네이터 패턴은 왜 써야하는가를 가장 먼저 생각해봐야겠죠?
이를 이해하기 위해서는 등장배경을 알면 좋습니다

아마 제가 지금 리팩터링을 진행하고 있는 프로젝트가 MVVM이었다면 Coordinator pattern을 도입해야겠다는 생각을 아주 강하게는 못했었을 수도 있습니다(하지만 지금은 MVVM이더라도 도입하는게 좋겠다는 생각을 가지고 있습니다)

처음에는 아주 작은 질문에서 부터 비롯된 고민이었습니다

화면전환을 ViewController가 하는게 맞는걸까?

기존 프로젝트가 MVC로 되어있고 MVC로 프로젝트를 진행하게 되면 발생하는 가장 common한 문제점인 viewcontroller가 massive해진다는 문제를 해결하기 위해서 최대한 viewcontroller의 역할과 책임을 줄이기 위한 고민을 하다가 나오게된 질문입니다

DI를 적용하면서 의존성도 줄이고 있었던 상황이어서 이런 고민을 좀더 깊게 해봤던거같습니다. 아무튼 이런 고민을 계속하다가 문득 이런생각이들었습니다.

다른 뷰컨으로 push를 하기위해서 해당 뷰컨이 굳이 다음 뷰컨의 정보를 알아야하고 객체까지 생성을 해야하는 역할을 짊어지고 있어야하나..?

그래서 처음찾은 해결책이 factory pattern을 통해서 객체의 생성의 역할을 대신해주는 생성 객체를 만드는것이었습니다. 그러다가 이런 글을 읽게됩니다

Massive VC의 가장 큰 문제 중 하나는 flow logic 및 business logic이 얽혀있다는 것이다!

글의 출처를 보니 코디네이터패턴을 제안했던 Khanlou가 한 이야기라는걸 알게되었습니다

func setButtonAction() {
    let bookmarkViewController = BookmarkViewController()1️⃣
    self.navigationController?.pushViewController(bookmarkViewController, animated: true)2️⃣
}

예를 들어서 이런 코드가 있다고 생각해보겠습니다 어떤 버튼을 눌렀을때 다음 뷰컨으로 넘어가는 자주쓰는 코드죠?

1️⃣코드를 보면 현재 viewcontroller가 다음단계를 인식하게 된다고 합니다
사실 이게 무슨말인지를 이해하지 못했는데 어떤 뷰컨에서 다른 뷰컨객체를 생성한다는 자체가 새로만들어진 뷰컨으로 어떤 task를 실행하게 된다는 의미기도 하니까 그런걸 의식이라고 표현했다고 생각합니다

2️⃣코드를 보면 해당 코드에 대한 설명을 이렇게 하고 있습니다 코드의 세 번째 줄에서는 부모 뷰 컨트롤러에게 무엇을 해야 할지 지시하는데 확실히 거꾸로 보이는 것 같습니다 처음에는 부모뷰컨이라는 말이 이해가 안갔는데 어쨌든 navigation을 통해 위에 쌓이는 뷰컨이라 그렇게 표현한게 아닌가 싶습니다. 부모뷰컨에게 화면전환을 통해 navigation stack에 push하라는 명령을 하고 있다라고 본것같습니다

결론적으로 Khanlou는 이렇게 이야기합니다

ViewController UI로 시작되며(UIViewController이기때문에), View객체이고, 사용자 흐름을 처리하는것은 범위(scope)를 벗어난다

우리가 UI를 붙여서 쓰고있는 여러 객체들은 View자체의 역할을 해야하는데(여기서 view자체의 역할은 단순히 model을 보여주는걸 이야기합니다) 이처럼 flow관련 logic이 들어가는건 UI의 역할범위 밖이라고 이야기합니다

그러면 MVVM을 사용하는 사람은 이렇게 말할수도 있습니다

그럼 MVVM을 사용할때는 ViewModel에서 화면전환 로직을 처리하면 되는거 아닌가요?

사실 저도 처음에는 이말이 맞겠다고 생각을 했었는데 그렇게 되면 viewModel내에서 UIViewController객체를 생성해야하기때문에 import UIkit을 해야하고 이는 UI관련 프레임워크에 의존하게 될 수 밖에없습니다

ViewModel은 Model에 뭔가 변화가 생기면 View에게 notification을 보내주는 역할을 합니다. 또한, View로부터 전달받는 요청을 해결할 비즈니스 로직들을 담고 있습니다. ViewModel은 UI 관련 코드로부터 완전히 분리되어있고, 따라서 ViewModel 파일에는 import UIkit을 할 이유가 없는겁니다

그렇기때문에Khanlou의 주장에 공감한다면 MVC와 MVVM에서 코디네이터 패턴을 적용시켜 flow logic의 역할을 맡아서 수행하는 객체가 필요하게 됩니다

Coordinator Pattern예제

이번에는 실제로 간단한 플로우를 가지고 있는 앱에 코디네이터 패턴을 적용해보겠습니다

실제로 이런 user flow가 있다고 해보겠습니다 큰 틀에서 보면 login관련 flow가 있고 home관련 flow가있고 profile관련 flow가 있습니다. 그리고 home관련 flow와 profile관련 flow는 탭바내에서 존재하는 flow입니다

이러한 flow들을 coordinator설계로 바꿔보면 아래와같이 바꿀수있습니다

여기서 제가 좀 헷갈렸던 부분이있는데요 우리가 보통 navigation push나 pop을 통한 flow의 방향이 뷰에서 뷰로 이어지는 그림이어서 코디네이터설계보다는 저 위의 그림의 flow가 익숙할겁니다

하지만 코디네이터는 화면전환의 역할을 해주는 객체이기 때문에 어떤 특정뷰(A)가 B라는 view로 화면전환을 하기 위해서 직접 B로 화면전환을 하는게 아니라 coordinator에게 화면전환을 요청만 하면 화면전환을 coordinator가 해주게 됩니다

늘 뷰컨을생성해서 직접 화면전환을 했던게 습관이되고 머릿속에서도 그런방식이 익숙해서 코디네이터에게 화면전환을 요청한다는 메커니즘이 적응이 안되서 초반에는 코드를 보는 단계에서도 많이 헷갈렸던것 같습니다

그러면 로그인뷰에서 홈뷰로 화면전환을 할때는 어떻게 해야할까요?? 코디네이터에게 화면전환을 맡겨야하는데 로그인뷰의 경우엔 auth coordinator에게 화면전환을 맡기고 있고 auth coordinator는 홈뷰로의 화면전환을 할 수가 없습니다. 그렇기 때문에 auth coordinator의 parent coordinator인 app coordinator에게 tabbar coordinator를 통해 홈뷰로 화면전환을 해달라고 요청을 해야합니다

이런경우때문에 coordinator간의 parent와 child가 존재해야합니다

protocol Coordinator : AnyObject {
    var parentCoordinator: Coordinator? { get set }
    var children: [Coordinator] { get set }
    var navigationController : UINavigationController { get set }
    func start()
}

extension Coordinator {
    /// Removing a coordinator inside a children. This call is important to prevent memory leak.
    /// - Parameter coordinator: Coordinator that finished.
    func childDidFinish(_ coordinator : Coordinator){
        // Call this if a coordinator is done.
        for (index, child) in children.enumerated() {
            if child === coordinator {
                children.remove(at: index)
                break
            }
        }
    }
}

기본적인 coordinator의 interface와 child를 지워서 할당해제시키는 코드입니다
해당 프로토콜을 coordinator객체가 채택하게 해서 구현부를 구현해주시면 됩니다

App Coordinator부터 보겠습니다

import UIKit

final class AppCoordinator: Coordinator {
    var parentCoordinator: Coordinator?
    
    var children: [Coordinator] = []
    
    var navigationController: UINavigationController
    
    func start() {
        print("앱코디네이터시작")
        startAuthCoordinator()
    }
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    1️⃣
    func startAuthCoordinator() {
        let authCoordinator = AuthCoordinator(navigationController: navigationController)
        children.removeAll()
        authCoordinator.parentCoordinator = self
        children.append(authCoordinator)
        authCoordinator.start()
    }
    
    2️⃣
    func startHomeTabbarCoordinator() {
        let homeTabbarCoordinator = HomeTabCoordinator(navigationController: navigationController)
        children.removeAll()
        homeTabbarCoordinator.parentCoordinator = self
        homeTabbarCoordinator.start()
    }
    
    deinit {
        print("앱코디네이터해제")
    }
}

우선 app coordinator를 통해 auth coordinator를 child에 추가하고 auth coordinator의 start메서드를 호출하겠습니다

auth coordinator의 start메서드를 호출하면 login view controller로 화면전환을 하겠죠?
만약에 로그인뷰에서 홈뷰로 가고싶다면 tabbar coordinator의 start메서드를 통해서 tabbar를 만들어서 tabbar viewcontroller로 화면전환을 해주는데 결국 우리가 화면전환을 해야하는 navigation flow는 home coordinator와 profile coordinator가 가지고 있고 tabbar coordinator는 단순히 home coordinator와 profile coordinator를 연결시켜주는 중간다리역할만을 하기때문에 2️⃣번코드에서 보면 app coordinator의 child에 tabbar coordinator를 append해주지 않습니다.

tabbar coordinator에서 start메서드를 호출하면 home coordinator와 profile coordinator를 tabbar coordinator의 parent인 appcoordinator의 child로 append시켜줍니다

tabbar coordinator의 start메서드를 볼까요?

func start() {
    print("홈탭코디네이터시작")
    goToHomeTabbar()
}

func goToHomeTabbar() {
    let tabbarController = UITabBarController()
    let homeNavigationController = UINavigationController()
    let homeCoordinator = HomeCoordinator(navigationController: homeNavigationController)
    1️⃣
    homeCoordinator.parentCoordinator = parentCoordinator
    
    ``` home tabbar item 설정 코드 ```
    
    let profileNavigationController = UINavigationController()
    let profileCoordinator = ProfileCoordinator(navigationController: profileNavigationController)
    1️⃣
    profileCoordinator.parentCoordinator = parentCoordinator
    
    ``` profile tabbar item 설정 코드 ```
    
    tabbarController.viewControllers = [homeNavigationController, profileNavigationController]
    navigationController.pushViewController(tabbarController, animated: true)
    navigationController.isNavigationBarHidden = true
    2️⃣
    parentCoordinator?.children.append(homeCoordinator)
    parentCoordinator?.children.append(profileCoordinator)
    
    3️⃣
    homeCoordinator.start()
    profileCoordinator.start()
}

deinit {
    print("홈탭코디네이터해제")
}

1️⃣번 코드를 보면 각각의 coordinator의 parent를 tabbar의 parent로 설정해둡니다 그리고 2️⃣번 코드를 통해서 app coordinator의 child에 home coordinator와 profile coordinator가 append되게 됩니다

그래서 tabbar coordinator의 start메서드가 호출되면 결과적으로는 3️⃣번의 start메서드가 수행되면서 비로소 homeviewcontroller가 push되게됩니다

그럼 home viewController가 push되는 경우는 로그인뷰에서 로그인버튼을 누를때겠죠?

protocol LoginNavigation: AnyObject {
    func goToRegisterViewController()
    func goToHomeViewController()
    func goToLogin()
}

final class LoginViewController: UIViewController {
    
    weak var coordinator: LoginNavigation!
    
    init(coordinator: LoginNavigation) {
        self.coordinator = coordinator
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

login뷰컨에서 navigation관련 interface타입의 coordinator변수가 있고 해당 coordinator를 통해 interface의 메서드를 호출하면됩니다

그리고 외부에서 해당 insterface를 채택한 coordinator객체를 주입시켜줍니다

extension AuthCoordinator: LoginNavigation {
    func goToRegisterViewController() {
        let registerViewController = RegisterViewController(coordinator: self)
        navigationController.pushViewController(registerViewController, animated: true)
    }
    
    1️⃣
    func goToHomeViewController() {
        let appCoordinator = parentCoordinator as! AppCoordinator
        appCoordinator.startHomeTabbarCoordinator()
        appCoordinator.childDidFinish(self)
    }
    
    func goToLogin() {
        let loginViewController = LoginViewController(coordinator: self)
        navigationController.pushViewController(loginViewController, animated: true)
    }
}

1️⃣번코드를 보시면 외부에서 AuthCoordinator객체가 LoginViewController에 주입되었을떄 goToHomeViewController라는 메서드가 호출되고 auth Coordinator에서는 HomeViewController로의 화면전환을 수행할 수 없기때문에 parent인 App Coordinator를 통해서 startHomeTabbarCoordinator라는 메서드를 호출하게 됩니다 그렇게 되면 home coordinator와 profile coordinator가 app coordinator의 child가 되고 tabbar viewController가 push되면서 home coordinator의 start메서드가 호출되면서 homeviewcontroller로 화면전환이 완료되게 됩니다

하지만 이때 중요한건 이제 앱의 화면전환의 역할을 home coordinator가 가져갔으므로(auth관련 flow logic이 끝났으니까 homeviewcontroller로 화면전환을 했을거니까요) auth coordinator를 app coordinator의 child에서 remove해줍니다

loginviewcontroller에서 homeviewcontroller로 화면전환이 끝났습니다...

그럼 이번에는 조금 간단하게 homeviewcontroller에서 물품상세뷰로 이동해보겠습니다
저희가 설계를 할때 home coordinator를 통해서 productlistviewcontroller로 이동할수있었습니다

다시 한번 헷갈릴수있는 부분을 짚어보면 우리가 지금까지 coordinator없이 해왔던 방식은 그림에서 빨간색 화살표 방향이었습니다 그래서 그냥 물품리스트뷰컨을 만들어서 전환을 홈뷰에서 바로 해줬으면 됐었죠

근데 우리는 coordinator를 이용해서 화면전환을 해야하기때문에 파란색 화살표 방향으로 홈뷰에서 home coordinator에게 물품리스트뷰로 화면전환을 해달라고 요청을하면 home coordinator가 화면전환을 수행해주게 되는 flow입니다

@objc func productListButtonTapped() {
    coordinator.goToProductListViewController()
}

홈뷰컨에서 해당 메서드가 button을 통해 수행되면 해당 interface의 메서드 구현부가 home coordinator이기때문에 코디네이터가 goToProductListViewController라는 메서드를 수행하게 됩니다

func goToProductListViewController() {
    let productListViewController = ProductListViewController(coordinator: self)
    navigationController.pushViewController(productListViewController, animated: true)
}

위와같은 메서드가 수행되면서 다음뷰컨으로 화면전환이 되게됩니다

@objc func productListButtonTapped() {
    coordinator.goToProductListViewController()
}

이 코드만 봐도 어떤 객체로 화면전환을 해야할지 어떻게 해야할지 뷰컨이 알지 않아도 된다는걸 아실수있습니다
그리고 상위 코디네이터를 통해서 다른 코디네이터로 넘어갈때 상위 코디네이터에 해당 코디네이터를 꼭 remove해줘야 memory leak이 발생하지 않습니다

그리고 추가로 이건 약간 문법내용이긴하지만 헷갈리시는 분들이 있을수도있어서 추가로 설명드리면

weak var coordinator: LoginNavigation!

우리가 LoginNavigation이라는 프로토콜 타입의 변수를 weak으로 선언할때 아마 compile오류가 발생하시는분들도 있을수 있습니다

그럴때는 해당 프로토콜이 AnyObject나 Class를 채택했는지를 확인해주시고 없으면 채택을 해서 이 프로토콜은 클래스에서만 채택가능한 프로토콜임을 명시해줘야합니다

protocol LoginNavigation: AnyObject {
    func goToRegisterViewController()
    func goToHomeViewController()
    func goToLogin()
}

왜냐하면 애초에 weak라는건 ARC관련해서 referece count를 늘리지 않기위해서 사용하는 문법인데요 그렇다면 weak이라는건 reference type에서만 사용이 가능한 문법인겁니다

protocol은 struct같은 value type에서도 채택이 가능하기때문에 이런경우 struct는 weak으로 선언이 불가능하기때문에 타입에의한 문제가 발생할 수 있습니다. 타입에 민감한 swift에서는 용납할수없는 일이죠


거의 하루종일 코디네이터 패턴을 공부하고 정리하고 이런저런 시도를 해보고 있는데요
기존에 우리가 화면전환을 하는 메커니즘과 완전히 다른 메커니즘이라 여전히 헷갈리는 부분이 많은것같습니다
그리고 해보면서 느낀건데 코디네이터 패턴을 적용할때 생각보다 러닝커브도 높고 적용하는데 시간과 노력이 꽤나 들어갈수도 있겠다는 느낌이 들었습니다. 그러다 보니 프로젝트 규모가 작은 경우에는 오히려 빠른 개발에 마이너스요소가 될 수도 있겠다는 생각이 들었습니다

하지만 저는 viewcontroller의 역할에 대한 내용이 많이 와닿았기도 했고 지금 리팩터링을 진행하고 있는 프로젝트 규모가 코디네이터를 적용하기 알맞다고 생각이 들어서 한번 시간을 들여서 적용을 해보려합니다

내일 팀회의에서 코디네이터 설계를 하기로 했는데 설계에 대한 내용과 실제 적용하게되면, 결과코드와 느낀점을 가지고 이 주제를 가지고 다시한번쯤 오지않을까 싶습니다

그럼 저는 이만 물러가보겠습니다!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료
post-custom-banner

0개의 댓글