본문 바로가기
Dev/고민과 삽질의 기록들🤔

[Combine] AnyCancellable과 cancel의 동작 파헤치기 + cancelBag을 쓰는 이유 (ft. OpenCombine)

by Mintta 2023. 12. 2.

정말 오랜만에 다시 Combine 이야기로 돌아왔습니다.

오늘 다룰 주제는 Cancellable과 cancel에 대한 이야기입니다. 글의 시작배경과 함께 이번에는 바로 시작해보겠습니다 !


시작 배경.

 

구독을 취소한다, 더 이상 Publisher로부터 값을 받지 않기 위해서 subscriber는 구독을 "취소"합니다.

이 때 저희는 cancel() 메서드를 쓰게 됩니다. 예시와 함께 살펴보겠습니다.

 

let publisher = PassthroughSubject<Int, Never>()
let cancellable = publisher
    .sink { num in
        print(num)
    }

cancellable.store(in: &cancelBag)


publisher.send(1)
publisher.send(2)
publisher.send(3)

cancellable.cancel()

publisher.send(4)
publisher.send(5)
publisher.send(6)



// 출력값
// 1
// 2
// 3

 

 

결과는 직관적입니다. 1, 2, 3을 보낸 후에 cancel, 구독 취소를 했으니 이 후 4, 5, 6의 값은 구독자에게 전달되지 않습니다.

 

let publisher = PassthroughSubject<Int, Never>()
let cancellable = publisher
    .sink { completion in
        print(completion)
    } receiveValue: { num in
        print(num)
    }


cancellable.store(in: &cancelBag)


publisher.send(1)
publisher.send(2)
publisher.send(3)

cancellable.cancel()

publisher.send(4)
publisher.send(5)
publisher.send(6)

// 출력값
// 1
// 2
// 3

 

 

이번 예시에서는 completion부분을 추가했습니다. stream이 completion을 받게 되면 출력되게끔 말이죠.

근데 어라? 뭔가 이상합니다. completion이 print되기를 기대했지만, break point를 찍어보아도 실제로는 해당 block으로 들어오지도 않습니다.

 

사실 이 예시가 오늘에서야 꼭 정리하자고 마음먹게 된 계기였습니다. 해당 위의 경우를 오늘 있었던 컴바인 스터디를 하던 중에 알게 되었고, cancel 메서드의 내부 동작을 파헤쳐봐야겠다고 생각했습니다.

(오늘 스터디 팀원분이 하셨던 말씀인데, Combine의 AnyCancellable에 대응되는 RxSwift의 Disposable 같은 경우에는 cancel을 해줄 때도 completion으로 넘어온다고 하셨는데, 이런 부분은 확실히 Rx진영과 차이가 존재하는 것 같습니다.)

 

Combine은 내부 구현을 볼 수 없으니..내부 동작을 보기 위해서 오늘도 어김없이 OpenCombine과 함께 어떤 흐름인지를 어느정도 파악해보도록 하겠습니다. 레츠고.

 

1. AnyCancellable.cancel()

사실 우리가 호출한 cancel은 AnyCancellable이라는 객체에 있는 cancel 메서드입니다.

이게 무슨 소리냐구요 ? 다시 위의 첫번째 예제 코드를 가져와보겠습니다.

let publisher = PassthroughSubject<Int, Never>()
let cancellable = publisher
    .sink { completion in
        print(completion)
    } receiveValue: { num in
        print(num)
    }

...

cancellable.cancel()

 

여기서 우리는 sink 메서드의 return값을 받아서 cancel을 해주고 있습니다. sink 메서드 안을 들여다보면 아래와 같습니다.

sink메서드에서 리턴되고 있는 타입을 보면 AnyCancellable입니다. 이제 cancel이 AnyCancellable 객체에 있는 cancel 메서드라는 것은 이해가 되네요 !

 

그런데 AnyCancellable의 init에 Sink Subscriber를 넣어주고 있습니다.

한번 AnyCancellable을 살펴보겠습니다.

AnyCancellable은 어떤 객체일까요 ? 🤔

 

AnyCancellable은 cancel 메서드를 가지고 있는 Cancellable 프로토콜을 채택하고 있습니다. 그렇기에 cancel을 구현해야하는 의무가 있습니다.

 

