[번역] How to implement flow coordinator pattern (Pavle Pesic)

삭제된 Velog·2025년 1월 21일

UIKit

목록 보기
19/21
post-thumbnail

본 글은 How to implement flow coordinator pattern (Pavle Pesic)를 한국어로 번역하여 옮긴 글입니다.

제가 iOS 개발에 대한 글을 쓰기 시작할 무렵, 잘 다뤄지지 않은 주제의 글을 작성하고 싶었습니다. 그래서 custom transitions using coordinator patternhow to implement delegation pattern using MVVM and coordinators에 대해 글을 작성했습니다.

이 글들에 대한 가장 큰 불만 중 하나는 제가 코디네이터 패턴(coordinator pattern)이 무엇인지 설명하지 않았다는 것이었습니다. 이 주제를 다룬 훌륭한 글들이 많습니다. 예를 들어, Andrey Panov의 Coordinator Essential Tutorial, Krzysztof Zabtocki의 lecture about Good iOS Application Architecture, Denis Walsh의 Flow Coordinator in iOS가 있습니다. 그래서 모두가 이 내용을 이미 익숙하리라 생각했지만, 제 생각이 틀렸네요.

Why do we need coordinators?

내비게이션에 대해 이야기할 때, 일반적으로 우리는 아래 코드를 생각합니다. 게다가, 이것은 좋은 시나리오라는 것을 명심하세요. if 구문, 전환 애니메이션(animating transtions)과 뷰 컨트롤러 간 데이터 전달이 없기 때문입니다.

@IBAction func goToB() {
	let bViewController = UIStoryboard.first.instantiateViewController(withIdentifier: "BViewController") as! BViewController
    bViewController.viewModel = BViewModel()
    self.navigationController?.pushViewController(bViewController, aniamted: true)
}

@IBAction func profile() {
	let profileViewController = UIStoryboard.first.instantiateViewController(withIdentifier: "ProfileViewController") as! ProfileViewController
    profileViewController.viewModel = ProfileViewModel()
    self.navigationController.pushViewController(profileViewController, animated: true)
}

이러한 접근법의 문제가 무엇일까요?

  • 뷰 컨트롤러에 코드가 너무 많음

  • 모든 뷰 컨트롤러가 다른 뷰 컨트롤러를 알고 있음

  • 흐름(flow)을 변경하기 어려움

  • 코드를 재사용하기 어려움

  • 테스트하기 어려움

What is Coordinator?

코디네이터는 앱 내비게이션 흐름을 다루고, 뷰 컨트롤러와 뷰 모델을 구성하는 객체입니다. 뷰 컨트롤러는 코디네이터에 대해 아무것도 알지 못합니다. 뷰 컨트롤러는 내비게이션이 발생할 때 코디네이터에게 이를 알리는 인터페이스만 노출합니다.

로그인, 등록, 비밀번호 변경과 몇 가지 다른 컨트롤러를 포함하는 앱을 만들어보겠습니다. 내비게이션 흐름에는 코디네이터 패턴을 사용하고, 결과를 분석해보겠습니다.

우리 앱에 어떤 코디네이터가 필요한지 살펴보겠습니다.

  • Application coordinator - 모든 앱은 이 코디네이터가 필요합니다. 애플리케이션 코디네이터는 내비게이션 흐름을 시작하고, 새호운 하위 코디네이터를 만듭니다.

  • Auth coordinator

  • Change password coordinator

  • Main corrordinator - 사용자가 로그인할 때 앱의 흐름 처리

  • Profile coordinator - 사용자 프로필을 변경할 때 앱의 흐름 처리

이 스키마를 보면, 우리는 코디네이터가 다른 코디네이터를 생성하고 추가하거나 제거하며, 뷰 컨트롤러를 생성하고 앱 내에서 내비게이션을 처리하는 방법을 알아야 한다는 걸 볼 수 있습니다. 이 로직을 구현하는 데는 정해진 방법이 없습니다. 제 접근 방식은 Andrey Panov의 방법을 따르고 있다는 것을 보게 될 것입니다.

코디네이터를 생성하기 위해 코디네이터 팩토리(coordinator factory)를 사용합니다.


