기존 Coordinator에서는 몇 가지 문제점들과 불편한 점들을 겪고 이들을 리팩토링의 필요성을 느꼈다. 

이전 버전의 Coordinator는 여기서 확인해볼 수 있다. 

https://seokyoungg.tistory.com/100

 

[Design Pattern] Coordinator 패턴

"Coordinator패턴"은 ViewController로부터 화면 전환의 부담을 줄여주기 위한 패턴이다. 이를 통해ViewController 간의 결합도를 낮춰주게 된다.   만약 ViewController에서 다음과 같이 화면 전환 로직을 담

seokyoungg.tistory.com

 

결과물을 SPM을 통해 배포했기 때문에, 사용법 및 코드들은 아래서 확인해 볼 수 있다. 

https://github.com/jungseokyoung-cloud/Coordinator

 

GitHub - jungseokyoung-cloud/Coordinator: Coordinator 패턴

Coordinator 패턴 . Contribute to jungseokyoung-cloud/Coordinator development by creating an account on GitHub.

github.com

 

 

 

기존 Coordinator 구조의 문제점

기존 Coordinator 구조

기존 Coordinator는 다음과 같은 BaseCoordinator가 존재하여, 필요한 곳에서 이를 채택해 활용했다. 

/// 화면 전환 로직 및, `ViewController`와 `ViewModel`생성을 담당하는 객체입니다.
public protocol Coordinating: AnyObject {
  var navigationController: UINavigationController? { get set }
  var children: [Coordinating] { get }
  
  func start(at navigationController: UINavigationController?)
  func stop()
  func addChild(_ coordinator: Coordinating)
  func removeChild(_ coordinator: Coordinating)
}

open class Coordinator: Coordinating {
  public var navigationController: UINavigationController?
  public final var children: [Coordinating] = []
  
  public init() { }
  
  public init(_ navigationController: UINavigationController?) {
    self.navigationController = navigationController
  }
  
  open func start(at navigationController: UINavigationController?) {
    self.navigationController = navigationController
  }
  ...
}

 

활용 

실제 사용방법은 다음과 같다. 

우선, Coordinator를 채택해주게 된다. 

import UIKit
import Core

final class SignUpCoordinator: Coordinator, SignUpCoordinatable {
  ...
  
  override func start(at navigationController: UINavigationController?) {
    super.start(at: navigationController)
    attachEnterEmail()
    // navigationController.pushViewController(viewController)
  }
}

 

이후, 시작되었을 때의 원하는 동작을 start(at:)메서드에 구현해 주면 된다.

이때, SignUpCoordinator와 같이 Coordinator의 구현체들을 Internal타입이기 때문에, 외부 모듈로 노출되지 않게 된다. 

 

활용 - 부모 coordinator

앞서 말했은 Coordinator들의 구현체들은 Internal타입이기 때문에, 부모 Coordinator에선 이를 Coordinating이라는 프로토콜 타입으로 알고 있게 된다. 

final class LogInCoordinator: Coordinator {
  ...
  
  private var signUpCoordinator: Coordinating?

 

부모에선 이를 통해 Routing을 진행하고 싶다면,  아래와 같이 진행한다.

// MARK: - SignUp
func attachSignUp() {
  guard signUpCoordinator == nil else { return }

  let coordinater = signUpContainable.coordinator(listener: self)
  addChild(coordinater)
  
  self.signUpCoordinator = coordinater
  // 이를 호출하면, navigationController에 띄어지게 됨.
  coordinater.start(at: self.navigationController) 
}

우선, 중복되어 View가 Present되는 것을 방지하기 위해 guard문을 통해 확인해 준다. 

 

최종적으로 자식 Coordinator의 start(at:)메서드를 호출한다. 

 

문제점

앞선, 구조로 프로젝트에서 4~5개월간 사용해 보면서 다음과 같은 문제점을 느끼게 되었다. 

