본문 바로가기
Dev/고민과 삽질의 기록들🤔

Coordinator Pattern 도입 이유와 실제 도입기 (ft. 객체들의 책임과 unit test)

by Mintta 2023. 10. 21.

https://github.com/Team-LionHeart/LionHeart-iOS

 

GitHub - Team-LionHeart/LionHeart-iOS: 라이옹 🦁

라이옹 🦁. Contribute to Team-LionHeart/LionHeart-iOS development by creating an account on GitHub.

github.com

Coordinator Pattern을 공부를 시작하기로 마음 먹은 횟수만 치면 이번이 두번째입니다.

처음 마음을 먹고 공부를 했었을 때는 장렬하게 실패하고, 드디어 두번째 마음을 먹고 난 후에서야 실제로 프로젝트에 적용하고 아티클까지 남길 수 있을 정도가 되었습니다.

 

Coordinator Pattern은 유독 공부하기가 까다로웠습니다. 제가 생각했을 때 그 이유는 바로 정형화되어 있지 않은 방식 때문입니다.

Coordinator Pattern을 키워드로 Github나 구글링을 해보면 Coordinator Pattern 개념을 기반으로 한 정말 정말 다양한 구현 방법이 존재했습니다. 따라서 여러 방법들 중, 가장 이해하기에 수월했던 조금 더 직관적이였던 예제를 바탕으로 이해한 Coordinator 방식으로 아티클을 정리해보고자 합니다.

 


Coordinator 도입 이유 ?

Coordinator를 공부하기로 마음먹은 이유는 프로젝트에서 Coordinator의 필요성을 느꼈기 때문입니다.

저는 현재 MVC구조로 되어있는 프로젝트를 계속해서 리팩토링해나가고 있습니다.

(여담으로 글을 쓰는 지금 기준으로 MVC-C의 아키텍처까지 리팩토링을 마쳤고 향후 MVVM + Combine과 Unit test를 목표로 리팩토링을 해나갈 예정입니다.

앞으로의 리팩토링이나 혹시나 프로젝트가 궁금하시다면 아래 링크로 놀러오세요~!)

https://github.com/Team-LionHeart/LionHeart-iOS#1%EC%B0%A8-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81

 

GitHub - Team-LionHeart/LionHeart-iOS: 라이옹 🦁

라이옹 🦁. Contribute to Team-LionHeart/LionHeart-iOS development by creating an account on GitHub.

github.com

 

 

아무튼 그래서 왜 Coordinator의 필요성을 느꼈는지로 돌아가보면.. 프로젝트의 구조는 현재까지는 MVC입니다.

MVC 아키텍처의 가장 흔하게 제기되는 문제점은 바로 Massive View Controller입니다. MVC의 ViewController의 책임이 너무나 많아져서 관련된 코드가 많아지는 것입니다.

 

MVC아키텍처에서의 ViewController가 지니고 있는 책임들에는 무엇이 있을까요 ?

- 유저 입력에 따라 데이터를 변경하는 데이터 로직

- UI 로직

- 네트워크 통신 후 얻게 된 Response를 다시 UI에 입히는 로직

- 화면 전환

- 화면 전환에 필요한 객체 생성

...

 

딱 봐도 너무나 많다는 것을 알 수 있습니다. 코드의 양은 당연히 방대해질 수 밖에 없습니다.

책임은 제가 앞선 아티클에서도 얘기했다싶이 해당 객체가 변경되어야 하는 이유에도 해당합니다. 이렇게 많은 책임이 존재하는 ViewController는 변경에 매우 취약한 객체가 됩니다.

 

응집도와 결합도 측면에서도 살펴봅시다.

응집도는 서로 관련된 메소드들이 얼마나 한 객체에 응집되어있는지를 나타내는데, 위에서 말한 책임들은 서로가 서로와 관련되어있다고 보기에는 어렵습니다. 따라서 ViewController는 응집도가 매우 낮은 객체입니다.

 

