[TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 1

2025. 10. 11. 16:22·iOS/Swift

새 프로젝트에서 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 print  
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
'iOS/Swift' 카테고리의 다른 글
  • [TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 2
  • [25.08.14] MOUP 리팩토링 및 트러블슈팅 - 동적 높이를 가지는 테이블뷰 셀
  • [25.08.04] MOUP 트러블슈팅 - .xcconfig 내 url 설정 시 주의사항
  • [25.07.31] MOUP 트러블슈팅 - tableHeaderView 레이아웃 제약 경고
subkyu-ios
subkyu-ios
subkyu-ios 님의 블로그 입니다.
  • subkyu-ios
    subkyu-ios 님의 블로그
    subkyu-ios
  • 전체
    오늘
    어제
    • 분류 전체보기 (59)
      • iOS (40)
        • Swift (40)
      • 내일배움캠프 (7)
      • Git, Github (3)
      • Algorithm (6)
      • 회고 (1)
      • 면접 질문 정리 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    반응형 프로그래밍
    본캠프
    내일배움캠프
    KPT
    tabman
    의존성 주입
    Swift
    til
    ios
    사전캠프
    회고
    프로그래머스
    algorithm
    github
    stackview
    UIKit
    트러블슈팅
    Wil
    RxSwift
    알고리즘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
subkyu-ios
[TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 1
상단으로

티스토리툴바