  • 한 가지 방식으로만 Routing이 가능한 문제 
  • 사용하는 곳이 아닌 사용되는 Coordinator에서 Routing방식을 결정하는 문제점 
  • UIKit에 대한 의존성 문제 

 

한 가지 방식으로만 Routing할 수 있는 문제점

우선, 가장 큰 문제점은 한가지 방식으로만 Routing 할 수 있다는 문제점이다. 

final class SignUpCoordinator: Coordinator, SignUpCoordinatable {
  ...
  
  override func start(at navigationController: UINavigationController?) {
    super.start(at: navigationController)
    attachEnterEmail()
    // navigationController.pushViewController(viewController)
  }
}

 

 

앞서 말했는 부모는 자식을 Coordinating으로만 알고 있게 된다. 

final class LogInCoordinator: Coordinator {
  ...
  
  private var signUpCoordinator: Coordinating?

 

따라서, 다른 Routing방식을 위해 메서드를 추가가 불가능하다. 

그렇다고, Interafce에 Routing 방식마다 메서드를 추가해 놓자니, 구체 타입에서 구현을 하지 않게 되는 메서드들이 생기게 된다. 즉, LSP에 위반하게 된다. 

 

사용하는 곳이 아닌 사용되는 Coordinator에서 Routing 방식을 결정하는 문제점

두 번째는 사용하는 곳이 아닌 사용되는 Coordinator에서 아래와 같이 Routing방식을 결정하게 된다. 

override func start(at navigationController: UINavigationController?) {
  super.start(at: navigationController)
  
  navigationController?.present(viewController, animated: true)
}

 

다음과 같은 예시를 들어보자. 

'LogIn' 화면에서 "회원가입하기" 버튼을 누르게 되면 'SignUp'으로 넘어가게 된다.

 

이때, Routing방식은 실제 해당 View를 띄우는 'Login'에서 결정하는 것이 아닌 'SignUp'에서 결정 나게 된다. 

 

해당 문제점은 앞서 말한, "한가지 방법으로만 Routing이 가능한 문제점"과 더불어, 결론적으로 View의 재사용성을 저하시키게 된다.

 

또한, 'Login'에서 'SignUp'이 띄어지는 방식이 modal 방식으로 변경되었다고 가정해 보자.

그렇다면,  'Login'을 수정하는 것이 아닌, 'SignUp'을 수정해야 한다. 

즉, 변경의 범위가 'SignUp'까지 확대되는 문제가 발생한다.

 

추가적으로 UISegmentControl에서 segment가 바뀔 때마다 ViewController가 변경된다고 가정해 보자. 

그렇다면, UISegmentControl를 가지고 있는 ViewController에선 각 segment별로 ViewController를 알고 있어야 한다. 

하지만, 사용되는 Coordinator에서 Routing방식을 결정하기 때문에, ViewController를 알지 못한다.

 

지금까지는 이를 해결하기 위해 임시방편으로 Coordinator의 ViewController프로퍼티를 다음과 같이 Internal로 구현해 놓았다. 

// 부모
final class ChallengeCoordinator ... {
 func attachSegments() {
   let feedSegmentCoordinator = feedSegmentContainer.coordinator(listener: self)
   // 부모는 Coordinating으로 알고 있기에 타입 캐스팅이 필요함.
   guard let feedSegmentCoordinator = feedSegmentCoordinator as? FeedSegmentCoordinator else { return }
   self.feedSegmentCoordinator = feedSegmentCoordinator
   viewController.attachViewControllers(feedSegmentCoordinator.viewController)
 }
}

// 자식
final class FeedSegmentCoordinator ... {
  let viewController: FeedSegmentViewController
  ...
}

 

만약, 부모 자식이 모두 같은 모듈에 존재한다면 internal로 해결이 가능하지만, 다른 모듈일 경우엔 불가능하다. 

viewController 프로퍼티를 public으로 변경한다 해도, Coordinator는 Internal타입이기 때문이다.

 

UIKit에 대한 의존성 문제

마지막으로는 Coordinator에서 UINavigationController로 인해 UIKit에 대한 의존성이 생긴다는 문제점이다. 

 

리팩토링 

앞선, 문제점들을 다시 정리해 보자. 

