LODY/정리

애플 플랫폼 백과사전 / RxSwift에서 Combine으로 넘어가며 정리한 것들

RxSwift에서 Combine으로 넘어가며 정리한 것들

반응형이란

  • 값의 변화를 시간 축 위의 스트림으로 다루고, 스트림에 변환을 연쇄 적용해 결과 스트림을 얻는 방식
  • 명령형: "지금의 값을 꺼내 계산한다"
  • 반응형: "값이 바뀔 때마다 어떻게 이어지는지 미리 정의해 둔다"
  • 목적: 수동 구독·콜백·상태 동기화가 중첩되며 생기는 복잡도를 값의 변화 자체에 위임

반응형의 바닐라 구현

최소 구조 — 구독 · 값 보관 · 변경 전파.

final class Observable<T> {
    private var observers: [(T) -> Void] = []
    private var currentValue: T
 
    init(_ value: T) { self.currentValue = value }
 
    func subscribe(_ observer: @escaping (T) -> Void) {
        observers.append(observer)
        observer(currentValue)
    }
 
    func send(_ newValue: T) {
        currentValue = newValue
        observers.forEach { $0(newValue) }
    }
}

사용:

let name = Observable("로디")
name.subscribe { print("이름: \($0)") }   // 이름: 로디
name.send("Lody")                         // 이름: Lody

스트림 · 파이프라인 · 오퍼레이터

  • 스트림: Observable 하나 = 시간 축 위의 값 연속체
  • 파이프라인: 스트림 위에 변환을 연쇄 적용한 체인
  • 오퍼레이터: 파이프라인 구성 블록 (map · filter · throttle 등)

바닐라로 map 구현:

extension Observable {
    func map<U>(_ transform: @escaping (T) -> U) -> Observable<U> {
        let mapped = Observable<U>(transform(currentValue))
        subscribe { newValue in mapped.send(transform(newValue)) }
        return mapped
    }
}

"반응형 라이브러리"가 제공하는 것

  • 오퍼레이터 모음 (map · filter · throttle · debounce · merge · combineLatest · flatMap · switchToLatest ...)
  • 스케줄러·스레드 관리 (observe(on:) / receive(on:))
  • 구독·메모리 관리 (DisposeBag · AnyCancellable)
  • 에러 전파 모델
  • 타입 계약 (Driver 같은 파생 타입)

= 바닐라 반응형 구조를 운영 가능한 수준으로 끌어올린 주변 인프라


반응형 vs pub-sub

  • pub-sub: 아키텍처 패턴. publisher가 브로커로 이벤트 발행, subscriber가 구독. 라우팅·느슨한 결합
  • 반응형: 프로그래밍 패러다임. 값의 시간 연속체 위 변환 파이프라인
  • 반응형 라이브러리의 Subject가 pub-sub 성격. Subject 위에 오퍼레이터를 얹으면 반응형 파이프라인
  • 반응형 = pub-sub을 기초 블록으로 쓰되 그 위에 변환·합성·스케줄 계층을 덧댄 체계

RxSwift와 Combine

RxSwiftCombine
등장2015~, ReactiveX 계열2019 WWDC, Apple 퍼스트
유지 주체커뮤니티Apple
기반 플랫폼UIKit 중심SwiftUI 친화
스트림 타입Observable<T>Publisher<Output, Failure>
구독 수명DisposeBagSet<AnyCancellable>
SubjectBehavior/PublishSubject, RelayCurrentValue/PassthroughSubject
UI 타입 계약Driver, Signal, ControlEvent없음

= 반응형이라는 같은 골격 위의 다른 표면. 큰 그림은 공통, 세부 설계가 다름.


유틸리티의 폭

  • RxSwift: 커뮤니티 축적으로 편의 오퍼레이터 풍부 (withLatestFrom · materialize · share(replay:) · startWith · retry(when:) · scan · window · buffer 등)
  • Combine: Apple 특유의 "기본 최소 + 직접 조합" 철학. 편의 오퍼레이터 상당수에 1:1 대응 없음
  • 참고: CombineCommunity — RxSwift to Combine Cheatsheet

