본문 바로가기
Project

[SOPT makers] Coordinator, Router 리팩토링 (1) 왜 화면이 움직이지 않나요?

by 차코.. 2025. 5. 15.

개요

 
지난 2024년 9월부터, 2년 간 개발 및 운영되었던 프로덕트인 SOPT 공식 iOS App의 유지보수를 맡게 되었습니다.
 
SOPT국내 최대 규모의 대학생 연합 IT벤처 창업 동아리로써, 약 3,000여명의 구성원이 존재하고 해당 구성원들을 연결하기 위한 프로덕트를 만드는 조직인 SOPT makers가 존재합니다.
 
SOPT makers 내부에는 프로덕트를 만들어가는 여러 팀들이 존재하는데, 그 중에서도 저는 웹 기반 프로덕트(커뮤니티, 모임, 프로젝트 등) 및 네이티브 자체 기능들을 통합해 제공하는 공식 앱 담당 APP팀으로 합류하게 되었는데요.
 

 
SOPT 공식 앱 iOS 레포
 
 
그 중에서도 처음 프로젝트 합류 시, 저를 혼란스럽게 했던 기존 프로젝트에서의 화면 전환 방식과, 이에 대한 해결 방안을 찾고 리팩토링을 전개한 과정을 기술하고자 합니다.
 
 
 


1. 문제 상황


 
우선, 앱팀에 합류해서 처음 맞닥뜨렸던 난관은 첫 스프린트 '오늘의 솝마디' 기능 개발에서 발생했습니다.
 
오늘의 솝마디는 아래와 같이 모달로 띄워지는 뷰가 반복되어, 뎁스를 여러 번 타고 들어가는 플로우를 가지고 있었는데요.
 

SOPT APP 네이티브 기능: 오늘의 솝마디


 
이 때 해당 기능의 플로우를 관장하는 Coordinator를 생성하고, 이 코디네이터의 흐름 내에서 어떠한 VC 위에 또 다른 VC를 present 해야 할 때, 화면 전환이 일어나지 않는 문제가 발생했습니다.

 
 

 
 
(위 플로우 중 특정 화면에서 새로운 바텀 시트를 띄워야 하는 경우였습니다.)
 
 
 
 


1.1. 화면 전환 구조


문제를 더 기술하기 이전에, 기존 프로젝트의 화면 전환 구조에 대한 설계를 설명하겠습니다. 관련 PR
 
저희 프로젝트에는 모든 코디네이터의 상위에 존재하는 ApplicationCoordinator가 존재하고, 이 최상위 코디네이터에 대해 하위의 코디네이터들이 강한 의존도를 가지고 있는 구조입니다. 
 
 

Coordinator 구조

 
 
 
또한 RouterCoordinator가 함께 쓰이며 Coordinator는 전체적인 플로우를 관리하고, Router는 직접적인 화면 전환을 담당하고 있습니다. Coordinator는 Router를 주입받아, 주입받은 router에게 화면 전환을 요청합니다.
 

Router는 UINavigationController를 주입 받아, 해당 네비컨을 활용해 push, present 등의 화면 전환을 수행합니다.
주입받은 UINavigationController를 저희 프로젝트에서는 rootController라고 명명합니다.

 

 


1.2. 기존 코드 살펴보기 

 

 

다시 문제 상황으로 돌아가보겠습니다.
 
해당 플로우의 Coordinator에서 주입 받은 router에 대하여 present를 요청하고자 했으나, 이상하게 화면 전환이 되지 않았습니다.
 
 


 
그래서 (위 사례와 유사한) 기존 화면들에서 바텀 시트를 어떤 식으로 띄워주고 있었는지 찾아보았는데요.
 
 

PokeCoordinator.swift

 
위는 기존 바텀시트를 만들어 띄워주고 있었던 코드입니다. 바텀시트를 띄우는 함수인 showMessageBottomSheet바텀 시트를 띄우고자 하는 VC인 pokeMain.vc.viewController, 즉 PokeMainVC를 넘겨주고 있었는데요.
 
 

해당 함수를 깊이 타고 들어가면...
 
 

BottomSheetManager.swift

 
아래에 깔리는 뷰(view)와 그 위로 올라오는 뷰(viewController) 모두를 주입받아, present 해주는 방식으로 짜여있습니다. 분명 현재 설계 구조를 생각했을 때 직접적인 화면전환은 router의 root controller로부터 진행되는 것이 자연스러운데, 특정 예외 케이스에서는 vc에서 직접 화면전환하는 것이 일관적이지 않게 느껴졌습니다.
 
 

 


2. 왜 이런 상황이 발생했을까? 

 
이러한 문제 상황이 발생하게 된 경위를, presentpush 두 가지의 경우에 대해서 설명해보겠습니다.
 
 
 

