RIBs Flattening

Jin Han·2021년 5월 28일
2

들어가기 앞서

RIBs 아키텍처에 대한 기본적인 이해가 있으신 분들을 위해 작성되었습니다. 처음 RIBs를 접하신 분들은 GitHub에서 튜토리얼을 진행하고 오시는 것을 추천 드립니다.

RIBs GitHub Wiki

Scalable

프로젝트가 커지면 처음에는 겪지 못했던 문제들을 만나게 됩니다. 밀려드는 피쳐들과 늘어나는 개발자들이 쏟아내는 코드들에서 정신을 차리고 속도와 안정성을 유지하는 것은 쉬운 일이 아닙니다. 확장성을 고민하게 되는 시기가 오게 되는 것이죠. 해결책은 여러 가지가 있겠지만 근본적인 방법을 위해 아키텍처를 연구하게 됩니다.

LEGO

확장 가능한 아키텍쳐는 같은 규격을 가지고 서로 다른 모양들의 블럭들이 호환되는 레고와 비슷합니다. 블럭들을 깎아내거나(Open/Closed Principle) 풀로 붙이지 않아도(Coupling) 큰 구조물을 만드는 것이 어렵지 않습니다. 여러명의 작업자가 붙어도 서로 약속(dependency)만 잘 한다면 멋있는 레고 마을을 빠르게 탄생 시킬 수 있습니다.

RIBs를 처음 접하게 되면 이러한 레고가 떠오르게 됩니다. 비즈니스 로직을 나누고 할당하여 개발자들끼리 각 RIB들로 개발하고 또 이를 합치는 작업이 수월하기 때문에 큰 구조를 만들어 나가는 것이 매우 간단하게 느껴집니다. 이렇게 빠르게 프로젝트를 확장해 나가다 보면 또 다른 문제들을 맞이하게 됩니다.

Tree Depth

RIBs는 parent-child 관계로 이뤄진 tree 구조로 되어 있습니다. iOS에서 present, push를 통한 화면 전환을 사용하면서 각 뷰 컨트롤러들을 parent-child RIBs 관계로 이어주게 되면서 tree가 확장되면서 depth가 깊어지는 것은 자연스러운 현상일 수도 있습니다.

하지만 깊은 depth를 가진 상태에서 다음과 같은 요구 사항이 발생하게 되면 난감해지게 됩니다.

'B' RIB에서 어떠한 비즈니스 로직을 수행한 후 '가' RIB으로 routing 하던 것을 '🔴' RIB으로 routing 하는 것도 지원하도록 바꾸려면 어떻게해야 할까요? 물론 RIBs에서는 이러한 상황에 대처하기 위해서 Builder를 제공하고 있습니다. 'AA' RIB에서 'B' RIB을 build 할 때 '🔴' RIB으로 routing 하는 Router를 붙여주면 되는 것이죠. 각각이 protocol로 추상화 되어 있기 때문에 그리 어려운 일은 아닙니다.

하지만 좀 더 복잡한 상황을 생각해볼까요?

이제 위와 같은 상황에 더해서 '나' RIB에서의 '다' RIB과 '🥝' RIB으로의 분기들이 추가됩니다. 이렇게 되면 'B' RIB에서 '가', '🔴' RIB들로의 분기, '나' RIB에서 '다', '🥝' RIB들로의 분기를 모두 고려해야 합니다.

이러한 문제는 tree의 depth가 깊기 때문에 발생합니다. 자신의 역할을 하고 다음으로 요구 사항(dependency)만 넘겨주는 강한 독립성을 간진 RIBs의 장점이 이러한 상황이 더 쉽게 일어나도록 합니다. 여기서 분기가 몇 개만 더 추가되어도 복잡성은 기하급수적으로 늘어나게 되며 안정성은 매우 취약해지게 됩니다.

Reusability

대규모 프로젝트에서 항상 높게 유지해야 하는 것 중에 하나가 재사용성입니다. 누군가 만들어 놓은 모듈을 copy & paste 해서 만들거나 수정해서 사용하는 일이 거의 없어야 합니다. 대규모 프로젝트는 일정, 시간, 안정성과의 싸움입니다. 재사용성이 높지 않다면 당연히 시간이 부족하게 되고 일정을 맞추기 힘듭니다. 재사용을 못한다면 문제가 발생했을 경우에 copy & paste 한 모든 곳을 다 찾아서 수정해 주어야 합니다. 안정성은 당연히 낮아집니다.

Solution

그렇다면 어떠한 방법으로 이러한 문제를 해결할 수 있을까요.

