[iOS]Combine의 Cancellable 딥다이브(2)

Youth·2023년 11월 2일
1

고찰 및 분석

목록 보기
3/21

안녕하세요~ combine의 cancellable 두번째 포스팅입니다
저번포스팅에서는 cancel의 내부 작동방식에 대해서는 말씀드리지 않았고 그냥 단순이 머릿속에서 떠오르는

cancel을 실행하면 publisher와 subscriber의 stream이 끊긴다!

정도의 추상화된 동작을 가지고 cancel자체가 어떤 상황에서 불리는지 그리고 우리가 cancel을 조금더 편안하게 사용할수있도록 store과 Anycancellable의 set을 이용하는 방법에 대해서 알아봤습니다

이번에는 실제로 publisher가 어떻게 subscriber와의 stream을 끊는지 내부로직에 대한 내용을 포스팅에 담아보려고 합니다

해당 포스팅은 선행지식이 조금 필요하지만 그부분도 포함해서 포스팅을 작성할예정이니 조금 어려울순있겠지만 차근차근 따라오다보면 잘 이해하실수 있으리라 생각됩니다!

오늘은 할 이야기가 많으니 바로 시작해보죠

Publisher와 Subscriber의 구독관계를 유지하는 방법

우선 Publisher가 Subscriber를 구독하는 순서에 대해서 이야기를 해보겠습니다
해당 내용은 제가 자세하게 따로 포스팅을 작성할 생각인데 그러면 그때 포스팅링크를 따로 첨부하겠습니다

Publisher가 Subscriber를 구독하는 과정 : <추후 추가될 포스팅 링크>

간단하게 요약을 해보면
우선 Subscriber가 Publisher를 subscribe(구독)하게 됩니다. 해당 과정은 Subscriber가 Publisher에게 구독을 요청한다는 느낌으로 봐주시면됩니다

그러면 아래와같은 형태로 subscriber메서드가 실행됩니다

Publisher객체.subscribe(Subscriber객체)

그러고나서 Publisher가 Subscriber의 구독요청을 승인하는 과정이 필요하겠죠?
그래서 Publiser의 subscriber메서드 내부에서 receive(subscriber:) 메서드를 통해서 구독을 요청한 subscriber를 승인해주게됩니다

// Publisher 내부의 subscribe메서드
receive(subscriber: Subscriber객체)

여기까지는 쉽게말해서 구두계약이라고 생각하시면 편합니다
subscriber입장에서는 구독이정말 된건지를 알수있는 무언가가 필요하고 publisher입장에서는 구독관계를 증명할수있는 무언가가 있어야 내가 지금 구독자가 몇명인지를 쉽게 확인할 수 있겠죠

그래서 나온개념이 subscription입니다
subscription은 구독권정도로 생각해주시면 편하고 결국은 해당 구독권을 통해서 publisher는 구독자들을 관리할수잇고 subscriber는 내가 누구를 구독하고있는지를 알 수 있게됩니다

subscription은 다음과 같이 protocol로 구현이 되어있습니다 그렇다면 실제 subscription의 구현체는 어떻게 표현되어있는지를 확인해보면

이렇게 ConduitBase라는 class가 subscription의 구현체임을 확인할수있습니다
근데 뭔가 갑작스럽게 Conduit이라는 단어가 나오니까조금 혼란스러울수도있죠...

도관이라는 단어인데 약간 우리가 publisher에서 subscriber로 어떤 값을 stream에 흘려보내는게 어쩌면 도관 위에 물이 흐르고 그 위에 값을 올려서 흘려보내는 모습을 상상할수있을거같은데 그래서 이런 단어를 선택한게 아닌가 싶습니다

근데 이름이또 ConduitBase인걸로봐서는 실제 Conduit객체가 있을거같은 냄새가 납니다...

실제로 Publisher들 내부에는 각각 ConduitBase를 채택하고있는 각각의 unique한 Conduit을 가지고 있습니다
위의 예시는 자주쓰이는 Publisher의 한종류인 CurrentValueSubject입니다

Conduit이라는 class를 가지고있는데 세가지 property를 가지고 있습니다
첫번째로 parent, 두번째로는 downStream, 마지막으로 demand입니다

parent는 currentvaluesubject자체를 가지고있는걸로봐서 conduit이라는 구독권에는 누가데이터를 보내는지에 대한 정보가 담겨있다고 생각하시면 좋을거같습니다. 여기서 누가는 항상 publisher겠죠?