결합도 측면에서 살펴보면, 화면 전환을 위한 객체를 온전히 ViewController 내부에서 생성하고 있습니다. 해당 객체를 생성할 때 필요한 의존 객체들도 모두 포함해서 말이죠. 실제 객체 타입을 바라보고 있기 때문에 ViewController 내부에서 생성하고 있는 객체가 ViewController와 강하게 결합됩니다. 그 말은 즉, 객체의 변경사항이 타고 타고 그 객체를 생성하고 있는 ViewController에까지 영향을 미치게 된다는 말입니다. 즉, ViewController는 객체간의 결합도는 매우 높은 객체입니다.

 

저희 프로젝트는 최종 리팩토링 과정에서 Unit test를 하고자 합니다. 싱글톤 글에서도 말했었지만 Unit test를 위해서는 응집도는 높고 결합도는 낮은 객체를 만들  필요가 있습니다.

그 이유에 관해서도 위의 글에서도 언급하긴 했지만, 다시 한번 얘기해보자면, Unit test의 목적은 "우리가 만든 단일 로직, 혹은 객체가 우리가 의도한대로 움직이는가 ?, 원하는 Output을 만들어내는가? " 라고 생각합니다.

 

그런데 응집도는 낮고 결합도는 높은, 즉 변경되어야 하는 이유가 많은, 다른 말로 책임이 많은 객체에서 Unit test를 한다고 했을 때 Unit test의 목적을 달성할 수 있을까요 ? 제가 생각했을 때는 불가능하다고 생각합니다.

 

많은 책임들이 얽혀있고, 한 객체에서 수행하고 있는 역할이 너무나 많아서 Test를 실패했을 때 그 원인이 이거다! 라고 단정짓기에는 많은 어려움이 있을 것이라고 생각합니다.

 

따라서 Unit test를 하기 위해서는 우선 객체들의 책임을 분리하여, 객체들간의 협력을 중시하는 객체지향적인 설계를 통해서 응집도는 높고 결합도는 낮은 객체를 만들어야할 필요성이 있다고 생각했습니다.

 

그런 객체를 만들기 위해, ViewController의 책임을 분리하는 과정 중에 첫발을 "네트워크 레이어의 재구축", 그리고 그 다음으로 

"화면전환과 화면전환에 필요한 객체와 거기에 따른 의존성 객체 생성" 이 두가지를 Coordinator, 그리고 또 다른 아티클에서 얘기할 Factory Pattern을 통해서 ViewController로부터 분리시키고자 합니다.


Coordinator Pattern ?

이제 Coordinator Pattern입니다. Coorinator Pattern에 대해서 알기 위해서는 Coordinator Pattern을 처음 제시한 사람의 의도를 듣는 것이 가장 정확할 것이라고 생각합니다.

 

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

 

Khanlou | The Coordinator

January 20, 2015 The Coordinator One of the biggest problems with the big view controllers is that they entangle your flow logic, view logic, and business logic. When a table cell is selected, that delegate method typically looks like this: - (void)tableVi

khanlou.com

 

1️⃣let myPageViewController = MyPageViewController()
2️⃣self.navigationController?.pushViewController(myPageViewController, animated: true)

 

너무나도 익숙한 NavigationController에서의 push 메서드입니다.

 

위의 코드에 대해 Khanlou가 어떻게 말하는지 살펴보면 아래와 같습니다.

The view controller knows how to create the next thing in the flow, and how to present it.
-Khanlou

ViewController는 Flow에서 다음 항목을 생성하는 방법과, 그것을 어떻게 화면에 보여줄지에 대해 알고 있다고 합니다.

이 것이 왜 문제라고 생각한지에 대해서는 조금 뒤에 말씀드리겠습니다.

 

그리고 2번 줄에 대한 Khanlou의 생각을 들어보겠습니다.

It tells its parent view controller what to do, which definitely seems backwards. 

여기서 it은 ViewController를 말합니다. ViewController가 자신의 상위(parent) ViewController에게 무엇을 해야 하는지 알려주고 있는데, 이는 확실히 뭔가 거꾸로 된것 같다..! 라고 유교적인 발언을 합니다.

 

여기서 ViewController의 Parent ViewController는 NavigationController입니다. NavigationController는 여러개의 ViewController를 stack으로 관리하는 Container이기 때문에 부모임에는 틀림 없습니다.

확실히 명령의 흐름이 역전된 듯 한 느낌이 듭니다.

 

그리고 추가로 이런 말까지 합니다.

And even worse, that flow code is distributed among multiple view controllers, each of which only knows how to perform the next step.