  • 한 가지 방식으로만 routing 할 수 있는 문제
  • 사용되는 Coordinator에서 Routing방식을 결정하는 문제
  • UIKit에 대한 의존성

이를 해결하기 위해, 가장 핵심은 ViewController를 외부에서 접근할 수 있게 하는 것이다. 이렇게 되면, 부모 Coordinator에서 어떤 방식으로 Routing 할지 결정할 수 있어 재사용성이 증가하게 된다. 

 

UIKit에 대한 의존성

ViewController를 외부로 분리하기에 앞서, UIKit에 대한 의존성을 제거해 보자. 

이를 달성하기 위해 다음과 같은 2개의 타입들을 추가했다. 

  • ViewControllerable: UIViewController를 랩핑 한 프로토콜 타입이다. 
  • NavigationControllerable: UINavigationController를 랩핑한 객체이다. 

ViewControllerable

이는 RIBs 아키텍쳐의 ViewControllerable을 참고했다.

public protocol ViewControllerable: AnyObject {
  var uiviewController: UIViewController { get }
}

public extension ViewControllerable where Self: UIViewController {
  var uiviewController: UIViewController { return self }
}

 

하지만, 이는 다음과 같은 방식으로 present를 하기 때문에 코드가 복잡해진다는 단점이 존재한다. 

let viewController = coordinator.viewControllerable.uiViewController 
viewController.modalPresentationStyle = .fullScreen 
self.viewControllerable.present(viewController, animated: true)

 

따라서, 편리성을 위해 extension을 통해 다음과 같이 구현해 주었다. 

// MARK: - Present Methods
public extension ViewControllerable {
  func present(
    _ viewControllable: ViewControllerable,
    animated: Bool,
    completion: (() -> Void)? = nil
  ) {
    self.uiviewController.present(
      viewControllable.uiviewController,
      animated: animated,
      completion: completion
    )
  }
  
  func present(
    _ viewControllable: ViewControllerable,
    animated: Bool,
    modalPresentationStyle: UIModalPresentationStyle,
    completion: (() -> Void)? = nil
  ) {
    viewControllable.uiviewController.modalPresentationStyle = modalPresentationStyle
    self.uiviewController.present(
      viewControllable.uiviewController,
      animated: animated,
      completion: completion
    )
  }
...
}

 

또한, present뿐만이 아닌 push방식을 고려하여 다음과 같이 추가해 주었다. 

// MARK: - Push Methods
public extension ViewControllerable {
  func pushViewController(_ viewControllable: ViewControllerable, animated: Bool) {
    if let nav = self.uiviewController as? UINavigationController {
      nav.pushViewController(viewControllable.uiviewController, animated: animated)
    } else {
      self.uiviewController
        .navigationController?
        .pushViewController(viewControllable.uiviewController, animated: animated)
    }
  }

 

NavigationControllerable

만약, Coordinator에서 다음 View를 Navigation으로 감싸 present하고 싶은 경우 UINavigationController를 생성해주어야 한다. 

따라서, 이를 랩핑 해주는 NavigationControllerable을 추가해 주었다. 

public extension ViewControllerable where Self: NavigationControllerable {
  var uiviewController: UIViewController { return self.navigationController }
}

@MainActor
public class NavigationControllerable: ViewControllerable {
  public let navigationController: UINavigationController
  
  // MARK: - Initializers
  public init(navigationController: UINavigationController) {
    self.navigationController = navigationController
  }
  
  public init(_ rootViewControllerable: ViewControllerable) {
    self.navigationController = UINavigationController(rootViewController: rootViewControllerable.uiviewController)
  }
  
