새 프로젝트에서 Combine을 도입해보기로 결정했기 때문에, 마침 배워보고 싶던 부분이라 공부해봤어요
반응형 프로그래밍을 왜 쓰는건지 다시 되짚어보고,, 기존 RxSwift할 때와 어떻게 다른지 중심으로 보고
간단한 open api 받아와서 tableView에 바인딩하는거까지 해보겠습니다~
1. 반응형 프로그래밍(Reactive Programming)
반응형 프로그래밍은 값의 변화(데이터, 이벤트 스트림)가 마치 데이터 흐름처럼 연결/구독되고, 변화가 생길 때마다 자동으로 반응이 흘러가는 것. 비동기들은 기본적으로 예측 어려운 시점에 발생(입력, 네트워킹, 타이머, 센서 등)하기에, 많은 이벤트, 콜백, 비동기 작업을 하나의 Stream으로 간단히 체인/구독해서 관리하려는 개념이다.
퍼스트 파티 기준으로는 비동기 이벤트에 대응하다보면 콜백 지옥, delegate도 많아지거나 관련 설정이 이곳저곳 조립되어야해서 관리가 어려워질 수 있다. 이러한 점을 반응형에서는 하나의 스트림에 대한 구독 등 처리를 통해 훨씬 가독성있게 볼 수 있고 컨트롤할 수 있다는 게 장점이다.
예를 들면 텍스트를 쓸 때에 대한 이벤트 처리를 한다면
class ExampleViewController: UIViewController, UITextFieldDelegate {
let textField = UITextField()
override func viewDidLoad() {
super.viewDidLoad()
// 델리게이트로 입력 이벤트 감지
textField.delegate = self
// 또는 타깃-액션 방식
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
}
// 델리게이트 메서드
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
print("입력 감지: \(textField.text ?? "") + \(string)")
return true
}
// 타깃-액션 방식
@objc private func textChanged(_ sender: UITextField) {
print("텍스트 변경됨: \(sender.text ?? "")")
}
}
퍼스트파티에선 viewDidLoad에서 텍스트필드의 delegate를 생명주기에서 연결해주면서 해당 컴포넌트 전용 메서드에 특정 동작을 정의해줘야하고 이러한 부분들이 한 곳에 모여있지않아 유지보수 시에도 다음 설정이 어디인지 찾아다니면서 수정해주어야 한다.
반면 Combine을 기준으로 하면
import Combine
import UIKit
class ExampleViewController: UIViewController {
let textField = UITextField()
var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
// 텍스트필드의 입력 이벤트를 하나의 스트림으로 구독
NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: textField
)
.compactMap { ($0.object as? UITextField)?.text }
.sink { text in
print("텍스트 변경됨(Combine): \(text)")
}
.store(in: &cancellables)
}
}
NotificationCenter의 publisher 기능은 나도 처음보지만 텍스트 변경에 대한 퍼블리셔를 제공해주고 해당 스트림을 구독해 특정 동작을 취하는 것까지 한번에 정의할 수 있다. 특히나, 퍼스트파티에서 한 스코프 내에 모든 동작을 어찌 정의한다고 해도 위 코드처럼 연산자 등의 조합을 적절히 섞어주면 훨씬 단계별로 데이터 정제가 어떻게 이뤄지는지 알 수 있으며 가독성도 동시에 향상된다.
보통 iOS 개발에서 반응형 하면 RxSwift가 많이 보였는데 최근엔 Combine으로 전환하는 분위기인듯하다..!
퍼스트파티라서 확실히 메리트가 있다. RxSwift는 15년에 첫 릴리즈, Combine은 19년에 첫 릴리즈되었다.
2. Combine
Combine은 Apple에서 공식 지원하는 Swift의 반응형 프로그래밍(reactive programming) 프레임워크로, 시간의 흐름에 따라 변화하는 값들과 다양한 비동기 이벤트(ex. 네트워크 응답, 사용자 입력 등)를 효율적이고 선언적으로 처리할 수 있게 해주는 도구
Rx를 해봤다고 생각하고 어느정도 글을 쓰자면, Rx와 마찬가지로 Combine의 주요 구성 요소는 세 가지다.
Publisher, Subscriber, Operator.
Publisher: 값이 바뀔 때마다, 혹은 특정 시점마다 값을 방출해주는 객체
Subscriber: Publisher가 방출한 값을 받아 처리하는 대상
Operator: Publisher와 Subscriber 사이에서, 데이터 흐름을 가공/필터/변환해주는 중간 역할
@Published var count: Int = 0
let countPublisher = $count
let cancellable = countPublisher
.map { $0 * 2 } // Operator: 값 변환 처리
.sink { value in // Subscriber: 값 받아서 로직 처리
print("방출된 값: \(value)")
}
count = 5 // -> Publisher가 값 방출 -> Operator map -> sink에 값 전달
여기서 @Published라는 프로퍼티 래퍼를 통해 count라는 변수가 프로퍼티 값이 바뀔 때마다 이벤트를 내보내는 Publisher를 자동으로 생성하게 했다. 다만 바로 이 count.sink를 통해 구독할 수 있는게 아니라 $를 붙여 따로 접근을 해야하기 때문에 위 코드에선 $count를 타 변수에 할당해 구독할 수도 있고 바로 $count.sink를 써서 구독할 수도 있다.
그럼 이 코드에선 $count인 countPublisher가 Publisher가 되겠다.
이후엔 cancellable에 countPublisher에서 오는 값들에 대해 map 연산을 하고 sink를 하는 흐름인데, map이 Operator가 되겠다.
스트림 내 값에 대해 연산을 마치고 해당 스트림에 대해 구독을 해 오는 값을 print하도록 구현되어있는데 여기서는 sink가 Subscriber가 된다.
우선 기초적으로는 이정도가 되겠다.
3. Rx vs Combine
Rx와 Combine의 기초적 차이, 뭐 퍼스트파티 서드파티 등에 대해 알아보기보단, RxSwift를 해왔던 내가 Combine을 하면서 Rx에서의 어떤 기능은 Combine에선 어떻게 쓰면될까? 궁금해서 찾아본것들을 정리한다.
우선 서로 차이점이 어떻게 되어있는지에 대해 시트로 정리되어있어서 첨부한다.
https://github.com/CombineCommunity/rxswift-to-combine-cheatsheet
RxSwiftCombineNotes
AnyObserver | AnySubscriber | |
BehaviorRelay | ❌ | Simple wrapper around BehaviorSubject, could be easily recreated in Combine |
BehaviorSubject | CurrentValueSubject | This seems to be the type that holds @State under the hood |
Completable | ❌ | |
CompositeDisposable | ❌ | |
ConnectableObservableType | ConnectablePublisher | |
Disposable | Cancellable | |
DisposeBag | A collection of AnyCancellables | Call anyCancellable.store(in: &collection), where collection can be an array, a set, or any other RangeReplaceableCollection |
Driver | ObservableObject | Both guarantee no failure, but Driver guarantees delivery on Main Thread. In Combine, SwiftUI recreates the entire view hierarachy on the Main Thread, instead. |
Maybe | Optional.Publisher | |
Observable | Publisher | |
Observer | Subscriber | |
PublishRelay | ❌ | Simple wrapper around PublishSubject, could be easily recreated in Combine |
PublishSubject | PassthroughSubject | |
ReplaySubject | ❌ | |
ScheduledDisposable | ❌ | |
SchedulerType | Scheduler | |
SerialDisposable | ❌ | |
Signal | ❌ | |
Single | Deferred + Future | Future has to be wrapped in a Deferred, or its greedy as opposed to Single's laziness |
SubjectType | Subject | |
TestScheduler | ❌ | There doesn't seem to be an existing testing scheduler for Combine code |
RxSwiftCombineNotes
amb() | ❌ | |
asObservable() | eraseToAnyPublisher() | |
asObserver() | ❌ | |
bind(to:) | assign(to:on:) | Assign uses a KeyPath which is really nice and useful. RxSwift needs a Binder / ObserverType to bind to. |
buffer | buffer | |
catchError | catch | |
catchErrorJustReturn | replaceError(with:) | |
combineLatest | combineLatest, tryCombineLatest | |
compactMap | compactMap, tryCompactMap | |
concat | append, prepend | |
concatMap | ❌ | |
create | ❌ | Apple removed AnyPublisher with a closure in Xcode 11 beta 3 :-( |
debounce | debounce | |
debug | ||
deferred | Deferred | |
delay | delay | |
delaySubscription | ❌ | |
dematerialize | ❌ | |
distinctUntilChanged | removeDuplicates, tryRemoveDuplicates | |
do | handleEvents | |
elementAt | output(at:) | |
empty | Empty(completeImmediately: true) | |
enumerated | ❌ | |
error | Fail | |
filter | filter, tryFilter | |
first | first, tryFirst | |
flatMap | flatMap | |
flatMapFirst | ❌ | |
flatMapLatest | switchToLatest | |
from(optional:) | Optional.Publisher(_ output:) | |
groupBy | ❌ | |
ifEmpty(default:) | replaceEmpty(with:) | |
ifEmpty(switchTo:) | ❌ | Could be achieved with composition - replaceEmpty(with: publisher).switchToLatest() |
ignoreElements | ignoreOutput | |
interval | ❌ | |
just | Just | |
map | map, tryMap | |
materialize | ❌ | |
merge | merge, tryMerge | |
merge(maxConcurrent:) | flatMap(maxPublishers:) | |
multicast | multicast | |
never | Empty(completeImmediately: false) | |
observeOn | receive(on:) | |
of | Sequence.publisher | publisher property on any Sequence or you can use Publishers.Sequence(sequence:) directly |
publish | makeConnectable | |
range | ❌ | |
reduce | reduce, tryReduce | |
refCount | autoconnect | |
repeatElement | ❌ | |
retry, retry(3) | retry, retry(3) | |
retryWhen | ❌ | |
sample | ❌ | |
scan | scan, tryScan | |
share | share | There’s no replay or scope in Combine. Could be “faked” with multicast. |
skip(3) | dropFirst(3) | |
skipUntil | drop(untilOutputFrom:) | |
skipWhile | drop(while:), tryDrop(while:) | |
startWith | prepend | |
subscribe | sink | |
subscribeOn | subscribe(on:) | RxSwift uses Schedulers. Combine uses RunLoop, DispatchQueue, and OperationQueue. |
take(1) | prefix(1) | |
takeLast | last | |
takeUntil | prefix(untilOutputFrom:) | |
throttle | throttle | |
timeout | timeout | |
timer | Timer.publish | |
toArray() | collect() | |
window | collect(Publishers.TimeGroupingStrategy) | Combine has a TimeGroupingStrategy.byTimeOrCount that could be used as a window. |
withLatestFrom | ❌ | |
zip | zip |
위에서 볼 수 있듯, 주로 사용하는 Observable<Void>, subscribe, subject, 그 밖에 다양한 연산자들이 다른 단어로 쓰이는걸 알 수 있다.
Rx Observable의 가장 근본이 되는 사용법인 Observable<타입>은 AnyPublisher<타입, 실패 시 타입>으로 combine에서 사용하면 되고, subject 혹은 relay를 전달할 때 마지막에 해주는 asObservable은 eraseToAnyPublisher를 사용하면 된다.
그 밖에도 많은데 이 부분은 다음 포스트에서 간단하게 만든 프로젝트를 뜯어보며 확인하겠다.
combine이 확실히 더 나중에 나온 점도 있지만, 퍼스트파티여서 그런지는 모르겠어도 매 부분 Rx보다 더 엄격하게 타입 등 정의를 해야하는 것 같다. 에러 타입을 따로 정의할 수 있어서 Relay와 대응되는 Publisher가 따로 제공되지 않는게 인상적인데, 이 부분은 에러를 Never로 처리하면 동일한 기능을 할 수 있어 큰 걱정은 없을 것 같다. 기능 자체는 Rx가 좀 더 많고 관련 라이브러리(ex. RxDataSources, ReactorKit)가 많은 반면 Combine은 이에 비해 좀 더 컴팩트하게 구성되어있으며 근본적인 기능들은 전부 담고 있는 점이 퍼스트파티의 성격을 드러내는 거 아닐까? 라고 생각해본다.
'iOS > Swift' 카테고리의 다른 글
[TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 2 (0) | 2025.10.11 |
---|---|
[25.08.14] MOUP 리팩토링 및 트러블슈팅 - 동적 높이를 가지는 테이블뷰 셀 (3) | 2025.08.14 |
[25.08.04] MOUP 트러블슈팅 - .xcconfig 내 url 설정 시 주의사항 (5) | 2025.08.05 |
[25.07.31] MOUP 트러블슈팅 - tableHeaderView 레이아웃 제약 경고 (2) | 2025.07.31 |
[25.06.24] MOUP 트러블슈팅 - Listener와 Rx의 timeout (0) | 2025.06.24 |