RIBs

최완식·2022년 6월 8일
0

Tech Talks

목록 보기
16/23
post-thumbnail

Uber의 RIBS Architecture를 공식 문서 Tutorial을 보고 느꼈던 것들을 정리해본다.

RIBs란?

많은 수의 엔지니어와 nested states를 관리하기 위한 mobile app Cross-platform Architecture

RIBs의 이름은 Router, Interactor, Builder의 약자이다. 공식 github에서는 다음의 것들을 장점으로 내세우고 있다.

  • Android와 iOS의 아키텍쳐를 공유할 수 있다.
    • 비슷한 아키텍쳐를 가질 수 있어 business logic에 대해 cross review가 가능하다.
  • Test가능하며, 격리되어 있다.
    • 개별적인 RIB은 각각의 책임을 가지고 있다. 거기다가 Child RIB 로직과도 분리되어 있다. 이러한 점에서 독립적으로 존재할 수 있다.
  • 개발자의 생산성을 위한 도구이다.
    • RIBs에는 코드 생성, 정적 분석 및 runtime integrations에 대한 IDE 툴링이 함께 제공되며, 이 툴은 크고 작은 팀의 개발자 생산성을 향상시킨다.
  • 확장가능한 아키텍쳐이다.
    • 많은 엔지니어와 함께 같은 코드베이스를 가지고 작업할 수 있음이 증명되었다.
  • Open-Closed Principle
    • 개발자는 가능하면 기존 코드를 수정하지 않고 새로운 기능을 추가 할 수 있어야 한다. RIBs를 사용하면 몇 군데서 볼 수 있죠. 예를들어 부모 RIB을 거의 변경하지 않고 부모의 종속성이 필요한 복잡한 자식 RIB을 attach하거나 build할 수 있다.

사실 실제로 Tutorial을 진행해보는 것이 더 이해가 쉽다. 여기서는 튜토리얼을 진행하면서 잘 그려지지 않았던 구조를 그림으로 나타내어 호출 흐름을 이해하는데 도움을 주기 위한 목적으로 작성한다.

간단한 구조

일단 RIB의 구조는 위와 같다.

  • 노란색 직사각형: RIB 내부에서 사용하는 Protocol
  • 빨간색 직사각형: RIB 외부와 통신하기 위해 사용하는 Protocol
  • 둥근 직사각형: Class

둥근 직사각형 (Router, Builder, Interactor, View) 근처에 있는 Protocol은 해당 파일 내에 작성되게 되어 근처에 배치하였다. 이제 요소 하나하나에 대해 간단하게 설명해보겠다.

Builder

해당 RIB이 가지는 의존성을 받고, build() method를 통해 동적으로 적용되어야 하는 의존성을 받영하고 내부 Component를 생성하고 의존성을 주입하는 역할을 담당한다. 우리가 앱을 만들 때, View tree를 따라서 해당 로직들이 위치했었다. 최종적으로 보이는 화면은 이전에 방문했던 View들에 대해 의존성을 갖고서 만들어지게 된다. 이런 의존성을 담당하는 것이 Builder이다.

그런데 의존성에는 정적 의존성, 동적 의존성 두가지 분류가 존재한다. 정적 의존성은 단순히 특정 component를 생성하는데 있어 외부 요소나 계산된 결과가 필요없이도 만들어질 수 있는 경우다. 예를 들어, A화면에서 B화면으로 이동하는데 있어 A가 가지고 있는 정보 그대로를 넣어주는 것으로 끝난다면 이 경우 정적 의존성을 가진다 할 수 있을 것이다. 그럼 동적 의존성은 무엇일까? 특정 action의 결과를 기반으로 다른 component가 생성되어야 하는 경우를 말한다. 예를 들어, A 화면에서 사용자 정보를 받고 이를 기반으로 B화면이 만들어져야 한다면, 이는 A화면에 submit이 된 이후에 해당 값을 같이 넣어주어 B를 만들어야 할 것이다. 이런 경우를 동적 의존성을 가진다라 할 수 있을 것이다.

Builder에서는 정적 의존성의 경우 component를 통해 넣어주고, 동적 의존성의 경우 build() 함수의 인수로 넣어줌으로서 이를 가능케한다. build() 함수에서 실제로 RIB의 구성요소가 모두 생성되고 의존성이 주입되기 때문에, 이 시점에 넣어서 처리하는 것이 가능하다.