Builder

RIBs의 컴포넌트 중 하나인 Builder를 적절히 사용하는 방법이 당연히 1순위입니다.

protocol BranchBuildable: Buildable {
    func buildForCaseA(withListener listener: BranchListener) -> BranchRouting
    func buildForCaseB(withListener listener: BranchListener) -> BranchRouting
}

final class BranchBuilder: Builder<BranchDependency>, BranchBuildable {

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

    func buildForCaseA(withListener listener: BranchListener) -> BranchRouting {
        let component = BranchComponent(dependency: dependency)
        let viewController = BranchViewController()
        let interactor = BranchInteractor(presenter: viewController)
        interactor.listener = listener
        return CaseARouter(interactor: interactor, viewController: viewController)
    }
    
    func buildForCaseB(withListener listener: BranchListener) -> BranchRouting {
        let component = BranchComponent(dependency: dependency)
        let viewController = BranchViewController()
        let interactor = BranchInteractor(presenter: viewController)
        interactor.listener = listener
        return CaseBRouter(interactor: interactor, viewController: viewController)
    }
}

(참고 : 당연히 CaseARouter과 CaseBRouter은 BranchRouting을 상속하여 구현해야 합니다.)

Interactor 등은 그대로 사용하면서 Builder에서 제공하는 build 메서드에 따라 Router만 각각에 맞게 사용하는 방법입니다. 각 build 메서드는 직계 parenet에서만 지정 가능하기 때문에 어느 정도 한계가 있지만 그래도 순정(?)이라는 메리트가 있습니다.

RIBs Flattening

문제의 근본 원인에 집중하는 방법입니다. 근본 원인은 tree의 depth가 깊어지는 것이었죠. depth가 깊어지는 것은 비즈니스 로직의 결과를 Router에 주로 의존하여 전달하기 때문에 발생합니다. 고정관념을 깨고 Listener를 통한 전달을 적극적으로 활용해 보는 것입니다.

재활용되는 RIB들은(위 그림의 B, C, D, E RIBs) 자식 RIB들을 가지는 것은 최소한으로 하고 비즈니스 로직의 결과는 listener를 통해 부모(위 그림의 BC, DE Wrapper RIBs)에게 전달합니다. 결과를 전달받은 부모 RIB이 다음으로의 routing 역할을 수행합니다. 오른쪽 tree의 순서를 나타내 보면 다음과 같습니다.

A -(routing)-> BC Wrapper -(routing)-> B -(listener)-> BC Wrapper -(routing)-> C -(listener)-> BC Wrapper -(routing)-> DE Wrapper -(routing)-> ...

여기서 중요한 점은 BC, DE Wrapper RIB들은 비즈니스 로직을 가지지 않고 순수한 routing 역할과 데이터 전달 역할만 한다는 것입니다. 비즈니스 로직을 가져가게 된다면 재활용에 불리합니다.

만약 B -> C -> D로 이어지는 로직을 꾸미고 싶다면 어떻게 하면 될까요? B, C, D RIB들을 자식으로 가지는 BCD Wrapper RIB을 만들면 됩니다. BCD Wrapper RIB은 단순 routing 역할만 하기 때문에 비즈니스 로직이 있는 B, C, D RIB들은 수정되지 않아 Open/Closed Principle을 충족 시키는데 용이합니다. 이렇게 필요에 따라 CB Wrapper, ECD Wrapper 등 조합과 순서에 구애받지 않는 tree 구조를 만들 수 있습니다.

이처럼 tree 구조를 vertical 방향이 아닌 horizontal 방향으로 배치하는 것이 RIBs Flattening의 핵심입니다.

(참고 : 플러그인 방식도 활용할 수 있는데 나중에 시간이 허용한다면 다른 글로 따뤄 다뤄보겠습니다.)

이제 RIBs Flattening을 극단적으로 활용해 보겠습니다.

iOS는 안드로이드와는 다르게 UINavigationController의 push를 많이 이용하는 편입니다. 공통적 목적을 가지는 뷰 컨트롤러들 하나의 뷰 컨트롤러(UINavigationController)에서 관리하기 위해 제공하는 인터페이스인데, 이를 RIBs로 구현할 때 RIBs Flattening을 이용하면 많은 이점을 누릴 수 있습니다.

RIBs Flattening에서 쓰였던 Wrapper RIB을 UINavigationController로 쓰는 방식입니다. UINavigationController을 이용한 Wrapper RIB은 다음과 같은 코드로 build 됩니다.

final class NavigationBuilder: Builder<NavigationDependency>, NavigationBuildable {

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

