UIKit RIBs 아키텍처
RIBs 유래
- RIBs는 우버에서 개발한 크로스 플랫폼 아키텍처 프레임워크이다
- 기존 VIPER 패턴을 개선하여, 비즈니스 로직만 있는 모듈을 쉽게 구현할 수 있다
- RIBs 관련 공식 블로그 글, RIBs 레포지토리
VIPER

- VIPER는 View, Interactor, Presenter, Entity, Router로 구성
- VIPER는 View Driven이기 때문에 비즈니스 로직만 있는 VIPER 노드 구현에 어려움이 있음
RIBs

- RIBs는 Router, Interactor, Builder의 약자
- 하나의 RIB은 Router, Interactor, Builder는 필수적으로 구성된다
- 필요에 따라 Presenter와 View까지 추가적으로 구성한다
- VIPER와 달리 View가 포함되지 않고 비즈니스 로직으로만 구성된 Viewless RIB 생성이 가능하다
RIBs 구성
Builder
- RIBs의 모든 구성 요소를 생성
- Router, Interactor, Presenter, View, Component 모두 생성
Dependency
- Dependency는 RIB이 올바르게 초기화 되기 위해 부모로부터 필요로 하는 의존성을 나열하는 프로토콜
Component
- Component는 Dependency 프로토콜을 구현하는 클래스
- Component는 RIB의 Builder에 부모 의존성을 제공하는 것 외에도, RIB이 자체적으로 생성하거나 자식 RIB을 위해 생성하는 의존성을 소유하는 역할도 담당
Router
- 자식 RIB을 attach 혹은 detach하여 RIBs의 논리적 트리 구조 형성
Interactor
- 비즈니스 로직을 수행하여 Router로 라우팅 호출
- RIBs의 attach와 detach를 요청
- Presenter로 data model을 전달
View
- UI를 생성하고 구성
- UI 이벤트를 Presenter로 전달
- ViewModel을 받아서 UI를 업데이트
Presenter
- Interactor와 View간의 통신을 담당
- Business Model을 ViewModel로 변환하는 역할
- 상태를 갖고 있지 않은 클래스
- Presenter를 생략하는 경우 ViewModel 변환은 View 또는 Interactor가 함
RIBs 트리

- RIBs는 트리 구조를 형성하여, 부모 RIB과 자식 RIB 간 통신을 함
- RIBs간의 통신은 각 RIB의 Interactor가 담당
- 부모에서 자식으로의 통신은 Stream을 넘겨주어서 데이터를 전달
- 자식에서 부모로의 통신은 자식 Interactor에서 부모 Interactor를 Listener로 접근
RIBs 예시
Board RIB
- 게시판의 게시물이 리스트 형태로 표시되는 화면
- 부모 RIB

PostDetail RIB
- 게시물의 상세 내용을 표시하는 화면
- 자식 RIB

파일 구조
- Board, PostDetail RIBs의 파일 구조는 다음과 같다