Combine으로 옮기는 이유

1. SwiftUI가 Combine 위에 서 있음

  • ObservableObjectCombine 프레임워크 안에 정의된 프로토콜
  • @StateObject / @ObservedObject / @EnvironmentObject 모두 ObservableObject 제약
@MainActor @frozen @propertyWrapper @preconcurrency
struct StateObject<ObjectType> where ObjectType : ObservableObject
  • SwiftUI 화면 비중이 오를수록 Combine 의존 자연 증가

2. UIKit + RxSwift와 SwiftUI + Combine 이중 스택 비용

  • 네트워크 모듈이 Observable<T> 반환 시 SwiftUI 측에서 Combine으로 변환 필요
  • 공용 유틸리티(디바운서·페이지네이션·로딩 상태)를 Rx용·Combine용 두 번 유지
  • 팀 온보딩 시 규칙 설명 피로

3. RxSwift 유지보수 속도 저하

  • 메이저 Swift·Xcode 업데이트 대응이 수개월 지연되는 시기 존재
  • 커뮤니티 패치 땜질 기간 길어짐
  • Combine은 Apple이 Swift·Xcode 릴리스 주기와 함께 유지

Combine이 RxSwift와 기술적으로 다른 지점

Demand / Backpressure

  • Combine Subscriberrequest(_:Subscribers.Demand)받고 싶은 이벤트 수를 명시적으로 요청
  • RxSwift: 생산자가 일방적으로 밀어 내리는 푸시 전용
  • 실무 대부분 .unlimited. 초당 수백 건 이벤트·느린 소비자에서 차이 드러남

Error 타입을 시그니처에 노출

RxSwiftCombine
타입Observable<T>Publisher<Output, Failure>
"에러 없음" 표현Driver · Signal 같은 파생 타입 필요Publisher<Output, Never> — 타입으로 증명

스케줄러 용어 분리

  • RxSwift: subscribeOn / observeOn — 이름만으로 상류·하류 구분 어려움
  • Combine: subscribe(on:) / receive(on:)로 분리
    • subscribe(on:): 구독 수립·상류 값 생산 스레드
    • receive(on:): 하류 값 소비·UI 바인딩 스레드
  • 공통 함정: 하류 스레드 미지정 시 상류 스레드 그대로 따름 → UI 업데이트 백그라운드 실행 크래시

마이그레이션 매핑

RxSwiftCombine
Observable<T>AnyPublisher<T, Error>
Single<T>AnyPublisher<T, Error>.first()
CompletableAnyPublisher<Void, Error>
Maybe<T>AnyPublisher<T?, Error>
BehaviorRelay<T>CurrentValueSubject<T, Never>
PublishRelay<T>PassthroughSubject<T, Never>
BehaviorSubject<T>CurrentValueSubject<T, Error>
PublishSubject<T>PassthroughSubject<T, Error>
DisposeBagSet<AnyCancellable>
disposed(by:).store(in:)
flatMapLatestmap { ... }.switchToLatest()
distinctUntilChangedremoveDuplicates
do(onNext:)handleEvents(receiveOutput:)
observeOnreceive(on:)
subscribeOnsubscribe(on:)
withLatestFrom대응 없음 (combineLatest + map 조합)
share(replay: 1, scope:)share() + multicast 조합 또는 커스텀 shareReplay
Driver<T>대응 타입 없음. AnyPublisher<T, Never> + receive(on: .main) + share() 조합
ControlEvent<T> · ControlProperty<T>대응 없음. CombineCocoa 등 커뮤니티 확장 또는 직접 제작

UI 스트림의 타입 보장 손실