init부분을 살펴보면 Cancellable프로토콜을 채택하고 있는 객체를 받고, init으로 주입 받은 객체(Sink)의 cancel 클로저를 AnyCancellable의 _cancel 클로저 변수에 저장하고 있습니다. Swift에서 함수는 일급함수이기 때문에 이러한 동작이 가능하죠.

 

이를 통해 알 수 있는 사실은 AnyCancellable의 init에 넣어준 Sink Subscriber는 Cancellable을 채택하고 있다는 것입니다.

 

cancel의 구현부를 살펴보면 앞서 주입받은 객체의 cancel 클로저를 저장했던 _cancel 변수를 실행하고 해당 변수를 nil로 초기화해주고 있습니다.

 

즉, 우리가 예제에서 cancellable.cancel() 했을 때 처음 실행된 것은 AnyCancellable의 cancel메서드이지만, 그 구현부에서 다시 Sink subscriber 객체가 가지고 있는 cancel() 메서드를 호출한 것 입니다.

 

그렇다면 다음에 봐야할 것은 sink 내부에 있는 cancel 메서드의 구현부겠네요 !

2. Sink.cancel()

 

Sink의 cancel메서드 구현부를 살펴보면, Sink Subscriber는 자신의 status를 가지고 있는데 이를 terminal로 바꿔주어 종료 상태를 저장하고 있습니다.

 

또한, 마지막에 subscription 객체의 cancel 메서드를 호출하고 있습니다.

 

여기서 subscription이 어떤 것인지에 대해서는 아래 포스팅에서 다루었습니다. 간단히 언급하고 넘어가면 Publisher와 Subscriber를 잇는 subscription 이름 그대로 구독권, 흔히 우리가 stream이라고 부르는 것에 해당됩니다.

https://codingmon.tistory.com/71

 

[Combine] Publisher와 Subscriber간 연결 과정 딥다이브 (ft. OpenCombine)

현재 MVC로 되어있는 프로젝트를 MVVM으로 리팩토링하기에 앞서 Combine을 공부하고 있습니다. Combine은 인터페이스 쪽만 모두 public이고 내부 구현은 알 수가 없어서 공부에 좀 어려움이 있었는데,

codingmon.tistory.com

 

위 글에서 썼던 이미지와 글을 조금만 가져와보겠습니다. Conduit, 즉 위의 Subscription에 해당하는 녀석입니다.

  • parent: PassthroughSubject (누가, 값을 보내는 주체)
  • downstream: Subscriber (누구에게, 값을 받는 구독자들)
  • demand: Subscriber.Demand (얼마나, 얼만큼의 양을 보낼지)

conduit은 위의 정보를 위한 프로퍼티들을 가지고 있고 딱 stream에 어울리는 녀석임을 알 수 있습니다.

 

이제 이 객체의 cancel 메서드를 살펴보면 되겠네요 !

 

3. Conduit(subscription).cancel()

subscription의 cancel 메서드 구현부를 살펴보면 take 메서드가 계속해서 나오기 때문에 해당 메서드를 먼저 살펴보고 가야할 것 같습니다.

 

위에서 take를 사용하는 주체의 타입을 살펴보면 모두 ?가 붙어 있는 Optional type임을 알 수 있습니다. Optional에 HasDefaultValue라는 프로토콜을 채택시킴으로써 Optional type은 take 메서드를 쓸 수 있게 되었습니다.

여기서 init()이 뜻하는 것은 init()를 호출한 주체(Optional)를 Optional nil로 바꾸는 것 입니다. 즉 초기화 시키는 것 입니다.

 

take() 메서드는 해당 메서드를 호출한 Optional type인 주체를 nil로 초기화시키며, 그 이전에 담고 있던 값을 return하고 있는 메서드입니다.

 

다시 원래 코드로 돌아와서 어떤 것들을 초기화시키고 있는지 살펴봅시다.

 

우선 downstream을 초기화시켜주고 있습니다. Conduit에서 downstream은 "누구에게", 즉 구독자를 말합니다. 우리의 예제 코드 상으로는 Sink Subscriber가 downstream이 되겠죠. Stream에서 Subscriber를 nil로 초기화시키는 것 입니다.

 

