cleanUrl: /programming/do-not-blame-mvc-3/

ViewController에게 Navigation을 맡기지 마세요

**“버튼이 눌렸을 때 다음 화면을 보여준다”**는 기능을 구현해봅시다. 이렇게 하면 되겠죠?

@IBAction func buttonClicked(_ sender: Any?) {
    let nextViewController = NextViewController()
    nextViewController.importantInfo = self.importantInfo
    nextViewController.anotherInfo = self.anotherInfo
    navigationController?.push(nextViewController)
}

복잡 할 것이 없는 코드입니다. 여기까진 말이죠. 하지만 현실에서, 이런 종류의 접근방식은 ViewController의 크기를 순식간에 늘려 버립니다.

만약 버튼을 눌렀을 때, 로그인이 안 되어있다면 NextViewController가 LoginViewController를 보여줘야 한다면 어떨까요?

class MyViewController: UIViewController {

    @IBAction func buttonClicked(_ sender: Any?) {
      if user.isLoggedIn {
        let nextViewController = NextViewController()
        nextViewController.importantInfo = self.importantInfo
        nextViewController.anotherInfo = self.anotherInfo
        navigationController?.push(nextViewController)
      }
      else {
        let loginVC = LoginViewController()
        loginVC.someContext = someContext
        navigationControler?.push(loginVC)
     }
   }
}

여기에 로직이 더 추가되면 어떻게 될까요? 앞선 ViewController에 따라 NextViewController가 달라져야 한다면? 혹은 시간대나 위치에 따라 달라져야한다면? 등등…

로직이 추가되면 될 수록, 이 네비게이션 코드는 아주 많이 길어 질 수 있습니다. 아마 이 글을 읽고 있는 많은 분들의 ViewController에도 비슷하게 짧지 않은 네비게이션 관련 코드들이 있을테지요.

하지만 ViewController가 아니라면, 누가 Navigation코드를 담당 할 수 있을까요?

이에 대해 Coordinator패턴등 의 디자인패턴도 좋은 대안이 되겠지만, 제게는 더 Obvious한 대안이 떠오릅니다. 바로 UINavigationController지요!

Navigation은 NavigationController에게!

위의 코드를 리팩토링해 보겠습니다.

class MyNavigationController: UINavigationController {

    func showLoginViewController(with context:Context) {
        let loginVC = LoginViewController.storyboardInstance
        loginVC.context = context
        push(loginVC)
    }

    func showNextViewController(with foo:Foo, bar: Bar) {
        let nextVC = NextViewController.storyboardInstance
        nextVC.foo = foo
        nextVC.bar = bar
        push(nextVC)
    }
}

class MyViewController: UIViewController {
    var myNavVC: MyNavigationController {
        return navigationController as! MyNavigationController
    }

    @IBAction func buttonClicked(_ sender: Any?) {
        if user.isLoggedIn {
            myNavVC.showLoginViewController(with: context)
        }
        else {
            myNavVC.showNextViewController(with: foo, bar: bar)
        }
    }
}

@IBAction 의 코드가 확연히 줄어든 것을 볼 수 있습니다. 그래도 전체적인 코드의 양 자체는 비슷한 거 아니냐구요? 그렇게 생각할 수도 있습니다. 하지만 이렇게 리팩토링한 결과, 우리는 여러가지 효과를 누릴 수 있습니다.

  1. MyViewController는 Navigation관련 코드를 콜하긴 하지만, 그 구체적인 방법에 대해서는 알 필요가 없게 됩니다. 위의 예시에서는 NextViewController() 와 같은 방법으로 NextViewController를 초기화 했지만, 사실 xib로 초기화 할 수도 있는 거고, 경우에 따라 navigationStack에 이미 있었던 인스턴스를 재활용 할 수도 있죠. 그 모든 책임이 이제 UINavigationController에게 넘어갑니다.
  2. 가독성이 훨씬 좋아졌습니다. 코드리뷰하는 입장에서, “이 부분의 Navigation로직은 어디쯤에 있는거지?”라는 생각이 들 때, 기존에는 ‘MyViewController’를 쭉 훑어야 했지만, 이제는 “Navigation 로직은 NavigationController에 있겠지”라는 생각으로 관련 코드를 훨씬 빠르게 발견하고 읽을 수 있습니다.

특히, Push Notification의 userInfo를 바탕으로 네비게이션을 하는 경우에, 이런 접근은 진가를 발휘하게 됩니다. 먼저 저희 팀에서 기존에 Push Noti정보로 네비게이션을 하는 코드는 다음과 같았습니다.

class AppDelegate: UIApplicationDelegate {
    func application(_ app: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                     fetchCompletionHandler _: @escaping (UIBackgroundFetchResult) -> Void) {
        let pageNavigator = PageNavigator.shared
        pageNavigator.targetPageInfo = userInfo
        rootNavigationController.viewControllers = [rootNavigationController.viewControllers[0]]
    }
}

class FirstViewController: UIViewController {
    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated:animated)
        if PageNavigator.shared.targetPageInfo["destination"] != FirstViewController.className {
            let nextVC = SecondVC.storyboardInstance
            navigationController?.push(nextVC)
        }
    }
}

class SecondViewController: UIViewController {
    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated:animated)
        if PageNavigator.shared.targetPageInfo["destination"] != SecondViewController.className {
            let nextVC = ThirdViewController.storyboardInstance
            navigationController?.push(nextVC)
        }
    }
}