RxSwift가 타입으로 고정하던 세 가지 계약

  • Driver<T>: 메인 스레드 방출 + Error 없음 + shared
  • Signal<T>: Driver와 유사, replay 없음 (일회성 이벤트)
  • ControlEvent<T> · ControlProperty<T>: UIKit 컨트롤이 자체적으로 메인 스레드·에러 없음 타입으로 약속

Combine에는 대응 타입 없음

  • 규약으로만 재현 가능:
extension Publisher where Failure == Never {
    func asDriver() -> AnyPublisher<Output, Never> {
        receive(on: DispatchQueue.main).share().eraseToAnyPublisher()
    }
}
  • receive(on:) 빠뜨려도 컴파일러가 막지 않음 → 타입 계약이 아닌 명명 규약
  • UIKit 컨트롤 publisher는 Apple 공식이 아닌 CombineCocoa 등 커뮤니티 영역

현실적 대응

  • SwiftUI 화면: 바인딩 자체가 메인 스레드 기반 → 자연 해결
  • UIKit 화면: asDriver() 확장 + 팀 코드 리뷰 규율. 타입 안전성 일부 손실

1:1이 아닌 시맨틱 차이

distinctUntilChanged vs removeDuplicates

  • 첫 값 통과 동작은 같음
  • @Published 업스트림 조합 시 초회 값이 예상과 다르게 걸러지는 경우 존재
  • 우회: .dropFirst() 조합

BehaviorRelayCurrentValueSubject

  • 두 타입 모두 같은 값 재방출 수용
  • @Published 재할당은 SwiftUI 기준 최적화가 있어 항상 구독자에게 전달되지 않을 수 있음
  • 패턴에 따라 명시적 재발행 구조 필요

share(replay: 1, scope: .whileConnected)

  • Combine 직접 대응 없음
  • 조합 예:
let subject = CurrentValueSubject<T, Error>(initial)
let shared = upstream
    .handleEvents(receiveOutput: { subject.send($0) })
    .share()
    .prepend(subject)
    .eraseToAnyPublisher()
  • 내부 확장 shareReplay(1) 만들어 재사용, 또는 CombineExt 도입

Swift Concurrency와의 공존

  • async / await / AsyncSequence / Task 등장 후 반응형의 3세대
  • Combine ↔ Swift Concurrency 다리:
// Publisher → AsyncSequence
for await value in publisher.values { /* ... */ }
 
// Future → async
let result = try await future.async()
  • 실무 경계
    • 상태를 시간 축으로 표현: Combine (@Published · CurrentValueSubject · 체인)
    • 한 회 비동기 · 예외 전파: Swift Concurrency (async throws · Task)
    • 두 세계 다리: AsyncPublisher · .values · withCheckedContinuation

Observation framework (iOS 17+)

  • @Observable 매크로로 단순 바인딩은 ObservableObject 없이 가능
@Observable
final class UserProfile {
    var name: String = ""
    var email: String = ""
}
  • SwiftUI가 @Observable 타입 인식. @StateObject 대신 @State로 보유
  • 단순 바인딩 영역에서 Combine 의존 제거 가능 (iOS 17+ 타깃 한정)
  • 반응형 스트림(combineLatest · switchToLatest · throttle 등)은 여전히 Combine 영역

실무 체크리스트

  • Custom Publisher가 정말 필요한지 검토 (Future · Deferred · Subject 조합으로 대체 가능한 경우 다수)
  • 하류 UI 바인딩에 receive(on: .main) 누락 체크
  • @Published 업스트림 + removeDuplicates 조합 시 초회 값 동작 확인
  • RxSwift share(replay:) 대응은 내부 shareReplay 확장 또는 CombineExt 도입 결정
  • UIKit 컨트롤 publisher 확장 방침 (직접 제작 vs CombineCocoa)
  • iOS 14 이하 지원 필요 시 AsyncPublisher 등 Swift Concurrency API 사용 범위 제한
  • 마이그레이션 완료 기준: 시그니처 변환이 아니라 의미상 동등성 + 회귀 테스트 통과

참고