2025. 1. 17. 15:01ㆍArchitecture, Design Pattern/Design Pattern
💡 해당 글은 Coordinator Pattern, Router, Factory Method Pattern을 사용한 리팩토링 코드를 설명하는 글입니다!
글이 너무 길어져 총 3개로 나누어 글을 올릴 예정이구요, 만약 잘못된 개념이나 개선할 부분, 질문 등이 있다면 댓글로 지적해주시면 빠르게 남겨드리도록 하겠습니다 ^__^
1️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (1) : 초기 세팅부터 로그인 화면까지
2️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (2) : 탭바와 앱 전체 흐름에 대하여
3️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (3) : 화면 로직 분리에 대한 깊은 고찰
1. 이번 리팩토링의 목적!
예전에 저는 화면 전환 로직을 VC에서 분리하기 위한 방법으로 코디네이터 패턴 (Coordinator Pattern),
단일 객체 생성을 담당하는 추상화된 프로토콜을 활용하는 팩토리 메서드 패턴 (Factory Method Pattern)을 소개한 적이 있는데요!
그때는 기본적인 개념 설명에서만 그친채로 글을 마무리해서,
블로그 글을 보고 실제 프로젝트에서 디자인 패턴을 활용하기에는 조금 어려운 점이 있었을 거라 생각했습니다!
저도 실제 프젝에서 활용한 것이 아니라.. 예시로 화면 두세개 혹은 하나의 컴포넌트 생성 과정을 팩토리 패턴을 사용한 정도라....아쉬웠기도 했고요.
아무튼 그래서 오늘 글에서는 실제 프로젝트에서 이 두 개의 디자인 패턴들을 사용해 기존 VC 생성 부분과 화면 전환 로직을 개선했던 내용을 소개하고자 합니다.
*앗 참고로! 편의상 저는 ViewController는 VC로, ViewModel은 VM으로 부른다는 점 참고해 주시길😊
일단 기존 코드에 어떤 문제가 있는지부터 살펴보죠. 아래는 기존 프로젝트 내에 한 VC 코드 일부입니다.
MVVM 아키텍처를 적용한 저희 프로젝트에서 흔히 볼 수 있는 코드 구조인데요.
현재 VC는 VM을 직접 생성해 의존하고 있고, 다음 화면 전환에 필더요한 EditClipVC도 역시 직접 만들어서 의존하는 것을 볼 수 있습니다.
즉, ClipVC는 VM과 화면 전환되는 VC와 강한 결합 (Tigh Coupling) 관계가 형성되어 있는 것이죠!
만약, EditClipVC의 초기화 방식이 바뀐다고 가정했을 때,
EditClipVC를 화면 전환의 목적지로 생성 - 강한 결합 관계가 형성되어 있는 VC에서는 모두 수정이 필요할 거예요. 하나의 수정이 다른 곳에 미치는 영향이 클 수도 있겠네요!
뿐만 아니라, 테스트 코드로 Mock ViewModel을 주입해서 사용할 수도 없어 - 무조건 기본 구현된 ViewModel을 사용해야 할 겁니다. 테스트 코드를 작성하기도 곤란한 상황이죠!
final class ClipViewController: UIViewController {
// Before Refactoring
private let viewModel = ClipViewModel()
@objc func editButtonTapped() {
let editClipViewController = EditClipViewController()
editClipViewController.setupDataBind(clipModel: viewModel.clipList)
self.navigationController?.pushViewController(editClipViewController, animated: false)
}
...
}
너무도 당연하게 사용했던 코드들인데,, 생각보다 문제점이 있었군요..!
조금 더 자세하게 이번 리팩토링의 목적이자, 리팩토링을 통해 얻게 되는 장점은 아래와 같이 정리할 수 있습니다.
1️⃣ VC 생성 로직의 중앙화 + 이에 따른 관리 효율성의 증가 (with Factory Method Pattern)
: 모든 VC는 이제부터 Factory 객체를 통해서만 생성합니다.
: VC에 수정 사항이 발생하더라도 이제부터는 VC 생성이 담겨 있던 각 부분이 아니라, 한 곳 (팩토리 메서드)만 수정해 주면 되겠네요. (코드의 유연성이 증가한 것입니다!)
2️⃣ VC 개선 : Massive ViewController 문제 해소 + SRP (단일 책임 원칙)의 준수
: VC의 많은 역할 중에서 화면 전환에 대한 책임은 이제부터 Coordinator와 Router가 갖게 됩니다. (VC는 Interaction의 책임만 / Coordinator는 화면 흐름에 대한 책임 / Router는 화면 전환 방식에 대한 책임만 나눠 갖는 거죠.)
: 또한, VC 없이 단일 Coordinator만으로도 화면 전환 로직에 대한 독립적인 테스트가 가능하겠군요!
3️⃣ 모듈 간 결합도 감소 + DIP (의존성 역전 원칙)의 준수
: VC가 직접 객체를 생성하여 형성되는 강한 결합 관계를 분리합니다.
: VM (ViewModel)과의 강한 결합은 팩토리로, 화면 전환을 위한 다른 VC와의 강한 결합은 코디네이터로 의존성을 관리하게 될 거에요. (약한 결합으로 개선되겠군요!)
그리고 무엇보다 프로젝트의 규모가 커지게 되면 복잡해지는 화면 전환 과정을 한눈에 알아보기도 쉬워지고, 많은 화면 로직 코드나 객체 생성에 대한 코드를 일관성 있게 작성할 수 있다는 점이 있을 거에요!
요약하자면, 코드의 유연성과 확장성을 높이고 협업 시 효율성을 높인다는 장점을 위해 이번 리팩토링 작업을 진행했답니다 :)
자 그럼 이제 본격적으로 리팩토링 된 상황에 대해 자세하게 살펴볼게요!
2. ViewControllerFactory : VM 외부 의존성 주입 & VC 생성에 대한 책임 분리하기
우선, 모든 VC에 대해 VM을 초기화 시 외부에서 주입받도록 코드를 수정했습니다.
💡 여기서 헷갈리면 안 될 점!
이 코드는 외부에서 의존성이 주입되는 방식으로 코드가 개선되기는 했지만 / SOLID에서 말하는 의존성 역전이 이루어졌다고 말할 수는 없습니다.
*의존성 역전 (DI)은 추상화된 인터페이스 (예를 들어, ViewModelType라는 공통된 ViewModel이 따르는 프로토콜이 되겠네요!)를 의존하도록 구성되었을 때 말할 수 있을 겁니다.
이 리팩토링 코드의 목적은 의존성 역전보다는 테스트 코드에서 Mock ViewModel을 사용할 수 있도록,
코드의 강한 결합 관계를 낮추고 유연성을 늘리는 것이었기 때문이죠!
final class ClipViewController: UIViewController {
// After Refactoring
private let viewModel: ClipViewModel!
init(viewModel: ClipViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
...
VC 생성에 대한 책임은 ViewControllerFactory가 담당합니다.
각 VC 생성이 필요할 때마다 팩토리 객체를 역시 일일이 만들지 않아도 되도록, 싱글톤을 사용해 줄 거예요!
해당 ViewControllerFactory에서 각 VC가 필요한 VM도 생성해서 주입하는 책임도 갖게 됩니다.
이제는 EditClipVC의 초기화 방식이 바뀐다고 가정했을 때, EditClipVC를 화면 전환의 목적지로 생성 - 강한 결합 관계가 형성되어 있는 VC를 모두 수정하지 않고 이 Factory 객체 부분만 수정해 주면 될 거예요!
protocol ViewControllerFactoryProtocol {
func makeEditClipVC() -> EditClipViewController
...
}
final class ViewControllerFactory: ViewControllerFactoryProtocol {
static let shared = ViewControllerFactory()
private init() {}
func makeClipVC() -> EditClipViewController {
let viewModel = EditClipViewModel()
let editClipVC = EditClipViewController(viewModel: viewModel)
return clipVC
}
...
}
// 사용
let editClipViewController = ViewControllerFactory.shared.makeEditClipVC()
객체 생성에 대한 책임은 이제 VC에서 Factory로 나눠 갖게 되었습니다.
하지만, 아직 VC가 갖고 있는 다른 책임인 "화면 전환"에 대한 책임을 나눠 갖지 않았어요! Coordinator와 Router를 사용해보러 갑시다.
3. Coordinator & Router 세팅 : VC로부터 화면 전환에 대한 책임 분리하기
이번 리팩토링에서는 Coordinator와 Router를 함께 사용했습니다.
Coordinator는 앱의 화면 전환 흐름 (flow)을 관리하고 / Router는 push, pop, setRoot 등 실제 화면이 전환되는 작업을 관리하게 될 거에요.
각 계층이 하는 역할은 코드를 보게 되면 더 자세하게 이해할 수 있을 겁니다! 일단 그렇구나 하고 넘어가자고요.
화면 전환 흐름을 담당할 Coordinator의 기초부터 설계하겠습니다.
Coordinator 프로토콜은 모든 Coordinator가 갖게 될 공통 요소들, CoordinatorFinishOutput 프로토콜은 Coordinator가 종료되었을 때 알리는 이벤트 핸들러를 담고 있는 녀석입니다.
- childCoordinators : 하위 Coordinator를 관리하기 위한 배열 (AppCoordinator -> TabBarCoordinator -> HomeCoordinator와 같이 흐름이 계층으로 이루어지는 것을 관리합니다.)
- start() : Coordinator의 진입 지점 (entry point)을 나타내는 메서드
- onFinish : Coordinator의 종료를 알리는 옵셔널 클로저
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
protocol CoordinatorFinishOutput {
var onFinish: (() -> Void)? { get set }
}
이 Coordinator를 채택한 기본 Base Coordinator를 이어서 생성했습니다.
BaseCoordinator는 위에서 정의한 Coordinator의 구현부로, 모든 Coordinator가 따르는 공통 기능을 정의하게 될 거에요!
핵심은 결국 하위 Coordinator의 계층인 Array를 관리하는 두 메서드가 정의되어 있다는 점입니다.
뭔가 싶겠지만...
결국 Coordinator가 시작될 때는 addDependency를,
Coordinator가 끝날 때는 (위에서 정의한 onFinish 핸들러 부분이겠죠?) removeDependency를 통해 Coordinator 간 계층을 관리하기 위한 녀석들입니다. 어렵지 않아요!
- addDependency(_:) : 하위 Coordinator를 childCoordinators 배열에 추가하는 메서드
- removeDependency(_:) : 하위 Coordinator를 childCoordinators 배열에서 제거하는 메서드
class BaseCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
func addDependency(_ coordinator: Coordinator) {
guard !childCoordinators.contains(where: { $0 === coordinator }) else { return }
childCoordinators.append(coordinator)
}
func removeDependency(_ coordinator: Coordinator?) {
guard let index = childCoordinators.firstIndex(where: { $0 === coordinator }) else { return }
childCoordinators.remove(at: index)
}
func start() {}
}
💡 Coordinator는 앱의 전환 흐름 (flow), 즉 계층을 관리하는 객체입니다.
---
1. 최상위 Coordinator인 AppCoordinator가 앱 전체 흐름을 관리합니다. -> AppCoordinator는 아래에서 설명합니다!
(AppCoordinator는 BaseCoordinator를 상속받지만, 종료되지 않으므로 CoordinatorFinishOutput은 채택하지 않습니다.)
2. 특정 작업이 시작될 때, 하위 Coordinator를 생성+배열 추가하고, 해당 하위 Coordinator를 시작합니다.
3. 하위 Coordinator가 종료되면, onFinish 트리거를 통해 상위 Coordinator에 종료를 알립니다.
4. 상위 Coordinator는 종료된 하위 Coordinator를 childCoordinators Array에서 제거하고 새로운 흐름을 시작합니다.
이번에는 실제 화면 전환 작업을 담당하는 Router를 구현해보도록 할게요.
루트 VC를 설정하는 setRoot 메서드부터, push present pop dismiss 등 다양한 화면 전환 방법과, 루트로 돌아가는 popToRoot까지 화면 전환에 사용될 메서드를 프로토콜에 정의해두었습니다.
protocol RouterProtocol: AnyObject {
func setRoot(_ viewController: UIViewController, animated: Bool)
func setRoot(_ viewController: UIViewController, animated: Bool, hideBottomBarWhenPushed: Bool)
func push(_ viewController: UIViewController, animated: Bool)
func push(_ viewController: UIViewController, animated: Bool, hideBottomBarWhenPushed: Bool)
func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?)
func pop(animated: Bool)
func dismiss(animated: Bool, completion: (() -> Void)?)
func popToRoot(animated: Bool)
}
해당 프로토콜을 채택해 실제 Router 객체를 생성합니다.
위에서 정의했던 메서드들의 구현부를 작성해주면 간단한 코드이죠! (bottomBar는 프젝에서 사용하는 코드라 캡슐화해줬습니다.)
이때 Router에서는 rootViewController라는 화면 전환 작업의 기준인 UINavigationController를 이용해 전환 작업을 수행합니다.
*rootViewController는 순환 참조를 방지하기 위해 weak로 선언해주는데요.
Router가 UINavigationController를 참조하고 있지만, UINavigationController 역시 Router를 참조할 수 있기 때문입니다! (UINavigationController를 UIViewController를 스택으로 소유하고 있고, 이 VC는 Coordinator와 / Coordinator는 Router와 연결될 수 있기 때문에 순환 참조 문제가 발생할 수 있겠네요.)
final class Router: RouterProtocol {
private weak var rootViewController: UINavigationController?
init(rootViewController: UINavigationController?) {
self.rootViewController = rootViewController
}
func setRoot(_ viewController: UIViewController, animated: Bool) {
rootViewController?.setViewControllers([viewController], animated: animated)
}
func push(_ viewController: UIViewController, animated: Bool) {
rootViewController?.pushViewController(viewController, animated: animated)
}
func push(
_ viewController: UIViewController,
animated: Bool,
hideBottomBarWhenPushed: Bool
) {
viewController.hidesBottomBarWhenPushed = hideBottomBarWhenPushed
rootViewController?.pushViewController(viewController, animated: animated)
}
func present(
_ viewController: UIViewController,
animated: Bool,
completion: (() -> Void)?
) {
rootViewController?.present(viewController, animated: animated, completion: completion)
}
func pop(animated: Bool) {
rootViewController?.popViewController(animated: animated)
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
rootViewController?.dismiss(animated: animated, completion: completion)
}
}
💡 Router는 앱의 화면 전환, 그 중에서도 특히 네비게이션 로직을 관리하는 객체입니다.
4. AppCoordinator & SceneDelegate : 앱 시작 시부터 화면을 제어해보자
자 이제 앱의 전체 흐름을 제어하게 될 최상위 Coordinator인 AppCoordinator 코드를 작성해보겠습니다.
위에서도 살짝 말했지만, AppCoordinator는 앱 전체 최상단에서 화면 흐름을 제어하게 될 것이기 때문에 / 해당 Coordinator는 종료되지 않습니다.
-> 즉 BaseCoordinator만 상속받고, CoordinatorFinishOutput 상속받지 않는다는 의미입니다!
AppCoordinator는 SceneDelegate에서 아래와 같이 생성되고, 실행합니다. (appCoordinator?.start 메서드 실행)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var appCoordinator: AppCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let isUserLoggedIn = appDelegate.isLogin
let navigationController = ToasterNavigationController()
let router = Router(rootViewController: navigationController)
let viewControllerFactory = ViewControllerFactory()
let coordinatorFactory = CoordinatorFactory()
appCoordinator = AppCoordinator(
router: router,
viewControllerFactory: viewControllerFactory,
coordinatorFactory: coordinatorFactory,
isLoggedIn: isUserLoggedIn
)
self.window = UIWindow(windowScene: windowScene)
self.window?.overrideUserInterfaceStyle = .light
self.window?.rootViewController = navigationController
self.window?.makeKeyAndVisible()
appCoordinator?.start()
}
...
자 이제 본격적으로 AppCoordinator의 구현 부분을 살펴보겠습니다.
AppCoordinator는 로그인 여부 (isLoggedIn)에 따라 적절한 초기 화면, 즉 다음 하위 Coordinator를 실행하는 코드를 담고 있습니다.
AppCoordinator가 갖고 있는 프로퍼티로는 화면 전환을 위한 router,
VC 생성과 Coordinator 생성을 담당하는 두 개의 Factory 객체, 사용자의 로그인 상태를 담는 isLoggedIn 등이 있는 것으로 보이네요.
*CoordinatorFactory는 별도로 글에서 설명하지 않습니다. ViewControllerFactory와 마찬가지로 객체 생성의 책임을 전담하는 프로토콜과 그 구현부로 이루어져 있습니다.
Coordinator가 생성되는 두 메서드 (setTabBarRootVC, setLoginRootVC)는 동일한 구성으로 이루어져 있습니다.
해당 Coordinator 객체를 생성하고 -> Coordinator가 종료되었을 때 호출될 onFinish 클로저 블록 (의존성 제거 + 상위 Coordinator 시작)을 구현하고 -> 의존성 추가 + 하위 Coordinator 시작하는 순입니다.
종료되는 시점에 isLoggedIn 값을 재설정하는 부분만 두 Coordinator에서 차이가 난다는 점만 구분하면 될 것 같아요!
final class AppCoordinator: BaseCoordinator {
private let router: RouterProtocol
private let viewControllerFactory: ViewControllerFactoryProtocol
private let coordinatorFactory: CoordinatorFactoryProtocol
private var isLoggedIn: Bool
init(
router: RouterProtocol,
viewControllerFactory: ViewControllerFactoryProtocol,
coordinatorFactory: CoordinatorFactoryProtocol,
isLoggedIn: Bool
) {
self.router = router
self.viewControllerFactory = viewControllerFactory
self.coordinatorFactory = coordinatorFactory
self.isLoggedIn = isLoggedIn
}
override func start() {
isLoggedIn ? setTabBarRootVC() : setLoginRootVC()
}
}
private extension AppCoordinator {
func setLoginRootVC() {
let coordinator = coordinatorFactory.makeLoginCoordinator(
router: router,
viewControllerFactory: viewControllerFactory,
coordinatorFactory: coordinatorFactory
)
coordinator.onFinish = { [weak self, weak coordinator] in
self?.isLoggedIn = true // LoginCoordinator가 종료되는 시점은 회원 가입이 완료된 시점
self?.removeDependency(coordinator)
self?.start()
}
self.addDependency(coordinator)
coordinator.start()
}
func setTabBarRootVC() {
let coordinator = coordinatorFactory.makeTabBarCoordinator(
router: router,
viewControllerFactory: viewControllerFactory,
coordinatorFactory: coordinatorFactory
)
coordinator.onFinish = { [weak self, weak coordinator] in
self?.isLoggedIn = false // TabBarCoordinator가 종료되는 시점은 로그아웃 혹은 회원탈퇴가 이루어진 시점
self?.removeDependency(coordinator)
self?.start()
}
self.addDependency(coordinator)
coordinator.start()
}
}
헷갈리실 분들을 위해 AppCoordinator 동작 흐름을 최종적으로 정리해보죠.
아래와 같이 정리할 수 있을 겁니다!
- 앱이 시작되면 SceneDelegate에 의해 AppCoordinator가 시작됩니다. (appCoordinator?.start() 실행)
- isLoggedIn 상태에 따라 LoginCoordinator 혹은 TabBarCoordinator가 이어서 시작됩니다. (해당 하위coordinator.start() 실행)
- (LoginCoordinator 진입 시) 로그인이 성공하면, LoginCoordinator.onFinish 클로저 호출 -> 로그인 상태 변경 -> 이후 appCoordinator.start() 재실행 -> 탭바 화면으로 수정 진입
- (TabBarCoordinator 진입 시) 자동 로그인 만료, 회원 탈퇴 등으로 계정이 로그아웃되는 경우, TabBarCoordinator.onFinish 클로저 호출 -> 로그인 상태 변경 -> 이후 appCoordinator.start() 재실행 -> 로그인 화면으로 수정 진입
5. LoginCoordinator : 로그인 화면의 화면 흐름 관리하기
이를 바탕으로 이어지는 LoginCoordinator 코드 부분은 매우 간단하게 이루어질 수 있을 것 같습니다.
- start() 부분에서 LoginVC를 router를 사용해 Root로 설정한다.
- 만약 로그인이 완료되면, CoordinatorFinishOutput에서 구현한 onFinish 클로저를 호출한다. -> AppCoordinator로 다시 넘어가는거죠!
final class LoginCoordinator: BaseCoordinator, CoordinatorFinishOutput {
var onFinish: (() -> Void)?
private let router: RouterProtocol
private let viewControllerFactory: ViewControllerFactoryProtocol
private let coordinatorFactory: CoordinatorFactoryProtocol
init(
router: RouterProtocol,
viewControllerFactory: ViewControllerFactoryProtocol,
coordinatorFactory: CoordinatorFactoryProtocol
) {
self.router = router
self.viewControllerFactory = viewControllerFactory
self.coordinatorFactory = coordinatorFactory
}
override func start() {
showLoginVC()
}
}
private extension LoginCoordinator {
func showLoginVC() {
let vc = viewControllerFactory.makeLoginVC()
vc.onLoginCompleted = { [weak self] in
self?.onFinish?()
}
router.setRoot(vc, animated: false)
}
}
여기까지 이해가 잘 되시는지요!
해당 코드에서 로그인이 완료되었다는 알림을 VC에서 넘겨받는 방법으로 저는 클로저를 사용했지만, 클로저가 아니라 Delegate를 사용해도 무방하겠죠?
다음 글에서는 이 부분에서 이어 TabBarCoordinator를 비롯한 앱 전체의 흐름을 Coordinator로 관리하는 코드에 대해 설명해보도록 할게요!
긴 글 따라오시느라 고생 많으셨습니다🙇🏻🙇🏻🙇🏻