[iOS] Rx 라이브러리들 활용해보기

이상진·2026년 4월 2일
post-thumbnail

개요

현재 프로젝트에서 사용하고 있는 Rx 관련 라이브러리들과 Util 로 같이 사용하기 좋은 라이브러리들은 다음과 같습니다.

  • RxSwift / RxCocoa / ReactorKit
  • RxRelay
  • RxDataSources / ReusableKit (with CompositionalLayout)

각 라이브러리 별 개념 설명보다는 “어떻게” 활용하고 있는지에 대해 회고해보고자 합니다.


RxSwift + RxCocoa + ReactorKit

1. ReactorKit 의 단방향 흐름

이 세 라이브러리는 하나의 흐름으로 묶어서 사용합니다. ReactorKit이 아키텍처 골격을 잡고, RxSwift가 비동기 흐름을, RxCocoa가 UIKit과의 연결을 담당합니다.

ReactorKit의 장점으로는 데이터를 단방향 흐름으로 구성할 수 있는 것인데요, Actionmutate()Mutationreduce()State 사이클을 따릅니다. 이 프로젝트에서 HomeReactor를 예시로 보면, 각 역할이 명확하게 분리됩니다.

  • HomeReactor 의 Action , Mutation , State
  enum Action {
      case fetchPostList
      case loadNextPage
      case toggleLike(String, Bool)
      case searchKeyword(String)
      ...
  }

  enum Mutation {
      case setLoading(Bool)
      case setFetchCompleted(Page<Post>)
      case setLikeStatus(String, Bool)
      case setNeedsLogin
      ...
  }

  struct State {
      var posts: [Post] = []
      var isLoading: Bool = false
      @Pulse var error: AppError?
      @Pulse var needsLogin: Bool = false
      ...
  }

mutate()는 사이드이펙트(API 호출 등)를 담당하고, reduce()는 순수하게 상태만 갱신합니다. 덕분에 버그가 생겼을 때 어느 레이어 문제인지 바로 좁혀집니다.

  • HomeReactor 의 mutate() , reduce()
extension HomeReactor {
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .fetchPostList:
            return fetchPosts(page: self.currentState.currentPage)

        case .loadNextPage:
            guard !self.currentState.isLoading else {
                return .empty()
            }
            
            guard self.currentState.hasNextPage else {
                return .empty()
            }

            return fetchPosts(page: self.currentState.currentPage + 1)
            ...
            
            
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case .setLoading(let isLoading):
            newState.isLoading = isLoading
            newState.sections = self.buildSections(
                selectedCategory: newState.selectedCategory,
                posts: newState.posts,
                isLoading: newState.isLoading
            )

        case .setFetchCompleted(let page):
            if page.page == 1 {
                newState.posts = page.data
            } else {
                newState.posts += page.data
            }
            
            newState.currentPage = page.page
            newState.hasNextPage = newState.posts.count < page.total
            newState.sections = self.buildSections(
                selectedCategory: newState.selectedCategory,
                posts: newState.posts,
                isLoading: newState.isLoading
            )

2. async/await ↔ RxSwift 브릿지

Domain/Data 레이어는 async/await 기반으로 설계했습니다.

  • Data/PostRemoteDataSource
public final class AuthRemoteDataSourceImpl: AuthRemoteDataSource {
		...
    public func signInWithIdToken(
        provider: AuthProvider,
        idToken: String,
        nonce: String?
    ) async throws -> AuthTokenResponse {
        guard let oidcProvider = OpenIDConnectCredentials.Provider(rawValue: provider.rawValue) else {
            throw AppError.auth(.providerFailed)
        }

        let session = try await performAuth {
            try await self.client.auth.signInWithIdToken(
                credentials: .init(
                    provider: oidcProvider,
                    idToken: idToken,
                    nonce: nonce
                )
            )
        }
        
        return AuthTokenResponse(
            accessToken: session.accessToken,
            refreshToken: session.refreshToken
        )
    }

    ...
  • Domain/Implement/CheckAuthOnLaunchUseCaseImpl
final class CheckAuthOnLaunchUseCaseImpl: CheckAuthOnLaunchUseCase {
    private let authRepository: AuthRepository
    private let userRepository: UserRepository
    private let userStore: UserStore

    init(
        authRepository: AuthRepository,
        userRepository: UserRepository,
        userStore: UserStore
    ) {
        self.authRepository = authRepository
        self.userRepository = userRepository
        self.userStore = userStore
    }

