반응형이란
- 값의 변화를 시간 축 위의 스트림으로 다루고, 스트림에 변환을 연쇄 적용해 결과 스트림을 얻는 방식
- 명령형: "지금의 값을 꺼내 계산한다"
- 반응형: "값이 바뀔 때마다 어떻게 이어지는지 미리 정의해 둔다"
- 목적: 수동 구독·콜백·상태 동기화가 중첩되며 생기는 복잡도를 값의 변화 자체에 위임
반응형의 바닐라 구현
최소 구조 — 구독 · 값 보관 · 변경 전파.
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
| RxSwift | Combine | |
|---|---|---|
| 등장 | 2015~, ReactiveX 계열 | 2019 WWDC, Apple 퍼스트 |
| 유지 주체 | 커뮤니티 | Apple |
| 기반 플랫폼 | UIKit 중심 | SwiftUI 친화 |
| 스트림 타입 | Observable<T> | Publisher<Output, Failure> |
| 구독 수명 | DisposeBag | Set<AnyCancellable> |
| Subject | Behavior/PublishSubject, Relay | CurrentValue/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 위에 서 있음
ObservableObject는 Combine 프레임워크 안에 정의된 프로토콜@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
Subscriber가request(_:Subscribers.Demand)로 받고 싶은 이벤트 수를 명시적으로 요청 - RxSwift: 생산자가 일방적으로 밀어 내리는 푸시 전용
- 실무 대부분
.unlimited. 초당 수백 건 이벤트·느린 소비자에서 차이 드러남
Error 타입을 시그니처에 노출
| RxSwift | Combine | |
|---|---|---|
| 타입 | Observable<T> | Publisher<Output, Failure> |
| "에러 없음" 표현 | Driver · Signal 같은 파생 타입 필요 | Publisher<Output, Never> — 타입으로 증명 |
스케줄러 용어 분리
- RxSwift:
subscribeOn/observeOn— 이름만으로 상류·하류 구분 어려움 - Combine:
subscribe(on:)/receive(on:)로 분리subscribe(on:): 구독 수립·상류 값 생산 스레드receive(on:): 하류 값 소비·UI 바인딩 스레드
- 공통 함정: 하류 스레드 미지정 시 상류 스레드 그대로 따름 → UI 업데이트 백그라운드 실행 크래시
마이그레이션 매핑
| RxSwift | Combine |
|---|---|
Observable<T> | AnyPublisher<T, Error> |
Single<T> | AnyPublisher<T, Error>.first() |
Completable | AnyPublisher<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> |
DisposeBag | Set<AnyCancellable> |
disposed(by:) | .store(in:) |
flatMapLatest | map { ... }.switchToLatest() |
distinctUntilChanged | removeDuplicates |
do(onNext:) | handleEvents(receiveOutput:) |
observeOn | receive(on:) |
subscribeOn | subscribe(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없음 + sharedSignal<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()조합
BehaviorRelay → CurrentValueSubject
- 두 타입 모두 같은 값 재방출 수용
- 단
@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
- 상태를 시간 축으로 표현: Combine (
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 사용 범위 제한 - 마이그레이션 완료 기준: 시그니처 변환이 아니라 의미상 동등성 + 회귀 테스트 통과
참고
- Apple Developer Documentation — ObservableObject
- Apple Developer Documentation — StateObject
- CombineCommunity — RxSwift to Combine Cheatsheet