Combine

Publisher 깊게 보기

thoonk: 2026. 5. 13. 15:21
반응형

Publisher의 종류 한눈에 보기

Combine에서 쓰는 Publisher는 크게 세 부류로 나뉨.

분류 예시 특징

기본 제공 Publisher Just, Empty, Fail, Future, Deferred 한 번 정의되면 값이 정해져 있음
외부 이벤트 Publisher URLSession.dataTaskPublisher, NotificationCenter.Publisher, Timer.publish 시스템 이벤트에서 값이 흘러나옴
수동 발행 Publisher PassthroughSubject, CurrentValueSubject, @Published 코드에서 send()로 값을 직접 주입

Apple이 기본 제공하는 Publisher들

Just — 값 하나 내보내고 끝

let publisher = Just("Hello")
// Output: String, Failure: Never

publisher.sink { print($0) } // "Hello"

테스트 코드나 기본값 제공할 때 자주 씀. 절대 실패하지 않는다(Failure: Never) 는 점이 핵심.

Empty — 아무 값도 안 내보내고 완료

let publisher = Empty<String, Never>()
publisher.sink(
    receiveCompletion: { _ in print("완료") },
    receiveValue: { _ in print("값 없음") }
)
// "완료"

조건에 따라 "아무것도 안 하기" 분기를 만들 때 유용함. 예를 들어 flatMap 안에서 특정 조건일 때 빈 스트림으로 처리.

Fail — 에러를 내보내고 종료

enum MyError: Error { case somethingWrong }

let publisher = Fail<String, MyError>(error: .somethingWrong)
publisher.sink(
    receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("에러: \\(error)")
        }
    },
    receiveValue: { _ in }
)
// "에러: somethingWrong"

분기 처리에서 "이 경우엔 에러로 처리" 라고 명시할 때 사용.

Future — 비동기 작업 한 번 → 값 하나

func loadUser(id: String) -> Future<User, Error> {
    Future { promise in
        API.fetchUser(id: id) { result in
            switch result {
            case .success(let user):
                promise(.success(user))
            case .failure(let error):
                promise(.failure(error))
            }
        }
    }
}

기존 Completion Handler 기반 API를 Combine으로 감싸는 가장 흔한 방법.

주의 : Future는 생성되는 순간 즉시 실행됨. 구독 전에 이미 작업이 시작됨. 이게 의도와 다르면 Deferred로 감싸야 함.

// Future를 감싸서 구독 시점에 실행되도록
Deferred {
    Future<User, Error> { promise in
        // 구독될 때마다 실행됨
    }
}

URLSession.dataTaskPublisher — 네트워크 요청

URLSession.shared.dataTaskPublisher(for: url)
    .map(\\.data)
    .decode(type: User.self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { user in print(user) }
    )
    .store(in: &cancellables)

URLSession 기반 네트워크 요청을 그대로 Publisher로 받을 수 있음. map → decode → receive(on:) → sink 가 표준 패턴.

Timer.publish — 주기적 이벤트

Timer.publish(every: 1.0, on: .main, in: .common)
    .autoconnect() // 구독 시 자동 시작
    .sink { date in print(date) }
    .store(in: &cancellables)

autoconnect()를 빼먹으면 타이머가 시작되지 않음. 이거 빼먹어서 시간 날리는 경우가 정말 많음.

Subject — 외부에서 값을 주입할 수 있는 Publisher

기본 Publisher들은 "정해진 값을 내보냄". 반대로 내가 원할 때 값을 넣고 싶다면 Subject를 씀.

Subject는 두 종류임.

PassthroughSubject — 초기값 없음, 구독 이후 값만 받음

let subject = PassthroughSubject<String, Never>()

subject.sink { print("받음: \\($0)") }
    .store(in: &cancellables)

subject.send("A") // "받음: A"
subject.send("B") // "받음: B"

CurrentValueSubject — 초기값 있음, 구독 시 현재 값을 받음

let subject = CurrentValueSubject<String, Never>("초기값")

