Swift Combine: Publisher와 Subscriber, Operator, @Published로 반응형 흐름 만들기

이 글의 핵심

Combine의 Publisher·Subscriber 구조와 구독 취소, map·filter·debounce 같은 Operator, @Published로 뷰 모델을 SwiftUI에 연결하는 방법을 예제로 정리합니다.

들어가며

Combine은 시간에 따라 들어오는 값을 Publisher–Subscriber로 연결합니다. UI 이벤트·네트워크 응답을 스트림처럼 합성할 때 씁니다.

콜백이나 delegate로도 비동기 코드는 충분히 짤 수 있습니다. Combine이 필요한 순간은 “여러 비동기 값의 관계”를 표현해야 할 때입니다. 예를 들어 “사용자가 입력을 멈추고 300ms가 지나면, 직전과 다른 검색어일 때만, 이전 요청은 버리고 새 요청을 보낸다”는 요구를 콜백으로 짜면 타이머, 마지막 값 저장, 요청 취소 로직이 여기저기 흩어집니다. Combine에서는 이것이 debounce → removeDuplicates → map → switchToLatest 네 줄로 표현됩니다. 대신 대가도 있습니다. 제네릭 타입이 길게 중첩되어 컴파일 에러 메시지가 읽기 어렵고, 디버깅할 때 스택 트레이스가 Combine 내부 프레임으로 가득 찹니다. 파이프라인 중간 값을 보고 싶다면 .print("tag")나 .handleEvents(receiveOutput:)를 끼워 넣는 것이 가장 빠른 방법입니다.

모든 Publisher는 Output과 Failure 두 개의 제네릭 타입을 가집니다. Failure가 Never면 에러가 절대 나지 않는 스트림이고, assign(to:)나 에러 처리 없는 sink(receiveValue:)는 Failure == Never일 때만 쓸 수 있습니다. 네트워크처럼 실패할 수 있는 스트림을 UI에 묶을 때 “Referencing instance method ‘sink(receiveValue:)’ requires the types ‘Error’ and ‘Never’ be equivalent” 같은 에러가 나는 이유가 이것입니다. replaceError(with:)나 catch로 에러를 먼저 처리해 Failure를 Never로 만들어야 합니다.


Publisher, Subscriber, CurrentValueSubject

기본 사용

import Combine
// Just: 단일 값 발행
let publisher = Just("Hello")
let cancellable = publisher.sink { value in
    print(value)  // Hello
}
// PassthroughSubject: 수동 발행
let subject = PassthroughSubject<String, Never>()
let subscription = subject.sink { value in
    print("받음: \(value)")
}
subject.send("첫 번째")
subject.send("두 번째")
subject.send(completion: .finished)

Just는 구독하는 순간 값 하나를 동기적으로 내보내고 끝나는 Publisher라, 테스트나 기본값 제공용으로 주로 씁니다. PassthroughSubject는 명령형 코드와 Combine을 잇는 다리입니다. delegate 콜백 안에서 subject.send(...)를 호출하면 그 이벤트가 스트림이 됩니다. 한 가지 주의할 점은 send(completion: .finished) 이후에는 send를 호출해도 아무 일도 일어나지 않는다는 것입니다. 에러로 끝난 경우도 마찬가지로, Combine의 스트림은 한 번 완료되면 다시 살아나지 않습니다. 그래서 네트워크 에러 하나 때문에 검색 파이프라인 전체가 죽는 버그가 흔합니다(아래 “자주 하는 실수” 참고).

CurrentValueSubject

let subject = CurrentValueSubject<Int, Never>(0)
let cancellable = subject.sink { value in
    print("값: \(value)")   // 값: 0, 값: 1, 값: 2
}
subject.send(1)
subject.send(2)
print("현재 값: \(subject.value)")  // 2

CurrentValueSubject는 현재 값을 들고 있는 Subject입니다. 구독하자마자 현재 값(여기서는 0)을 먼저 받고, .value로 언제든 최신 값을 동기적으로 읽을 수 있습니다. “상태”는 CurrentValueSubject, “이벤트”(버튼 탭, 알림)는 PassthroughSubject로 구분하면 대부분 맞습니다.

위 코드에서 sink의 반환값을 cancellable에 담은 데는 이유가 있습니다. 반환된 AnyCancellable을 버리면 그 자리에서 해제되면서 구독도 취소됩니다. Just나 배열처럼 구독 즉시 동기적으로 값을 내보내는 Publisher는 취소 전에 값이 전달되어 버려서 “잘 되는 것처럼” 보이지만, send(1)처럼 나중에 오는 값은 받지 못합니다. 컴파일러도 “Result of call to ‘sink(receiveValue:)’ is unused” 경고로 알려 줍니다.