protocol CoordinatorFactoryProtocol {
	func makeAuthCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactory) -> AuthCoordinator
    func makeMainCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactory) -> MainCoordinator
}

final class CoordinatorFactory: CoordinatorFactoryProtocol {

	// MARK: - CoordinatorFactoryProtocol
    
    func makeAuthCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactoryProtocol) -> AuthCoordinator {
    let coordinator = AuthCoordinator(router: router, coordinatorFactory: coordinatorFactory, viewControllerFactory: viewControllerFactory)
    return coordinator
    
    func makeMainCoordinatorBox(router: RouterProtocol, coordinatorFactory: CoordinatorFactoryProtocol, viewControllerFactory: ViewControllerFactoryProtocol) -> MainCoordinator {
    	let coordinator = MainCoordinator(router: router, coordinatorFactory: coordinatorFactory, viewControllerFactory: viewControllerFactory)
        return coordinator
    }
}

베이스 코디네이터(base coordinator)에는 코디네이터를 추가하거나 제거하는 로직이 포함되어 있습니다. 하위 코디네이터(child coordinator) 배열은 활성 상태의 코디네이터를 저장하며, 이 배열은 하위 코디네이터를 강하게 참조합니다. 그렇지 않으면 메모리에서 해제될 수 있습니다.

앱 스키마를 자세히 살펴보세요. 사용자가 프로필 화면(ProfileVC)에 있다면, 프로필 코디네이터는 메인 코디네이터의 하위 코디네이터 변수에 할당됩니다. 그와 동시에, 메인 코디네이터는 애플리케이션 코디네이터의 하위 코디네이터 변수에 할당됩니다. 프로필 화면에서 A 화면(A VC)로 되돌아간다면, 메인 코디네이터는 하위 코디네이터 배열에서 프로필 코디네이터를 제거하고, 더 이상 이를 강하게 참조하지 않을 것입니다.

protocol Coordinator: class {
	func start()
    func start(with option: DeepLinkOption?)
}

class BaseCoordinator: Coordinator {

	// MARK: - Vars & Lets
    
    var childCoordinators = [Coordinator]()
    
    // MARK: - Public methods
    
    func addDependency(_ coordinator: Coordinator) {
    	for element in childCoordinators {
        	if element === coordinator { return }
        }
        childCoordinators.append(coordinator)
    }
    
    func removeDependency(_ coordinator: Coordinator?) {
    	guard let childCoordinators.isEmpty == false, let coordinator = coordinator else { return } 
        
        for (index, element) in childCoordinators.enumerated() {
        	if element === coordinator {
            	childCoordinators.remove(at: index)
                break
            }
        }
    }
    
    // MARK: - Coordinator
    
    func start() {
    	start(with: nil)
    }
    
    func start(with option: DeepLinkOption?) {
    
    }
    
}

뷰 컨트롤러를 생성하기 위해 뷰 컨트롤러 팩토리(view controller factory)를 사용합니다. 이 클래스는 굉장히 단순합니다. 팩토리 패턴을 사용해 뷰 컨트롤러를 생성합니다.

import UIKit

class ViewControlerFactory {

	func instantiateAViewController() -> AViewController {
    	let aVC = UIStoryboard.first.instantiateViewController(withIdentifier: "AViewController") as! AViewController
        aVC.viewModel = AViewModel()
        return aVC
    }
    
    func instantiateBViewController(delegate: BViewControllerDelegate) -> BViewController {
    	let bVC = UIStoryboard.first.instantiateViewController(withIdentifier: "BViewController") as! BViewController
        bVC.viewModel = BViewModl()
        bVC.delegate = delegate
        return bVC
    }
}

라우팅(routing)의 경우, 라우터(router) 객체를 사용합니다. 라우터 객체는 내비게이션 컨트롤러에 대한 참조를 가지고 있기 때문에, 표시(present), 푸시(push)하거나 해제(dismiss)나 뷰 컨트롤러를 팝(pop)할 수 있습니다. 또한, 커스텀 전환도 가능합니다.

이제 코디네이터 객체가 필요한 모든 것을 갖췄으니, 한번 사용해봅시다.

Implementing the coordinator