    func execute() async throws -> AuthState {
        // 1. 토큰 없음 → 익명 로그인
        if case .noToken = self.authRepository.currentTokenStatus() {
            _ = try await self.authRepository.signInAnonymously()
            self.userStore.clear()
            return .anonymous
        }

        // 2. 토큰 있음 → GET /me (인터셉터가 토큰 주입 + 401 리프레시)
        do {
            if let user = try await self.userRepository.fetchMe() {
                self.userStore.setUser(user)
                return .authenticated(user)
            } else {
                self.userStore.clear()
                return .anonymous
            }
        } catch let error as AppError where error.isRequireReAuth {
            // 3. 401 (리프레시도 실패)
            self.userStore.clear()
            switch self.authRepository.currentTokenStatus() {
            case .valid(let isAnonymous), .expired(let isAnonymous):
                if isAnonymous {
                    _ = try await self.authRepository.signInAnonymously()
                    return .anonymous
                }

                try self.authRepository.clearToken()
                return .unauthenticated

            case .noToken:
                _ = try await self.authRepository.signInAnonymously()
                return .anonymous
            }
        }
    }
}

Data 와 Domain 모듈에서 async/await 형태로 구성한 이유는 우선 Data 모듈의 경우 “데이터를 어떻게 가져올 것인지” 에 대해 초점을 맞춘 모듈이기에, 주로 Remote Server DB 혹은 Local(Device) DB 에서 데이터를 가져오는 로직들로 구성이 되어 있고, Domain 의 경우 Data 모듈에서 가져온 데이터를 기반으로 “어떻게 데이터를 가공할지” 목적이므로 비즈니스 로직을 적용하는 부분입니다.

따라서 Feature 모듈의 경우 Domain 모듈의 UseCase 를 이용하여 가공된 데이터를 받아와 이를 화면에 구성해야 하는데요, 이전 Data/Domain 의 경우 데이터를 받아오고 가공하는 역할을 하므로 Reactor 에서 사용하는 Observable 을 반환하지 않고 async/await 로 구성하였습니다.

Feature 모듈에서는 주로 Reactor를 사용하고 이는 MVVM 구조에서 ViewModel 역할을 합니다. ReactorObservable을 요구하므로 이를 위해 Observable.task extension 을 만들어 사용하면 편리하게 사용할 수 있습니다.

  • Shared_ReactiveX/Rx/Observable+Task.swift
import RxSwift

extension Observable {
    /// async throws 함수를 Observable로 변환
    /// - Parameter work: async throws 클로저
    /// - Returns: 성공 시 onNext, 실패 시 onError를 방출하는 Observable
    public static func task(_ work: @escaping () async throws -> Element) -> Observable<Element> {
        Observable.create { observer in
            let task = Task {
                do {
                    let result = try await work()
                    observer.onNext(result)
                    observer.onCompleted()
                } catch {
                    observer.onError(error)
                }
            }
            return Disposables.create { task.cancel() }
        }
    }
}

위 extension 을 사용 시에는 이렇게 사용할 수 있습니다.

extension AuthReactor {
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .checkAuth:
		        // Observable.task extension 사용
            return Observable.task { try await self.dependency.checkAuthOnLaunchUseCase.execute() }
                .map { Mutation.setAuthState($0) }
                .catch { error in
                    let appError = (error as? AppError) ?? .unknown(message: error.localizedDescription)
                    return .just(.setError(appError))
                }
        }
    }

즉 해당 task 안에 UseCase 의 함수를 호출하는 클로저를 사용할 수 있습니다.

3. @Pulse - 일회성 이벤트

에러나 로그인 유도 같은 "한 번만 발생해야 하는 이벤트"를 State에 담으면 문제가 생깁니다. 화면 전환 후 다시 구독할 때 이전값이 replay되기 때문입니다. 다시 말해 “이전 상태에 영향을 받지 않고, 한 번만 처리되어야 하는 이벤트”일 때 ReactorKit의 @Pulse가 이를 해결합니다.

  // Reactor
  @Pulse var error: AppError?
  @Pulse var needsLogin: Bool = false

  // ViewController
  reactor.pulse(\.$needsLogin)
      .filter { $0 }
      .subscribe(onNext: { [weak self] _ in
          ConfirmDialog.show(on: self, ...)
      })
      .disposed(by: self.disposeBag)

RxRelay

BehaviorRelay는 앱 전역에서 공유되는 상태를 안전하게 관리하기 위해 사용했습니다. BehaviorSubject와 달리 onError / onCompleted를 방출할 수 없기 때문에, 스트림이 예상치 못하게 종료되는 사고를 막을 수 있습니다.

해당 프로젝트에서는 UserStore 라고 네이밍을 지어 로그인 이후 앱 내 사용되는 사용자의 정보들을 전역적으로 관리하였습니다.