변환·결합 Operator

변환 Operator

let numbers = [1, 2, 3, 4, 5].publisher
// map
numbers
    .map { $0 * 2 }
    .sink { print($0) }
// 2, 4, 6, 8, 10
// filter
numbers
    .filter { $0 % 2 == 0 }
    .sink { print($0) }
// 2, 4
// reduce
numbers
    .reduce(0, +)
    .sink { print("합계: \($0)") }
// 합계: 15

결합 Operator

let pub1 = Just(1)
let pub2 = Just(2)
// zip: 쌍으로 결합
Publishers.Zip(pub1, pub2)
    .sink { print("\($0), \($1)") }
// 1, 2
// combineLatest: 최신 값 결합
let subject1 = PassthroughSubject<Int, Never>()
let subject2 = PassthroughSubject<String, Never>()
subject1.combineLatest(subject2)
    .sink { print("\($0), \($1)") }
subject1.send(1)
subject2.send("A")  // 1, A
subject1.send(2)    // 2, A

zip과 combineLatest는 이름이 비슷하지만 의미가 전혀 다릅니다. zip은 양쪽에서 n번째 값끼리 짝을 지어서, 한쪽이 먼저 값을 세 개 보내면 다른 쪽이 세 개를 보낼 때까지 버퍼에 쌓아 둡니다. “요청 A와 요청 B가 둘 다 끝나면” 같은 병렬 대기에 맞습니다. combineLatest는 어느 쪽이든 새 값이 오면 각자의 최신 값으로 조합을 다시 내보냅니다. “아이디와 비밀번호가 둘 다 유효할 때 로그인 버튼 활성화” 같은 폼 검증에 씁니다. 두 연산자 모두 모든 입력이 최소 한 번은 값을 내야 첫 출력이 나옵니다. 위 예제에서 subject1.send(1)만으로는 아무것도 출력되지 않는 이유입니다. 한쪽 입력이 늦게 오는 게 정상이라면 prepend(초기값)으로 시작 값을 넣어 두면 됩니다.

reduce처럼 스트림이 완료되어야 결과를 내는 연산자도 조심해야 합니다. 배열 Publisher는 값을 다 내보내면 완료되니 합계가 나오지만, 끝나지 않는 Subject나 @Published에 reduce/collect()를 붙이면 영원히 아무것도 출력되지 않습니다. 누적 중간값이 필요하면 scan을 씁니다.


@Published로 SwiftUI와 연결하기

SwiftUI와 통합

import SwiftUI
import Combine
class ViewModel: ObservableObject {
    @Published var count = 0
    @Published var message = ""
    
    func increment() {
        count += 1
        message = "Count: \(count)"
    }
}
struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.message)
            
            Button("증가") {
                viewModel.increment()
            }
        }
    }
}

@Published를 붙이면 프로퍼티마다 Publisher가 하나씩 생기고, $count로 접근할 수 있습니다. ObservableObject는 @Published 프로퍼티가 바뀔 때마다 objectWillChange를 발행하고, SwiftUI는 이를 받아 뷰를 다시 그립니다. 이름에서 알 수 있듯 값이 바뀌기 직전(willSet)에 발행되므로, $count.sink { _ in print(self.count) }처럼 sink 안에서 프로퍼티를 다시 읽으면 이전 값이 나옵니다. 새 값은 클로저 인자로 받은 값을 써야 합니다. 뷰 쪽에서는 뷰가 소유하는 뷰 모델은 @StateObject, 부모에게서 받은 것은 @ObservedObject로 선언해야 합니다. @ObservedObject로 직접 생성하면 부모 뷰가 다시 그려질 때마다 뷰 모델이 새로 만들어져 상태가 초기화됩니다.

@Published 프로퍼티를 백그라운드 스레드에서 바꾸면 “Publishing changes from background threads is not allowed; make sure to publish values from the main thread” 경고가 뜹니다. 네트워크 응답을 받아 바로 대입하는 코드에서 자주 나오며, 파이프라인에 .receive(on: DispatchQueue.main)을 넣거나 뷰 모델을 @MainActor로 선언해 해결합니다. iOS 17 이상만 지원한다면 @Observable 매크로(Observation 프레임워크)가 ObservableObject+@Published를 대체하며, 실제로 읽힌 프로퍼티만 뷰 갱신을 일으켜 불필요한 재렌더링이 줄어듭니다. 다만 @Observable에는 $property Publisher가 없어서 아래 검색 예제 같은 Combine 파이프라인을 그대로 쓸 수는 없습니다.


