애플사이다의 iOS 개발 일지

[Combine] 기본 원리 - Publisher/Subscriber/Cancellable Protocol, Subscription 메커니즘 (2/3) 본문

iOS

[Combine] 기본 원리 - Publisher/Subscriber/Cancellable Protocol, Subscription 메커니즘 (2/3)

Applecider 2024. 2. 4. 21:34

지난번에는 RxSwift와 Combine을 비교하고, 

5가지 중요한 개념인 Publishers, Operators, Subscribers, Subscriptions, Cancellable을 간단히 알아봤다.

지난 포스팅 [Combine] 기본 원리 - Apple이 만든 RxSwift를 이해해 보자 (1/3)을 참고

 

계속해서 Combine 기본 원리를 알아보자.

이번에는 Publisher / Subscriber / Cancellable Protocol의 개념과 

Subscription 메커니즘 자세히 살펴봤다.


1. Publisher Protocol

AnyPublisher를 이해하려면, Publisher Protocol을 알아야 한다.

Publisher는 이벤트를 방출 (publish) 한다. 기존의 NotificationCenter와 비슷하다.

 

Publisher Protocol의 정의는 이렇다.

defines the requirements for a type to be able to transmit a sequence of values.

(값을 방출할 수 있는 타입에 대한 요구사항을 정의한다.)

 

즉, 값을 방출하는 Publisher를 만들고 싶다면

Publisher Protocol에서 만들어 둔 특정 요구사항을 따르게 해야 한다.

 

요구사항을 간단하게 요약하면 아래와 같다. 대략적인 구성만 보자.

Protocol의 연관타입으로 방출할 값의 타입 (Output), 방출할 수도 있는 에러 타입 (Failure)을 정해뒀다.

에러를 절대 방출하지 않는다면 Never를 사용한다.

struct AnyPublisher<Output, Failure> where Failure : Error

protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error
}

2. Subscriber Protocol

마찬가지로 Subscriber Protocol의 정의도 살펴보자.

defines the requirements for a type to be able to receive input from a publisher.

(publisher로부터 값을 받을 수 있는 타입에 대한 요구사항을 정의한다.)

 

Subscriber는 Publisher로부터 어떻게 값을 받아올 수 있을까?

sink 메서드, assign 메서드를 사용하는 2가지 방법이 있다.

 

sink 메서드

sink 메서드의 형태를 뜯어보자.

func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

 

Publisher로부터 받은 값의 타입인 Output,

escaping closure를 넘기게 될 receiveValue 매개변수,

그리고 AnyCancellable이 반환된다는 것을 알 수 있다.

 

즉, subscriber는 sink 메서드에 클로저를 전달해 publisher의 output을 처리하도록 한다.

 

예시는 이렇다.

let myNotification = Notification.Name("MyNotification")
let center = NotificationCenter.default

// ✅ NotificationCenter.Publisher 타입을 반환
let publisher = center.publisher(for: myNotification, object: nil)

// ✅ publisher로부터 받은 값을 처리
let subscription = publisher.sink { _ in
    print("Notification received from a publisher!")
}

// ✅ 값을 방출
center.post(name: myNotification, object: nil)

// ✅ 구독 취소
subscription.cancel()

assign(to:on:) 메서드

다음으로 assign(to:on:) 메서드를 뜯어보자.

func assign<Root>(
    to keyPath: ReferenceWritableKeyPath<Root, Self.Output>,
    on object: Root
) -> AnyCancellable

 

keyPath가 눈에 띈다.

publisher로부터 전달받은 값을 객체의 KVO 호환되는 프로퍼티에 할당한다.

 

예시를 보면 쉽다.

assign 메서드를 사용하면 label, textView 등에 직접 할당이 가능하므로 UIKit과 함께 사용할 때 특히 더 유용하다. 

class SomeObject {
    var value: String = "" {
        didSet {
            print(value)
        }
    }
}
let object = SomeObject()

