
현재 프로젝트에서 사용하고 있는 Rx 관련 라이브러리들과 Util 로 같이 사용하기 좋은 라이브러리들은 다음과 같습니다.
각 라이브러리 별 개념 설명보다는 “어떻게” 활용하고 있는지에 대해 회고해보고자 합니다.
이 세 라이브러리는 하나의 흐름으로 묶어서 사용합니다. ReactorKit이 아키텍처 골격을 잡고, RxSwift가 비동기 흐름을, RxCocoa가 UIKit과의 연결을 담당합니다.
ReactorKit의 장점으로는 데이터를 단방향 흐름으로 구성할 수 있는 것인데요, Action → mutate() → Mutation → reduce() → State 사이클을 따릅니다. 이 프로젝트에서 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()는 순수하게 상태만 갱신합니다. 덕분에 버그가 생겼을 때 어느 레이어 문제인지 바로 좁혀집니다.
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
)
Domain/Data 레이어는 async/await 기반으로 설계했습니다.
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
)
}
...
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 역할을 합니다. Reactor는 Observable을 요구하므로 이를 위해 Observable.task extension 을 만들어 사용하면 편리하게 사용할 수 있습니다.
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 의 함수를 호출하는 클로저를 사용할 수 있습니다.
에러나 로그인 유도 같은 "한 번만 발생해야 하는 이벤트"를 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)
BehaviorRelay는 앱 전역에서 공유되는 상태를 안전하게 관리하기 위해 사용했습니다. BehaviorSubject와 달리 onError / onCompleted를 방출할 수 없기 때문에, 스트림이 예상치 못하게 종료되는 사고를 막을 수 있습니다.
해당 프로젝트에서는 UserStore 라고 네이밍을 지어 로그인 이후 앱 내 사용되는 사용자의 정보들을 전역적으로 관리하였습니다.
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)
}
이 세개는 CollectionView 를 구성할 때 함께 사용됩니다.
UITableView / UICollectionView 여러 섹션의 데이터를 RxSwift 방식으로 바인딩하기 위한 라이브러리register → deque 과정에서 Identifier 하드코딩을 피하기 위함다음 홈 화면을 기준으로 설명해보겠습니다.
▲ 그림 1. 홈 화면 구성
홈 화면에서는 CollectionView 를 사용하고 있는데 우선은 크게 3가지로 나뉘어져 있습니다.
우선 CollectionView 에서 위 세가지로 섹션을 나누었다면, 이제 CollectionViewLayout 을 구성하여 각 Section 마다 레이아웃을 정의해야 하는데요, UICollectionViewCompositionalLayout을 섹션 인덱스 기반으로 3가지 레이아웃을 반환하는 구조입니다. 클로저 기반 초기화를 사용해서 섹션마다 완전히 다른 레이아웃을 정의하고, 하나의 CollectionView 에서 이를 구성하게 됩니다.
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 {
...
}
}
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 이 두개에서 관리하고 있는 상태가 있습니다.
즉 해당 Cell 들은 상태 변수를 가지고 있고 이에 따라 UI 를 업데이트해야 하는데, 이 때 필요한 것은 SectionModel 입니다. SectionModel 에서 각 Cell 마다 상태 변경을 감지해야 하는 경우 IdentifiableType, Equtable 을 이용해서 감지할 수 있습니다.
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
}
}
}
...
// 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를 함께 들고 있어서, register와 dequeue가 항상 같은 타입·같은 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!는 라이브러리 내부에서 한 번만 쓰고, register와 dequeue가 항상 같은 ReusableCell 인스턴스를 참조하기 때문에 타입 불일치가 구조적으로 불가능합니다. 결과적으로 CollectionView 셀 관련 코드에서 문자열과 강제 캐스팅이 완전히 사라집니다.
지금까지 사이드프로젝트를 진행하면서 활용중인 Rx 라이브러리들에 대해 알아보았는데요, 짧게 다시 요약해보자면
각 라이브러리가 담당하는 역할이 명확하게 분리되어 있어서, 어디서 문제가 생겼는지 추적하기 쉽다는 게 가장 큰 장점이었습니다. 상태 변경은 Reactor에서, UI 업데이트는 RxDataSources의 diff가, 전역 상태는 RxRelay가 각자 책임지는 구조 덕분에 코드를 읽는 것만으로도 데이터 흐름이 눈에 들어옵니다.
다만 초기 진입 장벽은 분명히 있습니다. SectionModel 설계나 ReactorKit의 Action-Mutation-State 사이클에 익숙해지기까지 시간이 필요했고, 간단한 화면에서도 보일러플레이트가 적지 않습니다. 그럼에도 화면이 복잡해질수록 이 구조가 빛을 발한다는 걸 직접 느꼈습니다. 앞으로 기능이 늘어나면서 이 구조가 어떻게 유지되는지도 계속 기록해보려 합니다.
해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.