  public init() {
    self.navigationController = UINavigationController()
  }
}

 

 

ViewController를 외부로 노출시키기

이제, 위의 ViewControllerable을 활용해 Coordinator에서 ViewController를 외부로 노출시켜 보자.

 

이에 앞서, 특정 Coordinator에선 ViewController가 필요 없는 경우가 있다. 

예를 들어, SignUp에는 여러 단계들이 포함될 수 있다. (아이디 입력, 비밀번호 입력 …)

이들을 묶어주는 SignUp에는 별도의 ViewController가 필요 없어지게 된다.

 

따라서, 이들을 분리해 주기 위해 Coordinator를 2가지 타입으로 분리했다. 

  • Coordinator: View가 없는 Coordinator
  • ViewableCoordinator: View가 있는 Coordinator

Coordinator

/// 화면 전환 로직을 담당하는 객체입니다..
public protocol Coordinating: AnyObject {
  var children: [Coordinating] { get }
  
  func start()
  func stop()
  func addChild(_ coordinator: Coordinating)
  func removeChild(_ coordinator: Coordinating)
}

open class Coordinator: Coordinating {
  public final var children: [Coordinating] = []
    
  /// 부모에게 attach되었을 때 원하는 동작을 해당 메서드에 구현하면 됩니다.
  open func start() { }
  
  /// 부모에게 제거되었을 때 원하는 동작을 해당 메서드에 구현하면 됩니다.
  open func stop() {
    self.removeAllChild()
  }
  
  public init() { }
  
  public final func addChild(_ coordinator: Coordinating) {
    guard !children.contains(where: { $0 === coordinator }) else { return }
    
    children.append(coordinator)
    
    coordinator.start()
  }
  
  public final func removeChild(_ coordinator: Coordinating) {
    guard let index = children.firstIndex(where: { $0 === coordinator }) else { return }
    
    children.remove(at: index)
    
    coordinator.stop()
  }
  
  private func removeAllChild() {
    children.forEach { removeChild($0) }
  }
}

View가 없기 때문에, 기존의 Coordinator와 NavigatoinController가 없다는 것 외에 차이점이 없다. 

 

ViewableCoordinator

다음은 View가 있는 경우의 Coordinator이다.

public protocol ViewableCoordinating: Coordinating {
  var viewControllerable: ViewControllerable { get }
}

인터페이스는 viewControllerable가 추가되었다. 

이를 통해 부모에서는 Routing을 담당하게 된다. 

 

또한, Coordinator에서 ViewController로 이벤트를 전달할 경우가 발생한다. 이전 방식에선 이를 구체타입으로 해결했었다.

이를 해결하고자 다음과 같이 구현했다.

open class ViewableCoordinator<PresenterType>: Coordinator, ViewableCoordinating

public let viewControllerable: ViewControllerable
  /// 내부적으로 `Coordinator`에서 `ViewController`로 이벤트를 전달할 경우 사용합니다.
public let presenter: PresenterType

public init(_ viewController: ViewControllerable) {
  self.viewControllerable = viewController
  
  guard let presenter = viewController as? PresenterType else {
    fatalError("\(viewController) should conform to \(PresenterType.self)")
  }
  
  self.presenter = presenter
  super.init()
  bind()
}

viewControllerable은 부모에서 Routing을 하기 위해 사용된다. 

또한, presenter는 내부에서 ViewController로 이벤트를 전달하기 위해 사용한다. 

 

이때, 부모는 ViewableCoordinating이라는 인터페이스로만 알기 때문에, presenter에 접근할 수 없다. 

'iOS > Pattern' 카테고리의 다른 글

[Design Pattern] Coordinator 패턴  (0) 2024.05.26
[Architecture Pattern] MVVM  (0) 2023.05.27
[Architecture Pattern] SoftWare Architecture  (0) 2023.05.24
[Design Pattern] DI(Dependency Injection)  (0) 2023.05.08
[Architecture Pattern] MVP  (0) 2023.03.24
복사했습니다!