subject.sink { print("받음: \\($0)") }
    .store(in: &cancellables)
// "받음: 초기값"  ← 구독 즉시 현재 값을 받음

subject.send("새 값") // "받음: 새 값"

print(subject.value) // "새 값" ← 현재 값에 직접 접근 가능

상태(state)를 다룰 때 적합함. ViewModel의 데이터 상태를 표현하는 데 가장 자주 씀. .value로 현재 값을 동기적으로 꺼낼 수 있는 것도 큰 장점.

언제 뭘 쓰는지 헷갈리면:

  • "지금 상태가 뭐야?" → CurrentValueSubject (예: 로그인 여부, 현재 선택된 탭)
  • "방금 무슨 일 있었어?" → PassthroughSubject (예: 버튼 탭, API 호출 트리거)

@Published — 프로퍼티를 Publisher로

@Published는 프로퍼티 래퍼로, 일반 프로퍼티를 자동으로 Publisher로 만들어줌.

class SearchViewModel {
    @Published var query: String = ""
    @Published var results: [Item] = []
}

let viewModel = SearchViewModel()

viewModel.$query // Published<String>.Publisher
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .sink { print("검색어: \\($0)") }
    .store(in: &cancellables)

viewModel.query = "Combine" // 0.3초 후 "검색어: Combine"

내부적으로는 CurrentValueSubject와 거의 동일하게 동작함. 다만 클래스 안에서 프로퍼티 형태로 쓸 때 훨씬 자연스러움.

주의 : @Published는 willSet 시점에 값을 내보냄. 즉, sink에서 받는 값은 곧 바뀔 값임. 이미 바뀐 값이 아님.

class ViewModel {
    @Published var count = 0
}

let vm = ViewModel()
vm.$count.sink { newValue in
    print("publisher: \\(newValue), property: \\(vm.count)")
}.store(in: &cancellables)

vm.count = 5
// 출력: "publisher: 5, property: 0"
// publisher가 먼저 발행되고, 그 다음에 실제 프로퍼티가 갱신됨

이 동작 때문에 디버깅하다 헷갈리는 경우가 있으니 알아두면 좋음.

Cold vs Hot

Cold Publisher — 구독해야 값을 내보냄

대부분의 Combine Publisher는 Cold임. 구독자가 생기기 전까지는 아무 일도 일어나지 않음.

let publisher = URLSession.shared.dataTaskPublisher(for: url)
// 이 시점엔 네트워크 요청 안 일어남

publisher.sink { _ in }.store(in: &cancellables) // 이제 요청 시작
publisher.sink { _ in }.store(in: &cancellables) // 두 번째 구독 → 또 요청

같은 Publisher를 두 번 구독하면 네트워크 요청도 두 번 발생함. 이게 의도한 동작이 아니라면 문제가 됨.

Hot Publisher — 구독 여부와 무관하게 값을 내보냄

Subject나 NotificationCenter.Publisher는 Hot임. 구독자가 없어도 값은 흘러감.

let subject = PassthroughSubject<Int, Never>()
subject.send(1) // 아무도 안 받음, 그냥 사라짐

subject.sink { print($0) }.store(in: &cancellables)
subject.send(2) // 2

Cold를 Hot처럼 쓰고 싶을 때

예를 들어, 네트워크 요청 결과를 여러 곳에서 공유하고 싶다고 하자. Cold 그대로 두면 구독할 때마다 요청이 날아감. 이때 쓰는 게 share() 또는 multicast().

let shared = URLSession.shared.dataTaskPublisher(for: url)
    .map(\\.data)
    .share() // 여러 구독자가 결과를 공유

shared.sink { _ in }.store(in: &cancellables) // 요청 발생
shared.sink { _ in }.store(in: &cancellables) // 요청 안 발생, 결과만 공유

커스텀 Publisher 만들기

직접 만들 일은 거의 없지만 한 번 만들어보면 Publisher 구조가 머리에 박힘.

struct CountdownPublisher: Publisher {
    typealias Output = Int
    typealias Failure = Never