Builder instance 자체는 추후 설명할 Router가 가지고 있다. 잠깐 설명하면, Router는 정적 의존성이 주입된 Builder instance를 생성시 가지고 있다가, Interactor가 하위 RIB을 생성하라는 명령을 받는 시점에 하위 Builder의 build() 함수를 호출한다.

Component

Builder에 동적 의존성을 넣어주는 요소이다. 하위 RIB의 Builder는 self.dependency를 통해 상위 RIB의 dependency에 접근할 수 있다. (정적 의존성) 하지만 해당 Type은 하위 RIB에서 사용하는 ChildDependency라는 Protocol로 interface를 분리해서 관리하고 있기 때문에, 상위 dependency 구현체에 막 접근해서 사용은 불가능하다. 만약 사용하고 싶다면 ChildDependency에 내가 사용하고 싶은 변수를 정의해야 접근 가능하다.

// 상위 RIB
protocol RootBuildable: Buildable {
    func build() -> LaunchRouting
}

final class RootBuilder: Builder<RootDependency>, RootBuildable {

    override init(dependency: RootDependency) {
        super.init(dependency: dependency)
    }

    func build() -> LaunchRouting {
        let viewController = RootViewController()
        let component = RootComponent(dependency: dependency,
                                      rootViewController: viewController)
        let interactor = RootInteractor(presenter: viewController)

        // 하위 RIB의 Builder를 생성할 때, Builder가 가지고 있는 dependency를 넣어준다.
        let loggedInBuilder = LoggedInBuilder(dependency: component) 
        
        // 상위 RIB의 Router에서 하위 RIB의 Builder를 가지고 있다.
        let router = RootRouter(interactor: interactor,
                                viewController: viewController,
                                loggedInBuilder: loggedInBuilder) 
                                

        return (router, interactor)
    }
}

// 하위 RIB: 이해를 돕기 위한 Protocol과 생성자만 가져옴, 자세한 사항은 Tutorial 진행

protocol LoggedInDependency: Dependency {
    var loggedInViewController: LoggedInViewControllable { get }
}

final class LoggedInBuilder: Builder<LoggedInDependency>, LoggedInBuildable {

    // 생성될 때 상위 RIB의 dependency를 받았으나, interface가 LoggedInDependency이기 때문에 접근이 제한된다.
    override init(dependency: LoggedInDependency) {
        super.init(dependency: dependency)
    }

}

이렇게 상위 RIB의 dependency를 Builder가 가지고 있음에도 불구하고, 만약 동적 의존성이 필요하다면 이 방법으로는 부족하다. 이를 위해 만들어진 것이 Component이다. 하위 RIB이 만들어질 때, 상위 RIB의 Router에서 instance로 가지고 있는 하위 RIB의 build() 함수를 호출하게 되는데, 이 때, 동적으로 발생한 값(의존성)을 build(player1, player2)와 같은 형식으로 넣어준다. 그리고 하위 RIB에서는 해당 값을 받아 Component를 생성하여 의존성을 해결한 RIB 내부 요소를 만든다.

// 이해를 위해 구체 사항은 제외하고 핵심만 가져왔다.

// 상위 RIB은 Interactor의 요청에 따라 Router에서 하위 RIB을 build한다.
final class RootRouter: LaunchRouter<RootInteractable, RootViewControllable>, RootRouting {

    func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) {
        
        // build에 동적으로 반영되어야 하는 값이 필요한 경우 넣어 보낸다.
        let loggedIn = loggedInBuilder.build(withListener: interactor, 
                                             player1Name: player1Name, 
                                             player2Name: player2Name)
        attachChild(loggedIn.router)
    }
}
// MARK: - Builder

protocol LoggedInBuildable: Buildable {
    func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> (router: LoggedInRouting, actionableItem: LoggedInActionableItem)
}

final class LoggedInBuilder: Builder<LoggedInDependency>, LoggedInBuildable {

    func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting {

        // build 함수 안에서 Component를 생성하여 새로운 의존성 요소를 만든다.
        let component = LoggedInComponent(dependency: dependency,
                                          player1Name: player1Name,
                                          player2Name: player2Name)
        let interactor = LoggedInInteractor(games: component.games)
        interactor.listener = listener

        let offGameBuilder = OffGameBuilder(dependency: component)
        let router = LoggedInRouter(interactor: interactor,
                                    viewController: component.loggedInViewController,
                                    offGameBuilder: offGameBuilder)
        return (router, interactor)
    }

}

실제 Component는 이렇게 생겼다.

final class LoggedInComponent: Component<LoggedInDependency> {

