RIBs? 왜 쓰는데?
RIBs란?
Uber의 크로스 플랫폼 아키텍쳐 프레임워크로 Router-Interactor-Builder의 앞글자를 따왔음
- Andorid와 iOS모두에서 유사한 개발 패턴을 사용할 수 있어 엔지니어들이 공동으로 설계된 구조를 공유할 수 있음.
- RIB이라는 단위로 라이프 사이클이 나뉘어져있기 때문에 전역 상태변경으로 인한 예기치않은 동작이 발생해 특정한 상태를 변경하는 것을 막을 수 있음.
- RIB의 구성 요소들(Router, Interactor, Builder…)에는 각각의 뚜렷한 책임들이 있고, 부모 RIB은 자식 RIB과 분리되어 있기 때문에 테스트하기 쉬운 구조를 띄고 있음. 또한 독립된 구조를 가지고 있기 때문에 부모 RIB을 거의 변경하지 않고 부모로부터의 종속성이 필요한 자식 RIB을 연결할 수 있음(모든 구성요소는 자체 클래스로 구현됨)
- RIB은 기능단위를 포함하는 하나의 단위이고, 모든 기능은 RIB을 단위로 짜게 됨.
- 비즈니스 로직을 중심으로 구조화되어 있음
- 필수 요소는 RIB이고 Presenter와 View는 optional
Interactor
가장 중요한 컴포넌트. API를 어떻게 호출할지, state는 어떻게 관리하는지 등의 모든 비즈니로직을 여기서 구현함. state에 대한 상태변경 작업도 여기서 이루어짐.
인터랙터가 수행하는 모든 작업은 해당 라이프사이클에 국한되어야 함.
인터랙터가 활성화될 때만 비즈니스 로직이 실행되도록 툴링이 되어있어(프레임워크단에서) 구독이 계속 되어 원치않는 때에 비즈니스 논리나 UI 상태가 변경되는 상황을 방지할 수 있음
- Rx 구독 수행
- 상태 변경 결정
- 어떤 데이터를 저장할 위치 결정
- 자식으로 연결해야하는 다른 RIB 결정
Router
Router는 Interactor의 비즈니스 로직을 따라 자식 RIB을 attach/detach 함
애니메이션을 어떻게 구현할지, 라우터를 붙이고 떨어질 때 어떤 과정으로 떨어질지를 다룸.(Interactor는 무슨 Router를 떨어뜨려라- 만 결정)
Interactor에서 일어나는 Routing을 도와주는 헬퍼개념
- 자식 Interactor의 mock을 만들거나 존재하는지에 대해 신경 쓸 필요 없이 복잡한 Interactor 논리를 더 쉽게 테스트할 수 있도록 하는 Humble Objects 역할을 함
- 부모 interactor와 자식 Interactor 사이에서 추가적인 추상화 계층이 됨. interactor간 상호 직접 참조를 방지해 인터랙터간 동기적인 통신이 어려워지고 router를 이용한 반응형 통신(?)이 이루어짐
- 인터랙터에 의해 구현될 간단하고 반복적인 라우팅 논리가 포함되어 있는데 이 상용구 코드를 리팩토링하면 인터랙터를 작게 유지하고 RIB에서 제공하는 핵심 비즈니스 로직에 더 집중할 수 있음
Builder
RIB 내의 모든 클래스의 RIB의 자녀 Builder를 인스턴스화 하는 역할
팩토리패턴으로 RIB 컴포넌트를 생성해주는 역할. 그래서 컴포넌트들의 mockability를 향상시키는 역할을 함.(DI를 구현할 때, 어떤 도구들을 이용할 수 있는데 빌더 내의 코드 변환 없이 빌더만 변경하는식으로 구현 가능)
A라는 립의 라우터와 B라는 립의 인터랙터를 꺼내서 새로운 C라는 립을 빌더가 만드는 등의 활용이 가능.
View
현재 상태를 화면에 보여주고, 유저 인터랙션을 받고, 애니메이션을 처리하는 작업들을 함. 립에서는 가능한 이 view의 역할을 간결하게 가져가고자 함.
Presenter
뷰처럼 뷰 로직이 필요할때만 추가되는 컴포넌트.
인터랙터에서 다루는 정보들은 때로는 너무 많거나 적기 때문에 뷰에서 직접적으로 사용하기에 적합하지 않을 때가 있음. 인터랙터에서 나오는 다양한 데이터 모델을 뷰가 해석할 수 있는 형태로 변형하거나, 뷰에서 나온 액션들을 인터랙터가 해석할 수 있는 형태로 변형해주는 로직을 주로 담당함.
그래서 왜 RIB이 좋은데?
- 모바일에서는 어플리케이션에서는 화면이 작고, 화면마다 제공하는 기능이 엮여있기도 하고 분리되어 있기도해서 다루어야 할 state가 많아지기 쉬운데, RIB은 이러한 state들을 관리하는데 용이함
- 비슷한 역할을 하는 로직이 붙어있는 경우도 있고, 어떠한 화면에 속한 기능은 특정 스코프 내에서만 작동하는 state일수도 있음. 이런것에서 착안해 모든 화면에서 다루는 모든 state를 트리형태로 다루면 어떨까 하는 물음에서 우버 립스가 나오게 됨.
- RIB에서 state변화는 뷰에 의한것이 아니라 비즈니스 로직에 의해 관리됨. 그리고 각 개별 RIB의 scope로 state가 한정되어 관리될 수 있다는 특징이 있음.
scope로 state를 한정할때의 이점
기존에 앱을 구현하는 방식에서는 특정 오브젝트 내에서 그 오브젝트가 필요로하는 state 이상으로 메모리에 살아있게 만들 수 있고, 다른 곳에서 쉽게 접근할 수 있게 만들기도 함.
만약 이게 stream으로 전달된다면 그 오브젝트를 사용하지 않는 곳에서도 해당 이벤트를 받게 될 수도 있음.
이러한 방식은 불필요한 optional chain이나 unwrapping, force unwrapping등을 계속 사용해야 하므로 앱을 심플하게 만드는 데 어려움을 줌. 그래서 stateful한 오브젝트들을 그 범위 내에서만 살아있도록 만들어 코드를 간략하게 작성되도록 할 수 있음.
또한 DI가 제대로 된다는 가정하에 코드를 작성하면 되므로 각 RIB은 독립적인 단위가 되어 모듈화를 하기에 좋음.
scope로 state의 범위를 한정할 때 아래와 같이 코드 작성 가능
// 범위를 한정하지 않았을 때, foo 변수는 있을 수도 있고 없을 수도 있음 class Something { var foo: Foo? func bar() { if let foo = foo { doSomething(foo) } else { ///... } } } // 해당 스코프 내에서는 foo가 옵셔널이 아님을 확신하고 동작하도록 할 수 있음 class Something2 { var foo: Foo func bar() { doSomething(foo) } }
다른 RIB과의 통신이 필요한 경우에는 어떻게?
트리형태의 구조로서 Upward와 Downward Communication 방식이 있음
Upward Communication(부모노드→자식노드)
- Listener 인터페이스를 사용
- (부모가)자신의 listener 인터페이스를 선언해 놓고, (자식이) 주입받은 listener를 동작시키는 방식으로 커뮤니케이션을 함
- 부모 노드는 자식노드를 알지만, 자식노드는 부모노드를 알지 못함. 그래서 자식이 부모의 동작을 직접적으로 호출하는 걸 막으면서 결합도가 높아지는 것을 방지하고자 함.
- (부모가)자신의 listener 인터페이스를 선언해 놓고, (자식이) 주입받은 listener를 동작시키는 방식으로 커뮤니케이션을 함
Downward Communication(자식노드→부모노드)
- Listener 인터페이스 사용 X
- 자식이 여러개 있을 수 있어서 어떤 자식의 어떤 기능을 호출해야하는지 판별하기가 복잡함
- Rx를 이용하여 전달
- 특정 이벤트가 발생할때마다 스트림을 만들고 이벤트를 emit하는 방식
- 부모는 계속 이벤트를 emit하면 자식중에 필요한 자식이 구독하여 사용하는 형태
단점
- 보일러플레이트 코드가 많음
- 하나의 RIB을 위해서는 5-6개의 컴포넌트가 필요
- 우버립스는 템플릿을 제공해줌. 이로 생성할때의 어려움은 해결됨
- 하지만 관리해야 할 클래스나 파일등이 많이 늘어나고 읽어야하는 코드량도 늘어남
- DI
- 모듈화가 잘되어있어서(?) 매번 새로운 객체를 생성하거나 RIB을 연결할때마다 일일히 DI를 해주어야 하는데 이게 번거로움