이러한 Flow 코드가 여기저기에 분산되어 있으며, 각 ViewController들은 다음 단계를 수행하는 방법만 알고 있다는 것 입니다.

위의 코드를 사용해서 화면전환을 구현해보셨다면 위의 말에는 그냥 공감이 갈 것이라 생각합니다.

 

그래서 이것들이 왜 문제인지에 대한 핵심은 아래 문장입니다. Khanolu는 결론적으로 아래와 같이 말합니다.

Don’t forget, the view controller base class is prefixed with UI. It’s view object, and handling user flow is out of its scope

ViewController는 UI가 붙어있는 객체이기에 view에 관련된 객체입니다. 따라서, user flow를 핸들링하는 것은 ViewController의 scope, 즉 역할 밖이라고 말입니다.

 

그렇다면 이런 의문도 듭니다.

 

UIViewController가 아니면 되는거 아닌가? 난 MVVM을 쓰는데, 그러면 MVVM이 처리해주면 아무 문제 없겠네 ?

 

한번 생각해보겠습니다. MVVM에서 ViewModel이 화면전환 로직을 처리하게 된다면 ViewModel이 UIViewController를 생성해야하기 때문에 UIKit을 import해야만 할 것 입니다.

 

벌써 거리낌을 느끼시는 분도 있을 것 같습니다. ViewModel은 본래 UI와는 상관이 없습니다. 데이터, 즉 Model에 변화가 생기면 View, 즉 ViewController에서 해당 변화를 감지하고 뷰를 업데이트 시키게 됩니다 (KVO, didSet + Closure, RxSwift, Combine 등등 이렇게 구현하는 방법은 다양하겠죠). UI와 완전히 분리되어있는 ViewModel은 UI관련 프레임워크를 import할 필요가 전혀 없습니다.

 

그렇기 때문에 MVC, MVVM과 상관없이 화면 전환에 책임을 전담할 Coordinator가 필요로 하게 되는 것 입니다.


Coordinator 방식과 구조 설계하기

최종 설계는 위와 같습니다.

 

최상위 루트에 AppCoordinator를 두고, 유저 플로우와 논리에 흐름에 따라 서로 관련된 ViewController들이 하나의 Coordinator안에 들어가 있는 형태로 트리 형태를 이루게 됩니다.

 

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

위는 저희가 사용하는 Coordinator 프로토콜입니다.

이 때 각 Coordinator의 위에 존재하는 Coordinator는 parentCoordinator에 해당하고 아래에 위치하는 Coordinator는 parentCoordinator의 children배열안에 들어가게 됩니다.

 

이 때 TabBarCoordinator는 정말 단순히 Coordinator를 묶어주는 컨테이너의 역할만 수행합니다. 예를 들어서 TabBarCoordinator안에 Today, ArticleCategory, Curriculum, Challenge Coordinator들이 있지만, 이 Coordinator들의 parent는 TabBarCooridnator가 아닌 SplashCoordinator입니다. 

 

parent에 해당하는 SplashCoordinator의 children 배열에도 TabBarCooridnator가 아닌 [Today, ArticleCategory, Curriculum, Challenge]가 들어가게 됩니다.

 

이런식으로 구성한 이유는 TabBarCoordinator에서의 parent와 children간의 depth를 한단계 줄여서 전환을 더욱 쉽게 하기 위해서였습니다.

 

extension Coordinator {
   
    func childDidFinish(_ coordinator : Coordinator){
        for (index, child) in children.enumerated() {
            if child === coordinator {
                children.remove(at: index)
                break
            }
        }
    }
}

 

 

이 메서드는 children에 들어간 자식 Coordinator가 자신의 flow를 모두 끝낸 경우에는 parent에게 childDidFinish 메서드를 통해 자신의 작업이 끝났음을 알리고 부모의 children 배열에서 자기 자신을 지우기 위한 메서드입니다.

 

예를 들어서 MyPageCoordinator안에 있는 MyPageViewController로 갈 수 있는 마이페이지 버튼이 NavigationBar에 항상 떠있어, 앱의 어디에서나 들어갈 수 있는 유저 플로우를 가지고 있습니다.

 