  • AppCore/UserStore.swift
  public final class UserStore {
      public let currentUser = BehaviorRelay<User?>(value: nil)

      public var isLoggedIn: Bool {
          return self.currentUser.value != nil
      }

      public func setUser(_ user: User) {
          self.currentUser.accept(user)
      }

      public func clear() {
          self.currentUser.accept(nil)
      }
  }

.value 로 현재 값을 동기적으로 읽을 수 있어서, Reactor 내부에서 로그인 여부 체크처럼 스트림을 구독하지 않아도 되는 경우에 유용합니다.

ex)

  case .toggleLike(let postId, let isLiked):
      guard self.dependency.userStore.isLoggedIn else {
          return .just(.setNeedsLogin)
      }

다른 Reactor에서 로그인 상태 변화를 구독할 때는 transform(mutation:)에서 merge합니다.

  func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
      let userMutation = self.dependency.userStore.currentUser
          .map { Mutation.setUser($0) }
          .asObservable()
      return .merge(mutation, userMutation)
  }

CompositionalLayout + RxDataSources + ReusableKit

이 세개는 CollectionView 를 구성할 때 함께 사용됩니다.

  • CompositionalLayout → 섹션별 레이아웃 정의
  • RxDataSources → UITableView / UICollectionView 여러 섹션의 데이터를 RxSwift 방식으로 바인딩하기 위한 라이브러리
  • ReusableKit → registerdeque 과정에서 Identifier 하드코딩을 피하기 위함

다음 홈 화면을 기준으로 설명해보겠습니다.

▲ 그림 1. 홈 화면 구성

홈 화면에서는 CollectionView 를 사용하고 있는데 우선은 크게 3가지로 나뉘어져 있습니다.

  1. 카테고리 배너(전체, 뷰티, 푸드, 패션,…)
  2. 이번 주 핫딜 Top 10 버튼
  3. 공동구매 피드들

우선 CollectionView 에서 위 세가지로 섹션을 나누었다면, 이제 CollectionViewLayout 을 구성하여 각 Section 마다 레이아웃을 정의해야 하는데요, UICollectionViewCompositionalLayout을 섹션 인덱스 기반으로 3가지 레이아웃을 반환하는 구조입니다. 클로저 기반 초기화를 사용해서 섹션마다 완전히 다른 레이아웃을 정의하고, 하나의 CollectionView 에서 이를 구성하게 됩니다.

  • HomeCollectionViewLayout.swift
enum HomeCollectionViewLayout {
    static func create() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { sectionIndex, _ in
            switch sectionIndex {
            case 0:
                return createCategorySection()
                
            case 1:
                return createBannerSection()
                
            default:
                return createPostListSection()
            }
        }
    }
    
    // MARK: - Section 0: 카테고리 칩

    private static func createCategorySection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .estimated(80),
            heightDimension: .absolute(36)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .estimated(80),
            heightDimension: .absolute(36)
        )
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: groupSize,
            subitems: [item]
        )

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.interGroupSpacing = 8
        section.contentInsets = NSDirectionalEdgeInsets(
            top: 12, leading: 16, bottom: 12, trailing: 16
        )
        return section
    }

    // MARK: - Section 1: TOP 10 배너

    private static func createBannerSection() -> NSCollectionLayoutSection {
        ...
    }

    // MARK: - Section 2: 공동구매 리스트

    private static func createPostListSection() -> NSCollectionLayoutSection {
        ...
    }
}
  • HomeViewController.swift
    private lazy var collectionView: UICollectionView = {
        let cv = UICollectionView(
            frame: .zero,
            collectionViewLayout: HomeCollectionViewLayout.create()
        )
        cv.backgroundColor = .systemBackground
        cv.register(Self.categoryCell)
        cv.register(Self.bannerCell)
        cv.register(Self.postCell)
        cv.register(Self.skeletonCell) // Shimmer 기능 -> 게시물 로드 전 스켈레톤 UI 제공
        return cv
    }()

