회사에서의 첫 프로젝트가 얼추 마무리 되어가면서 시간적 여유가 생기던 중, 문득 MVVM+Rx를 연습할 겸 이전까지 사용해보지 않은 프레임워크나 패턴을 공부해볼까라는 생각이 들었다. 평소 코드로 뷰를 짜다보니 이를 편하게 해줄 SnapKit이나 Then을 채택했고 네트워크와 관련해서는 편의성이 좋다는 평을 들었던 Moya를 학습해보기로 했다.
다만 패턴과 관련해서는 MVVM+Rx를 다시 연습하고 학습하는 것만으로도 벅찬데 굳이 해야되나라는 생각이 들었지만 그래도 이왕 학습할 거 그냥 하나라도 더 배워보자라는 마음으로 Coordinator 패턴을 마주하게 되었다.
Why Coordinator pattern?
왜 하필 Coordinator 패턴을 학습하려고 했는가.
사실 MVVM-Clean Architecture를 학습할 때부터 DI(의존성 주입)에 관한 중요성을 꽤 많이 접하였다. 굳이 프로토콜을 만들었던 것도 객체 간의 결합도를 낮추고 상호 의존적인 형태를 지양하기 위해서였는데 막상 DI를 적용해보려고 하니 쉽지 않았던 기억이 있었다.
특히 Navigation 이동을 할 경우에는 VC 인스턴스를 직접 생성하다보니 VC 간의 의존도가 생길 수 밖에 없었는데 Coordinator 패턴을 활용할 경우에는 VC부터 Repository까지의 생성과 주입을 모두 Coordinator가 관리하다보니 이러한 의존성을 해결할 수 있기에 해당 패턴을 우선적으로 학습하기로 생각했다.
이러한 컨셉을 유지하여 View 간의 이동을 수행하는 것을 Coordinator 패턴이라 부르다보니 자료들마다 비슷하지만 내부는 다른 경우가 많았는데 일단 본인의 경우에는 Naviagtion Controller를 활용한 패턴을 기반으로 삼았다.
Coordinator 패턴 정립하기
가장 먼저 전반적인 Coordinator의 틀을 잡아줄 프로토콜을 정의해주었다.
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get set }
func start()
}
Navigation을 활용한 패턴에서는 위와 같은 형태가 가장 보편적인 것으로 보인다.
먼저 하위 Coordinator들을 소유하고 관리하기 위한 배열 프로퍼티와 뷰 간의 이동을 수행하기 위한 Navigation Controller 프로퍼티, 마지막으로 소유한 뷰의 가장 메인화면을 출력하는 기능을 수행할 start 메서드를 보유한 형태이다.
이제 틀이 잡혔으니 이를 채택한 구현체들을 작성하려한다.
class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
init(_ navigation: UINavigationController) {
self.navigationController = navigation
}
func start() {
let mainCoor = MainCoordinator(self.navigationController)
childCoordinators.append(mainCoor)
mainCoor.start()
}
}
가장 먼저 구현한 것은 AppCoordinator로서 SceneDelegate에서 기반이 될 NavigationController를 rootView로 설정한 뒤, AppCoordinator를 생성하면서 해당 navigation을 넘겨주어 시조 Coordinator로서 작용하도록 했다.
현재는 메인 화면 밖에 없으나 앱에 따라서 로그인과 같은 화면이 추가가 될 수 있고, 로그인 여부에 따라 로그인 화면으로 바로 갈 지 아니면 메인 화면으로 갈 지 판단하여 push할 필요가 있다. 그러한 이유로 이를 판별해서 올바른 Coordinator의 start 메서드를 호출하는 AppCoordinator를 구현하였다.
다음으로는 AppCoordinator가 호출하는 MainCoordinator를 구현하였다.
class MainCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
init(_ navigation: UINavigationController) {
self.navigationController = navigation
}
func start() {
let tabVC = MainTabBarViewController()
tabVC.coordinatorDelegate = self
navigationController.pushViewController(tabVC, animated: false)
self.configureTabBarCoordinators(with: tabVC)
}
}
다만 위의 코드를 보면 알겠지만 본인이 연습하던 프로젝트는 TabBarVC를 메인 화면에 적용하고 있다. 따라서 단순히 MainCoordinator까지 올리고 나면 일단락되는 것이 아닌, 각 탭에 맞는 하위 Coordinator를 생성하고 해당 뷰를 띄워줘야만 했다.
일단 각 하위 탭의 Coordinator들의 구현은 기존 Coordinator들과 동일하기 때문에 복잡한 부분은 없었다.
class FirstSectionCoordinator: Coordinator {
weak var parentCoordinator: Coordinator?
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
init(_ navigation: UINavigationController) {
self.navigationController = navigation
}
func start() {
let mainRepository: BasicRepository = ViewDefaultMainRepository(serviceKind: .mainFoodFetch)
let mainUsecase: ViewMainUsecase = ViewDefaultMainUsecase(repository: mainRepository)
let mainVM = MainViewModel(usecase: mainUsecase)
let mainVC = MainViewController(viewModel: mainVM)
mainVC.deleage = self
mainVC.view.backgroundColor = .white
self.navigationController.pushViewController(mainVC, animated: false)
}
}
위와 동일한 Coordinator가 탭의 개수만큼 생성되어 MainCoordinator의 childCoordinators 배열에 정렬되도록 하였다. 다만 해당 작업에서 가장 고민이 되었던 것이 Navigation의 문제였다.
만약 AppCoordinator에서 받아온 기존의 Navigation을 사용하게 된다면 동일한 인스턴스이다보니 기존의 push 되어있던 VC들과의 관계 정리를 비롯하여 탭 이동 시마다 앞선 탭의 VC를 pop하고 새 탭을 push하는 번거로운 작업이 필요하게 된다.
또한 하나의 Navigation을 공유하다보면 원하는 pop 플로우를 구현하기 어려울 듯 했다. 예를 들어 회원가입을 진행하던 중, 취소하고 로그인 화면으로 돌아가고 싶을 경우에 동일한 Navigation을 사용하면 몇 번째 인덱스까지 pop을 할 지나 뒤로가기 제스처 등의 제약 같은 부분도 복잡해지지 않을까라는 생각이 든다.
이에 일단은 본인이 원하는 형태의 Coordinator 패턴 개요도를 그려보기 시작했다.
Coordinator 패턴 개요도
먼저 Coordinator를 생성하여 child로 두는 기준은 단순 View 생성이 아닌, Scene의 변화가 있을 경우로 기준을 두었다. 즉, 로그인 -> 회원가입 같은 경우에는 새로운 Coordinator와 Navigation을 발행하고 회원가입 내에서 여러 뷰 이동은 Coordinator 생성 없이 단순 VC init과 push/pop을 활용하는 형태로 생각하였다.
이와 함께 VC에서 View 이동을 원할 경우에는 기존에 만든 Coordinator 프로토콜이 아닌, 이동과 관련된 delegate를 따로 추가하여 해당 delegate의 메서드와 채택을 통해 이동하도록 구현하였다.
protocol DetailNavigateDelegate: AnyObject {
func moveToDetailVC(with hash: String, entity: OnbanFoodEntity)
}
class FirstSectionCoordinator: Coordinator, DetailNavigateDelegate {
weak var parentCoordinator: Coordinator?
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
init(_ navigation: UINavigationController) {
self.navigationController = navigation
}
func start() {
let mainRepository: BasicRepository = ViewDefaultMainRepository(serviceKind: .mainFoodFetch)
let mainUsecase: ViewMainUsecase = ViewDefaultMainUsecase(repository: mainRepository)
let mainVM = MainViewModel(usecase: mainUsecase)
let mainVC = MainViewController(viewModel: mainVM)
mainVC.deleage = self
mainVC.detailNavigationDelegate = self
mainVC.view.backgroundColor = .white
self.navigationController.pushViewController(mainVC, animated: false)
}
func moveToDetailVC(with hash: String, entity: OnbanFoodEntity) {
let detailRepository = ViewDefaultMainRepository(serviceKind: .foodDetailFetch(foodID: hash))
let detailUsecase = ViewDefaultDetailUsecase(repository: detailRepository)
let detailVM = DetailViewModel(usecase: detailUsecase)
let detailVC = DetailViewController(detailVM: detailVM, foodEntity: entity)
detailVC.hidesBottomBarWhenPushed = true
self.navigationController.pushViewController(detailVC, animated: true)
}
}
물론 delegate 없이 Coordinator 프로토콜에 이동 메서드를 기입하여 사용하여도 문제 없겠으나, 만약 하나의 View에서 굉장히 많은 View를 push할 수 있을 경우에는 프로토콜이 범용적이지 못 하게 되지 않을까 싶어서 이와 같은 형태로 구현하였다.
솔직히 아직도 해당 패턴을 완벽하게 이해했고 잘 쓰고 있다는 느낌은 들지 않는다. 다만 앞서 말했듯이 컨셉을 유지한 채로 다양한 형태들이 존재하는만큼 본인이 정리한 형태도 그 궤를 달리하지는 않다 생각이 들어서 블로그에 정리하려 했다.
여전히 Navigation 중첩 구조 등 탐탁치 않은 구석이 없지 않아 있지만 이러한 부분들은 앞으로 더 많은 작업을 하면서 차차 더 매끄럽게 다듬어나가기를 바라고 있다.