Swift Combine의 Hot 및 Cold Publishers
Source: Dev.to
Swift Combine에서 Hot & Cold Publisher
Combine을 사용하면서 Publisher가 “Cold”인지 “Hot”인지 구분하는 것이 중요합니다.
두 개념은 데이터가 언제, 어떻게 방출되는지를 결정하고, 이는 구독자(subscriber)의 동작에 직접적인 영향을 미칩니다.
Cold Publisher
- 정의: 구독자가 생길 때까지 데이터를 방출하지 않는 Publisher.
- 특징
- 구독이 시작될 때마다 새로운 스트림이 생성됩니다.
- 동일한 Publisher를 여러 번 구독하면, 각 구독자는 독립적인 데이터 흐름을 받습니다.
- 대표적인 예시
JustFutureDeferredURLSession.DataTaskPublisher
예시
let cold = Just(5) // Just는 Cold Publisher
cold.sink { print("첫 번째 구독: \($0)") }
cold.sink { print("두 번째 구독: \($0)") }
출력
첫 번째 구독: 5
두 번째 구독: 5
두 구독 모두 같은 값을 받지만, 각각 독립적인 구독이므로 내부적으로 두 번 실행됩니다.
Hot Publisher
- 정의: 구독자와 무관하게 데이터를 방출하는 Publisher.
- 특징
- 구독이 시작되기 전에도 이미 이벤트가 발생할 수 있습니다.
- 구독자가 늦게 연결되면, 그 시점 이후에 발생한 이벤트만 받게 됩니다.
- 대표적인 예시
PassthroughSubjectCurrentValueSubjectTimer.publishNotificationCenter.Publisher
예시
let hot = PassthroughSubject<Int, Never>()
hot.send(1) // 구독자 없이도 이벤트가 방출됨
hot.sink { print("구독자 A: \($0)") }
hot.send(2) // 구독자 A는 2를 받음
hot.sink { print("구독자 B: \($0)") }
hot.send(3) // 구독자 A와 B 모두 3을 받음
출력
구독자 A: 2
구독자 A: 3
구독자 B: 3
PassthroughSubject는 구독이 없을 때도 send(_:)를 호출하면 이벤트가 발생합니다. 구독이 늦게 연결되면 이전 이벤트는 놓치게 됩니다.
Cold Publisher를 Hot 로 변환하기
때때로 Cold Publisher를 Hot 형태로 바꾸고 싶을 때가 있습니다. Combine에서는 몇 가지 연산자를 제공하여 이를 쉽게 구현할 수 있습니다.
share()
share()는 내부적으로 multicast + autoconnect를 사용해 Cold 스트림을 Hot 스트림으로 변환합니다.
let shared = URLSession.shared.dataTaskPublisher(for: url)
.share() // 이제 Hot Publisher
- 첫 번째 구독이 시작될 때 네트워크 요청이 실행되고, 이후 구독자는 동일한 데이터를 공유합니다.
multicast + makeConnectable
더 세밀한 제어가 필요하면 multicast와 makeConnectable을 조합합니다.
let subject = PassthroughSubject<Data, URLError>()
let connectable = URLSession.shared.dataTaskPublisher(for: url)
.multicast(subject: subject) // Cold → Hot 변환 준비
.makeConnectable() // 연결을 수동으로 제어
// 구독 설정
let cancellable1 = connectable.sink(
receiveCompletion: { print("완료1: \($0)") },
receiveValue: { print("값1: \($0)") }
)
let cancellable2 = connectable.sink(
receiveCompletion: { print("완료2: \($0)") },
receiveValue: { print("값2: \($0)") }
)
// 실제 네트워크 요청 시작
connectable.connect()
connectable.connect()를 호출하기 전까지는 어떤 구독자도 데이터를 받지 못합니다.- 연결이 된 뒤에는 모든 구독자가 동일한 스트림을 공유합니다.
언제 Hot, 언제 Cold를 사용해야 할까?
| 상황 | 권장 Publisher 타입 |
|---|---|
| 데이터가 한 번만 생성되고, 구독마다 동일한 결과가 필요 | Cold (Just, Future, Deferred) |
| 실시간 이벤트(버튼 클릭, 센서 데이터 등)를 여러 구독자와 공유 | Hot (PassthroughSubject, CurrentValueSubject, Timer.publish) |
| 네트워크 요청을 여러 구독자가 공유하고 싶을 때 | Cold → Hot 변환 (share(), multicast) |
| 구독 시점에 현재 값을 즉시 제공하고, 이후 변화도 전달 | CurrentValueSubject (Hot) |
요약
- Cold Publisher: 구독 시점에 작업이 시작되고, 구독마다 독립적인 스트림을 가짐.
- Hot Publisher: 구독과 무관하게 작업이 진행되며, 구독자는 현재 시점 이후에 발생하는 이벤트만 수신.
- 필요에 따라
share(),multicast,makeConnectable등을 활용해 Cold → Hot 변환이 가능.
Combine을 사용할 때 이 두 개념을 명확히 이해하면, 데이터 흐름을 더 예측 가능하게 제어하고 불필요한 네트워크 호출이나 중복 작업을 방지할 수 있습니다.
위 내용은 원본 글을 한국어로 번역한 것이며, 코드와 URL 등 기술적인 요소는 원문 그대로 유지했습니다.
핫 퍼블리셔와 콜드 퍼블리셔는 무엇인가요?
콜드 퍼블리셔
콜드 퍼블리셔는 각 구독자마다 새로운 실행을 생성합니다. 구독할 때 작업이 새롭게 시작됩니다.
let coldPublisher = Deferred {
Future { promise in
print("Making API call")
// Simulate API call
promise(.success(Int.random(in: 1...100)))
}
}
coldPublisher.sink { print("Subscriber 1: \($0)") }
// Output: "Making API call", "Subscriber 1: 42"
coldPublisher.sink { print("Subscriber 2: \($0)") }
// Output: "Making API call", "Subscriber 2: 87"
각 구독자는 별도의 API 호출을 트리거합니다.
공통 콜드 퍼블리셔
JustFailDeferredURLSession.dataTaskPublisherFuture(다소 콜드‑같은)
핫 퍼블리셔
핫 퍼블리셔는 모든 구독자에게 단일 실행을 공유합니다. 작업은 구독자와 무관하게 진행되며, 각 구독자는 구독 시점부터 발생하는 이벤트를 받습니다.
let hotPublisher = PassthroughSubject<Int, Never>()
hotPublisher.sink { print("Subscriber 1: \($0)") }
hotPublisher.send(1)
// Output: "Subscriber 1: 1"
hotPublisher.sink { print("Subscriber 2: \($0)") }
hotPublisher.send(2)
// Output: "Subscriber 1: 2"
// Output: "Subscriber 2: 2"
두 구독자 모두 단일 방출에서 같은 값을 받습니다.
공통 핫 퍼블리셔
PassthroughSubjectCurrentValueSubjectTimer.publishNotificationCenter.publisher
Source:
왜 이것이 중요한가
1. 리소스 관리
콜드 퍼블리셔는 리소스를 낭비할 수 있습니다:
let apiCall = URLSession.shared.dataTaskPublisher(for: url)
apiCall.sink { print("UI: \($0)") }
apiCall.sink { print("Cache: \($0)") }
위 코드는 두 개의 별도 네트워크 요청을 발생시킵니다. 대부분의 경우 구독자들 사이에 하나의 요청을 공유하고 싶습니다.
2. 상태 일관성
핫 퍼블리셔는 모든 구독자가 동일한 데이터를 보도록 보장합니다:
let userState = CurrentValueSubject<User?, Never>(nil)
// 앱의 모든 부분이 동일한 사용자 상태를 관찰합니다
userState.sink { user in updateUI(user) }
userState.sink { user in syncToDatabase(user) }
3. 타이밍 문제
콜드 퍼블리셔는 공유 실행을 기대할 때 레이스 컨디션을 일으킬 수 있습니다:
let timestamp = Just(Date())
timestamp.sink { print("Time 1: \($0)") }
Thread.sleep(forTimeInterval: 1)
timestamp.sink { print("Time 2: \($0)") }
각 구독자는 Just가 구독마다 새로운 값을 생성하기 때문에 서로 다른 타임스탬프를 받게 됩니다.
Source: …
언제 어떤 것을 사용할까
콜드 퍼블리셔를 사용할 때
구독자마다 독립적인 실행을 원할 때
func fetchUserData(id: String) -> AnyPublisher<User, Error> {
URLSession.shared.dataTaskPublisher(for: makeURL(id))
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
// 각 호출이 자체 요청을 수행합니다
fetchUserData(id: "123").sink { … }
fetchUserData(id: "456").sink { … }
재사용 가능한 퍼블리셔 팩토리를 만들고 싶을 때
func createTimer(interval: TimeInterval) -> AnyPublisher<Date, Never> {
Deferred {
Timer.publish(every: interval, on: .main, in: .common)
.autoconnect()
}
.eraseToAnyPublisher()
}
지연 평가가 필요할 때 – 실제로 누군가 구독할 때만 작업이 수행됩니다.
핫 퍼블리셔를 사용할 때
여러 옵저버에게 이벤트를 방송해야 할 때
class LocationManager {
let locationPublisher = PassthroughSubject<CLLocation, Never>()
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
locations.forEach { locationPublisher.send($0) }
}
}
공유 상태를 관리하고 있을 때
class AppState {
let isLoggedIn = CurrentValueSubject<Bool, Never>(false)
let currentUser = CurrentValueSubject<User?, Never>(nil)
}
비싼 연산을 공유해야 할 때
let sharedAPICall = apiPublisher
.share()
.eraseToAnyPublisher()
// 여러 구독자, 단일 네트워크 요청
sharedAPICall.sink { updateUI($0) }
sharedAPICall.sink { cacheData($0) }
핫과 콜드 간 변환
콜드 → 핫: .share() 사용
let coldPublisher = URLSession.shared.dataTaskPublisher(for: url)
let hotPublisher = coldPublisher.share()
// Now multiple subscribers share one request
hotPublisher.sink { print("Subscriber 1: \($0)") }
hotPublisher.sink { print("Subscriber 2: \($0)") }
핫 → 콜드: Deferred 사용
let hotPublisher = PassthroughSubject<Int, Never>()
let coldPublisher = Deferred { hotPublisher }
// Each subscription creates a new relationship to the subject
핫을 콜드로 변환하는 경우는 실제로 거의 필요하지 않습니다.
일반적인 함정
함정 1: 의도치 않은 다중 실행
// Bad: Makes 3 separate API calls
let apiCall = URLSession.shared.dataTaskPublisher(for: url)
apiCall.sink { handleResponse($0) }
apiCall.sink { cacheResponse($0) }
apiCall.sink { logResponse($0) }
// Good: Makes 1 API call, shared among subscribers
let sharedCall = apiCall.share()
sharedCall.sink { handleResponse($0) }
sharedCall.sink { cacheResponse($0) }
sharedCall.sink { logResponse($0) }
함정 2: 구독 부작용 누락
핫 퍼블리셔를 만들었지만 구독하지 않으면 작업이 시작되지 않습니다:
let timer = Timer.publish(every: 1.0, on: .main, in: .common)
// No `.autoconnect()` or `sink` → timer never fires
항상 핫 퍼블리셔에 최소 하나의 활성 구독자가 있거나 Timer.publish에 대해 .autoconnect()를 호출하도록 하세요.
함정 3: .share() 사용 시 메모리 누수
let shared = expensivePublisher.share()
// If no subscribers exist, .share() can keep resources alive
// Consider using .share(replay: 0) or managing lifecycle explicitly
Hot Publishers에서 누락된 이벤트
let subject = PassthroughSubject<String, Never>()
subject.send("Event 1") // Lost, no subscribers yet
subject.sink { print($0) } // Subscribes now
subject.send("Event 2") // Received
늦게 구독하는 구독자도 값을 받게 하려면 CurrentValueSubject 또는 리플레이 연산자를 사용하세요.
빠른 참고
콜드 퍼블리셔 특성
- 구독자당 새로운 실행
- 작업은 구독 시 시작
- 각 구독자는 독립적인 결과를 받음
- 반복되어야 하는 작업에 적합
핫 퍼블리셔 특성
- 구독자 간 공유 실행
- 작업은 구독과 무관하게 발생
- 모든 구독자는 동일한 이벤트를 받음
- 브로드캐스트 및 공유 상태에 적합
결론
핫 퍼블리셔와 콜드 퍼블리셔를 언제 사용해야 하는지 알면 버그를 방지하고 성능을 최적화하며 보다 예측 가능한 리액티브 코드를 작성할 수 있습니다.
경험 법칙:
- 여러 구독자가 동일한 작업이나 상태를 공유해야 한다면 핫 퍼블리셔를 사용합니다.
- 각 구독자가 독립적인 작업을 트리거해야 한다면 콜드 퍼블리셔를 사용합니다.