// ✅ 총 2번 방출됨
let publisher = ["Hello", "world!"].publisher 

// ✅ 객체의 프로퍼티에 바로 반영시킴
_ = publisher.assign(to: \.value, on: object)

 

assign(to:) 메서드

참고로 assign(to:)을 통한 re-publish도 가능하다. 

publisher에서 방출된 값을 @Published 프로퍼티에다가 다시 republish 할 수 있다.

class SomeObject {
  // ✅ Property wrapper @Published 
  @Published var value = 0
}
let object = SomeObject()

// ✅ $
object.$value.sink {
    print("@@@", $0)
}

// ✅ 새롭운 값 할당함 -> sink 호출됨
(0..<5).publisher.assign(to: &object.$value)

// 출력
@@@ 0
@@@ 0
@@@ 1
@@@ 2
@@@ 3
@@@ 4

 

  • @Published를 붙이면, 해당 프로퍼티의 value의 변화를 추적하는 publisher가 생성된다.
  • object.$value 형태로 $를 붙이면, value에 대한 publisher에 접근하여 subscribe 할 수 있다.
  • assign(to:) 메서드에 &object.$value가 전달되었다. 이때 &는 value 프로퍼티에 대한 inout 참조를 의미한다.
    즉, 방출된 값 0~4는 object의 value publisher에다가 할당된다는 뜻이다.

 

여기서 한 가지 특이한 점은

assign(to:)는 Cancellables를 반환하지 않는다는 것이다.

@Published 프로퍼티가 deinit될 때 subscription이 자동으로 cancel 되도록 만들어져있다.

 

따라서 assign(to:on:) 메서드와 달리, assign(to:)에서는 retain cycle이 생성되지 않아서

메모리 관리 측면에서 보다 안전하다는 장점이 있다.

class MyObject {
    @Published var word: String = ""
    var subscriptions = Set<AnyCancellable>()
	
    init() {
        ["A", "B", "C"].publisher
        // 💥 retain cycle 형성됨!!! 
        .assign(to: \.word, on: self)  
        .store(in: &subscriptions)

        // ✅ retain cycle 안생김
        .assign(to: &$word) 
	  }
}

 

3. Cancellable protocol

AnyCancellable 타입이 채택하며, cancel()을 구현해야 한다.

더 이상 값을 받을 필요가 없으면 subscription을 cancel 하는게 자원 절약에 좋다.

 

더 자세히 보자면,

subscription은 cancellation token 역할을 하는 anyCancellable 인스턴스를 반환하는데,

이 토큰을 통해 원할 때 subscription cancel을 할 수 있다.

subscription.cancel()이 가능한 것은 Subscription Protocol이 Cancellable을 상속하기 때문이다.

 

cancel을 호출하지 않으면, subscription이 아래 상황까지 유지된다.

1) publisher가 완료될 때까지

2) subscription을 저장한 프로퍼티가 deinit 될 때까지


4. Subscriber가 Publisher를 구독하는 과정 (feat. 다이어그램)

Publisher와 Subscriber가 구독을 통해 연결되고 값이 전달할 때,

어떤 과정을 거치는지 자세히 알아보자.

 

1. Subscriber가 Publisher를 구독한다.

2. Publisher가 Subscription (구독)을 생성하고, Subscriber에게 준다.

3. Subscriber가 값을 요청한다.

4. Publisher는 값을 보낸다.

5. Publisher가 completion을 보낸다.

 

이 관점에서 다시 위에서 봤던 Protocol을 뜯어보자.

Publisher Protocol+

먼저 Publisher Protocol이다.

public protocol Publisher {
  associatedtype Output 
  associatedtype Failure: Error 

  // 2 - 1에서 내부적으로 이걸 호출함 (subscriber를 publisher한테 붙여서 subscription 생성)
  func receive<S>(subscriber: S)
    where S: Subscriber,
    Self.Failure == S.Failure,
    Self.Output == S.Input
}