탭바의 성격을 지니고 있는 셈이지만 차이가 존재합니다. 탭바에 존재하는 Coordinator의 경우, 언제나 Coordinator에서 처음 띄우는 화면이 해당 화면이 속한 flow의 시작점입니다. 하지만 NavigationBar에 존재하기 때문에, back버튼을 누를 시에 이전 화면으로 돌아가야 했습니다. 

 

구체적인 상황을 들어서 더 설명해보겠습니다.

 

만약 TodayCoordinator에서 마이페이지로 가기 위해 MyPageCoordinator를 start했다면, TodayCoordinator는 MyPageCoordinator의 parentCoordinator가 되고 children 배열에 MyPageCoordinator가 들어가게 되고 TodayCoordinator가 가지고 있던 navigationController를 넘깁니다.

 

MyPageCoordinator가 시작되면, 넘겨받은 navigationController에서 MyPageViewController를 push하면서 마이페이지 화면을 띄우게 됩니다. 이후 back 버튼을 누르게 된다면 기존에 있던 navigationController의 pop메서드를 수행하고 곧 바로 TodayCoordinator의 children 배열에서 자기 자신(MyPageCoordinator)를 지워주기 위해 childDidFinish 메서드를 호출합니다.

 

 

처음에 childDidFinish와 child에 대해 이해하는데 상당히 애를 먹었던 것 같습니다.. 해당 메서드를 쓰지 않는 방식인 self-deallocating coordinator 방식도 존재하고, 해당 메서드와 동일한 역할이 존재하지만 찾아보니 사용하지 않는 프로젝트 레퍼런스 또한 있어서 더 그랬던 것 같습니다.


Navigation ( Coordinator interface) 분리

글의 위에서 언급했듯이 Tabbar내부에 있는 각 탭에서 모두 Bookmark와 MyPage로 접근이 가능한 각각의 NavigationBar가 존재합니다. 또한 아래와 같은 규칙을 가지고 있습니다.

  1. 어느 화면에서나 NavigationBar에서 Bookmark로 이동하거나 MyPage로 이동할 수 있습니다.
  2. 각 탭마다의 Coordinator의 첫번째 이후의 ViewController부터는 NavigationBar가 pop혹은 dismiss의 기능을 수행할 수 있습니다.

따라서 아래와같은 세가지의 interface를 통해서 중복구현을 방지하였습니다.

 

protocol BarNavigation: AnyObject {
    func navigationRightButtonTapped()
    func navigationLeftButtonTapped()
}

protocol PopNavigation {
    func backButtonTapped()
}

protocol DismissNavigation {
    func closeButtonTapped()
}

 

예를들어서 TodayViewcontroller의 경우에 화면전환의 경우가 세가지가있는데

  1. NavigationBar를 통한 Bookmark로의 화면전환
  2. NavigationBar를 통한 MyPage로의 화면전환
  3. 오늘의 아티클 Tap을 통한 오늘의 아티클상세뷰로의 화면전환

공통되는 NavigationBar의 interface를 분리함으로서 아래와같은 navigation interface를 가질 수 있게됩니다.

 

protocol TodayNavigation: ExpireNavigation, BarNavigation {
    func todayArticleTapped(articleID: Int)
}

 

Navigation interface의 메서드 네이밍 (feat. 캡슐화)

외부에서 알필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 한종류를 우리는 캡슐화라고한다.
그중에서도 메서드명을 통해서 어떤 변수를 가지고 있는지 혹은 어떤 객체와 연관성을 가지는지를 노골적으로 public interface에 드러내는 것 또한 캡슐화에 위반된다. -오브젝트-

오브젝트 책을 읽으면서 캡슐화를 위반하지 않기 위해 최대한 interface에서 캡슐화를 위반하지 않기 위해 ViewController에서는 화면전환을 하기 위한 메서드명에 화면전환 flow를 노골적으로 드러내지 않게 하기 위해서 유추할 수 있는 정보(예를 들어 ViewController의 이름)를 모두 배제하고 action을 위주로 메서드명을 구성했습니다.

 

(이 캡슐화를 위해 메서드 네이밍까지 관심사 분리를 시켜주기 위해서 추후에 Adaptor Pattern을 도입하게 됩니다😂. 해당 부분에 대해서는 다시 따로 글을 남기겠습니다 !)


