iOS

iOS) RIBs에서의 ViewDidLoad() 호출 시점 - init()에서 ViewDidLoad()가 호출되는 것에 관하여

snowe 2022. 3. 21. 20:56

들어가며

요즘 RIBs라는 아키텍쳐를 사용해서 개발을 하고 있다. RIBs에는 Router, Interactor, Builder, View, (Presenter)라는 개념이 등장한다. VIPER패턴에 익숙한 사람이라면 이런 개념들 또한 익숙할 것이다.

무튼 주제와 가까워져 보자면, 이슈사항은 RIBs와 Reacorkit을 함께 사용하면서 발생했다.

RIBs에서 ViewDidLoad(), didBecomeActive()의 호출 순서에 대한 고찰

그리고 나의 이슈트래킹


ReactorKit을 사용하기 위해서는 View에서도 ReactorKit의 존재를 알아야했는데, 이를 막기 위해 RIBs의 PresentableListener를 사용하였다.

protocol TransactionDetailPresentableListener: AnyObject {
	typealias Action = TransactionDetailAction
	typealias State = TransactionDetailState
	
	var state: Observable<State> { get }
	var currentState: State { get }
	
	func sendAction(_ action: Action)
}


그리고 기존에는 Reactor를 통해서 bind가 이루어져야했지만 위와같이 구현하면서 View는 ReactorKit의 존재를 모르기 때문에 별도로 위 Listener를 통한 bind메서드를 구현해주었다.

init() {
    super.init(nibName: nil, bundle: nil)
    ...
    bind(to: listener)
}

// MARK: Reactor

func bind(to listener: TransactionDetailPresentableListener?) {
    guard let listener = listener else { return }
    bindActions(to: listener)
    bindStates(from: listener)
}

func bindActions(to listener: TransactionDetailPresentableListener) {}

func bindStates(from listener: TransactionDetailPresentableListener) {
    listener.state.map(\.transactionInfo)
        .distinctUntilChanged()
        .bind(with: self, onNext: {(owner, response) in
            owner.responseBindToCell(with: response)
        }).disposed(by: disposeBag)
}


이러면 이제 뷰컨은 PresentableListener의 sendAction(_ :)을 통해 액션을 전달하고, 비즈니스 로직을 담당하는 Interactor에서 액션에 대한 Mutate, Reduce가 이루어진 뒤, 최종적으로 State를 반환한다. bind메서드로 listener의 state를 구독해놓았기 때문에 변화된 상태가 반환되면 bindStates내의 코드가 동작하게 된다.

문제는 이때 발생했다. init() 내에 작성해둔 bind메서드가 제대로 동작하지 않아 새로운 State가 반환되어도 뷰컨은 아무런 업데이트도 일어나지 않았다. init()시점에 뷰컨에 작성해둔 bind의 파라미터로 들어가야 할 listener가 nil인것이 이유였다.

이때부터 혼란이 찾아왔다. 프로젝트에는 리액터 킷을 사용하고 있는 RIB이 3개 더 있었는데 현재 작업 중인 RIB에서만 ViewController에서 listener에 nil이 들어왔다.. 그래서 답답한 나머지 리액터킷을 사용중인 RIB들의 생명주기 호출 순서 로그를 다 찍어보았다.

앞서 언급한 문제없이 잘 되는(리액터킷을 사용중인 3개의) RIB에서는 didBecomeActive()가 viewDidLoad()보다 먼저 불렸고, 대부분의 RIB에서는 viewDidLoad()가 didBecomeActive()보다 먼저 불렸다. 내가 알고있던 것과 달랐다. RIBs 튜토리얼에서도 didBecomeActive() 이후에 viewDidLoad()가 불렸고, 어디를 찾아봐도 이 순서가 디폴트였기 때문이다.

그래서 당시 패스트캠퍼스 강의로 RIBs를 기반으로 한 슈퍼앱 강의가 있었는데 그 강사분께 메일로 질문을 드려 답변을 받았다.

그 강의에서 진행 중인 프로젝트에서도 내가 알고있던 것과 다르게 viewDidLoad() -> didBecomeActive() 순으로 호출이 이루어지고 있었다.
친절하게 답변해주신 노수진님께 감사드립니다 :)

메일을 통해 얻은 가장 큰 수확은 달라질 수 있는 viewDidLoad()의 호출 시점이다.

아까 말했듯 RIBs는 Router, Interactor, Builder, View로 이루어져있다. 이들은 각자의 생명주기가 있고, 이는 RIBs 깃헙에 나와있는 튜토리얼을 진행하다보면 자연스레 알게된다. 튜토리얼을 마친 뒤 나는 무의식적으로 Interactor의 didBecomeActive()가 먼저 호출되고 View의 ViewController()가 이후에 호출된다는 것을 당연하게 생각하고 있었다. 이 순서는 무조건 보장된다고 생각했다. 그래서 더 깊게 알아보지 않았던게 화근이 되었다.

각 RIB의 build메서드에서는 본인의 뷰컨 인스턴스 및 자식 RIB들의 builder를 만들고 이를 라우팅의 인스턴스 파라미터로 전달한다. 라우터에서는 이들을 가지고 attach, detach를 할 때 사용한다.

