Combine: Publisher, Subscriber, Cancellable

틀틀보·2025년 4월 22일

Combine

목록 보기
2/4

Publisher, Subscriber

Publisher: 시간이 경과함에 따라 일련의 값을 발행하는 객체

  • 값, 완료, 실패 세가지 이벤트 방출 가능

Future

비동기 작업의 결과를 한 번만 발행하는 Publisher

let future = Future<Int, Error> { promise in
	let success = true // 테스트용
	DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
        if success {
            promise(.success(44))
        } 
        else {
           promise(.failure(.somethingWentWrong))
        }
    }
}

let cancellable = future
    .sink(
        receiveCompletion: { completion in
        	switch completion {
            case .finished:
                print("작업 완료")
            case .failure(let error):
                print("작업 실패: \(error)")
            }
        },
        receiveValue: { value in
            print("받은 값: \(value)")
        }
    )

2초 후 44를 발행하고 완료.

Just

단일 값을 즉시 발행하고 완료하는 Publisher
간단한 값 전달과 기본값 설정에 유용

Just("Hello, Combine!")
    .sink(receiveValue: { print($0) })

Deferred

Publisher의 생성을 지연시켜, 구독자가 생길 때마다 새로운 Publisher를 생성하는 친구

let deferredFuture = Deferred {
    Future<Int, Never> { promise in
        print("Future 실행")
        promise(.success(Int.random(in: 1...100)))
    }
}

Empty

값을 발행하지 않고 즉시 완료되는 Publisher

Empty<Int, Never>()
    .sink(receiveCompletion: { print("완료: \($0)") })

Fail

즉시 실패 이벤트를 발행하는 Publisher

Fail<Int, MyError>(error: .invalidInput)
    .sink(receiveCompletion: { print("실패: \($0)") })

Record

미리 정의된 값과 완료 이벤트를 순차적으로 발행하는 Publisher

Record(output: [1, 2, 3], completion: .finished)
    .sink(receiveValue: { print($0) })

Subscriber

Subscriber: Publisher를 구독해 발행받은 값을 처리하는 객체

sink

import Combine

let publisher = Just("Hello, Combine!")

let cancellable = publisher.sink(
   receiveCompletion: { completion in
       print("완료: \(completion)")
   },
   receiveValue: { value in
       print("받은 값: \(value)")
   }
)
  • 발행된 값과 완료 이벤트에 대해 개별 처리 가능

    assign

import Combine

class MyViewModel: ObservableObject {
    @Published var message = ""
}

let viewModel = MyViewModel()

let cancellable = Just("안녕하세요!")
    .assign(to: \.message, on: viewModel)

print(viewModel.message) // "안녕하세요!"
  • 클로저 없이 값 자동 저장
  • 실패 타입이 Never 이어야 사용가능 (Failure == Never)
  • 타겟 객체가 @Published일 경우 SwiftUI View에 자동 업데이트 가능

Cancellable

Publisher와 Subscriber 간의 구독을 관리하고 취소할 수 있는 프로토콜

store

import Combine

var cancellables = Set<AnyCancellable>()

let publisher = Just("Hello")
publisher
    .sink { value in
        print("받은 값: \(value)")
    }
    .store(in: &cancellables)
// or
/* 
let cancellable = Just("Hello")
    .sink { value in
        print("받은 값: \(value)")
    } 
*/

Set 자료구조로 여러 구독을 저장하고 필요할 때 일괄적으로 취소가 가능하다.

주의할 점

AnyCancellable로 관리되는 구독들은 type-erasing 래퍼로 내부 구독 정보를 알 수 없게 되어 각각의 구독들을 식별 불가

해결 방법

  • 개별 cancellable 객체 변수로 관리
  • 커스텀 구조체를 이용한 메타데이터로 관리

cancel

let cancellable = publisher.sink { value in
    print("받은 값: \(value)")
}
cancellable.cancel()

구독하던 publisher 객체가 데이터를 발행하더라도 구독취소 시점부터 수신X

Cancellable과 ARC

  • Cancellable 객체는 PublisherSubscriber를 강한 참조로 가지고 있음.
  • Cancellable 객체가 자신을 소유하는 객체가 메모리에서 해제되었을 때, Cancellable 객체도 메모리에서 해제
  • 이 과정에서 PublisherSubscriber의 관계를 끊어주기 위한 cancel 동작이 필요함.
  • cancel이 되지 않으면 각각의 Ref Count가 처리되지 못해 메모리에 남아있게 됨.
public final class AnyCancellable: Cancellable, Hashable {
	...
    deinit {
        _cancel?()
    }
}

개발자를 위해 내부적으로 deinit시점에 cancel을 호출해주는 로직이 포함되어 있어 알아서 순환참조 문제를 해결하고, 해제 시점에 cancel에 대해 신경안써도 됨.
https://github.com/OpenCombine/OpenCombine/blob/master/Sources/OpenCombine/AnyCancellable.swift

다만 Subscriberself를 강하게 참조할 경우 이 또한 순환참조 문제가 생기므로, 약한 참조를 사용해주어야 한다.

Type Erasure

구체적인 타입을 공통된 인터페이스로 추상화하여 유연하고 재사용 가능한 코드를 작성할 수 있게 하는 기법

위의 store 메서드 예시를 보자

import Combine

var cancellables = Set<AnyCancellable>()

let publisher = Just("Hello")
publisher
    .sink { value in
        print("받은 값: \(value)")
    }
    .store(in: &cancellables)
// or
/* 
let cancellable = Just("Hello")
    .sink { value in
        print("받은 값: \(value)")
    } 
*/

이 상황에서 cancellables 변수가 Just만을 구독 관리할 수 있다면 각각의 다른 Publisher 마다 관리 해줄 cancellables 변수가 필요로 하게 된다.

하지만 여기서 Type Erasure로 특정 Publisher 타입에 대해 국한하지 않고 AnyCancellable 이라는 Type Erasure 기법이 적용된 타입을 넣어 하나의 변수로 모든 Publisher를 관리할 수 있게 할 수 있다.

또 다른 예시로 AnyPublisher 가 있다.

var validatedPassword: Publishers.Map<
    Publishers.CombineLatest<
        Published<String>.Publisher, Published<String>.Publisher>,
    String?> {
    return $password
        .combineLatest($passwordAgain)
        .map { password, passwordAgain in
            guard password == passwordAgain, password.count > 1 else { return nil }
            return password
        }
}

나는 그저 두 Publisher가 뱉는 값을 가공하고 다시 Publisher로 내보내고 싶었을 뿐이다.
하지만 위 예시처럼 구체적으로 리턴될 타입을 써야함으로써 코드가 읽기 힘들어지는 문제가 있다.

var validatedPassword: AnyPublisher<String?, Never> {
    return $password
        .combineLatest($passwordAgain)
        .map { password, passwordAgain in
            guard password == passwordAgain, password.count > 1 else { return nil }
            return password
        }
        .eraseToAnyPublisher()
}

하지만 AnyPublisher를 씀으로써 리턴될 타입을 추상화하여 코드가 한결 읽기 쉬워지는 장점이 있다.
출처: https://mini-min-dev.tistory.com/298

profile
안녕하세요! iOS 개발자입니다!

0개의 댓글