앱 델리게이트에서 애플리케이션 코디네이터를 초기화하고 최상위 뷰 컨트롤러를 설정하세요. 애플리케이션 코디네이터는 LaunchInstructor 객체를 사용하여 인증 흐름으로 갈지, 메인 흐름으로 갈지 결정합니다. 이는 단지 개념일 뿐이므로, 구현은 하지 않겠습니다. 그러나, 개념은 LaunchInstructor가 인증 토큰을 사용할 수 있는지 확인하고, 사용이 가능하다면 등록이 완료되었다고 판단하는 것입니다.

class AppDelegate: UIResponder, UIApplicationDelegate {
	
    var window: UIWindow?
    var rootViewController: UINavigationController {
    	return self.window!.rootViewController as! UINavigationController
    }
    private lazy var applicationCoordinator: Coordinator = ApplicationCoordinator(router: Router(rootController: self.rootController), coordinatorFactory: CoordinatorFactory())
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    	return true
    }
}
final class ApplicationCoordinator: BaseCoordinator {

	// MARK: - Vars & Lets
    
    private let coordinatorFactory: CoordinatorFactoryProtocol
    private let router: RouterProtocol
    private var launchInstructor = LaunchInstructor.configure()
    private let viewControllerFactory: ViewControllerFactory = ViewControllerFactory()
    
    // MARK: - Coordinator
    
    override func start(with option: DeepLinkOption?) {
    	if option != nil {
        
        } else {
        	switch launchInstructor {
            	case .auth: runAFlow()
                case .main: runMainFlow()
            }
        }
    }
    
    // MARK: - Private methods
    
    private func runAFlow() {
    	let coordinator = self.coordinatorFactory.makeAuthCoordinatorBox(router: self.router, coordinatorFactory: self.coordinatorFactory, viewControllerFactory: self.viewControllerFactory)
        coordinator.finishFlow = { [unowned self, unowned coordinator] in 
        self.removeDependency(coordinator)
        self.lanchInstructor = LaunchInstructor.configure()
        self.start()
        }
    	self.addDependency(coordinator)
    	coordinator.start()
    }
    
    private func runMainFlow() {
    	let coordinator = self.coordinatorFactory.makeMainCoordinatorBox(router. self.router, coordinatorFactory: self.coordinatorFactory, viewControllerFactory: ViewControllerFactory())
		coordinator.finishFlow = { [unowned self, unowned coordinator] in
        	self.removeDependency(coordinator)
            self.launchInstructor = LaunchInstructor.configure()
            self.start()
        }
        self.addDependency(coordinator)
        coordinator.start()
    }
    
    // MARK: - Init
    
    init(router: Router, coordinatorFactory: CoordinatorFactory) {
    	self.router = router
        self.coordinatorFactory = coordinatorFactory
    }
}

앱을 시작하면 로그인이나 회원가입을 할 수 있습니다. 로그인 과정을 완료하면 애플리케이션 코디네이터는 인증 코디네이터를 제거하고, 메인 코디네이터를 시작합니다.

지금까지 우리가 얻은 것은 무엇인가요?

  • 내비게이션 로직이 분리되었기 때문에 뷰 컨트롤러의 코드가 간결해졌습니다.

  • 뷰 컨트롤러는 다른 뷰 컨트롤러를 알지 못합니다.

Reusability

스키마에서 볼 수 있듯이, 비밀번호 변경 코디네이터는 두 곳에 있습니다. 어떤 플로우를 재사용하고 싶다면, 선택한 코디네이터의 새로운 인스턴스를 생성하면 모든 것이 준비됩니다.


final class AuthCoordinator: BaseCoordinator, CoordinatorFinishOutput {
	
    private func showForgotPassword(module: LoginViewController) {
    	let coordinator = self.coordinatorFactory.makeChangePasswordCooridnatorBox(router: self.router, viewControllerFactory: self.viewControllerFactory)
        coordinator.finishFlow = { [unowned self, unowned coordinator] in
        self.removeDependency(coordinator)
        self.router.popToModule(module: module, animated: true)
        }
        self.addDependency(coordinator)
    	coordinator.start()
    }
    
}

final class ProfileCoordinator: BaseCoordinator, CoordinatorFinishOutput {
    
