본문 바로가기
iOS/Combine

[Combine] .handleEvents

by Bokoo14 2026. 3. 12.

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