MVVM의 한계점
RxSwift + MVVM을 기반으로 프로젝트를 진행하면서 MVVM의 비즈니스 로직을 더욱 분리하는 방법에 대해 고민하게 되었습니다. 그 중 뷰를 생성하고 이동하는 flow logic과 business logic이 모두 ViewModel에 정의되어 있었는데, 이러한 부분은 단일 책임 원칙을 위배한다고 생각하였고, 이를 분리하는 Coordinator 패턴에 대해 학습하게 되었습니다.
Coordinator 패턴
Coordinator 패턴이란 Khanlou라는 개발자가 View Controller의 책임을 분리하면서 navigation flow와 관련된 로직을 하나의 패턴으로 정의하면서 나타났습니다. Coordinator 패턴은 화면 간 전환인 flow logic을 담당하며, Coordinator라는 객체를 통해 navigation flow를 정의하고 화면을 이동합니다. 화면 전환 시 delegate 등을 호출하고, 실제 구현은 Coordinator에서 진행되도록 하였습니다.
화면 전환 로직은 보통 ViewController에서 구현하기 때문에 ViewController의 비대함이 코드의 가독성을 떨어트릴 것이고, 재사용을 할 수 없는 문제점이 있었습니다. 하지만 Coordinator 패턴을 통해 flow logic을 분리함으로써 단일 책임 원칙을 준수함과 코드의 크기를 줄여 코드의 가독성을 향상시키고, flow logic을 재사용함으로써 불필요한 코드 구현을 줄일 수 있다는 장점이 있습니다.
예시
다음과 같은 ViewController의 flow logic과 Coordinator가 있다고 가정해봅시다.
A -> B -> C : XCoordinator
D -> B : YCoordinator
E -> F : ZCoordinator
앱의 flow를 기준(로그인, 회원가입 등)으로 Coordinator를 정의하고, 각각의 Coordinator를 통해 화면 전환을 구현해보고자 합니다.
protocol XCoordinatorProtocol: AnyObject {
func showNextScreenFromA()
func showCScreenFromB()
func showEScreenFromC()
func goBackToA()
func goBackToB()
}
final class XCoordinator: Coordinator, XCoordinatorProtocol, BViewControllerDelegate {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
showAViewController()
}
private func showAViewController() {
let viewController = DIContainer.shared.container.resolve(AViewController.self)!
viewController.coordinator = self
navigationController.pushViewController(viewController, animated: false)
}
// MARK: - XCoordinatorProtocol
func showNextScreenFromA() {
let viewModel = BViewModel()
let viewController = BViewController(viewModel: viewModel)
viewController.delegate = self
navigationController.pushViewController(viewController, animated: true)
}
func showCScreenFromB() {
let viewModel = CViewModel()
let viewController = CViewController(viewModel: viewModel)
viewController.coordinator = self
navigationController.pushViewController(viewController, animated: true)
}
func showEScreenFromC() {
let newNavController = UINavigationController()
let zCoordinator = ZCoordinator(navigationController: newNavController)
zCoordinator.parentCoordinator = self // 부모 코디네이터 참조 저장
childCoordinators.append(zCoordinator)
zCoordinator.start()
navigationController.present(newNavController, animated: true)
}
// ZCoordinator에서 돌아올 때 호출
func childDidFinish(_ child: Coordinator) {
for (index, coordinator) in childCoordinators.enumerated() {
if coordinator === child {
childCoordinators.remove(at: index)
break
}
}
}
func goBackToA() {
navigationController.popViewController(animated: true)
}
// MARK: XCoordinator에서 이동하는 메서드 (CViewController에서 사용됨)
func goBackToB() {
navigationController.popViewController(animated: true)
}
// MARK: - BViewControllerDelegate
func goBackFromB() {
navigationController.popViewController(animated: true)
}
}
final class AViewController: UIViewController {
weak var coordinator: XCoordinatorProtocol?
private let viewModel: AViewModel
private let disposeBag = DisposeBag()
private let titleLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.boldSystemFont(ofSize: 24)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let nextButton: UIButton = {
let button = UIButton(type: .system)
button.backgroundColor = .systemBlue
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
init(viewModel: AViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bind()
}
private func setupUI() {
view.backgroundColor = .systemBackground
view.addSubview(titleLabel)
view.addSubview(nextButton)
NSLayoutConstraint.activate([
titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
titleLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -50),
nextButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
nextButton.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 30),
nextButton.widthAnchor.constraint(equalToConstant: 200),
nextButton.heightAnchor.constraint(equalToConstant: 44)
])
}
private func bind() {
let input = AViewModel.Input(
nextButtonTap: nextButton.rx.tap.asObservable()
)
let output = viewModel.transform(input: input)
output.title
.drive(titleLabel.rx.text)
.disposed(by: disposeBag)
output.buttonTitle
.drive(nextButton.rx.title(for: .normal))
.disposed(by: disposeBag)
// Navigation Command 처리
output.navigationCommand
.drive(onNext: { [weak self] command in
self?.executeNavigation(command: command)
})
.disposed(by: disposeBag)
}
private func executeNavigation(command: AViewModel.NavigationCommand) {
switch command {
case .showNextScreen:
coordinator?.showNextScreenFromA()
case .goBack:
coordinator?.goBackToA()
}
}
}
다음과 같이 XCoordinator가 존재하고, XCoordinator가 AViewController를 포함하고 있다면, delegate 방식을 통해 Coordinator를 위임하고, 화면 전환을 진행할 수 있습니다.
여러 Coordinator를 사용하는 ViewController인 경우
하지만 Coordinator를 사용하다보면 하나의 ViewController가 여러 개의 Coordinator를 사용하는 경우도 존재합니다. 이러한 경우에는 여러 방법으로 해결할 수 있을 것 같은데, 제가 생각한 방식은 1) 해당 ViewController에 대한 Delegate를 독립적으로 선언하고, Coordinator들이 해당 Delegate를 채택하도록 하거나, 2) 타입 변환을 통해 진행하는 방법이 있습니다.
예를 들어 XCoordinator와 YCoordinator가 BViewController를 필요로 할 때, BViewControllerDelegate를 통해 구현할 수 있습니다.
protocol BViewControllerDelegate: AnyObject {
func showCScreenFromB()
func goBackFromB()
}
결국 BViewController에선 CViewController로 이동하거나, 뒤로 가는 행동이 있을 것이기 때문에 이에 대한 프로토콜을 선언해줍니다.
final class XCoordinator: Coordinator, XCoordinatorProtocol, BViewControllerDelegate {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
...
}
final class YCoordinator: Coordinator, YCoordinatorProtocol, BViewControllerDelegate {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController) {
...
}
그 뒤, 이것을 필요로 하는 XCoordinator와 YCoordinator에서 채택하고 메서드에 대한 구현체를 작성해준다면, 사용할 수 있습니다.
이것이 아니라면 다음과 같이 상위 Coordinator로 선언을 한 뒤, 타입을 변환해주어 사용할 수 있습니다.
final class DuplicatedPippoViewController: RootViewController {
// MARK: - Properties
private let pippoImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let faqLabel = UILabel()
private let newPippoLabel = UILabel()
private let faqButton = UIButton()
private let newPippoButton = UIButton()
private let backButton = UIBarButtonItem(image: .actionBack, style: .plain, target: nil, action: nil)
weak var coordinator: Coordinator?
...
faqButton.rx.tap
.bind(with: self) { owner, _ in
if let coordinator = owner.coordinator as? ProfileSettingCoordinatorProtocol {
coordinator.showServiceCenterViewController()
}
else if let coordinator = owner.coordinator as? PippoCoordinatorProtocol {
coordinator.showServiceCenterViewController()
}
}
.disposed(by: disposeBag)
}
faqButton을 보면 여러 Coordinator에서 오는 경우에 대해 다운캐스팅을 통해 각 Coordinator에서 왔을 때의 상황에 대해 정의하고 있습니다.
결론
Coordinator패턴은 flow logic을 담당하고 있으며, Coordinator들의 조합을 통해 화면의 흐름을 만들어낼 수 있습니다. 또한 여러 Coordinator를 필요로 하는 ViewController가 존재한다면, 다양한 방법을 통해 해결할 수 있습니다.
'iOS > 디자인 패턴' 카테고리의 다른 글
[디자인 패턴] Coordinator 패턴 및 적용 (0) | 2025.08.09 |
---|---|
[디자인 패턴] MVVM (Model-View-ViewModel) (0) | 2025.02.09 |
[디자인 패턴] MVC (Model-View-Controller) (0) | 2025.02.06 |