func build(withListener listener: HomeListener) -> HomeRouting {

    let component = HomeComponent(dependency: dependency)

    let viewController = HomeViewController()
    let interactor = HomeInteractor(dependency: component, presenter: viewController)

    let transactionDetailBuilder = TransactionDetailBuilder(dependency: component)
    interactor.listener = listener

    return HomeRouter(
            interactor: interactor,
            viewController: viewController,	
            transactionDetailBuilder: transactionDetailBuilder
        )
}


내가 만약 addSubView와 같은 view에 접근하는 코드를 뷰컨의 viewDidLoad()에 작성해두었다면, 이 메서드에서는 아무런 생명주기도 호출되지 않는다. 하지만 이슈를 겪었던 나처럼 init()에 view에 접근하는 코드를 작성해두었다면 build메서드가 호출되면서 viewController가 인스턴스화 될 때 viewDidLoad()가 호출된다.

init()에서 뷰에 접근하지 않았다면 attach될 때 didBecomeActive가 호출 된 이후 뷰가 처음으로 접근 될 때 loadView()가 호출되고 viewDidLoad()가 호출될 것이다. push, present함으로써 무조건 view에 접근하는 것은 아니다.

암튼 그래서 내가 겪은 이슈는 해결을 하였다. init()에 view접근을 함으로써 listener가 들어오기 전에 뷰가 그려진 것이다.
나는 지금까지 얻은 정보들을 팀원에게 공유했다. 그리고 팀원들 중 한명이 "init()에서 뷰에 접근하는 것이 괜찮을까?" 라는 궁금증을 갖게 해주었다.


ViewDidLoad()가 init() 시점에 호출되어도 괜찮을까?

궁금증은 정확하게 "init()에서 뷰에 접근하여 viewDidLoad()가 호출되는 것이 성능상의 비효율을 가져오지는 않을까?" 였다.
질문을 드렸던 강사님께서 init()에 view에 접근하는 방식을 자주 사용한다고 말씀하셔서 재차 질문을 드려보았다.

내가 잘못 생각하고 있던 점도 있었지만, 전체적으로는 고민해 볼 만한 이슈였다고 생각한다.
결론적으로 이 질문에 대한 답변은 두 가지 로 나눌 수 있을 것 같다.

1. RIBs처럼 비즈니스 로직의 시작이 view가 되어서는 안되는 아키텍쳐
2. MVC처럼 비즈니스 로직의 시작이 view가 될수도 있는 아키텍쳐

RIBs를 사용한다면 모든 비즈니스 로직의 시작은 Interactor에서 이루어져야 하며, view는 최대한 바보같이 Interactor가 시키는 대로만 하는 수동적인 객체로 만들어야한다. 따라서 내가 이슈를 만나게 된 계기인 bind(listener: )의 호출 또한 Interactor의 active시점에 맞추어 Interactor가 호출시켜주는 것이 옳은 방법이라고 생각이 들었다. viewDidLoad()의 호출 시점에 의해 이후 비즈니스로직이 좌지우지 되서는 안된다고 생각했기 때문이다.

내가 겪은 이슈 외에 다른 발생가능한 성능상의 이슈를 생각해보았지만, RIBs 아키텍쳐에서 view의 viewDidLoad()나 init()에서는 뷰를 그리는 일 이외에 다른 일이 일어나서는 안된다. 그것이 아키텍쳐의 장점 중 하나이다. 따라서 성능상의 비효율을 일으킬 수 있는 것은 view를 미리 로드하는 정도일 것이다. RIBs에서는 build메서드에서 뷰컨을 인스턴스화 한다. 그리고 이 build메서드는 Router에서 attach시점이 되어서야 present/push를 하기 위해 호출된다. attach와 present는 거의 셋트로 동작하기 때문에 무엇이 먼저 호출되던 성능상의 비효율을 야기할 정도는 아닐 것이라고 생각한다.

하지만 만약 비즈니스 로직이 viewDidLoad()에도 포함될 수 있는 MVC와 같은 아키텍쳐에서는 어떨까?
MVC에서는 아래와 같은 코드가 가능해진다.

override func viewDidLoad() {
    super.viewDidLoad()
    configureUI()
    callUserInfoAPI()
    callPermissionAPI()
}

이 말인 즉슨, 탭바와 같이 뷰컨을 인스턴스로 만들어서 가지고 있어야하는 경우 불필요한 시점에 view를 포함한 서비스 호출까지 이루어질 수 있음을 의미한다. 이게 점점 많아질 경우 성능상의 비효율은 충분히 일어날 수 있다고 생각이된다.

결론에 와서 생각해보면 RIBs구조에 맞지 않는 코드를 작성해서(bind 호출을 viewDidLoad()에게 맡겨버린) 이와같은 이슈를 만나게 되었다. 하지만 이를 통해 평소에 생각하지 못했던 것들을 생각하게 되었고, 앞으로 더 좋은 코드를 작성할 수 있게되었기에 좋은 경험이었던 것 같다 :)