그리고 뒤이어서 바로 Publisher에 해당하는 parent를 초기화시켜주고 있습니다. Stream에서 Publisher를 nil로 초기화시키고 있는 것 입니다.

 

살짝 헷갈렸던 포인트를 짚고 넘어가겠습니다. !

처음에 아래 상황에서 헷갈리는 지점이 있었습니다..🤔

let parent = self.parent.take()
parent?.disassociate(self) // 🤔

 

PassthroughSubject는 class입니다. 즉 reference type으로 객체를 주고받을 때 reference를 통해서 객체를 전달합니다.

그렇기 때문에, "self.parent를 nil로 초기화시키게 된다면 이는 원본, 즉 처음에 생성되었던 PassthroughSubject를 초기화시키는 작업이라고 생각했고, 이 후에 disassociate 메서드가 어떤 의미도 갖지 않게 되는 것이 아닌가" 하고 말이죠.

 

결론부터 말하자면 사실이 아닙니다. (당연하게도)

 

Optional은 사실 그저 enum case에 불과합니다.

 

@frozen public enum Optional<Wrapped> : ExpressibleByNilLiteral {
    case none
    case some(Wrapped)
}

 

 

nil인 case와 연관값과 함께 값이 있는 some case 두가지의 Case를 가지고 있는 enum입니다.

그렇다면 우리가 take() 를 통해 nil로 초기화한 것은 Optional case를 some(Wrapped)에서 nil case로 바꿔준 것 뿐입니다.

 

연관값(PassthroughSubject)인 객체를 초기화시키는 행동이 아닌 것이죠.

 

이와 관련된 간단한 예제 코드를 살펴봅시다.

class A {}

let a = A()
var b: A? = a

 

이 상태에서 a와 b를 찍어보면 아래와 같은 결과가 나옵니다.

 

이제는 이것이 당연하다는 것을 저희는 알고 있습니다.

 

이제 OpenCombine에서의 take() 메서드를 썼을 때 상황을 재현하기 위해 코드 한줄을 추가해보겠습니다.

 

class A {}

let a = A()
var b: A? = a
b = nil

 

이 상태에서 a와 b의 객체를 찍어보면 어떻게 나올까요 ??

 

Optional type인 b만이 nil case로 바뀔 뿐, 원본 a는 어떠한 영향도 없습니다.

 

그럼 이제 저희는 parent를 take()를 해도 이제 원본 PassthroughSubject에는 어떠한 영향도 없다는 것을 확신할 수 있습니다 !

(물론 아마도 저만 헷갈렸지만요..?)


다시 돌아와서 Conduit(Subscription)의 cancel 메서드 구현부의 마지막 줄 disassociate(self)를 살펴보겠습니다.

여기서 parent는 예제 코드 기준으로 PassthroughSubject에서 생성해준 Subscription이기 때문에 parent는 PassthroughSubject입니다.

 

그렇다면 다음으로 살펴봐야할 구현부는 PassthroughSubject의 disassociate메서드가 되겠네요. 이 때 인자로 self, 즉 Conduit(subscription) 자기 자신을 넘기고 있음을 생각하고 가봅시다 !

 

4. PassthroughSubject.disassociate()

 

downstreams는 Publisher가 가지고 있는 subscription sequence입니다. Publisher는 stream들을 알고 있어야 나중에 값을 보낼 수 있겠죠 ? 여기에 대한 더 자세한 설명이 필요하다면 아래 링크를 보셔도 좋을 것 같습니다 !

https://codingmon.tistory.com/71\

 

[Combine] Publisher와 Subscriber간 연결 과정 딥다이브 (ft. OpenCombine)

현재 MVC로 되어있는 프로젝트를 MVVM으로 리팩토링하기에 앞서 Combine을 공부하고 있습니다. Combine은 인터페이스 쪽만 모두 public이고 내부 구현은 알 수가 없어서 공부에 좀 어려움이 있었는데,

codingmon.tistory.com

 

지금까지 우리가 어떤 것들을 초기화 해주었죠 ?

Conduit의 Subscriber(downstream)과 Publisher(Parent)를 초기화시켜주었습니다. 그렇다면 이제 parent가 가지고 있는 Subscription sequence도 이제 자신이 가지고 있는 subscription sequence에서 인자로 넘겨온 subscription을 지워주면 구독 해제의 과정이 마무리가 됩니다.

 

 

