handleEvents는 Publisher의 생명주기 이벤트를 side effect로 처리하는 오퍼레이터
스트림 자체는 변형하지 않고, 각 이벤트 시점에 끼어들어 부가 작업을 수행
개념
map, filter 같은 오퍼레이터는 값을 변환하지만, handleEvents는 값을 그대로 통과시키면서 관찰 및 부가 작업만 수행
upstream ──→ handleEvents (관찰만, 변형 없음) ──→ downstream
↓
side effect 실행
(로깅, 로딩 상태 변경 등)
시그니처
모든 파라미터가 optional이라 필요한 것만 골라서 사용할 수 있음
func handleEvents(
receiveSubscription: ((Subscription) -> Void)? = nil,
receiveOutput: ((Output) -> Void)? = nil,
receiveCompletion: ((Subscribers.Completion<Failure>) -> Void)? = nil,
receiveCancel: (() -> Void)? = nil,
receiveRequest: ((Subscribers.Demand) -> Void)? = nil
) -> Publishers.HandleEvents<Self>
각 파라미터 시점
구독 시작 ──→ receiveSubscription
값 요청 ──→ receiveRequest
값 방출 ──→ receiveOutput
완료 / 에러 ──→ receiveCompletion
구독 취소 ──→ receiveCancel
receiveSubscription
- 구독이 시작되는 순간 호출됨
- sink 또는 assign이 연결되어 Publisher와 Subscriber 사이에 Subscription 객체가 만들어진 직후
- Subscription 객체를 통해 demand를 직접 제어할 수 있지만, 일반적으로는 로깅이나 초기화 작업에 활용
receiveSubscription: ((Subscription) -> Void)?
somePublisher
.handleEvents(receiveSubscription: { subscription in
// subscription: 구독을 제어할 수 있는 객체
print("구독 시작됨")
// 직접 demand를 요청할 수도 있음
subscription.request(.unlimited)
})
receiveOutput
- 값이 방출될 때마다 호출
- downstream으로 값이 전달되기 직전에 실행되므로, 값이 실제로 사용되기 전 시점을 관찰할 수 있음
- 값을 읽기만 할 수 있고, 변경은 불가능. 변환이 필요하면 map을 사용
receiveOutput: ((Output) -> Void)?
somePublisher
.handleEvents(receiveOutput: { value in
// value: 방출된 값 (Output 타입)
print("방출된 값: \\(value)")
// 분석 이벤트 전송, 로깅 등
Analytics.log("value_received", value)
})
receiveCompletion
- 스트림이 종료될 때 호출
- 정상 완료와 에러 완료 두 가지 케이스를 모두 처리
- Subscribers.Completion은 enum
- receiveCancel과 함께 쓰면 스트림이 끝나는 모든 경우를 커버할 수 있음
receiveCompletion: ((Subscribers.Completion<Failure>) -> Void)?
enum Completion<Failure: Error> {
case finished // 정상 완료
case failure(Failure) // 에러 발생
}
somePublisher
.handleEvents(receiveCompletion: { completion in
switch completion {
case .finished:
print("정상 완료")
self.isLoading = false
case .failure(let error):
print("에러 발생: \\(error)")
self.isLoading = false
self.showErrorAlert = true
}
})
receiveCancel
- 구독이 취소될 때 호출
- AnyCancellable이 cancel() 호출되거나 deinit될 때 트리거됨
- receiveCompletion과는 독립적으로 동작함
receiveCancel: (() -> Void)?
somePublisher
.handleEvents(receiveCancel: {
// 파라미터 없음 — 단순히 취소 시점만 알림
print("구독 취소됨")
self.isLoading = false
// 소켓 연결 해제, 타이머 중단 등 리소스 정리
})
receiveCompletion과의 차이
| receiveCompletion | receiveCancel | |
| 호출 시점 | .finished 또는 .failure | cancel() 호출 시 |
| 파라미터 | Completion<Failure> | 없음 |
| 상호 호출 | cancel 시엔 호출 안 됨 | completion 시엔 호출 안 됨 |
// 완전한 종료 처리를 위해 둘을 함께 사용
.handleEvents(
receiveCompletion: { _ in self.isLoading = false },
receiveCancel: { self.isLoading = false }
)
receiveRequest
- Subscriber가 값을 요청할 때 호출
- Combine의 Backpressure 메커니즘과 관련된 파라미터로, 주로 디버깅 목적으로 사용
- Subscribers.Demand는 요청 수량을 나타냄
- 일반적인 sink는 항상 .unlimited를 요청. Custom Subscriber를 구현하거나 Backpressure를 직접 다룰 때 의미 있는 값이 된다.
receiveRequest: ((Subscribers.Demand) -> Void)?
Subscribers.Demand.unlimited // 제한 없이 모두 요청
Subscribers.Demand.max(3) // 최대 3개만 요청
Subscribers.Demand.none // 현재는 요청 없음
somePublisher
.handleEvents(receiveRequest: { demand in
switch demand {
case .unlimited:
print("무제한 요청")
default:
print("요청 수량: \\(demand)")
}
})
전체 흐름
sink 연결
│
▼
receiveRequest ← Subscriber가 "값 주세요" 요청
│
▼
receiveSubscription ← Publisher가 구독 수락
│
▼
receiveOutput ← 값 방출마다 반복
receiveOutput
receiveOutput
│
├─── 정상 종료 → receiveCompletion(.finished)
├─── 에러 발생 → receiveCompletion(.failure(error))
└─── cancel() → receiveCancel
자주 쓰는 조합
apiPublisher
.handleEvents(
receiveSubscription: { [weak self] _ in
self?.isLoading = true // 로딩 시작
},
receiveOutput: { value in
print("[LOG] 응답: \\(value)") // 디버깅
},
receiveCompletion: { [weak self] completion in
self?.isLoading = false // 로딩 종료
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveCancel: { [weak self] in
self?.isLoading = false // 취소 시에도 로딩 종료
}
)
.sink(receiveCompletion: { _ in }, receiveValue: { [weak self] in
self?.data = $0
})
.store(in: &cancellables)
사용 예시
receiveOutput이 항상 sink보다 먼저 실행됨
오퍼레이터 체인 순서대로 위에서 아래로 흐르기 때문
[1, 2, 3].publisher
.handleEvents(
receiveSubscription: { _ in print("구독 시작") },
receiveOutput: { print("값 방출: \\($0)") },
receiveCompletion: { print("완료: \\($0)") },
receiveCancel: { print("취소됨") }
)
.sink { print("수신: \\($0)") }
// 출력:
// 구독 시작
// 값 방출: 1
// 수신: 1
// 값 방출: 2
// 수신: 2
// 값 방출: 3
// 수신: 3
// 완료: finished
로딩 상태 관리
가장 흔한 사용처
API 요청 전후로 로딩 인디케이터를 제어
func fetchUser(id: Int) {
apiClient.getUser(id: id)
.handleEvents(
receiveSubscription: { [weak self] _ in
self?.isLoading = true // 요청 시작 → 로딩 ON
},
receiveCompletion: { [weak self] _ in
self?.isLoading = false // 완료 or 에러 → 로딩 OFF
},
receiveCancel: { [weak self] in
self?.isLoading = false // 취소 → 로딩 OFF
}
)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] user in
self?.user = user
}
)
.store(in: &cancellables)
}
디버깅 / 로깅
- 스트림 중간에 끼워넣어 데이터 흐름을 추적
- Combine에는 print() 오퍼레이터도 있지만, handleEvents는 원하는 시점만 선택적으로 로깅할 수 있어 더 유연함
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.handleEvents(receiveOutput: { query in
print("[DEBUG] 검색 요청: '\\(query)'") // 실제 요청 직전 로깅
})
.removeDuplicates()
.flatMap { apiClient.search($0) }
.sink { results in
self.results = results
}
.store(in: &cancellables)
취소 감지
- AnyCancellable이 deinit될 때 어떤 정리 작업이 필요한 경우
somePublisher
.handleEvents(receiveCancel: {
print("구독이 취소됨 — 리소스 정리")
// WebSocket 연결 해제, 타이머 중단 등
})
.sink { ... }
.store(in: &cancellables)
handleEvents vs print()
| handleEvents | print() | |
| 목적 | 커스텀 side effect | 디버그 출력 전용 |
| 유연성 | 원하는 이벤트만 선택 | 모든 이벤트 자동 출력 |
| 실제 로직 | 가능 (상태 변경 등) | 불가 |
| 프로덕션 | 사용 가능 | 보통 제거 |
주의점
- handleEvents는 side effect 전용
- 클로저 안에서 값을 변환하거나 필터링하는 용도로는 사용하지 않아야 한다.
- 값 변환이 필요하면 map, filter 등 적절한 오퍼레이터를 사용해야 함
// ❌ 잘못된 사용 — handleEvents에서 값 변환 시도
.handleEvents(receiveOutput: { value in
transformedValue = value * 2 // 외부 변수에 의존하는 변환
})
// ✅ 올바른 사용 — 변환은 map으로
.map { $0 * 2 }
정리
- 스트림을 변형하지 않고 생명주기 이벤트에 끼어드는 오퍼레이터
- 로딩 상태 관리, 디버깅, 리소스 정리에 특히 유용
- 모든 파라미터가 optional이라 필요한 시점만 선택적으로 처리 가능
'iOS > Combine' 카테고리의 다른 글
| [Combine] Future, Deferred (0) | 2026.03.12 |
|---|---|
| [Combine] .removeDuplicates() (0) | 2026.03.11 |