    func build(withListener listener: NavigationListener) -> NavigationRouting {
        let component = NavigationComponent(dependency: dependency)
        let viewController = NavigationController()
        let interactor = NavigationInteractor(presenter: viewController)
        interactor.listener = listener
        
        let caseABuilder = CaseABuilder(dependency: component)
        let caseBBuilder = CaseBBuilder(dependency: component)
        let caseCBuilder = CaseCBuilder(dependency: component)
        let caseDBuilder = CaseDBuilder(dependency: component)
        
        return CaseARouter(interactor: interactor,
                           viewController: viewController,
                           caseABuilder: caseABuilder,
                           caseBBuilder: caseBBuilder,
                           caseCBuilder: caseCBuilder,
                           caseDBuilder: caseDBuilder)
    }
}

Navigation RIB은 UINavigationController가 하던 역할을 그대로 수행합니다. 시작되면 A RIB을 root view controller로 설정하고, A RIB으로부터 listener를 통해 결과를 전달받으면 B RIB을 push 하고 C, D, ...로 계속 진행하는 것입니다. Navigation RIB의 Router는 다음과 같이 구성되어 있을 것입니다.

protocol NavigationControllable: ViewControllable {
    func push(viewController: ViewControllable, animated: Bool)
}

final class NavigationRouter: LaunchRouter<NavigationInteractable, NavigationControllable>, NavigationRouting {

    init(interactor: NavigationInteractable, viewController: NavigationControllable, caseABuilder: CaseABuildable, ...) {
        self.caseABuilder = caseABuilder
        //... B, C, D builders
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
    
    private let caseABuilder: CaseABuildable
    private var caseARouter: CaseARouting?
    
    func routeToCaseA() {
        guard caseARouter == nil else { return }
        
        let router = caseABuilder.build(withListener: interactor)
        
        caseARouter = router
        
        attachChild(router)
        
        viewController.push(viewController: router.viewControllable, animated: false) // root view controller이므로 animated false
    }
    
    //... B, C, D routings
}

위에서 설명한 RIBs Flattening의 이점들도 있겠지만 UINavigationController를 사용하기 때문에 발생하는 이점도 있습니다. 각각을 비교하면서 알아보겠습니다.

UINavigationItem

  • 수직적 관계 : 각각의 child RIB들은 UINavigationBar의 UINavigationItem에서 일어나는 일들(닫기 등)을 각각 구현해야 합니다.
  • Navigation RIB : Navigation RIB이 UINavigationController이기 때문에 UINavigationItem의 액션을 Navigation RIB 한 곳에서만 처리하면 됩니다.

Pop

  • 수직적 관계 : 현재 D RIB에서 A RIB으로 pop을 하고 싶다면 listener를 통해 A RIB까지 계속해서 액션을 전달해야 합니다.
  • Navigation RIB : B, C, D RIB들을 모두 detach 하면 간단합니다.

분기처리

  • 수직적 관계 : 각각의 RIB들의 분기를 Router와 Builder의 조합으로 경우의 수를 따져 구현해야 합니다. 위치가 바뀌거나 다른 RIB이 끼어들거나 분기가 달라지게 되면 수정이 전방위적으로 일어납니다.
  • Navigation RIB : Navigation RIB이 child RIB을 한 번에 관리하기 때문에 변경이 매우 간단합니다. Router나 Builder를 새롭게 만들거나 수정할 필요가 없습니다.

재사용

  • 수직적 관계 : 재사용되는 RIB들의 Router에 child RIB들을 계속해서 붙여나가야 합니다. (참고 : Adapter를 활용하는 방법도 있으나 그 또한 매우 피곤합니다. 시간이 허락하면 다른 글로 다뤄보겠습니다.)
  • Navigation RIB : 재사용되는 RIB에서 listener를 통해 결과 전달받은 후 다음 routing을 진행하기만 하면 됩니다. 재사용되는 RIB의 Router를 수정할 필요가 없습니다.

마치며

RIBs Flattening이 모든 이슈를 해결하지는 못합니다. 위에서 말씀드린 Builder/Router 조합도 적절히 사용하는 것이 좋습니다. 개발에 silver bullet은 없으니까요. 하지만 강력한 방법이 될 수는 있습니다.

대규모 프로젝트에서 RIBs를 적용하며 어려움을 겪는 분들, RIB 재사용이 힘든 분들, RIBs의 여러 분기로 인해 힘드신 분들에게 도움이 되었으면 좋겠습니다.

profile
iOS 개발자

0개의 댓글