← iOS 기술면접 — 모던 스택 5종 4 / 5

04 · Combine — RxSwift에서 넘어오기

대응표로 끝나지 않는 진짜 차이 세 가지와 메모리 누수 함정, async/await 브릿지
진행률
0 / 0 완료
이 장에서 답할 수 있게 되는 질문
  1. RxSwift와 Combine은 어떻게 대응되나요? 그리고 대응되지 않는 것은 무엇인가요?
  2. Combine의 Failure가 타입에 박혀 있어서 생기는 실제 불편은?
  3. demand(백프레셔)는 언제 개발자 눈에 보입니까?
  4. assign(to:on:)이 메모리 누수를 만드는 이유는?

먼저 대응표 — 여기까지는 쉽다

Rx를 써봤다면 Combine의 8할은 이름만 바꾸면 됩니다. 두 프레임워크는 같은 아이디어의 다른 구현입니다.

RxSwiftCombine비고
Observable<E>PublisherCombine은 Output·Failure 두 연관타입
ObserverSubscriber
Disposable / DisposeBagAnyCancellable / Set<AnyCancellable>
PublishSubjectPassthroughSubject
BehaviorSubjectCurrentValueSubject
observe(on:)receive(on:)다운스트림 실행 위치
subscribeOnsubscribe(on:)업스트림 구독 위치
flatMapLatestmap { }.switchToLatest()단일 오퍼레이터 없음
SingleFuture
Driver / Signal대응 없음직접 구성해야 함
shareReplay기본 제공 없음multicast로 직접 구성
⚠️ 주의

면접에서 대응표만 읊으면 "문서 봤구나"로 끝납니다. 변별력은 대응되지 않는 칸과 아래 세 가지 차이에서 나옵니다.

진짜 차이 ① — Failure가 타입에 박혀 있다 🔥 핵심

Rx에서 에러는 전부 Error입니다. Combine의 PublisherOutput과 함께 Failure라는 연관타입을 가집니다. 그래서 실패 타입이 다른 퍼블리셔는 그냥 합칠 수 없습니다.

publisherA Failure == URLError publisherB Failure == Never .mapError { … } → AppError .setFailureType(to:) → AppError combineLatest Failure가 같아야 결합 가능 Failure == Never = "절대 실패하지 않음"을 컴파일러가 보증 그래서 assign(to:on:)은 Never일 때만 쓸 수 있다
Rx만 쓰던 사람이 Combine에서 가장 먼저 막히는 지점입니다. 타입 에러가 나는데 원인이 값이 아니라 실패 타입이라 처음엔 이유를 못 찾습니다.
enum AppError: Error { case network(URLError), decoding }

let a = urlPublisher.mapError { AppError.network($0) }   // URLError → AppError
let b = localPublisher.setFailureType(to: AppError.self) // Never    → AppError

Publishers.CombineLatest(a, b)                            // 이제 결합 가능
  .sink(receiveCompletion: { _ in }, receiveValue: { _ in })
  .store(in: &cancellables)
🔑 핵심

Failure == Never는 단순한 표기가 아니라 계약입니다. "이 스트림은 실패로 끝나지 않는다"를 컴파일러가 보증하므로, UI 바인딩처럼 실패를 처리할 곳이 없는 자리에서 안전하게 쓸 수 있습니다.

진짜 차이 ② — demand(백프레셔) 🔥 핵심

Rx에는 없는 개념입니다. Combine에서 구독은 3자 관계이고, Subscriber가 "몇 개까지 받겠다"고 요청합니다. 값은 밀려오는 게 아니라 당겨집니다.

Publisher 값의 원천 Subscriber 값의 소비자 Subscription 둘 사이의 연결·수도꼭지 ① subscribe → Subscription 전달 ② request(.max(n)) — 여기가 Rx엔 없다 ③ 요청한 개수만큼만 값 전달 .sink는 내부적으로 .unlimited를 요청 → 평소엔 이 단계가 안 보인다
일반 앱 코드에서 demand가 안 보이는 이유는 .sink가 무제한을 요청하기 때문입니다. 커스텀 Publisher나 Subscriber를 직접 만들면 반드시 마주칩니다.
📝 노트

"왜 이런 게 있나요?"에 대한 답 — 생산자가 소비자보다 빠를 때 소비자가 감당할 수 있는 만큼만 당겨가게 하기 위해서입니다. 센서 데이터나 대용량 파일 스트림처럼 값이 무한히 쏟아지는 상황에서 메모리를 지켜줍니다.

진짜 차이 ③ — share()에 리플레이가 없다

Rx의 share(replay:)에 익숙하면 반드시 걸리는 함정입니다. Combine의 share()멀티캐스트만 하고 값을 저장하지 않습니다.

원본 1 2 3 구독 A 구독 구독 B 늦게 구독 1·2는 영영 못 받음
구독 시점 이후의 값만 받습니다. "화면에 값이 안 뜬다"는 버그의 흔한 원인이고, 원인이 타이밍이라 재현이 어렵습니다.
// 최신 1개를 늦은 구독자에게도 주려면 직접 구성한다
let shared = upstream
  .multicast(subject: CurrentValueSubject<Output, Failure>(initial))
  .autoconnect()

메모리 누수 함정 🔥 핵심

self ViewModel cancellables Set sink 클로저 self 캡처 소유 보유 강한 참조 — 고리가 닫혀 둘 다 해제되지 않음 순환 참조
고리를 끊는 지점은 클로저가 self를 잡는 화살표입니다. 여기만 약하게 만들면 됩니다.
// ❌ self → cancellables → 클로저 → self 로 고리가 닫힌다
publisher.sink { self.update($0) }.store(in: &cancellables)

// ✅ 고리를 끊는다
publisher.sink { [weak self] in self?.update($0) }.store(in: &cancellables)

// ❌ assign(to:on:)은 대상(on:)을 강하게 잡는다
publisher.assign(to: \.title, on: self).store(in: &cancellables)

// ✅ @Published에 직접 연결 — 내부적으로 순환을 만들지 않는다
publisher.assign(to: &$title)
⚠️ 주의

assign(to:on:)이 특히 위험한 이유는 클로저가 없어서 캡처 리스트를 쓸 자리도 없기 때문입니다. [weak self]를 붙일 곳이 없으니, assign(to: &$prop) 형태를 쓰거나 sink로 바꿔야 합니다.

Concurrency와 섞어 쓰기 🧩 롱테일

Combine 계속 흐르는 값 @Published · Timer · Notification Swift Concurrency 한 번 요청 · 한 번 응답 API 호출 · 파일 · 이미지 처리 for await v in pub.values Future { Task { … } } 실무 판단 — 새 코드는 Concurrency, Combine은 프레임워크와 맞닿은 곳에 남긴다 SwiftUI의 @Published, NotificationCenter, Timer는 여전히 Combine이 자연스럽다
둘 중 하나를 고르는 문제가 아니라 값이 몇 번 오느냐로 나뉩니다. 한 번이면 async/await, 계속이면 Publisher.
🔑 핵심

"Combine과 Concurrency 중 뭘 쓰시겠어요?"에 대한 좋은 답은 "용도가 다릅니다"로 시작하는 것입니다. Concurrency 심화는 Swift Concurrency 면접 꼬리질문 10선에서 이어집니다.

출처 · 참고자료