결국 맨 처음 시작배경에서 cancel을 해도 completion이 불리지 않는 이유는 사실 그저 그렇게 개발이 되었기 때문이라는 것을 알 수 있었습니다. 지금까지의 로직중에서 Subscriber에게 completion을 전달하는 로직은 아예 존재하지 않았기 때문입니다.

 

애플에서는 아마 cancel은 error나 Completion등으로 종료가 된 경우가 아니라 개발자가 명시적으로 취소한 것이기 때문에 이를 구분하고 싶지 않았을까 싶네요 !


+ cancelBag을 쓰는 이유

cancelBag: Set<AnyCancellable>

 

사실은 우리가 명시적으로 cancel()을 쓰지 않아도 우린 이 메서드를 호출하고 있었습니다.

 

어디에서 ?

 

바로 AnyCancellable이 deinit되는 지점에서 우리는 cancel() 메서드를 호출하고 있었습니다.

 

예시를 들어서 설명해드리겠습니다.

 

 

이렇게 코드를 작성한다면 어떻게 될까요 ??

결론부터 이야기하면 어떤 값도 출력되지 않습니다.

 

그 이유는 sink의 return 값인 AnyCancellable의 deinit에 있습니다.

앞서 말씀드렸듯이, AnyCancellable은 인자로 받은 Cancellable타입의 객체, Sink의 cancel메서드를 cancel시에 호출하고 있었습니다. deinit되는 시점인 여기서도 완전히 동일하게 호출하고 있습니다.

 

예시 코드를 기반으로 한다면 저희는 sink를 통해 받는 Return값인 AnyCancellable을 어디에도 저장하지 않고 있기 때문에, 생성되자마자 deinit이 되는 것이죠.

 

왜 ? 해당 AnyCancellable 객체의 reference count가 0이기 때문입니다.

 

 

그렇다면 아래 코드는 어떨까요 ? 

 

a라는 변수에 AnyCancellable을 담아서 Reference count를 1증가시켜주었습니다. 결과가 예상가시나요 ?

 

의도했던 결과값이 잘뜨는 것을 볼 수 있네요. 하지만 이 코드에는 또 문제점이 존재합니다.

 

바로 scope입니다..!

 

let a 변수는 viewDidLoad라는 메서드안에서 호출된 지역 변수 입니다. 

 

지역변수는 함수의 scope가 끝나는 시점에서 스택 영역에서 모두 pop되게 됩니다. viewDidLoad scope내부에서만 살아있게 되는 stream이 되는 것 입니다.

 

우리가 Combine을 활용해서 실제 개발을 할 때는 아래와 같이 사용합니다.

 

혹시 이제 그 이유가 짐작가시나요 ? stream의 Lifetime을 viewDidLoad의 scope가 아닌 ViewController class의 Lifetime에 맞추게끔 하기 위해서, stream을 끊어지게 않게끔 하기 위해서입니다.

 

위에서 let a 라는 변수로 AnyCancellable객체의 reference count를 1 증가시켰던 것을 똑같이 ViewController의 Set에 담음으로써 해주고 있는 것 입니다.

 

그리고 cancelBag이 메모리에서 할당 해제 되는 순간은 ViewController가 할당 해제가 되는 순간이 됩니다. 그렇기에 위에서 말한 lifetime을 바꾸는 효과를 얻을 수 있게 되는 것 입니다.

 

위 방법을 통해서 저희는 user가 보고 있는 화면에서 user action을 비동기적으로 언제나 받을 준비를 할 수 있습니다.


마무리

드디어 미루고 미루었던 cancel에 대한 이야기를 정리할 수 있었네요. 사실 진작 한참 전에 정리해서 올렸어야 하는 주제인데...2주 정도가 지나고 나서야 결국 썼습니다. 그래도 제가 알고 있던 것을 정리할 때 마다 제가 생각하고 있던 것을 한번 더 의심하고, 확신할 수 있는 기회가 되는 것 같아서 쓰는 과정은 오래걸리고  쉽지 않지만 깔끔하게 저만의 언어로 정리된 글을 쓰면 뿌듯해지네요.

다음 아티클도 최대한 빨리 써봐야겠습니다. 화이팅ㅇ

 

댓글