    fileprivate var loggedInViewController: LoggedInViewControllable {
        return dependency.loggedInViewController
    }

    fileprivate var games: [Game] {
        return shared {
            return [RandomWinAdapter(dependency: self), TicTacToeAdapter(dependency: self)]
        }
    }

    internal let player1Name: String
    internal let player2Name: String

    init(dependency: LoggedInDependency, player1Name: String, player2Name: String) {
        self.player1Name = player1Name
        self.player2Name = player2Name
        super.init(dependency: dependency)
    }
}

여기서 주목할 점은, component의 변수를 처리하는 방식이다. 실제로 사용하는 변수만 적고, 접근 제어자를 통해 명시적으로 변수 사용을 관리하는 것이 좋다.

Interactor

Interactor는 RIB의 Business logic이 담기는 곳이다. 세개의 Protocol을 기본으로 갖는다.

  1. Listener의 경우 상위 RIB과 통신하기 위한 Interface이다. 해당 Listener는 상위 RIB의 Interactor가 채택하고 있다.
  2. Routing의 경우 Router에 요청하기 위한 것들이 나열되어 있다.
  3. Presentable의 경우 View에 요청하기 위한 것들이 나열되어 있다.

View

View는 View와 View Controller 모두를 가리킨다. 다른 아키텍쳐 패턴에서 처럼 View는 멍청하다. 단순히 그리는 용도로만 사용한다. View에서 변경된 사항을 알리기 위해서 PresentableListner를 채택한 곳으로 메시지를 보낸다. 기본적으로는 Interactor가 그 역할을 한다.

Router

Router는 Interactor의 요청을 받아 RIB을 Attach, Detach하는 역할을 담당한다. Interactable은 Interactor가 채택하는 Protocol로, 하위 RIB의 Interactor의 Listener, Router가 Interactor에 요청해야 하는 요소들을 모두 채택하고 있다.

ViewControllable은 View 요소의 transition에 관련된 것들을 정의한 Protocol이다. RIB Attach, Detach시 발생하는 transition을 처리하기 위해 가지고 있으며, 해당 Protocol은 View가 준수하고 있다.

protocol LoggedInInteractable: Interactable, OffGameListener, GameListener {
    var router: LoggedInRouting? { get set }
    var listener: LoggedInListener? { get set }
}

protocol LoggedInViewControllable: ViewControllable {
    func replaceModal(viewController: ViewControllable?)
}

final class LoggedInRouter: Router<LoggedInInteractable>, LoggedInRouting {

    // MARK: - LoggedInRouting

    func cleanupViews() {
        if currentChild != nil {
            viewController.replaceModal(viewController: nil)
        }
    }

    func routeToOffGame(with games: [Game]) {
        detachCurrentChild()
        attachOffGame(with: games)
    }

    func routeToGame(with gameBuilder: GameBuildable) {
        detachCurrentChild()

        let game = gameBuilder.build(withListener: interactor)
        self.currentChild = game
        attachChild(game)
        viewController.replaceModal(viewController: game.viewControllable)
    }
    ...
}

Build시 동작 과정

그럼 이번에는 Router에서 하위 RIB의 build()를 호출할 때 어떠한 순서로 진행되는지 알아보자.

  1. 먼저, 동적 의존성이 있을 경우 component를 생성해준다.
  2. 다음으로는 View, Interactor, Router의 의존 순서에 맞춰 View 부터 만들어준다.
  3. Interactor를 만들어 준다. 이 때 View를 생성자 주입한다.
  4. 해당 RIB의 하위 RIB의 Builder를 만들어준다.
  5. Router를 만들어 준다. 이 때, InteractorView, 하위 RIB Builder 모두를 주입한다.
  6. 마지막으로 해당 Router를 반환한다. 반환한 Router는 상위 RIB의 Router가 관리한다.

RIB과 통신하기

생성시에 어떠한 흐름으로 동작하는지 알았다면, 이번에는 RIB간의 소통을 알아볼 차례다. 일단 위에서 하위 RIB의 InteractorListener라는 Protocol의 요소를 채우고, 상위 RIB의 Interactor가 이를 채택하고 있다고 했던 것을 기억해보자.

// OffGameInteractor
protocol OffGameListener: AnyObject {
    func startGame(with gameBuilder: GameBuildable)
}

// GameInteractor
public protocol GameListener: AnyObject {
    func gameDidEnd(with winner: PlayerType?)
}