2.1. present에서: 코드 및 도식화


 
present를 하기 위해선 최상단에 올라와 있는 VC가 무엇인지를 알아야 합니다.
 
하지만 현재 Router에서는 모든 화면전환을 rootController에 의존하고 있는데요.
 

Router.swift

 
여기서의 rootController는 앞서 서술했듯, SceneDelegate 혹은 상위 Coordinator에서 생성되어 주입된 UINavigationController입니다.

 
 

SceneDelegate.swift

 
즉, 하위 코디네이터들이 상위에서 생성된 UINavigationController에 강한 의존성을 가지고 있는 구조인데요.
 
 

ApplicationCoordinator.swift

 
위처럼 각 플로우별 Coordinator들이 생성될 때, router에 UINavigationController를 주입합니다.
 
 
 

DailySoptuneCardCoordinator.swift

 
 
그런데.. 어떠한 플로우를 실행시키면, rootController에서 present가 들어가므로, 플로우가 실행되고 이후부터의 최상위 뷰는 더이상 rootController가 아닌 플로우에서 띄워진 VC가 됩니다.
 
즉, 지금의 routerrootController에서 뷰를 띄워줘야 하는데, 이미 rootController가 새로운 VC를 present하고 있는 상태이기 때문에, router가 그 위에 또 VC를 쌓을수가 없는 것입니다.
 
 
 

PokeCoordinator.swift

 
그래서 기존 코드들도 살펴보면, 어떤 Coordinator에서 뎁스를 하나 더 들어가서 모달의 띄워야 하는 경우, 현재의 최상단 뷰로부터 present를 하고 있는 것을 알 수 있습니다. router의 초기 목적인 화면 전환에 대한 완전한 책임 분리가 일어나고 있지 않은 상황입니다.

 

문제 상황: present


 
그림으로 나타내면 위와 같습니다.
  
 
 
 


2.2. push에서: 코드 및 도식화


초기에는 present에서의 문제점만 발견되었지만, push에서도 비슷한 이슈가 있습니다.
 
 

 
 
예를 들어, SOPT APP의 네이티브 기능 중 하나인 콕찌르기의 경우, 유저 프로필 사진을 누르면 해당 유저에 대한 플레이 그라운드 프로필 (웹 프로덕트)로 연결되는 웹뷰가 push 되는데요. 해당 부분의 기존 코드를 살펴보았습니다.
 

 

PokeMyFriendsCoordinator.swift

 
어떤 플로우에서 새로운 VC를 push해야 할 경우, router의 rootController 아니라 self의 rootController에서 push를 하고 있습니다.
 
 
 

PokeMyFriendsCoordinator.swift

 
 
여기에서 self.rootController나 자신 VC를 UINavigationController에 감싼 것입니다.
 
 
 
 

🤔 왜 router의 rootController가 아니라 self의 rootController에서 push를 해야 했을까요?
문제 상황: push


router로 화면 전환을 해주려면 router가 참조하고 있는 네비바가 가려지면 안되는데, 이미 새로운 뷰를 present한 상황이므로 이 네비바가 가려져 뒤에서 push되는 것입니다. 이를 위해선, 또 최상단의 VC에서 네비게이션 컨트롤러를 만들어 새롭게 push해야 하는 것입니다.
 
 
 



 
다시 그림으로 정리하면 아래와 같습니다.
 

 

present의 경우
- Router는 모든 화면 전환을 rootController에 의존
- 플로우가 실행되면 rootController에서 present가 들어가므로, 이후 최상위 뷰는 rootController가 아닌 새로 띄워진 VC가 됨
- 결과적으로 Router가 rootController에서 새로운 VC를 present 할 수 없는 상황 발생

✅ push의 경우
- Router가 참조하는 네비게이션 바가 가려진 상태이기에 push 할 수 없는 상황 발생

 

 
 


3. 초기 설계 의도 파악

 
이쯤에서 초기에 왜 이러한 구조를 가지게 되었는지 파악할 필요성을 느끼게 되었습니다.
 

Coordinator 도입 관련 PR 설명

 
 
선임 개발자 분께서 적어주신 초기 라우터의 설계 의도를 확인해보면, 플로우 관리화면 전환의 역할을 각각 CoordinatorRouter에게 담당하도록 하기 위해서였다는 것을 알 수 있었습니다.

 
 

 
 