Board RIB
BoardBuilder
- BoardBuilder는 RIB의 모든 구성요소를 생성하고 의존성을 주입하는 역할
- postsStream을 통해 게시물 데이터를 관리하고 필요하다면 자식 RIB과 공유
- 자식 RIB인 PostDetailBuilder를 생성하는 역할까지 함
protocol BoardDependency: Dependency {
...
}
final class BoardComponent: Component<BoardDependency>, PostDetailDependency {
let postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)> = .init(value: (posts: [], initial: true, reload: true))
}
protocol BoardBuildable: Buildable {
func build(withListener listener: BoardListener) -> ViewableRouting
}
final class BoardBuilder: Builder<BoardDependency>, BoardBuildable {
func build(withListener listener: BoardListener) -> ViewableRouting {
let component = BoardComponent(dependency: dependency)
let viewController = BoardViewController()
let interactor = BoardInteractor(
presenter: viewController,
postsStream: component.postsStream
)
interactor.listener = listener
let postDetailBuilder = PostDetailBuilder(dependency: component)
return BoardRouter(
interactor: interactor,
viewController: viewController,
postDetailBuilder: postDetailBuilder
)
}
}
BoardInteractor
- 게시판의 비즈니스 로직을 담당
- 게시물 목록을 fetch 하는 네트워크 요청 처리
- postsStream을 구독하여 데이터 변경 시 View 업데이트
- PostDetail RIB 라우팅 호출
protocol BoardRouting: ViewableRouting {
func routeToPostDetail(post: PostEntity)
func detachPostDetail()
}
protocol BoardPresentable: Presentable {
var listener: BoardPresentableListener? { get set }
func update(posts: [Post], initial: Bool, reload: Bool)
}
protocol BoardListener: AnyObject {
...
}
final class BoardInteractor: PresentableInteractor<BoardPresentable>, BoardInteractable {
weak var router: BoardRouting?
weak var listener: BoardListener?
private let disposeBag = DisposeBag()
private let postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)>
private var selectedBoardId: String?
...
init(
presenter: BoardPresentable,
postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)>
) {
self.postsStream = postsStream
super.init(presenter: presenter)
presenter.listener = self
}
override func didBecomeActive() {
super.didBecomeActive()
Task {
do {
try await fetchPosts(initial: true)
} catch {
logError("Error fetching initial data: \(error)")
}
}
postsStream
.subscribe(onNext: { [weak self] posts in
self?.presenter.update(posts: posts.posts, initial: posts.initial, reload: posts.reload)
})
.disposed(by: disposeBag)
}
}
extension BoardInteractor {
private func fetchPosts(initial: Bool = false, reload: Bool = true) async throws {
let endpoint = PostEndpoint.fetchPosts(boardId: selectedBoardId, reload: reload)
let response: FetchPostsResponse = try await Network.shared.request(endpoint: endpoint)
let posts = response.data
self.postsStream.accept((posts: posts, initial: initial, reload: reload))
}
}
extension BoardInteractor: BoardPresentableListener {
func postDetailButtonDidTap(post: PostEntity) {
router?.routeToPostDetail(post: post)
}
}
extension BoardInteractor: PostDetailListener {
func detachPostDetail() {
router?.detachPostDetail()
}
}
BoardViewController
- 게시판 UI를 담당하며, UITableView를 통해 게시물 목록 표시
- NavigationController를 통해 화면 이동 기능 구현 (push / pop)
- Interactor와 통신하여 사용자 이벤트 전달
protocol BoardPresentableListener: AnyObject {
func postDetailButtonDidTap(post: PostEntity)
}
final class BoardViewController: UIViewController {
weak var listener: BoardPresentableListener?
private var posts = [PostEntity]()
...
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
...
}
extension BoardViewController: BoardPresentable {
func update(posts: [Post], initial: Bool, reload: Bool) {
Task {
self.posts = try await withThrowingTaskGroup(of: PostEntity.self) { group in
...
}
// UI 업데이트
await MainActor.run {
...
}
}
}
}
extension BoardViewController: BoardViewControllable {
func push(_ viewControllable: ViewControllable, animated: Bool) {
DispatchQueue.main.async {
self.navigationController?.pushViewController(viewControllable.uiviewController, animated: animated)
}
}
func pop(_ viewControllable: ViewControllable, animated: Bool) {
DispatchQueue.main.async {
viewControllable.uiviewController.navigationController?.popViewController(animated: animated)
}
}
}
extension BoardViewController: UITableViewDelegate, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
...
let post = posts[indexPath.row]
listener?.postDetailButtonDidTap(post: post)
}
}
BoardRouter
- Board RIB과 PostDetail RIB 간의 라우팅 담당
- PostDetail RIB의 attach 및 detach 관리
protocol BoardInteractable: Interactable, PostDetailListener {
var router: BoardRouting? { get set }
var listener: BoardListener? { get set }
}
protocol BoardViewControllable: ViewControllable {
func push(_ viewControllable: ViewControllable, animated: Bool)
func pop(_ viewControllable: ViewControllable, animated: Bool)
}
final class BoardRouter: ViewableRouter<BoardInteractable, BoardViewControllable> {
private let postDetailBuilder: PostDetailBuildable
private var postDetailRouter: PostDetailRouting?
init(
interactor: BoardInteractable,
viewController: BoardViewControllable,
postDetailBuilder: PostDetailBuildable
) {
self.postDetailBuilder = postDetailBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
}
extension BoardRouter: BoardRouting {
func routeToPostDetail(post: PostEntity) {
let router = postDetailBuilder.build(withListener: interactor, post: post)
postDetailRouter = router
attachChild(router)
viewController.push(router.viewControllable, animated: true)
}
func detachPostDetail() {
guard let router = postDetailRouter else { return }
viewController.pop(router.viewControllable, animated: true)
detachChild(router)
postDetailRouter = nil
}
}
PostDetail RIB
PostDetailBuilder
- PostDetail RIB의 구성요소 생성
protocol PostDetailDependency: Dependency {
...
}
final class PostDetailComponent: Component<PostDetailDependency> {
...
}
protocol PostDetailBuildable: Buildable {
func build(withListener listener: PostDetailListener, post: PostEntity) -> PostDetailRouting
}
final class PostDetailBuilder: Builder<PostDetailDependency>, PostDetailBuildable {
override init(dependency: PostDetailDependency) {
super.init(dependency: dependency)
}
func build(withListener listener: PostDetailListener, post: PostEntity) -> PostDetailRouting {
let component = PostDetailComponent(dependency: dependency)
let viewController = PostDetailViewController()
let interactor = PostDetailInteractor(presenter: viewController, post: post)
interactor.listener = listener
return PostDetailRouter(interactor: interactor, viewController: viewController)
}
}
PostDetailInteractor
- 게시물 상세 화면의 비즈니스 로직 담당
- 게시물 데이터를 View에 전달
- PostDetail RIB 라우팅을 부모 RIB에 호출
protocol PostDetailRouting: ViewableRouting {
...
}
protocol PostDetailPresentable: Presentable {
var listener: PostDetailPresentableListener? { get set }
func update(post: PostEntity)
}
protocol PostDetailListener: AnyObject {
func detachPostDetail()
}
final class PostDetailInteractor: PresentableInteractor<PostDetailPresentable>, PostDetailInteractable {
weak var router: PostDetailRouting?
weak var listener: PostDetailListener?
private let post: PostEntity
init(presenter: PostDetailPresentable, post: PostEntity) {
self.post = post
super.init(presenter: presenter)
presenter.listener = self
}
override func didBecomeActive() {
super.didBecomeActive()
presenter.update(post: post)
}
}
extension PostDetailInteractor: PostDetailPresentableListener {
func backButtonDidTap() {
listener?.detachPostDetail()
}
}
PostDetailViewController
- 게시물 상세 화면 UI 담당하여, 게시물 정보 표시
- 뒤로가기 버튼 등 UI 이벤트 처리
protocol PostDetailPresentableListener: AnyObject {
func backButtonDidTap()
}
final class PostDetailViewController: UIViewController {
weak var listener: PostDetailPresentableListener?
...
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
}
extension PostDetailViewController {
...
@objc private func backButtonTapped() {
listener?.backButtonDidTap()
}
}
extension PostDetailViewController: PostDetailViewControllable {
...
}
extension PostDetailViewController: PostDetailPresentable {
func update(post: PostEntity) {
// UI 업데이트
}
}
PostDetailRouter
- PostDetail RIB의 라우팅 담당
- 필요한 경우 추가적인 자식 RIB 연결 가능
protocol PostDetailInteractable: Interactable {
var router: PostDetailRouting? { get set }
var listener: PostDetailListener? { get set }
}
protocol PostDetailViewControllable: ViewControllable {
...
}
final class PostDetailRouter: ViewableRouter<PostDetailInteractable, PostDetailViewControllable>, PostDetailRouting {
override init(interactor: PostDetailInteractable, viewController: PostDetailViewControllable) {
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
}
DeepLink
DeepLink란
DeepLink는 앱 내의 특정 화면으로 직접 이동할 수 있게 해주는 링크. 웹처럼 앱에서도 특정 리소스에 대한 고유한 주소를 가질 수 있으며, 다음과 같은 형태로 구성.
- Custom URL Scheme (앱 고유의 스킴 사용):
myapp://product/123
- Universal Link (앱과 웹 모두에서 동작):
https://example.com/product/123
Deep Link 활용 예시
- 게임 초대:
ribs-training://launchGame?gameId=ticTacToe&inviteCode=ABC123
- 특정 화면 이동:
ribs-training://profile?userId=12345
- 프로모션 연결:
ribs-training://promotion?code=SUMMER2024
Workflow란
Workflow는 RIBs 트리에서 비동기 작업의 시퀀스를 나타내는 패턴으로, DeepLink 처리에 이상적인 도구. 콜백 지옥이나 복잡한 상태 관리 코드 없이 복잡한 플로우를 Workflow로 구현할수 있다.
- RIBs 트리에서 비동기 작업의 시퀀스를 나타내는 패턴
- 여러 RIB을 거쳐가며 복잡한 네비게이션을 처리
- Reactive Stream을 사용하여 단계별 진행 관리
Workflow 동작 원리
- Step 정의: 각 단계에서 수행할 작업을 정의
- ActionableItem 전달: 각 RIB의 Interactor가 ActionableItem 프로토콜을 구현
- 비동기 처리: Observable을 반환하여 비동기 작업 지원
- 상태 전이: ReplaySubject를 사용하여 ActionableItem 전달
RIBs에서의 구현
URLHandler:
- URL을 처리하는 프로토콜을 정의
- AppDelegate에서 구현하여 URL 수신 및 라우팅 담당
protocol UrlHandler: AnyObject {
func handle(_ url: URL)
}
AppDelegate:
- iOS에서 URL 스킴을 통해 앱이 열릴 때 호출
- URL을 RootInteractor로 전달하여 처리
class AppDelegate: UIResponder, UIApplicationDelegate {
private var urlHandler: UrlHandler?
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
urlHandler?.handle(url)
return true
}
}
RootInteractor:
- 앱의 최상위 RIB의 Interactor로, 앱 전체의 진입점 역할
- UrlHandler를 통해 DeepLink 처리를 담당
- Workflow를 시작하고 구독하여 DeepLink 네비게이션 실행
extension RootInteractor: UrlHandler {
func handle(_ url: URL) {
let launchGameWorkflow = LaunchGameWorkflow(url: url)
launchGameWorkflow
.subscribe(self)
.disposeOnDeactivate(interactor: self)
}
}
LaunchGameWorkflow:
- DeepLink를 처리하는 Workflow 클래스로, 복잡한 네비게이션을 단계별로 관리
- 로그인 상태를 확인하고 URL에서 게임 ID를 추출해서 게임을 실행하는 플로우 구현
- Observable 체인을 통해 비동기 작업의 순서를 보장
public class LaunchGameWorkflow: Workflow<RootActionableItem> {
private let url: URL
public init(url: URL) {
self.url = url
super.init()
self
.onStep { (rootItem: RootActionableItem) -> Observable<(LoggedInActionableItem, ())> in
// 1단계: 로그인 대기
rootItem.waitForLogin()
}
.onStep { (loggedInItem: LoggedInActionableItem, _) -> Observable<(LoggedInActionableItem, ())> in
// 2단계: 게임 ID 파싱 및 게임 실행
guard let gameId = self.extractGameId(from: self.url) else {
return Observable.empty()
}
return loggedInItem.launchGame(with: gameId)
.map { (loggedInItem, ()) }
}
.commit()
}
private func extractGameId(from url: URL) -> String? {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
return components?.queryItems?.first(where: { $0.name == "gameId" })?.value
}
}
ActionableItem:
- Workflow가 각 RIB에서 수행할 수 있는 작업을 정의하는 프로토콜
- 각 RIB의 Interactor가 이 프로토콜을 구현하여 Workflow와 통신
- Observable을 반환하여 비동기 작업의 완료를 알리고 다음 단계로 진행
// Root 레벨 ActionableItem
extension RootInteractor: RootActionableItem {
func waitForLogin() -> Observable<(LoggedInActionableItem, ())> {
return loggedInActionableItemSubject
.map { ($0, ()) }
}
}
// LoggedIn 레벨 ActionableItem
extension LoggedInInteractor: LoggedInActionableItem {
func launchGame(with gameId: String) -> Observable<Void> {
router?.routeToGame(with: gameId)
return Observable.just(())
}
}
ActionableItemSubject:
class RootInteractor {
private let loggedInActionableItemSubject = ReplaySubject<LoggedInActionableItem>.create(bufferSize: 1)
private func routeToLoggedIn() {
let router = loggedInBuilder.build(withListener: self)
self.loggedInRouter = router
attachChild(router)
// Workflow에 ActionableItem 전달
loggedInActionableItemSubject.onNext(router.interactable)
}
}
SwiftUI
UIKit 기반의 RIBs 아키텍처에 SwiftUI를 사용하는 방법.
Flow
- State: Interactor → Presenter → ViewModel → View
- Action: View → ViewModel → Interactor
State & Action
struct ProfileViewState {
var member: EntityMember?
var logoutAlert = false
var deleteAlert = false
}
enum ProfileViewAction {
case logout
case deleteAccount
}
ViewModel
@Observable
final class ProfileViewModel {
typealias State = ProfileViewState
typealias Action = ProfileViewAction
var state: State
private weak var listener: ProfilePresentableListener?
init(state: State) {
self.state = state
}
func update(state: State) {
self.state = state
}
func request(action: Action) {
listener?.request(action: action)
}
}
extension ProfileViewModel {
func handleLogout() {
request(action: .logout)
}
func handleDelete() {
request(action: .deleteAccount)
}
}
View
struct ProfileView: View {
@State var viewModel: ProfileViewModel
init(viewModel: ProfileViewModel) {
self._viewModel = .init(initialValue: viewModel)
}
var body: some View {
VStack(spacing: .zero) {
Button {
viewModel.state.logoutAlert = true
} label: {
...
}
...
Button {
viewModel.state.deleteAlert = true
} label: {
...
}
}
.alert("정말 로그아웃하시겠습니까?", isPresented: $viewModel.state.logoutAlert) {
Button("확인", role: .destructive) {
viewModel.handleLogout()
}
Button("취소", role: .cancel) {}
}
.alert("정말 탈퇴하시겠습니까?", isPresented: $viewModel.state.deleteAlert) {
Button("확인", role: .destructive) {
viewModel.handleDelete()
}
Button("취소", role: .cancel) {}
}
}
}
Presenter
protocol ProfilePresentable: Presentable {
var listener: ProfilePresentableListener? { get set }
func update(state: ProfileViewState)
}
final class ProfileViewController: UIViewController {
let viewModel: ProfileViewModel
weak var listener: ProfilePresentableListener? {
didSet {
viewModel.listener = listener
}
}
private let disposeBag = DisposeBag()
private let memberStream: BehaviorRelay<EntityMember?>
init(memberStream: BehaviorRelay<EntityMember?>) {
self.memberStream = memberStream
self.viewModel = ProfileViewModel(state: .init(myself: memberStream.value))
super.init(nibName: nil, bundle: nil)
}
...
override func viewDidLoad() {
super.viewDidLoad()
bindMemberStream()
}
}
extension ProfileViewController {
private func bindMemberStream() {
memberStream
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] member in
guard let self else { return }
self.update(state: .init(myself: member))
})
.disposed(by: disposeBag)
}
}
extension ProfileViewController: ProfilePresentable {
func update(state: ProfileViewState) {
viewModel.update(state: state)
}
}
Interactor
final class ProfileInteractor: PresentableInteractor<ProfilePresentable>, ProfileInteractable {
weak var router: ProfileRouting?
weak var listener: ProfileListener?
override init(presenter: ProfilePresentable) {
super.init(presenter: presenter)
presenter.listener = self
}
}
extension ProfileInteractor: ProfilePresentableListener {
func request(action: ProfileViewAction) {
switch action {
case .logout:
handleLogout()
case .deleteAccount:
handleDeleteAccount()
}
}
private func handleLogout() {
...
}
private func handleDeleteAccount() {
...
}
}