안녕하세요~ 오늘도 combine에 대한 이야기를 가지고 돌아온 킴스캐슬입니다
지금 sopt에서 combine스터디의 팀원으로 참가해서 스터디를 진행하고있는데 단순히 써보면서 익히자!
보다는 우리진짜 deep dive해보자!
라는 목표를 가지고 공부를 해나가고 있습니다
그래서 아마도 지금 제목이 Cancellable이지만 combine에서 실제로 cancel이 어떤 로직으로 작동하는지에 대한 아주아주 깊은 내용이 될겁니다
물론 우리는 combine의 실제 내부코드는 모르기때문에 combine의 요소들을 구현해놓은
open combine
이라는 라이브러리의 구현부를 기준으로 이야기할겁니다!
rx같은 경우엔 의문이 생기면 실제로 라이브러리내부에있는 코드를 보면서 이건 이렇게 구현이 되어있구나라는걸 알 수 있지만 combine은 그게 불가능했어서 처음에 공부를 시작할때 뭐... combine이나 rx나 있는거 거기서 거기인데다가 이름만 다르고 동작하는건 똑같으니 rx를 코드를 봐야겠다고 생각했었는데요
open combine이라는 라이브러리에서 swift로 combine의 모든 요소를 구현을 해놨더라고요... 실제로 combine말고 open combine을 import해서 사용해도 실제로 combine을 사용하는것처럼 작동을 해서 저와 팀원은 open combine을 통해서 내부로직을 공부해보고 있습니다
그럼 서론은 이정도로 끝내고 바로 본론으로 들어가보죠
우선 본격적인 분석(?)에 들어가기전에 우리가 combine을 쓸때 cancel하는 경우를 한번 떠올려볼까요?
크게 두가지정도가 있습니다 아마도 실제로 cancel이라는 메서드를 호출하거나 store를 통해서 cancel을 예약하는것 정도일겁니다
public class ViewController: UIViewController {
let subject: PassthroughSubject<Int,Never> = PassthroughSubject()
var cancelBag = Set<AnyCancellable>()
public override func viewDidLoad() {
super.viewDidLoad()
let a = subject.sink { print($0) }
.cancel() <- 1번 경우
let b = subject.sink { print($0) }
.store(in: &cancelBag) <- 2번 경우
subject.send(1)
}
위의 코드처럼 우리가 실제로 코드로 구독을 취소하는 경우는 위의 두가지정도일겁니다
근데 아마도 combine이나 rx를 조금 사용해보신 분들이라면 1번경우처럼 실제 stream의 구독을 직접 한땀한땀 취소하는 경우는 그렇게 많지않다라는걸 알고 계실겁니다
보통은 저런식으로 cancelBag이라는걸 만들어서 store해놓는 방식이 일반적일겁니다. 그렇다면 혹시 왜 굳이 cancel을 직접하지 않고 cancelBag이라는걸만들어서 store해놓는걸까요? 지금부터 이질문에 대한 대답이 될수있는 이야기를 해보려합니다
우리가 위에서의 코드를 보면 sink라는 메서드를 통해서 실제로 publisher를 구독한 상황이죠?
실제 open combine을 보면 sink라는 메서드를 통해서 AnyCancellable이라는 타입을 return해줍니다 그렇다는 말은 sink의 결과가 AnyCancellable이라는 뜻이 되겠죠
그리고 AnyCancellable을 만들수있는 initalize가 두개가있는데 그중에서 우리는 빨간네모박스에있는 init을 보겠습니다 보니까 Cancellable이라는 프로토콜을 채택하고있는 객체를 넣어주고 그 객체의 cancel이라는 메서드를 AnyCancellable이라는 객체의 _cancel에 넣어주게됩니다
자 그럼 다시 sink메서드로 돌아가서 우리가 sink를 하게되면 Sink라는 클래스 객체를 만들어주고 그 객체를 AnyCancellable이라는 init에 넣어주기때문에 우리가 Sink는 Cancellable이라는 프로토콜을 채택했겠구나? 그리고 Cancellable이라는 프로토콜의 cancel이라는 메서드를 AnyCancellable의 cancel에 넣어줘야하니까 Cancellable이라는 프로토콜은 cancel을 추상화하고 있겠구나라는걸 알 수가 있습니다
실제로 Sink라는 class는 Cancellable이라는 프로토콜을 채택하고 있고
실제로 해당 프로토콜에서는 cancel이라는 메서드를 추상화 하고 있습니다
우선 실제 cancel의 구현부에 대해서는 조금이따 알아보기로 하고 지금까지 알게된 내용을 정리해보죠
anycancellable을 만들때는 cancellable프로토콜(cancel이라는 메서드를 추상화한)을 채택한 객체를 init에 넣어줘야합니다. 이때 sink를 통해서 Sink객체를 만들게되는데 Sink가 Cancellable이라는 프로토콜을 채택한 객체이기때문에 sink메서드의 결과로 Sink를 넣은 anycancellable객체가 return되게 됩니다
이 흐름이 아주 크지만 중요한 cancel의 흐름입니다. combine에서 cancel은 중요한 역할을 합니다
그러면 우리가 하나 알게된게있습니다 stream을 끊어야할때는 Sink의 cancel이라는 메서드가 호출된다는걸요
그러면 우리가 실제로 구독이 끊어졌는지를 알려면 Sink의 cancel의 bp를 찍고 보면됩니다
실제로 어떤 메커니즘으로 작동하는지는 우선 모르겠고 한번 Sink에 cancel이 실행되면 앱이 일시정지할수있도록 해봅시다
그리고 store대신 cancel로 구독을 취소할수있도록 코드를 살짝 손을 보겠습니다
이렇게 구독을 하자마자 cancel을 하고 앱을실행해보면
cancel에서 두번 bp가 멈추게됩니다. 그렇다면 우리가 지금까지 예상했던 결과가 맞았다는걸 알 수가 있습니다
그러면 여기서 궁금증이 드는부분이 있는데요 만약에 cancel이 없다면 영원히 메모리에 stream이 연결된채로 남아있는걸까요?
이렇게 cancel없이 sink를 하고 앱을 실행해보겠습니다
이상하죠 어디에도 cancel을 호출해주지 않았는데 cancel에서 bp가 멈추는걸로봐서는 실제로 cancel이 호출이되었다는 뜻입니다... 대체 왜 이런 작동이 발생하게 되는걸까를 열심히 찾아봤습니다...
등잔밑이 어둡다더니 AnyCancellable이라는 객체에 deinit에 cancel이라는 클로저를 실행시키는 로직이 있었습니다 그렇다는 말은 AnyCancellable이 할당해제 되는 순간에 자동으로 cancel이 실행된다는 말입니다
우리가 위에서 cancel없이 썼던 코드가 viewdidload안에서 호출을 했는데 만약에 viewdidload가 호출이 끝나면 해당 메서드에서 지역변수로 쓰였던 변수가 할당해제되면서 sink를 통해 만들어진 Anycancellable이 deinit되어서 자동으로 cancel이 호출된겁니다
근데 우리가 stream이 사실 앱을 사용하고 있는 언제 사용될지 모르죠
하지만 한가지 확실한게 있다면 앱화면이 떠있는 동안 해당 앱화면에 필요한 stream은 유지되어야합니다 왜냐면 화면이 떠있는 동안에는 언제 어떤 action을 통해 stream이 필요할지 모르는거니까요
이런 사실을 알게되었다면 우리가 stream을 앱이 끝날때까지 유지하고싶다는 생각이 들게됩니다
그리고 viewdidload나 init에 넣으면 메서드 호출이 끝나면 그냥 deinit되니까 애초에 viewcontroller자체에 stream을 연결시켜놓으면 되겠다는 생각이 듭니다. 그러면 viewcontroller가 deinit될때 자동으로 stream이 deinit되면서 cancel이 호출될테니까요
결국 우리가 cancel을 직접 호출하지 않더라도 deinit되는 시점만 잘 조절하면 자동으로 cancel을 통해 stream을 끊어줄수있게되는겁니다
위에서 알게된 사실대로 우리가 stream을 연결시키는 곳이 viewcontroller자체의 내부라면 viewcontroller가 deinit될때 cancel이 호출되게 할 수 있습니다
실제로 그렇게 코드를 짜보면 당연하겠지만 self의 접근 시점문제로 오류가 발생합니다
self라는건 애초에 initalize를 통해서 객체를 생성한 후에만 접근이 가능하고 init을 통해서 저장속성을 정의할 수 있는데 애초에 위의 코드처럼 subject에 init이전에 접근을 해버리면 self를 정의하기전이기때문에 당연히 self에 있는 subject에도 접근을 할 수가 없습니다
이건 swift의 기초적인 문법이기때문에 이렇게 접근을 하면 안됩니다. 다른 방법이 필요하겠죠
결론부터 말씀드리면 이런문제를 해결해줄 수 있는 방식이 store과 AnyCancellable의 set을 활용한 방식입니다
public class ViewController: UIViewController {
let subject: PassthroughSubject<Int,Never> = PassthroughSubject()
var cancelBag = Set<AnyCancellable>()
public override func viewDidLoad() {
super.viewDidLoad()
let a = subject.sink { print($0) }
.store(in: &cancelBag)
let b = subject.sink { print($0) }
.store(in: &cancelBag)
subject.send(1)
}
}
이런식으로 AnyCancellable타입의 a와 b의 rc를 1늘려주는 cancelBag이라는 Set에다가 store메서드를 통해서 넣어주게되면 만약에 viewdidload가 종료되더라도 a의 rc가 0이 아니라 viewcontroller자체의 내부에있는 cancelBag내부에서 a와 b를 강하게 참조하고있기때문에 rc가 1로 유지되게 됩니다
결국 cancelBag자체가 deinit되어서 내부에서 a와 b를 강하게 참조하지 못하게 되어 a,b가 deinit되는 경우는 viewcontroller자체가 deinit되는 경우가 됩니다 그럴 경우의 a와 b의 rc가 0이 되면서 AnyCancellable의 deinit이 호출되고 cancel이라는 메서드를 호출해 stream을 끊어주게됩니다
cancelbag쓰는 이유
만약에 viewdldload나 init같은 메서드내부에 stream을 연결하는 코드가 있다면 해당 메서드가 끝나면(when cancelbag이 없을때) a, b가 anycancellable인데 이게 deinit(=할당해제, rc가 0이된다)되어 내부의 cancel이 불려서 자동으로 사라지는데 이걸 cancelbag이라는 set에다가 넣어놓으면 rc를 cancelbag이 들고있으니까 결국은 cancelbag이 사라지는 뷰컨이 할당해제될때 deinit이 불리게됩니다
즉, viewcontroller가 살아있을때는 stream이 살아있게됩니다
사실 store의 사용법이나 sink가 AnyCancellable이다라는 개념정도만 알아도 크게 문제는 없을거라는 생각은 들지만 제가 처음에 들었던 의문은 왜 사람들은 cancel이라는 메서드를 직접호출하지 않는걸까?
였거든요
그래서 이런 코드 저런코드를 뜯어보다보니 cancel이라는 메서드자체가 deinit될때 자동으로 호출된다는걸 알게되었고 store도 결국은 rc를 강제로 증가시켜놓고 deinit되면 내부에 있던 AnyCancellable들이 각자 cancel을 호출한다는걸 알게되었던것 같습니다
그러다보니 결국 store나 cancel이나 크게 다르지 않다는걸 알게되기도 했구요 내부 구현로직을 보니까 결국은 cancel의 불편함을 해결하기 위해서 나온 방식이 store를 통한 방식이라는걸 알게되었습니다
다음 포스팅에서는 실제로 우리가 publisher와 subscriber간의 구독관계를 어떻게 해제시키는지 자세한 로직을 알아볼 예정입니다. 어제오늘 이 부분을 알아내고 의도를 파악하느라 너무 힘들었답니다... 그만큼 재미있구요(제 기준에서는요...) combine하고 한껏 친해질 수 있는 기회가 될 포스팅을 들고 오겠습니다!
그럼 20000!
와우... store 를 사용해주지 않으면, 퍼블리셔 사용을 못하게 되어서 왜 그러지 하고
한참을 찾아도 rx에 비유한 설명뿐이어서(rx를 전혀 모름...) 고생했는데.. 완벽히 궁금증이 해결되었어요
더불어 AnyCancellable 전반에 대해서도 이해하는데 많은 도움이 되었습니다
진심으루 감사해요...!