그리고 downStream이라는녀석을 가지고있는데 이건 파란색 박스처럼 generic으로 선언이 되어있고 Subscriber프로토콜을 채택한녀석이라고하니 subscriber를 downStream이라고 하네요
여기서 알수있는건 구독권에는 데이터를 누구에게보내는지도 명시가 되어있다는겁니다. 여기서 누구에게는 항상 subscriber겠죠?

마지막으로 demand를 통해서 데이터를 갯수또한 구독권내부에서 가지고있게됩니다

현 시점에서 코드를 보면서 기억하고 있어야할점은 구독권내부에는 누가 누구에게 몇개의 데이터를 보낼지가 명시 되어있다는 점이고 이걸 조금 구체적으로 바꿔보면 어떤 Publisher어떤 Subscriber에게 몇개의 데이터를 보낼지가 명시되어있다고 생각하시면 좋을거같습니다

자, 그럼 다시 위로 돌아가보겠습니다

// Publisher 내부의 subscribe메서드
receive(subscriber: Subscriber객체)

subscriber가 구독을 요청하면 publiser가 구독을 승인해주는데 이건 구두계약이니 실제로 구독관계라는걸 증명해주는 구독권 즉, subscrption이 필요하다고 말씀드렸고 위에서 subscrption에 대해 이야기를 했습니다

그럼 이제 receive내부에서 어떤 로직이 동작해서 구독을 승인할때 subscrption을 전달함으로써 두 객체간의 연결관계가 명시되는지 알아보겠습니다

우선 subject라는 Publisher에서 receive라는 메서드가 호출되면 당연히 값을 보내는 Publisher는 자기자신일테니까 self를 전달객체로 명시하고 구독을 요청한 subscriber객체를 downStream으로 명시해주는 구독권인 Conduit을 만들어줍니다

그리고 publiser입장에서는 누가 구독했는지를 모아놔야 나중에 값을 전달할때 누구에게 전달할수있을지 알기위해서 publisher내부의 downStreams이라는 conduit을 모아놓는 set에 새로 만들어진 conduit을 넣어줍니다

그리고 마지막으로 receive(subscription:)메서드를 호출해서 subscriber가 해당 conduit을 받을수있게 해줍니다

만약에 subscriber가 Sink객체라고 가정하고 실제로 receive(subscription:)가 어떤로직으로 되어있는지까지만 한번 보고 가보죠

subscription인 conduit이 전달되면 Sink자체의 status를 subscribe되었다는 뜻의 .subscribed라는 상태로 바꾸고 연관값으로 conduit을 전달해줍니다(다른 subscriber를 보면 아얘 내부에 conduit을 저장할수있는 저장속성이 존재해서 그 변수에 넣어주기도 합니다)

중요한건 pulisher도 subscription을 가지고 있고 subscriber입장에서도 subscription자체를 가지고 있음으로써 서로의 구독관계를 명확하게 명시할수있다는겁니다

그러면 전체적인 그림이 이렇게 되겠네요
여기서 중요한 부분은

  1. subscriber가 conduit을 가지고 있다
  2. conduit은 publisher를 parent라는 속성으로 가지고 있게된다
  3. 2번에서 publisher자체의 reference를 conduit의 parent가 가지고 있게된다

조금 헷갈리실수있는데 요약을 해보면 subscriber가 가지고 있는 conduit의 parent는 publisher와 reference를 공유한다는 부분은 기억하셔야합니다!

Publisher와 Subscriber의 구독관계를 취소하는 방법

자 그러면 위의 그림을 바탕으로 구독을 취소한다는 어떤 action을 의미할까요?
아마도 conduit list에서 해당 conduit을 삭제한다라는 action일겁니다

conduit이 없어진다는건 conduit의 parent인 publisher와 내부의 subscriber와의 구독 관계가 명시되지 않은거니까요

구독을 취소하는 로직을 확인한다는건 conduit list에서 conduit을 삭제하는 로직을 확인한다는것과 동일한 말이됩니다

그러면 실제로 AnyCancellable의 cancel이 실행될때 ConduitList의 Conduit이 삭제되어야하는데 Conduit을 삭제하는 주체는 Conduit일까요 ConduitList일까요? 아니면 Publisher일까요?

위에서 봤던 Cancellable을 채택한 Subscriber의 cancel메서드를 보면 바로 알수있습니다