// LoggedInInteractor
protocol LoggedInInteractable: Interactable, OffGameListener, GameListener {
    var router: LoggedInRouting? { get set }
    var listener: LoggedInListener? { get set }
}

final class LoggedInInteractor: Interactor, LoggedInInteractable {

    // MARK: - OffGameListener
    func startGame(with gameBuilder: GameBuildable) {
        router?.routeToGame(with: gameBuilder)
    }

    // MARK: - GameListener
    func gameDidEnd(with winner: PlayerType?) {
        router?.routeToOffGame(with: games)
    }

}

이해를 돕기 위해 하나의 위치에 여러 파일에 있던 Listener들을 가져왔다. 또 실제 tutorial과는 약간은 다를지 모른다. 하지만 위와 같은 방식으로 동작한다. 하위 RIB에서 통신하고 싶은 것들이 있다면 Listener에 정의하고, 이를 상위 RIB의 Interactable Protocol이 채택하도록 하고, 결과적으로 이 Interactable 프로토콜을 Interactor가 준수하도록 하여 통신을 가능하도록 한다.

그럼 그림을 통해 따라가면서 머릿속에 구체화작업을 해보자. Child RIB에서 로그인 버튼을 눌렀고, Another RIB에 있는 화면이 띄워져야 하는 상황이다.

  1. Child RIB의 View에서 Interaction이 일어난다. PresentableListener를 준수하고 있는 Interactor에 있는 method를 호출한다.
  2. Interactor는 해당 요청은 상위 RIB에서 처리해야 하므로 Listener Protocol을 준수하고 있는 부모 RIB의 Interactor 구현체에 요청한다.
  3. Parent RIB의 Interactor는 이를 준수하고 있기 때문에 요청을 받아 처리한다.
  4. 로직을 수행한 후, Router에게 하위 RIB으로 가야한다고 알린다.
  5. Router는 Child RIB을 detach한다. (이 때, Child RIB을 만들었을 때 받은 router 객체를 넣어 해제한다.)
  6. View에 현재 보여지고 있는 화면을 dismiss 해야 한다고 요청한다.
  7. Another RIB을 만들고 Attach한다.
  8. 이 때, 동적 의존성이 필요하다면 build() 함수의 인수로 넣어 보낸다.
  9. Another RIB의 Builder는 component를 만들고, View를 만든다.
  10. 만든 View를 Interactor에 주입하고 Router도 만든다.
  11. Router는 생성되는 시점에 View의 present를 호출한다.

Deep Link 적용하기

Deep link를 적용하기 위해서는 AppDelegate에서 응답을 받아 처리해야 한다. 해당 내용은 그림으로 그리기가 너무 벅차 말로 간단하게 대체하려 한다.

  • Reactive 방식을 통해 상위에서 하위로 stream을 전달하여 하위 RIB의 특정 화면을 그리는 방식을 사용한다.
  • 이 때, 기존에 RIB의 Builder의 build() 함수의 반환 값이 Router였던 것에서 ActionableItem이라는 프로토콜을 채택하는 구현체도 같이 던져준다. 보통은 Interactor가 이 역할을 한다.
  • ActionableItem은 하위 RIB이 생성된 시점에 하위 RIB의 Actionable Item을 Publisher로 전달하는 역할을 적는다.
  • 이렇게 정의된 stream을 관리하는 녀석이 필요한데 이녀석을 Workflow라고 하고 맨처음 이 stream을 시작하고 관리하는 구현체가 들고 있다.

말이 좀 어려운데, 이건 RIB의 Tutorial 4를 해보는 것을 추천한다.

느낀점

  • 일단 해당 내용을 다 따라가면서 쳐보는 것을 추천한다.
  • 아무래도 framework라고 공식 문서에 적혀있는 것으로 보아 진입장벽이 있을 것으로 보인다.
  • 오히려 핵심 아이디어만 가져와서 사용하고, 유연하게 적용하는 것이 좋지 않을까하는 생각이다.
  • 핵심 아이디어는 Protocol로 서로를 모두 격리조치하여 영향을 적게 끼치도록 하는 점이라는 생각이 들었다.
  • 의존성 주입 방식도 깔끔한 것 같다.
  • 가장 좋은 점은 RIB을 하나의 단위로 관리할 수 있다는 점이었다. 서로 분업하기가 용이해 보였다.
  • 아, 추가적으로 Presenter라는 친구도 있는데 이녀석은 Optional이라 제외했다. 참고.

Reference

profile
Goal, Plan, Execute.

0개의 댓글