    private func showForgetPassword(module: ProfileViewController) {
        let coordinator = self.coordinatorFactory.makeChangePasswordCoordinatorBox(router: self.router, viewControllerFactory: self.viewControllerFactory)
        coordinator.finishFlow = { [unowned self, weak module, unowned coordinator] in
            self.removeDependency(coordinator)
            self.router.popToModule(module: module , animated: true)
        }
        self.addDependency(coordinator)
        coordinator.start()
    }
    
}

Flow changes

프로덕트 매니저가 다가와서 작은 변경 하나만요. 그저 워크쓰루(walk-through)와 딥링크만 추가하면 돼요.라고 말한 적이 몇 번이나 있나요? 제게 물어보면, 너무 많았습니다. 운이 좋게도 이제 이러한 전환은 훨씬 덜 고통스러울 겁니다.

워크쓰루를 추가하려면 새로운 코디네이터를 생성하고 LaunchInstructor 객체를 변경하면 됩니다. 앱 델리게이트나 뷰 컨트롤러에는 어떤 변경을 가할 필요가 없습니다.

final class ApplicationCoordinator: BaseCoordinator {
	
    // MARK: - Coordinator
    
    override func start(with option: DeepLinkOption?) {
    	if option != nil {
        
        } else {
        	switch launchInstructor {
            	case .onboarding: runOnboardingFlow()
                case .auth: runAFlow()
                case .main: runMainFlow()
            }
        }
    }
    
    // MARK: - Private methods
    
    private func runOnboardingFlow() {
    	let coordinator = self.coordinatorFactory.makeWalkthroughCoordinatorBox(router: self.router, viewControllerFactory: ViewControllerFactory())
        coordinator.finishFlow = { [unowned self, unowned coordinator] in
        	self.removeDependency(coordinator)
            self.launchInstructor = LaunchInstructor.configure()
            self.start()
        }
        self.addDependency(coordinator)
        coordinator.start()
    }
    
}

프로젝트에 딥링크와 푸시 알림을 추가하는 건 매우 고통스러울 수 있습니다. 그러나, 플로우 코디네이터는 이를 위한 해결책도 가지고 있습니다. 코디네이터를 시작할 때, startWithOption 메서드를 사용하거나 기존 메서드를 재사용하세요.

override func start(with option: DeepLinkOption?) {
	if let option = option {
    	switch option {
        case .onboarding: runOnboardingFlow()
        case .signUp: runAuthFlow()
        default: childCoordinators.forEach { coordinator in
      		coordinator.start(with: option)										
        	}
        }
    } else {
    	switch instructor {
        case .onboarding: runOnboardingFlow()
        case .auth: runAuthFlow()
        case .main: runMainFlow()
        }
    }
}

Testing

이제 뷰 컨트롤러와 뷰 모델이 더 깔끔해지고, 재사용성이 높아졌으며, 의존성이 크게 줄었습니다. 그래서 테스트를 작성하기가 더 쉬워졌습니다. 하지만, 새로운 주제가 될 수 있기 때문에 이 글에서는 테스트를 다루지 않겠습니다.

Conclusion

플로우 코디네이터를 사용하여 뷰 컨트롤러에서 내비게이션 로직을 제거했습니다. 코드가 더 깔끔해지고, 재사용성이 높아졌으며, 변경에 더 유연해지고 테스트하기 쉬워졌습니다. 처음에는 작성해야 할 코드가 너무 많아 보일 수 있습니다. 그러나 BaseCoordinator, Router, LaunchInstructor와 같은 코드는 다른 프로젝트에서도 재사용할 수 있기 때문에, 처음 한 번만 작업량이 많을 뿐입니다. 몇몇 사람들은 너무 복잡하다고 말할 수 있지만, 저는 그렇게 생각하지 않습니다. 심지어 그 말이 맞더라도, 한번 시도해보세요. 그 이점은 놓치기에는 너무 아깝습니다. 아래는 예제 코드입니다.

Resources

읽어주셔서 감사합니다:)

이 글이 유용하셨고 비슷한 주제를 더 보고 싶으시다면, 박수를 보내주시고, 공유와 팔로우를 해주세요. 댓글도 남겨주세요.

profile
rlarjsdn3.github.io

0개의 댓글