그럼 여기까지해서 우선 CollectionViewLayout 을 통해 각 섹션 별로 레이아웃 정의하고, CollectionView 까지 ViewController 에서 생성했다면 이제는 RxSwift 로 바인딩할 차례입니다. 이 때 사용되는 라이브러리가 바로 RxDataSources 입니다.

바인딩하기 전에 먼저 현재 홈 화면 특징을 살펴보면, 카테고리 Cell 과 공동구매 피드 Cell 이 두개에서 관리하고 있는 상태가 있습니다.

  1. 카테고리 Cell → 카테고리 필터링 기능이라 사용자가 선택한 카테고리에 따라 UI 업데이트
  2. 공동구매 Cell
    1. 좋아요 버튼
    2. 좋아요 수: 좋아요 버튼 사용자가 클릭 시 좋아요 수 + 1

즉 해당 Cell 들은 상태 변수를 가지고 있고 이에 따라 UI 를 업데이트해야 하는데, 이 때 필요한 것은 SectionModel 입니다. SectionModel 에서 각 Cell 마다 상태 변경을 감지해야 하는 경우 IdentifiableType, Equtable 을 이용해서 감지할 수 있습니다.

  • HomeSectionItem ⇒ 무엇이 변경되었는가
enum HomeSectionItem: IdentifiableType, Equatable {
    case category(GroupBuyingCategory?, Bool)
    case top10Banner
    case post(Post)
    case skeleton(Int)

    var identity: String {
        switch self {
        case .category(let category, _):
            return "category_\(category?.rawValue ?? "all")"

        case .top10Banner:
            return "top10Banner"

        case .post(let post):
            return "post_\(post.id)"
            
        case .skeleton(let index):
            return "skeleton_\(index)"
        }
    }

    static func == (lhs: HomeSectionItem, rhs: HomeSectionItem) -> Bool {
        switch (lhs, rhs) {
        case (.category(let lCat, let lSel), .category(let rCat, let rSel)):
            return lCat == rCat && lSel == rSel

        case (.top10Banner, .top10Banner):
            return true

        case (.post(let lPost), .post(let rPost)):
            return lPost.id == rPost.id
                && lPost.likesCount == rPost.likesCount
                && lPost.isLiked == rPost.isLiked
        
        case (.skeleton(let l), .skeleton(let r)):
            return l == r

        default:
            return false
        }
    }
}
...
  • HomeSectionModel ⇒ 어느 섹션에 속하는가
// MARK: - Section Model

enum HomeSectionModel {
    case category(items: [HomeSectionItem])
    case top10Banner(items: [HomeSectionItem])
    case postList(items: [HomeSectionItem])
}

extension HomeSectionModel: AnimatableSectionModelType {
    typealias Item = HomeSectionItem

    var identity: String {
        switch self {
        case .category:
            return "category"

        case .top10Banner:
            return "top10Banner"

        case .postList:
            return "postList"
        }
    }

    var items: [HomeSectionItem] {
        switch self {
        case .category(let items):
            return items

        case .top10Banner(let items):
            return items

        case .postList(let items):
            return items
        }
    }

    init(original: HomeSectionModel, items: [HomeSectionItem]) {
        switch original {
        case .category:
            self = .category(items: items)

        case .top10Banner:
            self = .top10Banner(items: items)

        case .postList:
            self = .postList(items: items)
        }
    }
}

결국 SectionModel 은 “CollectionView 의 데이터 구조를 Rx 스트림과 연결하기 위한 컨테이너” 이고, diff 로직의 입력값 역할을 합니다.

이제 그럼 바인딩할 준비는 모두 되었습니다. 위에 언급한 컨테이너까지 만들었으니 바인딩을 해봅시다.

extension HomeViewController: View {
    func bind(reactor: HomeReactor) {

        // MARK: - DataSource

        let dataSource = RxCollectionViewSectionedAnimatedDataSource<HomeSectionModel>(
            animationConfiguration: .init(insertAnimation: .none, reloadAnimation: .none, deleteAnimation: .none),
            configureCell: { [weak reactor] _, collectionView, indexPath, item in
                switch item {
                case .category(let category, let isSelected):
                    let cell = collectionView.dequeue(HomeViewController.categoryCell, for: indexPath)
                    cell.configure(
                        dependency: .init(),
                        payload: .init(
                            category: category,
                            isSelected: isSelected
                        )
                    )
                    return cell

                case .top10Banner:
                    let cell = collectionView.dequeue(HomeViewController.bannerCell, for: indexPath)
                    cell.configure(dependency: .init(), payload: .init())
                    return cell

                case .post(let post):
                    let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)
                    cell.configure(dependency: .init(), payload: .init(post: post))

                    cell.likeButton.rx.tap
                        .map { HomeReactor.Action.toggleLike(post.id, post.isLiked) }
                        .bind(to: reactor!.action)
                        .disposed(by: cell.disposeBag)

                    return cell

                case .skeleton(_):
                    let cell = collectionView.dequeue(HomeViewController.skeletonCell, for: indexPath)
                    return cell
                }
            }
        )

위와 같이 DataSource 를 만들었다면 이제 Reactor 의 State 와 연결할 차례입니다.