    let from: Int

    func receive<S: Subscriber>(subscriber: S)
        where S.Input == Output, S.Failure == Failure {
        let subscription = CountdownSubscription(subscriber: subscriber, from: from)
        subscriber.receive(subscription: subscription)
    }
}

final class CountdownSubscription<S: Subscriber>: Subscription
    where S.Input == Int, S.Failure == Never {

    private var subscriber: S?
    private var current: Int

    init(subscriber: S, from: Int) {
        self.subscriber = subscriber
        self.current = from
    }

    func request(_ demand: Subscribers.Demand) {
        while current >= 0 {
            _ = subscriber?.receive(current)
            current -= 1
        }
        subscriber?.receive(completion: .finished)
    }

    func cancel() {
        subscriber = nil
    }
}

// 사용
CountdownPublisher(from: 3)
    .sink { print($0) }
    .store(in: &cancellables)
// 출력: 3, 2, 1, 0`

쓸 일은 별로 없음. 대부분의 경우 Subject나 Future로 충분히 해결됨. 이런 구조로 동작한다 정도만 알면 됨.


꿀팁

1. 네트워크는 거의 항상 dataTaskPublisher + share

func fetchUser(id: String) -> AnyPublisher<User, Error> {
    URLSession.shared.dataTaskPublisher(for: url)
        .map(\\.data)
        .decode(type: User.self, decoder: JSONDecoder())
        .mapError { $0 as Error }
        .share() // 같은 요청 여러 곳에서 구독해도 한 번만 발생
        .eraseToAnyPublisher()
}

.eraseToAnyPublisher()로 타입을 감추는 게 거의 표준. 내부 구현(map, decode 등)이 호출하는 쪽에 노출되지 않음.

2. ViewModel 상태는 CurrentValueSubject 또는 @Published

final class HomeViewModel {
    // UIKit 베이스
    let isLoading = CurrentValueSubject<Bool, Never>(false)
    let items = CurrentValueSubject<[Item], Never>([])

    // SwiftUI 베이스 또는 ObservableObject 쓸 때
    @Published var isLoading2 = false
    @Published var items2: [Item] = []
}

UIKit + Combine 조합에서는 CurrentValueSubject, SwiftUI나 ObservableObject 패턴에서는 @Published가 자연스러움.

3. 이벤트성 입력은 PassthroughSubject

final class HomeViewModel {
    // 입력 (View → ViewModel)
    let viewDidLoad = PassthroughSubject<Void, Never>()
    let didTapRefresh = PassthroughSubject<Void, Never>()
    let didSelectItem = PassthroughSubject<Item, Never>()
}

Input/Output 패턴으로 ViewModel을 설계할 때 거의 표준 형태.

4. Future는 한 번만 실행됨 → 결과 캐싱 필요한가 체크

Future는 한 번 실행되고 결과를 내부에 저장함. 같은 Future를 두 번 구독하면 두 번째는 캐시된 결과를 받음.

이게 원하는 동작이면 OK, 매번 새 요청을 보내고 싶다면 Deferred { Future { ... } }로 감싸야 함.

5. @Published의 willSet 동작 기억하기

@Published 프로퍼티를 변경한 직후 그 값에 접근하면, 구독자가 받은 값과 실제 값이 한 사이클 어긋날 수 있음. 디버깅하다 시간 낭비하지 않으려면 외워두기.


정리

Publisher의 종류는 크게 셋. 기본 제공(Just, Empty, Fail, Future), 시스템 이벤트(URLSession, NotificationCenter, Timer), 수동 발행(Subject, @Published).

  • 네트워크 → URLSession.dataTaskPublisher + share + eraseToAnyPublisher
  • 상태 → CurrentValueSubject 또는 @Published
  • 이벤트 → PassthroughSubject
  • Completion Handler 감싸기 → Future (필요 시 Deferred)

그리고 가장 헷갈리는 Cold vs Hot. 대부분의 Publisher는 Cold이고, 구독할 때마다 재실행됨. 공유가 필요하면 share().

반응형