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().
'Combine' 카테고리의 다른 글
| Combine이란? — 핵심 개념 3가지 (0) | 2026.04.08 |
|---|---|
| 왜 Combine인가? (0) | 2026.03.25 |
댓글