  // MARK: - State

  reactor.state.map(\.sections)
      .observe(on: MainScheduler.asyncInstance)
      .bind(to: self.collectionView.rx.items(dataSource: dataSource))
      .disposed(by: self.disposeBag)

reactor.state에서 sections 프로퍼티만 추출해서 CollectionView에 바인딩합니다. 이제 Reactor에서 sections 배열이 바뀔 때마다 RxDataSources가 diff를 계산해서 변경된 셀만 업데이트합니다. 이게 전부입니다.

DataSource 를 생성할 때 configureCell 에서 셀을 반환하는 부분을 다시 보면

let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)

원래 UIKit 에서는 이렇게 써야 합니다.

let cell = collectionView.dequeueReusableCell(
      withReuseIdentifier: "HomePostCardCell",
      for: indexPath
  ) as! HomePostCardCell

여기서 문제점은 as!강제 캐스팅, 다른 하나는 문자열 identifier입니다. identifier 오타가 나거나 등록하지 않은 셀을 dequeue하면 런타임 크래시가 납니다. 그러므로 이 때 활용되는 라이브러리인 ReusableKit 이 있는데요,

ReusableKit은 이 두 문제를 제네릭으로 해결합니다. ReusableCell<Cell>이 셀 타입과 identifier를 함께 들고 있어서, registerdequeue가 항상 같은 타입·같은 identifier를 보장합니다.

  // 타입과 identifier를 한 곳에서 정의
  static let postCell = ReusableCell<HomePostCardCell>()

  // 등록 — Cell 타입을 이미 알고 있으므로 identifier 별도 관리 불필요
  cv.register(Self.postCell)

  // 재사용 — 반환 타입이 HomePostCardCell로 이미 결정됨
  let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)

내부 구현을 보면 단순합니다.

// ReusableKit 내부
public func dequeue<Cell>(_ cell: ReusableCell<Cell>, for indexPath: IndexPath) -> Cell {
    return self.dequeueReusableCell(withReuseIdentifier: cell.identifier, for: indexPath) as! Cell
}

as!는 라이브러리 내부에서 한 번만 쓰고, registerdequeue가 항상 같은 ReusableCell 인스턴스를 참조하기 때문에 타입 불일치가 구조적으로 불가능합니다. 결과적으로 CollectionView 셀 관련 코드에서 문자열과 강제 캐스팅이 완전히 사라집니다.


결론

지금까지 사이드프로젝트를 진행하면서 활용중인 Rx 라이브러리들에 대해 알아보았는데요, 짧게 다시 요약해보자면

  • RxSwift / RxCocoa / ReactorKit ⇒ 데이터의 단방향 흐름 관리
  • RxRelay ⇒ 전역적으로 공유하는 데이터 관리
  • RxDataSources / ReusableKit (with CompositionalLayout) ⇒ CollectionView 의 데이터 바인딩

각 라이브러리가 담당하는 역할이 명확하게 분리되어 있어서, 어디서 문제가 생겼는지 추적하기 쉽다는 게 가장 큰 장점이었습니다. 상태 변경은 Reactor에서, UI 업데이트는 RxDataSources의 diff가, 전역 상태는 RxRelay가 각자 책임지는 구조 덕분에 코드를 읽는 것만으로도 데이터 흐름이 눈에 들어옵니다.

다만 초기 진입 장벽은 분명히 있습니다. SectionModel 설계나 ReactorKitAction-Mutation-State 사이클에 익숙해지기까지 시간이 필요했고, 간단한 화면에서도 보일러플레이트가 적지 않습니다. 그럼에도 화면이 복잡해질수록 이 구조가 빛을 발한다는 걸 직접 느꼈습니다. 앞으로 기능이 늘어나면서 이 구조가 어떻게 유지되는지도 계속 기록해보려 합니다.


GitHub

해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.

https://github.com/sangjin-hash/09Market

0개의 댓글