예제: 검색 기능

import Combine
import SwiftUI
class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [String] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] text in
                self?.search(text)
            }
            .store(in: &cancellables)
    }
    
    func search(_ text: String) {
        let allItems = ["사과", "바나나", "오렌지", "포도", "딸기"]
        results = allItems.filter { $0.contains(text) }
    }
}
struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()
    
    var body: some View {
        VStack {
            TextField("검색", text: $viewModel.searchText)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            List(viewModel.results, id: \.self) { item in
                Text(item)
            }
        }
    }
}

이 파이프라인의 각 줄이 하는 일은 이렇습니다. $searchText는 구독 즉시 현재 값(빈 문자열)을 한 번 내보내고, 이후 입력마다 값을 냅니다. debounce는 값이 올 때마다 300ms 타이머를 다시 시작해 입력이 멈춘 뒤 마지막 값만 통과시킵니다. removeDuplicates는 “abc”를 지웠다가 다시 “abc”를 입력한 경우처럼 직전과 같은 값을 걸러 불필요한 검색을 막습니다. [weak self]는 self가 cancellables를 통해 구독을 소유하고, 구독의 클로저가 다시 self를 잡는 순환 참조를 끊습니다. 이걸 빼면 화면을 닫아도 뷰 모델이 해제되지 않고, deinit에 로그를 찍어 보면 한 번도 호출되지 않는 것을 확인할 수 있습니다.

스케줄러로 RunLoop.main을 쓸 때는 알려진 함정이 하나 있습니다. RunLoop 기반 스케줄러는 기본 모드에서만 동작해서, 사용자가 리스트를 스크롤하는 동안(트래킹 모드)에는 debounce 타이머가 멈춰 결과가 늦게 뜹니다. 스크롤 중에도 반응해야 한다면 DispatchQueue.main을 쓰는 편이 안전합니다. 실제 검색이 네트워크 요청이라면 sink 안에서 요청을 보내지 말고 map { query in api.search(query) } → switchToLatest()로 연결하세요. 그래야 새 검색어가 들어왔을 때 이전 요청이 자동으로 취소되어, 늦게 도착한 옛 응답이 최신 결과를 덮어쓰는 경쟁 상태가 생기지 않습니다.


페이지네이션 API를 flatMap으로 이어 붙이기

아래는 첫 응답의 nextPageURL이 있으면 다음 요청을 이어서 배출하는 패턴입니다. 실제 네트워크 대신 Future를 시뮬레이션합니다.

// 필요한 모듈 import
import Combine
import Foundation
struct Page: Decodable {
    let items: [String]
    let next: URL?
}
func fetchPage(url: URL) -> AnyPublisher<Page, Error> {
    // 실제로는 URLSession.shared.dataTaskPublisher
    Future { promise in
        promise(.success(Page(items: ["a", "b"], next: nil)))
    }
    .eraseToAnyPublisher()
}
enum PageLoader {
    static func allItems(start: URL) -> AnyPublisher<[String], Error> {
        func loop(_ url: URL) -> AnyPublisher<[String], Error> {
            fetchPage(url: url)
                .flatMap { page -> AnyPublisher<[String], Error> in
                    if let next = page.next {
                        return loop(next)
                            .map { page.items + $0 }
                            .eraseToAnyPublisher()
                    } else {
                        return Just(page.items)
                            .setFailureType(to: Error.self)
                            .eraseToAnyPublisher()
                    }
                }
                .eraseToAnyPublisher()
        }
        return loop(start)
    }
}

여기서 핵심은 재귀입니다. flatMap은 값 하나를 받아 새 Publisher를 반환하고, 그 Publisher의 값을 바깥 스트림으로 펼쳐 줍니다. 다음 페이지가 있으면 loop(next)를 다시 호출하고 결과 앞에 현재 페이지 항목을 붙이며, 없으면 Just로 끝냅니다. Just의 Failure는 Never라서 setFailureType(to: Error.self)로 타입을 맞춰야 if/else 두 분기의 반환 타입이 일치합니다. 분기마다 eraseToAnyPublisher()를 붙이는 것도 같은 이유로, 이를 빼면 두 분기의 구체 타입(Publishers.Map<...>와 Publishers.SetFailureType<Just<...>>)이 달라 컴파일되지 않습니다.

Future에 대해서도 알아 둘 점이 있습니다. Future는 생성되는 즉시 클로저를 실행하고 결과를 한 번만 캐싱합니다. 구독자가 없어도 요청이 나가고, 두 번 구독해도 요청은 한 번만 갑니다. 구독 시점에 요청을 보내고 싶다면 Deferred { Future { ... } }로 감싸야 하며, 이걸 모르면 retry를 붙여도 재시도가 일어나지 않는 버그를 만나게 됩니다. 이미 완료된 같은 Future를 다시 구독하기 때문입니다.

