본문 바로가기
Combine

Combine이란? — 핵심 개념 3가지

by thoonk: 2026. 4. 8.
반응형

세 개의 역할만 기억하면 됨.

Combine의 모든 구조는 세 가지 역할로 설명됨.

  • Publisher — 데이터를 내보내는 쪽
  • Subscriber — 데이터를 받는 쪽
  • Operator — 중간에서 데이터를 변환하거나 필터링하는 쪽

아무리 복잡한 Combine 코드도 결국 "누가 데이터를 만들고, 누가 중간에서 가공하고, 누가 최종적으로 받는가"로 분해됨.

Publisher — "나 값 줄게"

Publisher는 시간이 지남에 따라 값을 내보내는 프로토콜. 네트워크 응답이 될 수도 있고, 텍스트필드의 입력값이 될 수도 있고, 타이머의 틱이 될 수도 있음.

Publisher가 내보낼 수 있는 건 두 가지뿐임.

  1. 값(Output) — 0개 이상
  2. 완료(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

댓글