안녕하세요!
오랜만에 또 포스팅 글을 적는 느낌이네요...(쓸건많은데말이죠)
이번 포스팅에서는 combine의 operator에 대한 내용을 가져와봤습니다
combine의 operator는 정말 종류가 많습니다
map, filter, zip, scan, reduce 등등
근데 단순히 map은 이런녀석입니다! filter는 이런녀석입니다!라는 내용의 글은 아님을 미리 밝히겠습니다
혹시나 이 글을 우연히 보게되신 여러분이 대체 map이라는 녀석을 통해서 값이 어떻게 sink로 전달되는거지?
, 내부적으로 operator의 로직은 어떻게 되는걸까?
라는 deep dive적인 고민을 한번쯤 해보셨다면 조금의 힌트가 될수있는 글이 되리라 생각됩니다
제가 해당 내용을 공부하면서 keynote로 정리한 글이 18페이지가 되더라고요...
그래서 아주아주 긴글이 될수도 있겠다는 생각이 드네요 그래도 차근차근 열심히 설명을 해보겠습니다
해당 글은 open combine이라는 open source의 코드를 기반으로 작성되었습니다
let subject: PassthroughSubject<Int,Never> = PassthroughSubject()
var cancelBag = Set<AnyCancellable>()
subject.map { value in
return value * 2
}
.sink { value in
print(value)
}
.store(in: &cancelBag)
subject.send(1)
살펴볼 예시는 아주 간단한 코드입니다
subject를 sink로 연결시켜놓고 받은 값에 2를 곱해서 출력하는 코드입니다
그리고나서 subject에 1이라는 값을 전달합니다
그러면 1이라는 값이 map을 통해 2라는 값으로 return되고 그걸 출력하니까 2라는 값이 출력될겁니다
이렇게 간단한 예제를 선정한 이유는(뒤에 두개의 operator를 결합한 예시도 나오긴합니다만) 사실 flatmap을 제외하고는 모든 operator의 동작 로직이 똑같습니다 다른게 있다면 operator자체가 어떻게 값을 바꾸느냐의 차이정도만 존재하기때문에 가장 심플한 예시에서 원리를 찾아가는게 맞지 않을까 라는 생각을 해봤습니다
자 그러면 한번 시작해보겠습니다
우선 예시코드에서 subject에 map이라는 메서드를 실행하겠죠?
그러면 map이라는 메서드는 어떤 메서드인지를 한번 보겠습니다
우선 map을 실행하기위해서는 transform이라는 값을 넣어줘야하는데요 Output을 받아서 Result라는 Type을 반환해주는 클로저를 넣어줘야합니다
그런데 이때 Output은 Publisher의 Output이기때문에 map에 들어가는 value(map의 Input이라고 할 수 있겠네요)와 Publisher의 타입을 일치시켜야한다는 규칙이 있다는걸 알 수 있겠네요
그리고 output타입을 result타입으로 반환하는데 result는 단순히 아무런 타입이나 가능하도록 generic으로 설정이 되어있네요
자, 그러면 우리가 map을 실행해주기 위해서 클로저를 넣어줬습니다
Output타입을 받아서(여기서는 Int) 두배해서 보내줘!
지금당장 클로저는 실행되지 않고 클로저 자체로 Publisher.Map이라는 객체 상태로 return됩니다
이때 Map이라는 객체의 upstream으로는 self가 들어가니까 여기는 map이라는 메서드를 실행한 주체인 passthroughsubject(예시에서 subject라는 변수에 담겨있죠)가 들어갈거고 transform에는 클로저가 그대로 들어가게 됩니다
간단한 동작이지만 갈길이 멀고 뒤에가면 헷갈릴수도있기때문에 정리를 한번 하고 가겠습니다
중간 정리
publisher가 map이라는 메서드를 실행하면 Map이라는 구조체를 return해주는제 Map은 Publisher이고 upstream으로 (예시에서는) PassthroughSubject를 저장하고 transform에는 map메서드를 실행할때 넣어줬던 클로저를 넣어줍니다
Map이라는 객체를 return해주는걸로 map이라는 메서드는 끝이났고 다시 예시 코드로 돌아가보겠습니다
다음 동작은 sink라는 메서드를 실행해야하네요
sink를 한번 따라가 보겠습니다
근데 이녀석도 map과 약간 비슷합니다
sink메서드를 호출할때 receiveValue라는 클로저를 받고 내부적으로 Sink라는 객체를 만들어줍니다 근데 Sink라는 객체에는 receiveValue라는 클로저를 저장해놓네요
그리고 나서 사진에서 아래 빨간 박스를 보면 subscribe메서드를 호출하고 이때만든 Sink객체를 넣어주고있습니다
근데 여기서 한가지 중요하게 짚고 넘어야가하는 부분이 있습니다
subscribe라는 메서드를 누가실행했을까요?
Publisher의 extension에 있으니까 Publisher가 실행한건 맞는데 우리가 지금 까지 나온 publisher가 두개가 있죠?
PassthroughSubject와 Map입니다
결론부터말씀드리면 map이라는 메서드를 통해서 Map이라는 객체가 return되었고 sink를 했기때문에 여기서 subscribe 호출한 Publisher는 Map입니다
자 그러면 Map의 subscribe는 어떤 동작을 하는지 알아보러 가겠습니다
위에서 subscribe라는 메서드를 호출할때 우리가 Sink라는 객체를 넣어줬었죠 그래서 subscribe메서드에서 subscriber는 Sink객체라는걸 기억하고 메서드의 동작을 살펴보겠습니다
if문들의 조건은 좀 뒷전으로 두더라도 모든 경우에 최종적으로는 Map의 receive(subscriber:_)메서드를 실행한다는걸 알 수 있습니다
원래 기존 구독방식과 마찬가지로 subscribe메서드를 호출하면 내부에서 receive메서드가 호출되는 방식과 일치합니다
Map의 receive(subscrbier:)라는 메서드가 호출되었고 subscriber로 Sink객체를 넣어줬습니다
이번에는 receive(subscrbier:)에서 어떤 동작을 하는지 알아보겠습니다
아래쪽을 보면 upstream의 subscribe함수를 호출합니다 (뭔가 동작이 반복되는듯한 느낌이 계속 드네요...?)
자 그러면 여기서 upstream은 무엇일지 한번 생각해보겠습니다
upstream은 위의 코드에서 볼수있듯이 initalize에서 upstream으로 받은 Publisher입니다 우리가 Map객체를 만들때 self를 upstream에 넣어줬던거 기억하시나요?(기억이 혹여나 안나신다면 위에를 보고오시면 됩니다 ㅎㅎ) 즉 여기선 map이라는 메서드를 호출한 PassthroughSubject가 Map의 upstream입니다
즉 Map에서 receive(subscrbier:_)가 호출되면 upstream인 PassthroughSubject의 subscrbie메서드를 호출하는데 sink를 넣어주는게 아니라 Inner라는 녀석을 직접만들어서 넣어줍니다...?
그럼 간단하게 Inner라는 녀석은 어떤 녀석인지를 알아보고 가겠습니다
우선 큰틀로 바라보면 Inner는 Map내부에서 정의된 구조체입니다
그리고 Subscriber프로토콜을 채택하고있으면서 Subscriber를 가지고 있는 가지고있는 친구입니다
즉, subscriber이기에 누군가를 구독할수있지만 내부적으로 다른 subscriber를 가지고 있는 친구라는거죠
그리고 내부적으로 가지고있는 녀석을 downstream이라는 변수명으로 가지고있고 map이라는 클로저또한 가지고 변수에다가 넣어주고있습니다
뭐 대충 이런 모습이겠네요 위에서 Inner객체를 만들떄 map이라는 곳에 Map을 만들때 넣어줬던 transform을 넣어줬기때문에 그림에서도 클로저를 말로 풀어서 넣어줘봤습니다
다시 원래대로 돌아가서 Map에서 Inner를 만들어서 upstream인 PassthroughSubject의 subscriber메서드를 호출하고 만든 Inner를 넣어줬습니다
Publisher의 subscriber메서드가 호출되고 이번에는 Inner가 들어왔습니다
그리고 이 메서드는 Map에서도 불렸었죠?? 그때와 같습니다
이번에도 if문은 잘 모르니까 두고 어쩃든 모든 분기에서 receive(subscriber:_)메서드가 호출된다는점에 집중해보겠습니다
이번에 receive(subscriber:)의 호출은 당연히 PassthroughSubject가 했겠죠?
이번에는 PassthroughSubject의 receive(subscriber:)메서드의 구현부를 살펴보겠습니다
⭐️subscriber로 Inner객체(downstream이 Sink이고 map에 transform이 들어간)가 전달되었습니다
PassthroughSubject의 receive(subscriber:_)를 살펴보겠습니다
조금 복잡해보이긴하는데 차근차근보면 어렵지 않습니다ㅎㅎ
우선 if문은 active가 true라서 위의 if문에서 실행된다고 가정하겠습니다
이번에는 Conduit이라는걸 만들어줍니다(이게 subscription입니다, 구독권이라고 하죠?)
근데 Conduit을 만들때 parent라는 변수에 self를 넣어줍니다 여기서 self는 당연히 PassthroughSubject겠죠? 그리고 downstream에 subscriber를 넣어주는데 이건 메서드를 호출할때 받았던 Inner객체입니다
그러면 Conduit내부가 어떻게 생겼을지를 생각해보면
parent로 PassthroughSubject를 가지고 있고
downstream으로 Inner를 가지고 있는데 Inner내부에는 downstream으로 sink를 가지고 있는 구조일겁니다(transform은 뺴고 이야기했습니다, 객체간의 관계를 이야기하는게 쉬울거같아서요)
그러면 최종적으론 이런 모양으로 추상화가 가능할듯싶습니다
그리고 이렇게 만든 conduit을 PassthroughSubject에 downstreams에 저장해줍니다
PassthroughSubject가 Conduit을 저장한다
는 꼭 기억해주세요
그리고 이번에는 subscriber의 receive(subscription:_)메서드를 호출합니다
subscriber는 뭐였지...?
라고 생각하시는분들이계실수있기때문에 다시 위에 그림을 보시면 conduit을 만들때 넣어준 친구인 Inner입니다
즉, Inner객체의 receive(subscription:_)메서드를 실행해서 Conduit을 넘겨주게됩니다
Map의 Inner에서의 receive(subscription:_)이 어떤 동작을 하는지 보겠습니다
이전 메서드들보다는 훠얼씬 단순합니다
downstream의 receive(subscription:_)를 호출하고 받았던 subscription을 그대로 넘겨주기만합니다, 여기서 subscription은 Conduit(구독권)입니다
여기서 알수있는건 Inner라는 객체는 보통 operator객체에서 내부적으로 만드는 객체인데 이 객체의 역할중 하나는 publisher로 부터 받은 구독권을 단순히 downstream에 전달해주는 역할을 한다는걸 알 수있습니다
여기서 downstream은 Inner의 downstream이니까 Sink겠네요?
Sink의 receive(subscription:_)를 호출하고 Conduit을 넘겨줍니다
Sink가 구독권인 Conduit을 받으면 해당 Conduit을 enum의 연관값 형태로 가지고 있게됩니다
그러면 드디어 이제 passthroughsubject와 sink가 구독권을 가지고 stream형태로 연결되어있는 상태가 된겁니다
여기서 살짝 Conduit관련해서 궁금하실수있는 분들이 계실거같아서 Conduit이 swift에서 어떤 형태로 저장되어있는지를 확인해봤습니다
실제로 보면 Conduit의 downstream은 Inner지만 Inner내부의 downstream이 Sink로되어있는 구조를 가지고 있습니다
자 이제 연결은 끝났으니 실제로 값을 보내볼까요?
우리가 값을 보내기위해서 send라는 메서드를 호출했고 1이라는 값을 보내줬습니다
그러면 passthroughsubject는 conduit에 있는대로 downstream인 Inner객체에게 값을 보내주게 될겁니다
좀전에 제가 꼭 기억해달라고 했던 한문장 기억하시나요?
PassthroughSubject가 Conduit을 저장한다
는 꼭 기억해주세요
passthroughSubject가 저장한 Conduit을 for문으로 돌면서 각 conduit의 offer메서드를 호출하고 1이라는 값을 전달해줍니다
뭐가 좀 길긴한데요 중요한 부분은 빨간박스부분입니다 현재 conduit의 downstream인 Inner객체의 receive메서드를 호출하고 1이라는 값을 넣어줍니다
이번에는 Inner의 downstream인 sink의 receive메서드를 호출하는데 이때 1이라는 값을 넣어서 map이라는 클로저를 실행해서 반환된 값을 전달해줍니다
map이 뭐였죠?? 위에가시면 Map객체를 만들떄 transform이라는 이름으로 넘겼던 클로저를 map이라는 변수에 저장했었는데요 말로 풀어보면 input을 두배해서 return해줘
라는 클로저였습니다
그렇게 2라는 값이 반환되고 그값을 sink의 receive메서드에 전달해줍니다
sink에서 receive메서드가 호출되면 2라는 값을 받아서 그값을 receiveValue라는 메서드를 실행할때 인자로 전달해주게됩니다
갑자기 receiveValue가 뭐지...?
하시는 분들도 계실거같아서 잠깐 sink의 구조를 다시 보여드리면
sink를 호출할때 값을 print하라는 클로저를 넣어줬는데 그 클로저를 receiveValue라는 변수에 넣어서 가지고 있었습니다. 그러니까 2라는 값을 받아서 어떤값을 받아서 print하라는 클로저에 인자로 넣고 실행을해서 2라는 값이 출력되게되는겁니다
그러면 send를 통해 전달된 value의 여정이 끝나게됩니다
이리저리 왔다갔다하는 코드를 보니까 헷갈리더라고요... 그래서 정리하는데 오래걸린것도있는데
약간 그래서 꼭 알아야 하는 결론을 정리해보겠습니다
첫번째 결론
Conduit은 실제로 값을 보내는 녀석과 최종적으로 값을 받는 녀석만 가지고 있으면 된다
(operator가 존재하는경우 Publisher는 Operator내부에 정의된 Inner객체에 Conduit을 보내게되는데 Inner는 최종적으로 값을 받는 subscriber까지 Conduit을 전달만 해준다)두번째 결론
Publisher는 Conduit에 정의된대로 operator의 Inner에 값을 보내게되는데 Inner는 각각의 operator의 정의된 클로저를 통해 받은 값을 변환해주면서 최종적인 subscriber에게 값을 보내준다
(publisher가 직접 sink에게 값을 보내는게아니다, operator라는 대리인에게 값을 보내면 대리인이 값을 잘 바꿔서 sink에게 전달해준다)
실제로 map과 filter를 동시에 썼을때 Conduit은 어떤형태일지 지금까지 공부했던 방식으로도 설명이 될지를 혼자서 공부해봤던 기록들입니다(추가적인설명은 생락하겠습니다 ㅎㅎ)
정말 정말 너무 힘들게 완성한 포스팅이네요...뭔가 지금까지 한 포스팅중에서 가장 긴글을 갱신했을거같은 느낌이드네요...
사실 combine내부적으로 코드가 공개되어있지않아서 단순히 operator를 쓰면 값이 변해서 간다
정도만 알고 써도 크게 상관은 없다고 생각을 합니다만 이게 확실히 알고쓰는거랑 모르고쓰는거랑은 차이가 생긴다고 믿고있어서 이렇게 나름의 deepdive를 가장한 비효율챌린지를 해봤습니다
결론적으로 operator를 쓰면 값이 변해서간다
라는걸 재확인했던 그런 시간이었다고 생각합니다 ㅎㅎ...
뭐 지금 이렇게 open source를 보고 분석했던 경험과 combine의 작동원리를 이해하려했던 한 이틀의 시간은 언젠간 저한테 도움이 되어있지 않을까싶습니다 ㅎㅎ
오늘 포스팅은 여기까집니다
아마 한동안은 open combine이라는 open source를 뜯어보면서 combine을 deepdive하는 글이 대부분이지 않을까싶습니다
그럼 다음에봐요!