그리고 해당 논의을 읽으며 초기에도 이러한 문제점이 인지되고 있었음을 알았는데요. SOPT makers는 6개월 단위의 기수제로 운영되기 때문에, 유지보수 인원이 기수마다 바뀔 수 있습니다. 그만큼 새로 들어온 인원들이 프로젝트를 이해하고 적응하는 기간이 주기적으로 반복되는데, 해당 부분은 원활한 프로젝트의 유지보수에 병목이 될 수 있다고 생각했습니다.
 
 
문제에 대한 해결 방안을 모색하기 위해 여러 자료들을 찾아보던 도중, 29CM 팀에서 작성해주신 아티클을 발견하고 이와 유사한 사고의 흐름을 가지고 문제를 접근하자는 논의를 거쳤습니다.
 
 
 
 


4.Router를 제거하자


먼저 29CM 팀이 해당 문제에 대한 대안점으로 제시한 부분들을 읽어보았습니다.
 

아티클 중

 
 
아티클을 읽으며 29CM 팀은 의존도가 높았던 RootController를 제거하고, 최상위 Navigation Stack을 기준으로 화면 전환을 진행하도록 리팩토링했다는 점을 확인할 수 있었는데요. 여기에서 저희 프로젝트도 RootController를 제거해야 한다는 실마리를 얻었습니다.
 
 
 
 



그 과정에서 챕터원분들과 Router의 필요성에 대해서 논의를 거치게 되었는데요.
 
Router의 모든 책임을 Coordinator에게 넘기고, Coordinator만 존재하면 해결되지 않을까?
 

 
 
초기 프로젝트의 Coordinator 구조에 참고가 되었던 아티클에서는, Coordinator에서 직접 UINavigationController의 참조를 유지하고 화면을 푸시하면 안 되는 이유와 더불어 Router의 필요성에 관해 다음과 같이 서술하고 있었습니다.

 

Coordinators Essential tutorial. Part I

 
 
 
요약하자면, 화면 전환 로직을 Router에서 따로 관리해야 하는 이유
 

1. SRP에 대한 위반 - Coordinator는 화면 전환 흐름을 관리할 뿐, 직접적인 라우팅에 대한 책임을 가지면 안 됨
2. iPad 지원 혹은 커스텀 화면 전환을 추가해야 할 경우 - Coordinator는 구체적인 화면 전환 방식을 알 필요가 없음


인데요. 

 




여기에서 회의를 통해 두 가지 의문점을 갖게 되었고, 해당 의문점에 대한 정당성을 생각해보았습니다.
 

의문 1. Coordinator가 네비게이션 흐름을 담당하는 객체라면, 구체적인 화면 전환 처리까지 담당하는 것이 과한 책임일까?
 의문 2. Router가 실제로 어떤 역할을 수행하는지, 그리고 그 역할이 정말 중요한지?

 

 
의문 1에 관해서, Coordinator가 화면 전환 처리까지 담당하는 것은 과한 책임이 아니라고 생각했습니다. 


1. 화면 전환네비게이션 흐름의 핵심적인 부분이고, Coordinator가 결국 화면 전환의 시점과 방식을 결정하기 때문입니다.
2. Router가 존재하고 있음에도, 실제 화면 전환의 의사 결정은 여전히 Coordinator에서 이루어지고 있습니다.
 
 
 
 
 
의문 2에 관해서, Router의 추상화가 불필요한 복잡성을 야기하고 있다 판단했습니다.

 

Router.swift

 
 
실제로 Router의 코드를 살펴보니, 대부분의 함수들이 단순히 UIKit의 기본 함수들을 감싸고 있음을 알 수 있었습니다. Router가 과도한 추상화를 하고 있고, 또 이를 위해 ViewController를 ViewControllable로 감싸는 것 또한 불필요한 추상화로 판단되었습니다.
 

 

 


5. 결론


 
그래서 정리한 결론은!
 

Router를 제거하고, Router에 대한 책임을 Coordinator에게 위임한다.

 
 
인데요.
 
 
사실 올해 초 이렇게 결론을 내렸지만, 이밖에도 화면 전환 관련 코드에서 거론되었던 많은 문제점들과, 다른 기능 개발 피처들을 함께 병행하며 병렬로 레거시를 제거하는 작업이 엄두가 나지 않아 선뜻 시작하지 못했습니다. (개인적으로는 제거되어야 하는 레거시가 계속해서 잔존하는 이유라고 체감했습니다..) 그리고 이제 와서 생각하면 Router를 제거한다는 단순한 결론인데, 레거시에서의 문제점을 찾아내기까지 많은 시간이 걸린 것 같습니다.
 
 
그렇지만 챕터원들과 함께, 우선 1차적인 문제인 Router부터 제거하는 리팩토링을 차근차근 진행하기로 계획했고, 현재 진행 중에 있습니다.
 
 
다음 글은 Router를 제거했던 내용들을 서술해보겠습니다!