[iOS-1] Coordinator 패턴을 사용한 화면 전환 관리
Coordinator 패턴이란?
Coordinator 패턴은 화면 전환 로직을 ViewController로부터 분리하여 별도의 객체(Coordinator)에게 위임하는 아키텍처 패턴입니다. 이 패턴을 통해 ViewController는 UI 표시와 사용자 입력 처리에만 집중할 수 있고, 복잡한 화면 전환과 앱의 전체 흐름은 Coordinator가 관리하게 됩니다.
Coordinator 패턴 구현
Coordinator 프로토콜을 정의하여 각 화면을 관리하는 Coordinator들이 반드시 포함해야할 구현을 강제합니다.
/// Coordinator 패턴을 구현하기 위한 기본 프로토콜
/// 앱의 화면 흐름을 관리하고 뷰 컨트롤러 간 전환 로직을 캡슐화합니다.
protocol Coordinator: AnyObject {
/// 현재 Coordinator가 관리하는 자식 Coordinator들의 배열
/// 계층적 흐름 관리와 메모리 관리를 위해 사용됩니다.
var childCoordinators: [Coordinator] { get set }
/// Coordinator가 화면 전환에 사용하는 네비게이션 컨트롤러
/// 뷰 컨트롤러 푸시, 팝 등의 전환을 담당합니다.
var navigationController: UINavigationController { get set }
/// Coordinator의 화면 흐름을 시작하는 메서드
/// 일반적으로 첫 번째 뷰 컨트롤러를 네비게이션 스택에 추가합니다.
func start()
}
각 계층의 화면 Flow를 관리하는 Coordinator들은 위에서 정의한 프로토콜을 채택하고 구현함으로써 화면 계층과 화면간 전환에 대한 실제 방법을 구현합니다.
/// 앱의 최상위 Coordinator
/// 각 화면은 담당 Coordinator들이 관리하고 여기서는 Coordinator들을 관리
class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
private let window: UIWindow
func start() {
window.rootViewController = navigationController
window.makeKeyAndVisible()
// 로그인 상태에 따라 적절한 flow 보여주기
if isUserLoggedIn() {
showMainFlow()
} else {
showAuthFlow()
}
}
...
}
/// 인증 흐름 관리
class AuthCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
weak var delegate: AuthCoordinatorDelegate?
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let loginVC = LoginViewController()
loginVC.coordinator = self
// 애니메이션과 함께 stack 교체
navigationController.setViewControllers([loginVC], animated: true)
}
func goToRegister() {
let registerVC = RegisterViewController()
registerVC.coordinator = self
navigationController.pushViewController(registerVC, animated: true)
}
func didFinishLogin() {
delegate?.didFinishAuth(self)
}
func didFinishRegister() {
delegate?.didFinishAuth(self)
}
}
Coordinator 패턴에서는 ViewController는 화면 전환에 대한 책임을 모두 Coordinator에게 전가합니다. 따라서 CoordinatedViewController라는 프로토콜을 정의하고 내부 프로퍼티로 coordinator를 두어서 모든 ViewController들이 coordinator를 통해 화면 전환을 하도록 강제합니다.
// 모든 화면에 공통으로 적용되는 기본 프로토콜
protocol CoordinatedViewController: UIViewController {
associatedtype CoordinatorType: Coordinator
var coordinator: CoordinatorType? { get set }
}
각 ViewController에 요구되는 화면 전환 종류를 명시하는 프로토콜을 정의하고 여기에 CoordinatedViewController를 채택과 동시에 where절을 통해 사용되어야할 구체 coordinator 타입을 명시합니다. 그리고 ViewController에게 이 프로토콜을 채택 시킴으로써 화면 전환을 모두 coordinator에게 넘기도록 합니다.
// 인증 화면 관련 프로토콜
protocol LoginViewControllerProtocol: CoordinatedViewController where CoordinatorType == AuthCoordinator {
func didTapLoginButton()
func didTapRegisterButton()
}
class LoginViewController: UIViewController, LoginViewControllerProtocol {
weak var coordinator: AuthCoordinator?
화면 전환 방식
1. 부모-자식 관계 (Parent-Child): 상위 Coordinator가 하위 Coordinator를 생성하고 관리하는 계층적 구조입니다.
AppCoordinator (최상위)
├── AuthCoordinator
└── MainCoordinator
├── ChatCoordinator
└── ProfileCoordinator
/// 앱의 최상위 Coordinator
/// 각 화면은 담당 Coordinator들이 관리하고 여기서는 Coordinator들을 관리
class AppCoordinator: Coordinator {
...
func start() {
window.rootViewController = navigationController
window.makeKeyAndVisible()
// 로그인 상태에 따라 적절한 flow 보여주기
if isUserLoggedIn() {
showMainFlow()
} else {
showAuthFlow()
}
}
...
private func showAuthFlow() {
let authCoordinator = AuthCoordinator(navigationController: navigationController)
authCoordinator.delegate = self
childCoordinators.append(authCoordinator)
authCoordinator.start()
}
private func showMainFlow() {
let mainCoordinator = MainCoordinator(navigationController: navigationController)
childCoordinators.append(mainCoordinator)
mainCoordinator.start()
}
}
AppCoordinator는 로그인 상태에 맞는 하위 coordinator를 생성하고 start( ) 메서드를 호출하면서 적절한 화면으로 이동합니다.
2. 위임 (Delegation): 주로 하위 Coordinator가 이벤트를 상위 Coordinator에게 알릴 때 사용합니다.
class AppCoordinator: Coordinator {
...
init(window: UIWindow) {
self.window = window
self.navigationController = UINavigationController()
}
...
private func showMainFlow() {
let mainCoordinator = MainCoordinator(navigationController: navigationController)
childCoordinators.append(mainCoordinator)
mainCoordinator.start()
}
}
/// AuthCoordinatorDelegate 채택 및 구현으로 AuthCoordinator에서 이벤트 발생시 AppCoordinator에서 동작가능
extension AppCoordinator: AuthCoordinatorDelegate {
func didFinishAuth(_ coordinator: AuthCoordinator) {
// Child coordinator에서 AuthCoordinator 제거 후 메인 화면으로 이동
childCoordinators = childCoordinators.filter { $0 !== coordinator }
showMainFlow()
}
}
/// Delegate 패턴을 활용하여 상위, 하위 컴포넌트간의 이벤트 발신, 수신
protocol AuthCoordinatorDelegate: AnyObject {
func didFinishAuth(_ coordinator: AuthCoordinator)
}
/// AuthCoordinator에서 이벤트 감지시 delegate(AppCoordinator)를 이용한 화면 전환
class AuthCoordinator: Coordinator {
...
weak var delegate: AuthCoordinatorDelegate?
...
func didFinishLogin() {
delegate?.didFinishAuth(self)
}
func didFinishRegister() {
delegate?.didFinishAuth(self)
}
}
Delegate 패턴은 동작을 수신하는 컴포넌트와 실제 동작을 수행해야하는 컴포넌트가 분리되어 있을 때 사용하는 통신 방식입니다.
위의 경우 회원 가입 완료 버튼을 관리하는 것은 AuthCoordinator인데, 회원 가입 완료 버튼이 눌렸을 때 메인 흐름으로 전환시키 위해서는 AppCoordinator가 동작해야합니다. 따라서 AuthCoordinatorDelegate를 정의한 다음 이를 AppCoordinator에 채택시킵니다. 이후 AppCoordinator에서 AuthCoordinator를 생성할 때 자기 자신을 delegate(대리자)로 설정함으로써 AuthCoordinator에서 동작이 일어났을 때 AppCoordinator의 자원(프로퍼티, 메서드)를 활용하여 이에 대한 구현을 제공할 수 있습니다.
3. 직접 참조 (Direct Reference): ViewController가 자신의 Coordinator 메서드를 직접 호출하는 방식입니다.
class ProfileViewController: UIViewController, ProfileViewControllerProtocol {
weak var coordinator: ProfileCoordinator?
...
private let editProfileButton = UIButton(type: .system)
...
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
...
// 버튼 설정
editProfileButton.addTarget(self, action: #selector(editProfileButtonTapped), for: .touchUpInside)
}
@objc private func editProfileButtonTapped() {
didTapEditProfileButton()
}
...
func didTapEditProfileButton() {
coordinator?.showEditProfile()
}
...
}
editProfileButton에 대해 사용자 액션이 발생하면 addTarget을 통해 매핑된 @objc 메서드 내부에서 자신이 weak 참조하는 coordinator를 사용해 화면을 전환합니다.
4. 알림 (Notification):여러 컴포넌트에 동시에 알림이 필요한 전역 이벤트에 사용됩니다.
// ProfileViewController에서 로그아웃 요청
func didTapLogoutButton() {
// 알림창 표시 후
coordinator?.logout()
}
// ProfileCoordinator에서 로그아웃 처리
func logout() {
NotificationCenter.default.post(name: NSNotification.Name("LogoutNotification"), object: nil)
}
// SceneDelegate에서 로그아웃 알림 관찰
private func setupLogoutObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleLogout),
name: NSNotification.Name("LogoutNotification"),
object: nil
)
}
@objc private func handleLogout() {
if let window = self.window {
appCoordinator = nil
appCoordinator = AppCoordinator(window: window)
appCoordinator?.start()
}
}
로그아웃은 앱 전체에 영향을 미치는 이벤트이므로 NotificationCenter를 통해 처리됩니다. LogoutNotification은 프로필 화면에서 로그아웃 시 발생하며, SceneDelegate가 이를 감지하여 AppCoordinator를 재설정합니다.
TalkTalk 앱의 전체 화면 흐름
- 앱 시작 시 SceneDelegate가 AppCoordinator를 생성하고 시작합니다.
- AppCoordinator는 로그인 상태에 따라 인증 흐름 또는 메인 앱 흐름을 시작합니다.
- 인증 흐름에서 사용자가 로그인 또는 회원가입을 완료하면 AuthCoordinator는 delegate를 통해 AppCoordinator에게 알립니다.
- AppCoordinator는 메인 앱 흐름을 시작하고, MainCoordinator가 탭 바 컨트롤러를 설정합니다.
- 메인 앱에서는 ChatCoordinator와 ProfileCoordinator가 각 탭의 화면 흐름을 관리합니다.
- 사용자가 로그아웃하면 알림이 발생하고, SceneDelegate가 이를 감지하여 AppCoordinator를 재설정합니다.