회원탈퇴시 앱종료 관련 Navigation interface

회원탈퇴를 하면 앱을 강제로 종료시키는 기능이 존재합니다.

또한, 유저가 앱을 사용하면서 API를 호출하는데, 이 때 header에 넣는 Token이 만료된 Token인 경우에 logout을 통해 Token을 만료시키고 잘못된 접근 이라는 앱재실행이 필요한 오류가 존재합니다.

 

해당 상황이 발생하는 경우를 추상화하기위해서 아래와같은 interface로 분리를 해줬습니다.

 

protocol ExpireNavigation: AnyObject {
    func checkTokenIsExpired()
}

 

그리고 해당 interface의 동작이 모든 경우에 동일하기때문에 채택한 모든 coordinator에서 구현해줄 필요가 없기 때문에 protocol의 extension을 활용해 기본구현을 해주었습니다.

 

또한, coordinator라는 프로토콜을 채택한 객체에서만 해당 interface를 구현해줄 필요가 있기때문에 where절을 통해 coordinator객체 내부에서만 기본구현이 존재하도록 구현했습니다.

 

extension ExpireNavigation where Self: Coordinator {
    func checkTokenIsExpired() {
        UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            exit(0)
        }
    }
}

Bookmark와 MyPage로의 화면전환을 기본 구현으로 빼지 못한 이유

바로 위에서 했던 작업을 생각해보면, 중복되는 로직은 protocol의 extension으로 기본 구현을 제공해주었습니다.

 

Bookmark와 MyPage의 작업을 한번 보겠습니다.

 

protocol BarNavigation: AnyObject {
    func navigationRightButtonTapped()
    func navigationLeftButtonTapped()
}

func navigationRightButtonTapped() {
    let mypageCoordinator = MypageCoordinator(navigationController: navigationController)
    mypageCoordinator.start()
    children.append(mypageCoordinator)
}

func navigationLeftButtonTapped() {
    let bookmarkCoordinator = BookmarkCoordinator(navigationController: navigationController)
    bookmarkCoordinator.start()
    children.append(bookmarkCoordinator)
}

 

동일한 모양의 메서드의 구현부가 반복되서 사용되고 있습니다. 하지만 해당 코드를 Extension으로 기본 구현을 제공해줄 수 없었습니다.

 

가장 큰 이유로는 Bookmark의 화면전환 로직을 BookmarkCoordinator가 담당하고 MyPage의 화면전환 로직을 MypageCoordinator가 담당하고있기때문에 해당 Coordiantor를 부모 Coordinator의 children 배열에 추가해줘야하는데 이를 extension을 통해서 기본구현을 제공하게 된다면 어떤 Coodinator의 children에 추가를 해줘야할지 알 수가 없습니다.

 

다른 앱을 보면 각각의 탭의 NavigationBar가 동일한 view로 이동할수있게 되어있는 앱이 거의 존재하지 않지만 라이온하트앱은 모든 탭의 ViewController에서 모두 동일한 뷰로 이동할 수 있는 NavigationBar 형태를 가지고 있기때문에 이러한 상황이 발생했습니다.

 


마무리

객체의 책임을 분리하고, 객체간 협력을 하게끔 만드는 것이 객체지향적인 프로그래밍의 시작이라고 합니다. 하지만 그 과정은 확실히 녹록치 않고 해야하는 일, 생각해줘야 일도 정말 많네요..! 그래도 드디어 Coordinator를 나름 이해를 했다고 생각하니 뿌듯합니다.

 

아직 써야할 글들이 많이 남았네요.. factory pattern을 도입하기까지의 과정, Adaptor Pattern을 도입하기까지의 과정 등등..

하나씩 쳐내보겠습니다.

 

혹시 진행과정이 궁금하시다면 레포에 놀러오세요! 리드미에 대략적인 저희의 리팩토링 과정을 정리해놨습니다 !

감사합니다 !

https://github.com/Team-LionHeart/LionHeart-iOS#1%EC%B0%A8-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81

 

GitHub - Team-LionHeart/LionHeart-iOS: 라이옹 🦁

라이옹 🦁. Contribute to Team-LionHeart/LionHeart-iOS development by creating an account on GitHub.

github.com

 

댓글