실무에서는 flatMap(maxPublishers: .max(1))로 동시 요청 수를 제한하며, retry나 catch와 delay를 조합해 백오프를 구현해 네트워크 안정성을 높입니다(Combine에는 백오프를 내장한 retry가 없습니다).

구독 취소·combineLatest·에러 종료에서 생기는 실수

  • sink 반환값을 저장하지 않아 구독이 즉시 취소되는 경우.
  • @Published를 그냥 var로 공개해 뷰가 뷰 모델 상태를 직접 바꿀 수 있게 되는 경우. 외부에는 읽기만 허용하려면 @Published private(set) var로 선언합니다.
  • combineLatest의 초기 방출 조건을 몰라 첫 값이 안 나오는 문제를 겪는 경우.
  • 파이프라인 중간에서 에러를 처리하지 않아 한 번의 실패로 스트림 전체가 종료되는 경우. 검색 파이프라인에서 flatMap 안쪽 요청에 catch { _ in Just([]) }를 붙이지 않으면, 네트워크 에러 한 번 뒤로는 검색어를 입력해도 아무 반응이 없습니다.

제가 Combine을 처음 도입할 때 가장 오래 헤맨 문제가 마지막 항목이었습니다. 에러를 바깥 스트림에서 catch하면 대체 값을 내보내고 원래 스트림은 끝나 버립니다. 에러 처리는 반드시 flatMap 안쪽, 즉 요청 하나 단위에서 해야 바깥의 입력 스트림이 계속 살아 있습니다.

  • Scheduler 선택(메인 큐 vs 백그라운드)에 따라 UI 업데이트 레이스가 생깁니다.
  • 메모리 순환 참조는 [weak self]와 store(in:) 패턴으로 끊습니다.

디바운스 검색과 에러 매핑

  • 입력 → 디바운스 → switchToLatest로 검색·자동완성을 구현합니다.
  • 에러는 catch/replaceError로 UI에 친화적인 메시지로 매핑합니다.
  • 단위 테스트에서 debounce처럼 시간이 걸리는 연산자는 스케줄러를 주입받게 만든 뒤, 테스트에서는 시간을 수동으로 진행할 수 있는 스케줄러로 바꿔 끼웁니다. Combine 자체에는 테스트 스케줄러가 없어서 Point-Free의 combine-schedulers 같은 라이브러리나 직접 만든 스케줄러를 씁니다.

Combine, AsyncSequence, RxSwift 비교

방식메모
CombineApple 네이티브, SwiftUI와 궁합
AsyncSequence + AsyncStreamSwift 5.5+ 단순 파이프라인
RxSwift레거시 코드베이스

새 코드에서 Combine과 async/await 중 무엇을 쓸지는 요즘 가장 자주 받는 질문입니다. 요청 하나를 보내고 응답 하나를 받는 흐름이라면 async/await가 훨씬 읽기 쉽고 에러 처리도 do/catch로 자연스럽습니다. 반면 debounce, combineLatest, throttle처럼 시간과 여러 스트림의 조합을 다루는 연산자는 표준 라이브러리의 AsyncSequence에 없어서, Apple의 swift-async-algorithms 패키지를 추가해야 합니다. 그래서 단발성 비동기 작업은 async/await로, UI 입력 조합과 @Published 바인딩은 Combine으로 나누어 쓰는 경우가 많습니다. 두 세계는 publisher.values(AsyncSequence로 변환)로 이어 붙일 수 있습니다.

참고 자료


Combine 요약

  1. Publisher: 값 발행, Just, PassthroughSubject
  2. Subscriber: 값 구독, sink
  3. Operator: map, filter, combineLatest
  4. @Published: 자동 발행, SwiftUI 통합
  5. AnyCancellable: 구독 취소

다음 단계

Swift 시리즈를 완료했습니다! 다른 언어도 배워보세요:


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. sink로 구독했는데 값이 한 번도 들어오지 않는 이유는 무엇인가요?

A. sink가 반환하는 AnyCancellable을 저장하지 않으면 그 값이 바로 해제되면서 구독도 즉시 취소됩니다. 프로퍼티에 담거나 store(in: &cancellables)로 보관해야 구독이 유지되며, 이때 클로저 안에서는 [weak self]로 순환 참조를 끊어야 합니다. combineLatest를 쓰는 경우에는 모든 Publisher가 최소 한 번씩 값을 내보내야 첫 값이 나온다는 점도 확인해야 합니다.