세 개의 역할만 기억하면 됨.
Combine의 모든 구조는 세 가지 역할로 설명됨.
- Publisher — 데이터를 내보내는 쪽
- Subscriber — 데이터를 받는 쪽
- Operator — 중간에서 데이터를 변환하거나 필터링하는 쪽
아무리 복잡한 Combine 코드도 결국 "누가 데이터를 만들고, 누가 중간에서 가공하고, 누가 최종적으로 받는가"로 분해됨.
Publisher — "나 값 줄게"
Publisher는 시간이 지남에 따라 값을 내보내는 프로토콜. 네트워크 응답이 될 수도 있고, 텍스트필드의 입력값이 될 수도 있고, 타이머의 틱이 될 수도 있음.
Publisher가 내보낼 수 있는 건 두 가지뿐임.
- 값(Output) — 0개 이상
- 완료(Completion) — 정상 종료 또는 에러. 한 번 발생하면 더 이상 값을 내보내지 않음.
public protocol Publisher {
associatedtype Output // 내보내는 값의 타입
associatedtype Failure: Error // 에러 타입 (Never면 에러 없음)
func receive<S: Subscriber>(subscriber: S)
where S.Input == Output, S.Failure == Failure
}
핵심은 Output과 Failure 두 개의 연관 타입. Publisher<String, URLError>라면 "문자열을 내보내다가 URLError로 실패할 수 있는 퍼블리셔"라는 뜻. Publisher<Int, Never>라면 "정수를 내보내되 절대 실패하지 않는 퍼블리셔".
Apple이 제공하는 대표적인 Publisher 몇 가지를 보면,
// 1. Just — 값 하나를 내보내고 즉시 완료
let justPublisher = Just(42)
// Output: Int, Failure: Never
// 2. URLSession.dataTaskPublisher — 네트워크 응답
let networkPublisher = URLSession.shared.dataTaskPublisher(for: url)
// Output: (data: Data, response: URLResponse), Failure: URLError
// 3. NotificationCenter.Publisher — 노티피케이션
let keyboardPublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
// Output: Notification, Failure: Never
// 4. @Published — 프로퍼티 변경 감지
class ViewModel: ObservableObject {
@Published var searchText = ""
}
// $searchText의 타입: Published<String>.Publisher
Publisher는 "아직 아무것도 하지 않은" 상태라는 점이 중요. 구독(subscribe)하기 전까지는 값을 내보내지 않음. 이걸 "cold" 라고 부르기도 함.(일부 Publisher는 예외)
Subscriber — "나 값 받을게"
Subscriber는 Publisher로부터 값을 전달받는 프로토콜.
public protocol Subscriber {
associatedtype Input // 받는 값의 타입
associatedtype Failure: Error // 에러 타입
func receive(subscription: Subscription)
func receive(_ input: Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Failure>)
}
Subscriber 프로토콜을 직접 구현하는 경우는 드물지만 대신 Combine이 제공하는 두 가지 내장 Subscriber를 씀.
sink — 가장 흔한 방식
let cancellable = Just(42)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("완료")
case .failure(let error):
print("에러: \\(error)")
}
},
receiveValue: { value in
print("받은 값: \\(value)")
}
)
// 출력: 받은 값: 42
// 출력: 완료
sink는 클로저 두 개를 받음. 하나는 값이 올 때마다 실행되고, 하나는 스트림이 완료될 때 실행됨. Publisher의 Failure가 Never라면 receiveCompletion을 생략한 간소화 버전도 쓸 수 있음.
let cancellable = Just(42)
.sink { value in
print("받은 값: \\(value)")
}
assign — 프로퍼티에 직접 바인딩
class MyViewModel {
var username: String = ""
}
let viewModel = MyViewModel()
let cancellable = Just("Andy")
.assign(to: \\.username, on: viewModel)
print(viewModel.username) // "Andy"
assign은 Publisher의 Output을 특정 객체의 KeyPath에 직접 할당하고 UI 바인딩에서 자주 쓰임. 단, assign은 Failure가 Never인 Publisher에서만 사용할 수 있음.
Operator — "중간에서 내가 좀 손볼게"
Operator는 Publisher와 Subscriber 사이에서 데이터를 변환, 필터링, 결합하는 역할을 함. 사실 Operator도 내부적으로는 Publisher. 입력을 받아서 새로운 Publisher를 반환하므로 체이닝이 가능함.
let cancellable = [1, 2, 3, 4, 5].publisher
.filter { $0 % 2 == 0 } // Operator 1: 짝수만 통과
.map { $0 * 10 } // Operator 2: 10 곱하기
.sink { print($0) } // Subscriber
// 출력:
// 20
// 40
Operator는 수십 가지가 있지만, 자주 쓰는 것들을 카테고리별로 아래와 같음.
카테고리 대표 Operator 하는 일
| 변환 | map, flatMap, scan | 값을 다른 형태로 바꿈 |
| 필터링 | filter, removeDuplicates, first | 조건에 맞는 값만 통과 |
| 결합 | combineLatest, zip, merge | 여러 Publisher를 합침 |
| 타이밍 | debounce, throttle, delay | 값의 전달 시점을 조절 |
| 에러 처리 | catch, retry, replaceError | 에러 발생 시 대응 |
세 개의 관계: 파이프라인
Publisher, Operator, Subscriber가 연결된 전체 구조를 **파이프라인(pipeline)**이라고 부름.
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐
│Publisher │───▶│Operator 1│───▶│Operator 2│───▶│ Subscriber │
│(데이터원본)│ │ (filter) │ │ (map) │ │ (sink) │
└──────────┘ └──────────┘ └──────────┘ └────────────┘
값 발행 ──────▶ 필터링 ──────▶ 변환 ──────▶ 최종 소비
데이터는 왼쪽에서 오른쪽으로 흐르고 각 단계는 이전 단계의 출력을 입력으로 받음. 중간에 에러가 발생하면 나머지 Operator를 건너뛰고 Subscriber의 receiveCompletion으로 직행함.
AnyCancellable — "이 구독 내가 관리할게"
sink나 assign은 AnyCancellable을 반환함. 이 객체가 메모리에서 해제되면 구독도 자동으로 취소됨.
class MyViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
somePublisher
.sink { value in
// 값 처리
}
.store(in: &cancellables) // Set에 저장
}
// MyViewController가 해제되면 cancellables도 해제되고,
// 그 안의 구독들도 모두 자동 취소된다.
}
Set<AnyCancellable>에 모아두는 패턴은 사실상 표준, RxSwift의 DisposeBag과 동일한 역할을 함.
중요한 점은 AnyCancellable을 아무 곳에도 저장하지 않으면 구독이 즉시 취소된다는 것.
// ⚠️ 잘못된 예: cancellable을 저장하지 않음
func setupBinding() {
somePublisher
.sink { value in
print(value) // 이 클로저는 실행되지 않을 수 있다
}
// 반환된 AnyCancellable이 이 스코프를 벗어나면서 즉시 해제 → 구독 취소
}
이 실수는 Combine 입문자가 가장 많이 하는 실수 중 하나로 "코드가 맞는 것 같은데 왜 안 되지?" 싶으면 AnyCancellable을 제대로 할당했는지 확인 필요.
정리
Combine을 이해하는 데 필요한 핵심 개념은 세 가지.
Publisher는 시간에 따라 값을 내보낸다. 무엇이 발행되는지(Output)와 어떤 에러가 가능한지(Failure)를 타입으로 명시함.
Subscriber는 Publisher의 값을 소비한다. 실무에서는 sink와 assign 두 가지로 거의 모든 상황을 커버함.
Operator는 Publisher와 Subscriber 사이에서 데이터를 가공함. 체이닝으로 연결하여 복잡한 변환도 읽기 쉬운 파이프라인으로 표현함.
그리고 이 모든 것을 묶어주는 AnyCancellable이 구독의 생명주기를 관리함.
'Combine' 카테고리의 다른 글
| 왜 Combine인가? (0) | 2026.03.25 |
|---|
댓글