extension Publisher {
  // 1 - subscribe가 이걸 호출해서 구독을 시작함
  public func subscribe<S>(_ subscriber: S)
    where S : Subscriber,
    Self.Failure == S.Failure,
    Self.Output == S.Input
}
  1. Subscriber는 Publisher를 구독하기 위해 해당 Publisher의 subscribe 메서드를 호출한다.
  2. subscribe 메서드는 내부적으로 receive 메서드를 호출하는데, 이때 Subscription이 생성된다.
    (Subscriber가 Publisher와 연결된다.)

Subscriber protocol+

public protocol Subscriber: CustomCombineIdentifierConvertible {
  associatedtype Input  
  associatedtype Failure: Error  

  // 1 - subscriber에게 subscription을 전달 (publisher가 이걸 호출)
  func receive(subscription: Subscription)

  // 2 - subscriber에게 새로운 값을 전달 (publisher가 이걸 호출)
  func receive(_ input: Self.Input) -> Subscribers.Demand

  // 3 - subscriber에게 subscription 끝났음을 전달 (publisher가 이걸 호출)
  func receive(completion: Subscribers.Completion<Self.Failure>)
}
  1. Publisher는 Subscriber의 receive(subscription:) 메서드를 호출해서
    Subscriber에게 생성된 Subscription을 전달한다.
  2. Publisher는 Subscriber의 receive(_:) 메서드를 호출해서 방출한 값을 보낸다.
  3. Publisher는 Subscriber의 receive(completion:) 메서드를 호출해서
    더 이상 값을 방출할 필요가 없는 상황이라서 구독을 취소한다고 알려준다.

Subscription protocol+

그런데 위에서 계속 언급한 Subscription이란 뭘까?

단순히 Publisher와 Subscriber의 연결이라고 봐도 무방하다.

 

public protocol Subscription: Cancellable, CustomCombineIdentifierConvertible {
    // subscriber가 이걸 호출해서 값을 받겠다고 전달함
    // subscriber가 받을 새로운 값의 최대개수를 지정 가능
    func request(_ demand: Subscribers.Demand)
}
  • Subscriber는 request(_:) 메서드를 호출해서 앞으로 계속 값을 받을 의향이 있다고 알려준다.
  • 이때 Demand를 통해 subscriber가 받아올 값의 최대 개수를 지정할 수 있다.

*참고로 Subscription은 커스텀이 가능해서 꽤 복잡한 내용인데,

사실상 개발자가 직접 커스텀할 일이 없을 것 같아서 내용을 매우 많이 생략했다.

 

예를 들면 Demand에 받아올 값의 최대 개수를 지정할 때,

새로운 max값이 기존 max값에 더해진다. (새로운+기존 max값 >= subscriber가 지금까지 받은 총 값의 개수)

그래서 negative값이 전달되면 fatalError 호출된다.

이런 거... 아마 평생 몰라도 될듯


 

RxSwift를 처음 접했을 때, Binding이라는 개념 자체가 너무 생소해서 메커니즘을 찾아볼 여유가 없었는데

이번에 Combine을 분석하면서 내부적인 Flow를 이해할 수 있게 되어 의미있었다.

 

그리고 개인적으로 Combine을 파헤쳐 본 소감을 말하자면,

최대한 공식문서를 통해 스터디를 하는 게 바람직하다고 생각하고, 그러려고 노력하는 편이지만

솔직히 Combine 같은 주제는 공식 문서만으로는 이해하기 어려운 게 맞는 것 같다.

"Publisher가 Subscriber에게 Subscription을 전달한다"는 것을 알기 위해 
얼마나 많은 선행 지식을 이해해야 하는지... 절레절레... 

일에 치이고, 기술 부채에 허덕이는 개발자라면 이럴 때는 현실적으로 Kodeco의 도움을 받는 것도 좋은 것 같다. 

 

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

Comments