Combine 탐구 시리즈
1. Publisher와 Subscriber 그리고 Subscription(with OpenCombine)
지난번 Publisher, Subscriber, Subscription 학습에 이어서 오늘은 Combine의 Cancellable에 대해 깊게 공부해 보도록 하겠습니다.
오늘도 역시 OpenCombine을 기반으로 코드 레벨까지 내려가 Combine에서 Cancel이 발생하는 과정에 대해 살펴보겠습니다.
Cancel은 무엇일까?
우선 가장 먼저 생각해봐야 할 것은 cancel 자체에 대한 개념입니다.
Combine에서는 cancel을 공식적으로 지원합니다.
공식 문서를 보면 Cancellable 프로토콜이 cancel 함수를 명시하고 있습니다.
이 함수의 역할은 Cancel the activity라고 적혀있습니다.
만약 커스텀 Cancellable 구현체를 구현한다면 cancel()을 구현하여 퍼블리셔가 다운스트림 구독자를 호출하는 것을 중지하도록 요청해야 한다고 적혀있습니다.
Combine에서 퍼블리셔는 즉시 stop 할 필요는 없지만 cancel()의 호출은 빠르게 적용되어야 한다는 것도 명시되어 있습니다. (cancel이 호출되면 동기적으로 cancel 로직 동작이 즉시 발생해야 함)
cancel이 발생하면 현재 보유하고 있는 강한 참조 또한 제거해야 한다고 합니다.
여기까지 읽으면 어느정도 감은 오지만 이 cancel이 어떻게 동작하는지 확실히 느낌이 오지는 않습니다.
따라서 OpenCombine에서는 Cancellable 구현체가 어떻게 cancel을 구현하고 있는지 확인해 보겠습니다.
그전에 cancel은 왜 필요한 것일까요?
Combine에서는 Subscriber가 더 이상 Publisher로부터 value를 수신하고 싶지 않은 경우 subscription을 cancel 하여 리소스를 확보하고 네트워크 호출과 같은 작업이 발생하지 않도록 할 수 있습니다.
즉, 리소스를 관리하기 위함이 cancel의 가장 큰 목적입니다.
비슷한 목적으로 Swift Concurrency의 Task에도 Cancel 기능이 있습니다. (공식 문서)
만약 다수의 이미지를 서버로부터 가져와야 하는 네트워크 요청이 발생하고 곧바로 이 요청이 필요가 없어졌다면 cancel을 통해 리소스 낭비를 막는 것입니다.
사실 cancel을 했다고 해서 이미 실행 중인 모든 작업이 도중에 멈추는 것은 아닙니다.
이것은 Combine의 cancel과 Swift Concurrency의 cancel 모두에 해당됩니다.
대신 API 호출 전에 cancel이 먼저 호출되면 네트워크 요청을 막을 수 있고 만약 네트워크 요청이 완료가 되었더라도 수신한 데이터를 사용하는 측에서 값을 사용하지 않게 막을 수 있다는 점에서 의의가 있습니다. (필요 없는 데이터 처리를 위한 추가적인 메모리 사용 등을 방지할 수 있습니다.)
OpenCombine에서 Cancel 탐구
이제 OpenCombine에서 코드 레벨의 cancel 동작을 따라가 보겠습니다.
Cancellable 프로토콜
역시나 cancel 함수를 명시하고 있습니다.
그리고 extension 기본 구현으로 store(in:) 함수를 구현하고 있습니다.
즉, Cancellable 자신을 AnyCancellable로 Casting 하여 집합에 넣고 있습니다.
- Cancellable 객체를 collection에 붙잡아두고 원하는 시점에 cancel 시키기 위한 전략입니다.
- RxSwift의 disposed(by:)와 목적이 같습니다.
- Set<AnyCancellable>은 RxSwift의 DisposeBag과 같은 용도입니다.
그럼 이 Cancellable 프로토콜의 구현체는 어떤 것일까요?
앞서 Subscription 탐구에서 배웠던 것처럼 각 Subscription이 Cancellable 프로토콜을 채택하여 cancel 함수를 구현하게 됩니다.
Subscription 프로토콜
Subscription 프로토콜이 Cancellable 프로토콜을 채택(상속)하고 있는 것을 확인할 수 있습니다.
추가로 주석에 Subscription의 cancel은 thread-safe 해야 한다고 명시하고 있습니다.
당연하게도 cancel이 멀티 스레드 환경에서 동시에 발생한다면 Race condition이 발생하는 등 예상하지 못한 결과가 발생할 수 있습니다.
좀 전에 cancel()의 동작은 호출 즉시 동기적으로 동작해야 한다는 요구 사항에서 이어진 말로 생각됩니다.
Subscription 또한 프로토콜이기 때문에 구현체 예시를 확인해 볼까요?
이 부분 부터는 Apple의 공식 문서에는 없기 때문에 OpenCombine에서 구현체 예시를 찾아봤습니다.
OpenCombine에서는 Subsciption 구현체를 Conduit 이라는 이름으로 구현하고 있었습니다.
ConduitBase 클래스
Conduit의 구현체는 각 Publisher의 nested type으로 구현되어 있습니다.
모든 Conduit 객체가 공통으로 상속하는 부모 클래스이자 Abstract class인 ConduitBase는 위와 같이 선언되어 있습니다.
cancel 함수 역시 구현되어 있습니다. 하지만 이 클래스 자체가 상속을 위한 추상 클래스이기 때문에 구현부 자체는 의미가 없습니다.
사실 Swift에서는 Abstract class를 공식적으로 제공하고 있지는 않습니다.
그래서 OpenCombine 프로젝트에서는 이렇게 fatalError 발생시키는 함수를 만들어서 반드시 override 하지 않으면 에러가 발생하도록 조치하고 있었습니다.
다시 본론으로 돌아와서 ConduitBase를 상속하는 구현체를 살펴보겠습니다.
Conduit 클래스
Future Publisher의 Conduit 구현체를 대표로 분석해 보겠습니다.
Future의 extension에 Nested Type으로 구현되어 있습니다.
역시 ConduitBase를 상속하고 있습니다.
(즉, Conduit 자체가 Subscription 니다.)
DownStream은 Subscriber이며 이 Subscriber의 Input이 ConduitBase의 Output과 타입이 같아야 하며 Failure 타입 또한 같아야 한다고 선언하고 있습니다. (생각해보면 당연합니다. 업스트림 퍼블리셔가 방출하는 값과 다운스트림인 구독자가 받을 값의 타입은 같아야 합니다.)
Conduit에는 역시 다양한 프로퍼티와 메서드들이 있지만 오늘은 Cancellable에 대해 공부하고 있기 때문에 cancel 메서드에 집중하겠습니다.
Future의 Conduit의 cancel은 위와 같이 구현되어 있습니다.
순서대로 흐름을 따라가 보겠습니다.
1. 우선 lock을 걸어 synchronize 하게 동작하도록 합니다. (앞서 설명했던 것처럼 cancel은 thread-safe해야 하기 때문입니다.)
- Conduit은 클래스이기 때문에 여러 스레드에서 동일한 Conduit 인스턴스에 접근할 수 있기 때문에 이처럼 직접 동기화 매커니즘을 적용해야 합니다. (커스텀 Publisher, Subscription을 만들 때 꼭 주의해야 하는 사항!!!)
2. 상태를 .terminal로 만듭니다. (Future의 동작을 위한 상태 값입니다.)
3. self.parent.take()를 호출하고 새 parent 변수에 할당하고 있습니다.
- 여기서 parent는 Conduit 객체를 생성한 Future 객체입니다.
- take 함수는 위와 같습니다. (이 예제에서는 Optional에 구현된 take()가 실행됩니다.)
- 스스로를 nil로 만들고 이전에 저장한 taken을 리턴합니다.
- 즉, 새 변수인 parent에는 기존의 Future 객체가 담기게 됩니다.
- 이렇게 self.parent를 nil로 만드는 이유는 업스트림인 Future에 대한 Reference count를 감소시키기 위함으로 추측됩니다.
- cancel을 한다는 의미는 더 이상 Conduit 객체가 업스트림으로부터 값을 수신하지 않고 스트림을 해제하겠다는 의미입니다. 따라서 Future 또한 ARC로 관리되는 Class이기 때문에 이 Conduit 객체로부터 증가된 RC를 다시 감소시키는 작업을 한 것입니다.
- 물론 taken에 기존 Future 객체를 담아서 리턴했기 때문에 Future 객체의 RC가 1 이상 존재하여 메모리에서 사라지지 않습니다.
4. 다시 cancel 함수로 돌아와서 lock을 해제합니다.
5. parent?.disassociate(self)를 호출합니다.
- 3번 작업에 의해 Conduit이 직접 프로퍼티로 참조하고 있던 parent는 nil이 되었고 그대신 cancel 함수 스코프 안에서 사용하기 위해 기존의 self.parent 객체를 할당한 parent에게 disassociate 메서드를 호출하고 있습니다.
- 이 함수가 완료되면 cancel 함수는 종료되기 때문에 Static scope 원칙에 따라 parent 객체는 메모리의 Stack frame에서 사라지게 됩니다.
- 즉, 이제는 더이상 Conduit 객체에서 업스트림인 Future에 대한 참조가 남아있지 않게 된 것이죠.
- 다른말로 이 Conduit에서 증가시킨 Future 객체의 Reference Count가 전부 원상복구(감소) 되었다는 의미입니다.
6. disassociate 함수 실행
- Future의 disassociate 함수는 Conduit 객체를 받아서 해당 객체를 downstreams에서 제거하는 역할을 수행합니다.
- downstreams는 ConduitList라는 자료구조이며 내부적으로 Set에 ConduitBase 구현체들을 저장하고 있습니다.
- 즉, Future가 가지고 있는 "다운스트림들 == Subscription들 == Conduit들" 중에서 우리가 cancel로 지정한 것을 remove 한 것입니다.
- remove는 우리가 흔히 사용하는 Set의 remove를 호출하고 있습니다.
자!! 길어보이지만 정리하면 간단합니다.
Conduit(== Subscription)의 cancel을 호출하면 thread-safe하게 본인이 가진 업스트림 퍼블리셔의 참조를 해제하고 해당 퍼블리셔가 참조하고 있는 다운스트림 리스트에서 우리가 cancel한 Conduit 자기 자신을 remove 시킵니다.
그 결과로 업스트림인 Future의 Reference Count가 1 감소하게 되고 Conduit 객체 또한 Reference Count가 감소하게 됩니다.
Future에서 생성한 값을 다운스트림에 보내줄 때는 위 사진의 promise 함수가 불리게 됩니다.
하단에 downstreams.forEach { $0.offer(output) } 구문을 보면 다운스트림 리스트를 반복문을 돌며 값을 Conduit에게 전달합니다.
우리가 cancel 한 Conduit은 저 downstreams 리스트에 제거되었기 때문에 Future가 생성한 값을 더이상 받지 못하게 되는 것입니다!!
여기까지 Combine에서 cancel이 동작하는 방식을 살펴보았습니다.
추가로 Combine의 Cancel에서 중요한 개념인 AnyCancellabe에 대해 알아보겠습니다.
AnyCancellable 클래스
AnyCancellable은 Subscription에서 cancel 기능만을 제공하기 위해 type-erasing한 추상 객체입니다. (공식 문서)
생성자를 보면 Cancellable을 받아서 해당 객체의 cancel 함수를 내부 프로퍼티인 _cancel에 저장합니다.
AnyCancellable의 cancel()을 호출하면 앞서 저장한 _cancel 함수가 실행되는데 이는 생성자에서 받은 구체 타입의 Subscription을 직접 cancel한 것과 같은 결과를 발생시킵니다.
Future의 Conduit에 직접 cancel을 호출하는 것과 이 Conduit을 감싼 AnyCancellable에 cancel을 호출하는 것은 동일하게 cancel의 기능을 수행하게 되는 것입니다.
그리고 또 중요한 부분이 deinit 부분입니다.
AnyCancellable이 deinit이 될 때 _cancel()를 호출하고 있습니다.
즉, 퍼블리셔를 구독하여 발생한 취소 토큰인 AnyCancellable을 메모리에서 제거하게 되면 자동으로 cancel이 되도록 한 것입니다.
이것은 매우 개발자 입장에서 모든 스트림의 생명주기를 직접 관리할 필요가 없도록 도와주는 매우 편리한 기능입니다.
AnyCancellable을 참조하고 있는 ViewController나 ViewModel이 deinit 되면 AnyCancellable 객체 또한 RC가 감소되어 deinit 될 것입니다. 이러면 자동으로 stream이 cancel 되기 때문에 더이상 개발자는 메모리 낭비를 걱정하지 않아도 됩니다.
AnyCancellable의 extension에는 역시 store(in:)이 기본 구현되어 있습니다.
Cancellable에 구현된 것과 마찬가지로 집합에 자신을 넣게됩니다.
이 때 Set은 inout으로 받아와야 하는데 Set 자체가 Struct로 구현된 Value type이기 때문입니다. (복사 방지)
이렇게 Set에 넣게 되면 이 Set이 deinit 될 때 Set이 들고 있는 모든 AnyCancellable의 deinit 함수가 불려 자동으로 cancel이 발생하게 됩니다.
우리가 개발을 할 때는 스트림을 여러개 생성하게 되고 이로 인해 Cancellable 객체도 많이 생기게 됩니다. 이것들을 따로따로 변수에 할당하게 되면 코드가 매우 지저분해지겠죠?
하지만 store(in:)을 활용해 모든 Cancellable 객체를 하나의 집합 변수에 담아 관리하기 때문에 매우 편리하게 사용할 수 있습니다.
애플에서는 이것을 AnyCancellable이라는 Type-erased 객체를 활용하여 Subscription에서 cancel 기능만 노출시킨 것입니다!!
실제로 Subscription 구현체는 cancel 말고도 다양한 메서드와 프로퍼티를 가지고 있는데 우리는 cancel 기능만 필요하기 때문에 나머지 기능은 모두 캡슐화(Encapsulation)하여 숨긴 것입니다.
대표적인 예로 구독를 만드는 대표적인 함수인 sink 에서도 리턴 값으로 Cancel 기능만 남기고 다른 기능은 은닉한 AnyCancellable을 리턴하고 있습니다.
우리가 실수로 Subscription의 다른 메서드를 호출할 가능성을 없애고 cancel만 할 수 있게 된 것이죠!
정리
마무리
오늘은 Combine에서 cancel이 어떻게 동작하는지 알아봤습니다.
구독과 관련된 객체들이 메모리에서 해제되는 과정과 Type-erase 개념까지 학습하면서 꽤나 의미있는 시간이었습니다.
물론 실제 Apple의 Combine 구현체에서는 조금 다르게 동작할 수 있습니다. (오늘 내용은 어디까지나 OpenCombine을 기반으로 합니다.)
Thread-safe, class, ARC, Type-earse, Set 자료구조 등 다양한 cs 관련 키워드들 또한 등장했는데 지금까지 공부한 내용들 덕분에 cancel 로직을 이해할 수 있었습니다. 역시 기본기부터 차근차근 공부해야 어려운 내용을 받아들일 때 유리한 것 같습니다!
다음에도 Combine에 대해 딥다이브하는 글로 찾아오겠습니다!
'iOS > Combine' 카테고리의 다른 글
[Combine] 스케줄링을 위한 subscribe(on:)과 receive(on:)의 원리 (with OpenCombine) (0) | 2024.01.16 |
---|---|
[Combine] Combine과 Backpressure (with OpenCombine, RxSwift) (0) | 2023.12.14 |
[Combine] Publisher와 Subscriber 그리고 Subscription (with OpenCombine) (1) | 2023.11.15 |
Combine in Practice (0) | 2023.10.24 |
Introducing Combine (1) | 2023.10.23 |
댓글