Coordinator Pattern

koi·2022년 11월 11일
post-thumbnail

MVC-C, MVVM-C 등 아키텍쳐 패턴 뒤에 붙은 C가 바로 코디네이터 패턴을 뜻하는데요.

들어본 적은 있지만, 프로젝트에 적용하려니 막상 제대로 공부한 적이 없어서 이번 포스팅에서는 코디네이터 패턴에 대해 정리해보려고 합니다 👀

코디네이터 패턴이 나오게된 배경

우선 코디네이터 패턴은 2015년에 Soroush Khanlou가 고안했습니다.
왜 코디네이터 패턴이 나오게 됐을까요?

꽉 찬 AppDelegate

앱 델리게이트는 앱의 시작점이고, OS와 앱 하위 시스템 사이에서 메세지를 전달하기 때문에 앱 델리게이트에 책임을 과도하게 떠넘기기 쉽습니다!


너무 많은 책임

앱 델리게이트와 마찬가지로 뷰 컨트롤러도 너무 많은 책임을 갖게 됩니다.
흔히 말하는 Massive View Controller가 되기 때문입니다.

  1. Model-View Binding
  2. Subview Allocation
  3. Data Fetching
  4. Layout
  5. Data Transformation
  6. Navigation Flow
  7. User Input
  8. Model Mutation
  9. 그 외의 수많은 것들...

스무스한 흐름

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
	let object = self.dataSource[indexPath.row]
	let detailViewController = DetailViewController() 
	self.navigationController?.pushViewController(detailViewController, animated: true)
}

아주 흔하게 쓰이지만.. 저자가 garbage라고 칭하는 화면 전환 코드를 살펴봅시다!


let object = self.dataSource[indexPath.row]

첫번째 줄에서 dataSource는 뷰컨의 논리적인 자식이기 때문에 딱히 문제가 되지 않습니다.


let detailViewController = DetailViewController() 

두번째 줄부터 문제점이 발생합니다.
현재 뷰컨트롤러가 다음에 나타날 뷰컨에 대해 알고있다는 점입니다.
뷰컨이 다음 흐름에 대해서 알고있다는 것이 너무 TMI라고 하네요.


self.navigationController?.pushViewController(detailViewController, animated: true)

특히 세번째 코드의 자식 뷰컨이 부모 뷰컨에게 지시를 내리는 부분이 궤도를 완전히 이탈하는 부분입니다.

프로그래밍에 있어서 자식은 부모가 누군지도 몰라야한다고 주장합니다.


그래서 뷰컨트롤러를 더 높은 수준의 객체로 관리할 수 있는
코디네이터 패턴을 제안한 것입니다.

Coordinator Pattern

  1. AppDelegate(SceneDelegate)는 AppCoordinator를 유지
  2. 모든 Coordinator에는 하위 Coordinator가 있음

코디네이터 패턴의 기본 컨셉은 위와 같습니다.


코디네이터 패턴에 대한 예시는 블로그 글을 참고했습니다!

Coordinator 프로토콜 설정

일단 Coordinator의 역할을 프로토콜을 통해 정의해줍니다.
일반적으로 아래와 같이 구성됩니다. (세부사항은 변경될 수 있습니다.)

protocol Coordinator: NSObject {
	// 📍 자식 코디네이터를 관리
	var childCoordinators: [Coordinator] { get set }
    // 📍 뷰컨을 제어할 수 있는 내비게이션 컨트롤러
	var navigationController: UINavigationController { get set }
    // 📍 앱을 관리할 준비가 됐을 때 호출하는 start 메서드
	func start()
}

Coordinator 프로토콜 구현

이제 이 프로토콜에 대해 구현합니다.

class MainCoordinator: Coordinator {
	var childCoordinators = [Coordinator]()
	var navigationController: UINavigationController

	init(navigationController: UINavigationController) {
    	self.navigationController = navigationController
	}

	func start() {
    	// 📍 띄울 뷰컨을 생성합니다.
		let vc = ViewController.instantiate()
        // 📍 뷰컨 내부 코디네이터에 자신을 할당합니다.
		vc.coordinator = self  
		navigationController.pushViewController(vc, animated: false)
	}
}

이제 구체 타입 설정이 끝났으니 AppDelegate(혹은 SceneDelegate)에서도 이를 활용해 rootViewController를 설정해줍니다.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
	// 📍 코디네이터를 활용해 루트 뷰컨 설정
    let navigationController = UINavigationController()
    coordinator = MainCoordinator(navigationController: navigationController)
    coordinator?.start()
    
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = navigationController
    window?.makeKeyAndVisible()

	return true
}

ViewController에서 Coordinator 사용

이제 viewController 쪽에서 Coordinator를 보유하도록 하고 coordinator 내부에 필요한 동작을 정의하면 됩니다.

class MainCoordinator: Coordinator {
	func buySubscription() {
    	let vc = BuyViewController.instantiate()
   		vc.coordinator = self
    	navigationController.pushViewController(vc, animated: true)
    }
    
    func createAccount() {
    	let vc = CreateAccountViewController.instantiate()
		vc.coordinator = self
		navigationController.pushViewController(vc, animated: true)
	}
}
class ViewControlelr: UIViewContoller {
	/// ...
	var coordinator: Coordinator?
    @IBAction func buyTapped(_ sender: Any) {
    	coordinator?.buySubscription()
    }

	@IBAction func createAccount(_ sender: Any) {
    	coordinator?.createAccount()
    }
    // ...
}