보면 conduit을 삭제하는 주체는 conduit에게 있다는걸 알수가있습니다(subscription이라는 interface의 구현체가 conduit이니까요)

그럼 실제로 Conduit에서 cancel이라는 메서드가 어떻게 동작하는지를 확인해보겠습니다

코드가 조금은 간단해보이는데 사실 내부적으로 이해하기 어려운 코드가 조금은 섞여있어서 개인적으로는 조금 어렵긴했던것같습니다 :(

자그럼 차근차근 가보겠습니다
우선 lock을 걸어주는데 아마도 비동기적으로 처리가 될때 data race가 발생할가능성이 있어서 특정작업이 끝날때까진 lock을 걸어주는 로직같습니다 이거는 크게 신경쓰지 않도록 할게요

다음은 downstream의 take라는 메서드를 통해 얻은 값이 nil인지를 확인하는 로직이 있습니다
여기서 downstream이 뭔지를 보니까 subscriber고 실제로는 optional type이네요?

여기서 좀 중요한게 subscriber인 downstream과 publisher인 parent가 모두 optional로 설정이 되어있다는 부분입니다

그러면 위의 로직으로 다시 돌아가서 take라는 메서드가 어떻게 구현되어있는지를 보겠습니다

우선 take라는 메서드가 실행되면 self를 taken이라는 변수에 넣어주고 self에 .init()의 결과를 넣어줍니다

제가 여기서 조금 헷갈렸던부분이 사실 HasDefaultValue라는 프로토콜을 Publisher나 Subscriber가 채택하고 있지 않거든요 근데 downstream이라는 subscriber에서 take라는 메서드를 어떻게 실행시켜주는거지?라는 생각이 들었었는데 subscriber자체를 optional로 선언해주면 optional type이기도해서 기본적으로 HasDefaultValue를 optional에서 채택해주고있어서 extension에 HasDefaultValue의 기본 take가 구현되어있어서 사용할수가 있게되는 흐름이었습니다

그래서 지금 상황에서 보면

downstream(여기서는 sink겠죠)의 take가불리고 17번째줄에서 break point가 걸린상태입니다 여기서 self를 찍어보면 sink라는 옵셔널타입의 객체가 나오게됩니다

그리고 나서 18번째줄의 init이 실행되어야하는데 이건 optional에서의 init이기때문에

여기서 self를 nil로 바꿔주는 로직을 실행하게 되고 18번째줄에서 self를 찍어보면 nil로 변한걸 알수있습니다

그리고나서 taken이라는 변수가 return되는데 애초에 17번째줄이전에 self를 taken에 넣어줬기때문에 taken에는 sink객체가 들어있을겁니다

결론적으로는 sink객체가 return되게됩니다

파란색블럭의 로직이 결론적으로 if문 내부로 들어가지 않겠네요 take의 결과가 sink지 nil이 아니니까요
그러면 이번에는 parent의 take를 실행시킵니다, parent는 publisher인데 optional로 선언되어있기때문에 take를 실행할수있습니다

실제로 init이 되기전에 self에는 passthroughsubject가 제대로 들어있습니다
그리고 .init이 호출된 뒤에는 self가 nil이 된걸 알 수 있습니다

그렇지만 taken에는 제대로 passthroughsubject가 들어있을거고 return될겁니다

그러면 위의 코드에서 parent에는 passthroughsubject가 들어갈거고 parent의 disassociate메서드를 호출하고 conduit자체를 self로 넣어줌으로써 parent인 publisher가지고 있는 conduit list에서 해당 conduit(self)가 사라지게 됩니다

자 그러면 왜 take라는 걸 통해서 변수를 임시적으로 담았다가 원래건 nil로 바꿔주고 임시변수를 return해주는 로직이 필요했던걸까요...?

optional에 대한 부연설명

그전에 optional에 대한 작은 부연설명을 먼저 해보겠습니다
optional은 nil이라는 case와 some(연관값)이라는 case가 있는 enum타입입니다
그말은 우리가 어떤 변수에다가 nil을 할당한다는건 option타입의 case를 바꿔주는 action인겁니다

var parent: Int? = 2 // Optional(some(0x100000))
let taken = parent // Optional(some(0x100000))
parent = Optional(nil) // Optional(nil)
print(taken) // Optional(some(0x100000))

실제로 위의 코드를 동작시켜보면(이해하기 쉽게 take의 실행순서와 비슷하게 구현했습니다)
parent라는 optional타입의 변수에 2를 넣으면 실제로는 optional의 some이라는 case의 연관값으로 2라는 값이 들어간 상태가 됩니다
그리고 taken이라는 변수에다가 parent를 넣어주면 당연히 optional의 some이라는 case가 연관값으로 들어가겠죠 그리고 parent에 optional의 nil case를 할당해주면 당연히 parent는 nil이 될겁니다
taken에는 여전히 optional의 some타입이 들어있을거구요
만약에 some에 들어있는 연관값이 reference라면 실제로 reference자체를 taken에다가 임시적으로 저장했기때문에 optional nil case로 바꿔 대입해준다고해도 taken이 nil로 변하지 않는다는점만 이해하고 넘어가시면됩니다

그러면 실제로 conduit을 conduit list에서 삭제할때 왜 이런 take를 이용했는지에대해서 제가 내린 나름의 결론을 말씀드려보겠습니다

그림과 함께 설명을 해보겠습니다

기본적으로 우리가 conduit을 제거하려면 conduit입장에서는 내부에 parent가 외부의 publisher와 self라는 reference로 연결되어있어서 이 연결을 끊어주는게 1순위의 작업이 될겁니다

그래서 parent자체의 reference를 nil로 만들어주는 작업을 하려했을겁니다
conduit내부에 있는 publiser의 reference를 그냥 nil로 만들어주면 어떻게 될까요?
아마 외부의 publisher자체가 nil이 되어버려서 conduit하나 지우려고했는데 conduit list자체가 아애 사라지게됩니다(사실 conduit list가 사라지는것보다 publisher자체가 사라지는게 더 큰 문제이긴하죠)

그리고 두번째로 conduit에서 cancel을 하게되면 결국은 conduit의 parent인 publisher내부의 disassoicate라는 메서드를 호출함으로써 publisher가 conduit과의 관계를 끊어야합니다

즉 conduit입장에서는
내부의 publisher와의 관계를 끊기는 해야겠는데 외부의 publisher의 할당해제를 고려안할수가없는 상황이된거죠 외부의 publiser의 reference가 살아있어야 parent의 disassoicate라는 메서드가 호출되어서 conduit이 publisher에서 삭제될수있는거니까요

그래서 take내부에서 taken이라는 변수를 통해서 외부 publisher의 reference를 유지시켜 할당해제를 막고 conduit내부의 publisher를 nil로바꿔서 conduit내부에서 publisher와의 관계를 끊고 taken으로 유지시킨 reference덕분에 parent로서의 publisher에는 접근이 가능해서 disassoicate메서드를 통해서 publisher의 conduit list에서 깔끔하게 해당 conduit만 삭제될수있게되는겁니다

만약에 conduit내부에서 publisher를 끊지 않았다면 publisher가 conduit을 삭제하는 순간 publisher가 사라지는데 PassthroughSubject의 경우엔 클래스라서 reference가 nil이되어서 publisher자체가 할당해제되는 문제가 발생할겁니다


이렇게 해서 실제로 구독관계가 끊어지는 과정에 대한 로직을 알아봤습니다...

오랜만에 좀 긴 포스팅을 작성하는 느낌이 드네요
저도 분명 공부를 하고 나름의 결론을 내린상태에서 작성한 포스팅인데 또 작성하다보니까 머릿속에서 꼬이는부분들이 발생해서 차근차근 다시 정리하면서 쓰느라 시간이 더 걸렸던것같습니다

사실 open combine이 정확하게 실제 combine과 동작방식이나 코드가 일치하지는 않을겁니다...하지막 동작을하는데있어서 로직상으로 문맥은 맞기때문에 이렇게 동작을 하는 과정들을 보면서 익혀가는게 도움이 되리라 생각이듭니다

그리고뭐... 사실 이렇게 개발잘하는 사람들이 만들어 놓은 라이브러리를 뜯어보는것만으로도 도움이 될만한 건덕지가 많으니까요

이렇게 해서 cancel에 대한 내용을 마무리짓게되었습니다 ㅎㅎ
그런데도 여전히 쓸 포스팅 주제가 밀려서 열심히 다음포스팅을 위한 정리작업에 들어가야할거같습니다
(오늘은 우선 쉬어야겠습니다ㅎㅎ...)

혹시나 잘못된부분이있다면 언제든지 태클 걸어주세요!!!
긴 글 읽어주셔서 감사합니다

그럼 20000!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글