특정 동작이 필요할 때 Coordinator에 특정 명령 보내 처리하도록 합니다.
여기까지 가장 기본적인 Coordinator를 생성하고 사용하는 과정이었습니다.


Child Coordinator

코디네이터 내에 child Coordinator가 분명 있었는데 아직까지 사용되지 않았습니다.

앞서 살펴본 방식처럼 하나의 Coordinator에서 child Coordinator 없이 모든 화면을 관리한다면, 각 뷰컨들은 불필요한 다른 화면 전환의 메서드까지 모두 갖게됩니다.

이는 상당히 비효율적이기 때문에 Child Coordinator 사용해서 현재 필요로하는 화면 전환 메서드만 보유할 수 있습니다!


먼저 Child Coordinator가 되어줄 Coordinator를 먼저 생성합니다.

기존 메인 코디네이터에서 buySubscription() 메소드 안에서 직접 뷰컨트롤러를 생성하던 부분을 자식 코디네이터에 구현한다고 생각하면 됩니다!

class BuyCoordinator: Coordinator {
	var childCoordinators = [Coordinator]()
	var navigationController: UINavigationController
    // 📍 부모 코디네이터에 대한 약한 참조 (retain cycle 방지)
	weak var parentCoordinator: MainCoordinator?

	init(navigationController: UINavigationController) {
		self.navigationController = navigationController
	}

	func start() {
    	// 📍 메인 코디네이터 내에 있던 뷰컨트롤러 생성 부분을 이곳에 구현
		let vc = BuyViewController.instantiate()
		vc.coordinator = self
		navigationController.pushViewController(vc, animated: true)
	}
}
class MainCoordinator: Coordinator {
	// ...
	func buySubscription() {
    	// 📍 여기서 직접 하위 뷰컨트롤러를 생성하지 않고, 코디네이터를 생성해서 전환합니다.
    	let child = BuyCoordinator(navigationController: navigationController)
        // 📍 부모 코디네이터를 자신으로 할당
        child.parentCoordinator = self
        // 📍 자식 코디네이터 배열에 추가
        childCoordinators.append(child)
        // 📍 화면에 자식 뷰컨 띄웁니다.
        child.start()
    }
}

앞서 살펴본 방식과는 다르게 화면 전환에서 coordinator만을 사용하게 되었습니다!

하지만 이대로라면 child Coordinator가 담당하는 화면이 사라진 뒤에도 childCoordinators에 child Coordinator가 남게 됩니다.

그래서 parent coordinator에게 이를 알리고 child coordinator 를 지워주어야 합니다.

방법이 여러가지가 있습니다!

1️⃣ UIViewController의 viewDidDisappear() 사용

해당 child coordinator를 활용 중인 ViewController의 viewDidDisappear에서 coordinator의 didFinishBuying이라는 메서드를 호출하도록 하는 방법입니다.

말로 풀어쓰면 어렵게 느껴지는데, 코드로 보면 간단해요.

class BuyViewController: UIViewController {
	weak var coordinator: BuyCoordinator?
    // ...
    override func viewDidDisappear(_ animated: Bool) {
    	super.viewDidDisappear(animated)
        // 📌 사라지고난 후, 뷰컨의 코디네이터의 메소드 호출
        coordinator?.didFinishBuying()
    }
    // ...
}
class BuyCoordinator: Coordinator {
	weak var parentCoordinator: MainCoordinator?
	// ...
    // 📌 뷰컨이 사라지면서 호출한 메소드
	func didFinishBuying() {
    	// 📌 부모 코디네이터 배열에서 자신을 없애는 메소드 호출
    	parentCoordinator?.childDidFinish(self)
	}
	// 생략
}
class MainCoordinator: Coordinator {
	// ...
    func childDidFinish(_ child: Coordinator?) {
        for (index, coordinator) in childCoordinators.enumerated() {
            // 📍 참조 비교 연산자 === 를 사용하기 위해 Coordinator 를 클래스 전용 프로토콜(class-only protocol) 로 만든 것
            if coordinator === child {
                childCoordinators.remove(at: index)
                break
            }
        }
    }
	// ...
}

굉장히 줄줄이 비엔나같은 느낌으로 화면에서 사라진 코디네이터를 삭제하는 모습입니다..ㅎ

뷰컨 viewDidDisappear
→ 뷰컨의 코디네이터 didFinishBuying
→ 뷰컨의 코디네이터의 부모 childDidFinish

2️⃣ UINavigationControllerDelegate의 didShow() 사용

화면 전환이 발생할 때마다 delegate method를 통해 뷰컨이 남아있는지 확인하고, childDidFinish 메서드를 실행시키는 구조입니다.

너무 길어질 것 같아 따로 코드를 적지는 않겠습니다!

느낀 점

  • 기본적인 컨셉은 간단한데 구현 방식이나 일어날 수 있는 상황이 굉장히 다양해서 생각보다 복잡하다.
  • 화면 전환을 하기 위해서 클래스와 파일이 많아져 비효율적일 수도 있다.
  • 역할 분리가 확실해진다.
  • 뷰컨트롤러가 많고, 화면 전환이 많이 이루어지는 경우에 유용할 것 같다.

사실 개념정도만 정리하려고 했는데 너무 길어져버렸네요.
결론적으로 뷰 전환이 많이 이루어지는 프로젝트라면 써볼만 하다고 생각합니다 :)

profile
Don't think, just do 🎸

0개의 댓글