안녕하세요!
직전 포스팅의 주제가 DI Container를 구현해보자라는 주제였는데요
사실 저번 포스팅에서 해결을 못했던 부분(memory leak이 발생하는부분)을 그때당시에는 어쩔수없으니까 swinject를 써야겠다고 생각을 하고 마무리했었는데 요 며칠 계속 찜찜하더라고요
문제는 발견했지만 그 문제를 해결하는데 실패를 했고 그 해결책이 단순히 라이브러리를 쓰면 해결된다고 결론을 내렸던게 제가 바라보는 성장의 방향은 아닌거같다는 생각을 계속 했습니다
그래서 3일정도의 시간동안 저와 같은 생각을 하고 있던 팀원과 계속해서 이부분을 파본 결과를 정리하는 글이라고 생각해주시면 좋을거같습니다
(3일동안 거의 20시간을 팀원이랑 얘기하는데 쓴거같아요...)
swinject는 container방식을 사용하면서 발생할수있는 문제점을 내부에서 어떻게 해결했을까 평소에 궁금하셨던분들 혹은 방금 제가한 말을듣고 궁금하신 분들이 보시면 도움이 될거같습니다
들어가기에 앞서 본 포스팅은 swinject의 사용방법에 대한 글이 아님을 밝힙니다!
하지만 이 포스팅의 내용이 swinject가 내부에 어떤 로직으로 되어있는지를 분석한 글이기때문에 읽으시는 분들에게 더 의미있는 내용이 될거라고 생각합니다(열심히 분석했지만 틀린부분이 있을수도 있습니다)
그럼 시작해보겠습니다
저번 포스팅에서 했던 이야기이긴하지만 못보신분들도 있으실거같아서 왜 이런 포스팅을 쓰게되었는지 스토리라인(?)을 주저리주저리 읊어보겠습니다
우선 저희는 지금 기존에 진행했던 프로젝트를 리팩터링 하는 중이었는데요
네트워크 레이어를 나누는 과정에서 객체끼리의 의존성을 줄이기 위해 Dependency Injection 즉, DI를 적용하던 중이었습니다
하지만 단순히 객체를 외부에서 주입하려다보니 네트워크레이어안에 객체 안에 객체를 매번 넣어줘서
/// 객체주입의 무한 굴레....
let VC = SplashViewController(authService: AuthService(api: AuthAPI(apiService: APIService())))
ViewControllerUtil.setRootViewController(window: window, viewController: VC, withAnimation: false)
객체를 하나 만드는데만해도 3개에서 많게는 5개의 객체를 매번생성해서 넣어줘야하는 상황이었습니다
그래서 이런 불편함을 해소시키기 위해서 DI Container객체를 이용하는 방식에 대해 알게되었습니다
실제로 swinject라는 라이브러리가 DI를 위한 이러한 기능을 제공해주는 라이브러리라고 해서 실제 라이브러리를 먼저쓰기보다는 실제로 구현을 먼저 해보자하는 방향으로 결정을 해서 DI Container를 만들었습니다
(이틀전에는 몰랐다 이게 이렇게 내 머리를 아프게 할 줄은....)
Container객체를 만들어서 등록하고(register) 꺼내쓰는(resolve)방식을 통해서 의존성이 주입된 객체를 간편하게 꺼내 쓸수있었고 위에있던 긴 코드가 이렇게 간단해지는 모습을 보면서 정말 좋은 방식이라고 생각이 들었습니다
LHDIContainer.shared.resolve(type: SplashViewController.self)
그러다 문득 이런생각이 들었습니다
그러면 굳이 swinject를 쓸 필요가 없을까?
우리가 만든 container객체로도 충분히 DI가 가능한 상황이었고 굳이 라이브러리를 써야하나라는 생각이 들었습니다
그런데 이게... 깊게 파면팔수록 그렇게 간단한 문제가 아니었습니다
구글링을 하던 팀원이 링크를 하나 던져줬습니다
바로 dictionary의 strong reference에 대한 내용이었는데요
저희가 사용하던 방식은 type자체를 key로만들고 객체를 value에 넣어주는 방식이었는데 그렇게 되면 저장되는 순간 객체의 reference count가 1이 되게됩니다(weak으로 가지고있는게 아니라서)
그 사실을 알고나니 이런 생각이 들었습니다
그러면 container가 싱글톤이니까 deinit이 불릴리가 없고 메모리에서 할당해제가 안되겠네...?
이렇게 단순하게 구현하면 메모리릭(memory leak)이 발생하게됩니다
단순히 편하다는 이유로 메모리릭이라는 문제가 발생하는걸 아는데 사용할수가 없었습니다
그래서 찾은 방식중에 하나가 value자체를 dictionary에 넣어주는게 아니라 weak으로 value를 가지고 있는(저장속성으로) wapper객체를 만들어서(여기서는 Weak이라는이름으로 객체를 만들었습니다) 그걸 딕셔너리에 넣어주는 방식이었습니다
근데 register까지는 잘되는데 resolve하려고 보니 계속 guard문을 통과를 못하길래 bp를 찍어보니
들어갈때는 잘들어갔는데 꺼내려고 보니 nil이 상황이 펼쳐졌습니다
😀나 : 맡긴 객체주세요~~~
💻엑코 : 없어요
😢나 : 넣어줬자나? 왜 없어?
💻엑코 : 아니 없어요 그냥
근데 생각을 해보면 간단한 문제였습니다(간단하긴한데 깨닫는데 2시간이 넘게걸렸습니다) 변수에 넣자마자 그 변수는 weak으로 선언되어있다보니 그 객체에 rc를 올려주는 다른 객체가 없어서 바로 할당해제가 되는 문제였습니다
대체 이걸 어떻게 해야하는거지 라는 생각이 들 때쯤 이런 생각도 함께 떠올랐습니다
swinject도 분명히 큰 틀에선 이런 로직일텐데 이 문제를 해결할 수 있는 로직이 분명히 있지 않을까
그래서 라이브러리를 뜯어서 이런 문제를 해결하는 로직을 찾기 시작했습니다
그렇게 Swinject라이브러리에서 코드를 뒤적뒤적하던 중에 하나의 객체를 발견하게됩니다
처음에는 instances라는 딕셔너리가 있고 Weak라는 객체를 넣어주는게 저희가 시도했던 방식이랑 비슷해서 유심히 보게되었습니다
그런데 instance라는 변수가 Any로 선언되어있는걸 발견했습니다
노란색 네모를 보면 객체를 할당한 변수에 nil을 할당하는 메서드도 있고
아래에 내리니 setInstance라는 메서드가 있는데 네이밍에서부터 딕셔너리에 key value를 넣는 느낌이 강하게 들었습니다
근데 좀 신기했던 부분이 아래 빨간 두개의 박스인데
instance라는 변수에 메서드의 파라미터로 받은 instance를 넣어주고 실제로 딕셔너리에 변수를 넣을때는 self.instance가 아니라 instance를 넣는단 말이죠...?
처음엔 뭐야 코드 왜 이렇게 짠거지...?라고 생각했다가 순간 이런생각이 들었습니다
저 Weak는 어떻게 생긴 녀석이지?
들어가보니 Swinject에서도 내부에 weak변수를 담는 Wrapper클래스를 하나 만들어서 딕셔너리에 넣어주더라고요
이걸보자마자 이런생각이 들었습니다
weak으로 선언된 변수에 넣으면 rc가 0이되어 바로 할당해제 되니까 self.instance가 rc를 1개 늘려줘서 weak으로 선언된 변수에 들어가도 자동으로 할당해제가 되지 않게끔 하는거구나...?
swinject의 코드를 보고 이걸 딱 느껴서 우리 코드에서도 이런식으로 외부 변수가 weak에 들어갈 객체의 rc를 늘려주면 weak에 할당되도 외부변수가 rc를 1늘려줘서 할당해제가 되지 않는구나라는걸 알게되었습니다
그래서 기존에 container 객체에서 rc를 1늘려줄수있는 외부객체를 만들어서 그 객체가 주입될 instance를 가리키게끔 해줬습니다
array로 한 이유는 container 자체가 싱글톤이라 register할때마다 변수에 instance를 새로 넣어주면 기존 객체에 rc를 다시 0으로 줄여서 할당해제가 되기때문입니다
이렇게 만들고 dictionary와 weak에 들어간 value를 print해보니 아래와같이 잘 나오는걸 확인 할 수있었습니다
그런데 문들 이런 생각이 또 들었습니다
딕셔너리에 넣을때 강제로 rc를 올려주는걸보면 swinject도 우리처럼 rc를 고려하는구나
근데 얘네도 결국 객체가 무조건 rc를 1은 가지고 있으니까 weak에 넣어도 할당해제가 안되는건 똑같은데...?
분명히 swinject도 이런 문제를 인지하고 해결했을텐데 이걸 해결하는 코드는 없을까?
그런 생각이 들어서 코드를 다시 보고 있는데 신기한 단어가 눈에 들어왔습니다ResolutionDepth
라는 단어였는데 뭔가 depth라고하니까 층같은 느낌이 들어서 해당 코드를 자세히 보게되었습니다
최대 ResolutionDepth도 설정이 되어있고
보니까 ResolutionDepth가 0일때의 분기처리도 되어있고 1씩 늘려주고 1씩 줄여주는 로직이었습니다
그리고 맨아래 초록 네모를 보면 ResolutionDepth가 0일때 graphResolutionCompted를 호출하고 그게 뭔지를 들어가보니 우리가 좀전에 봤던 코드에서 instance에 nil을 시켜주는 메서드였습니다
즉, instance자체를 할당해제시켜주는 메서드라는걸 알게되었습니다
이 코드를 보고 이런생각을 하게되었습니다
swinject 내부에서 swift의 rc가 아니라 내부적으로 reference count를 관리해주는데 그게 ResolutionDepth구나
그리고 이러 생각에 확신을 준 코드가 fatalError에 있는 string이었습니다 ResolutionDepth가(swinject에서의 rc) 200이 넘어가면 circular dependency를 감지할수있다
라는 문장을 봤을때 강한순환참조가되면 rc가 계속 올라가고 그게 200에 도달하면 강한순환참조라고 판단한다는 로직이었습니다
그래서 결론적으로는
- 객체가 dictionary에 들어가서 어떤 로직(다른 변수에 할당된다면) ResolutionDepth가 0이 되고 nil을 할당해 dictionary에서 객체가 nil이 되는구나 그런방식으로 dictionary에서 강한참조가 일어나지 않게 하는구나
- 그리고 나중에 다시 꺼낼때는 내부로직에 의해서 새로운 객체가 생성되어서 객체를 받을수있게되는구나
라는 생각을 하게 되었습니다
이런 로직은 결국 register할때와 resolve할때 불리지 않을까라는 생각이들어서 최종적으로 container에서 객체를 register하고 resolve하는 로직을 분석하게되었습니다
우선 register로직을 봤는데
처음에는 많이 당황을 했습니다 어디에서도 ResolutionDepth를 건드리는 로직이 없었습니다
순간 지금까지 생각했던 모든게 틀렸던게 아닐까라는 생각이 들던찰나에 이런생각을 하게되었습니다
register 메서드는 객체가 들어가만 있으면 되는거기때문에(=들어가서 할당해제만 안되면 된다) 들어간 이후에 resolution depth를 신경쓰면되는거니까 굳이 register하는 메서드에서는 resoution depth를 관리해줄 필요가 없지 않을까?
좀 복잡할 수 있는데 register의 역할을 weak에 객체를 넣는데 할당해제가 되지 않게 rc를 하나만 강제로 늘려서 넣어주기만 하면되는거고 넣은 이후에 swinject의 ResolutionDepth를 가지고 rc를 관리해주면 되니까 굳이 ResolutionDepth를 바꿔줄 필요가 없겠다는 생각이 들었습니다
실제로 ResolutionDepth는 resolve할때만 잘 관리해주면 register된 객체를 할당해제시키는 작업또한 가능하겠다는 생각이 들었습니다 결국 container가 관리하는거니까요
위의 코드도 실제로 swinject라이브러리에 있는 resolve 코드입니다
보시면 ResolutionDepth를 늘려주고 defer를 통해 클로저가 끝나기 직전에 ResolutionDepth를 낮춰줍니다
분명히 어떤 객체에게 resolve해주면 rc가 늘어나니까 늘어나는건 알겠는데 굳이 왜 클로저가 끝나기직전에 다시 낮춰주는거지?라는 생각이 처음엔 들었었는데요
두가지 이유가 있다고 생각했습니다
첫번째 이유(예측)
클로저니까 캡쳐현상이발생하고 여기서 하는 작업에 의해 실제 swift의 rc가 변화하는데 그 변화를 당연히 swinject내부에서 사용하는 rc 변수(바로 동기화가 되지않으니까 아얘 다른 변수니까 수동으로 관리해야함)인 resoution depth에도 sink를 맞춰주기 위해서 increment랑 decresement를 해주는게 아닐까
두번째 이유(예측)
dictionary에서 resolve가 되었다는 말은 다른 변수가 rc를1늘렸단 소리고 resolution depth또한 1이 늘어났다는 의미니까 resolve를 통해 다른 변수에 할당을 한 순간부터는 다시 dictionary가 객체를 참조할 필요가 없기때문에 (swift에서의 rc가 1증가했기때문에) resolution depth를 줄여서 0으로만들고 할당해제하는 메서드가 호출되어서 nil로 바뀌게됩니다(weak처럼 작동) 그러면 dictionary가 객체를 강하게 참조하고 있지 않아 외부변수로 할당된 객체(이때부터는 resolution depth가 아니라 rc와 관계성이있음)가 pop되면 메모리에서 자연스럽게 해제됩니다
즉, 이런 로직으로 작동하기 때문에 객체가 weak처럼 작동할 수 있게됩니다
이렇게 찾은 3~4개의 로직이 DI를 위한 container객체를 만들었을때 마주했던 문제를 해결하는 열쇠이자 힌트였고 실제로 swinject에서도 이러한 로직으로(큰 그림에서의 로직) 작동한다는걸 확인할 수 있었습니다
열심히 운동을 하고 있는데 갑자기 이런 의문이 들었습니다 우리가 DI Container를 만들어서 쓰는데 이건 싱글톤으로 만들었단말이죠?
근데 swinject도 결국은 container를 활용해서 DI에 사용할 수 있으니 이 Container를 생성하면 어디서든 쓸 수 있는 싱글톤일 까요...?
(이렇게 보낸 카톡으로 인해 다시한번 디스코드에서 토론의 장이 열리게됩니다)
싱글톤이라고 하면 어떤 객체 하나를 유일하게 한번만 생성해서 사용할 수 있는 디자인패턴이란 말이죠. 그래서 register한 dictionary를 가지고 있는 객체를 여러군데서 resolve하기 위해서 사용을 했습니다
근데 생각보다 swinject는 singleton인가요
라는 검색결과에 명확한 답이 없더라고요?
그래서 공식문서를 뒤져보니 singleton
이라는 단어를 발견할 수 있었습니다
공식문서에서 저 문장을 발견했는데 해석을 해보니
다른 DI 프레임워크에서
싱글톤
으로도 알려져 있다.
라고 해석을해서 저는 저 container가 dictionary를 container라고 하는구나라고 생각을 해서 그럼 swinject의 container는 싱글톤이네~
하고 넘어가려 했습니다
근데 좀 이상한 부분을 발견했습니다
엥...? Container가 default가 아니네...? graph가 default인데 해석을 해보니까 이 친구는 매번 객체를 생성해주는 친구라고 합니다
여기서부터 좀 혼동이 오기 시작했습니다(해석을 제대로 안하고 제 맘대로 해석해서 그런였죠...)
해석을 해보니 이런 단어가 보이기 시작합니다
resolve된 instance
결국 container라는건 register할때 설정하는 하나의 타입인데 resolve되는 instance를 싱글톤(이라고 알려진)행태로 resolve하게 된다는 거였고 graph는 resolve되는 instance를 매번 새로 생성해서 resolve해주는 녀석이었습니다
즉 실제 객체를 저장하는 container에 관련있는 친구가 아니고 container에서 resolve되는 객체에 관련있는 친구였던겁니다
결국 container(여기서는 객체를 저장하는 dictionary의 역할을 하는 객체)는 singleton이 아닌거니까 싱글톤처럼 사용하려면 싱글톤 객체를 만들어서 거기에 swinject의 container를 넣어서 사용을 해줘야하는겁니다
그러면 이 글을 보는 어떤 분은 이렇게 물어볼 수도 있습니다
그러면 DI Container도 싱글톤으로 만들어야하고 swinject를 써도 singleton으로 만들어야하면 굳이 swinject를 써야할 필요가 없는거아닌가요?
제가 내린 결론은 이렇습니다
결국 DI Container를 custom하게 만들면 DI Container의 싱글톤 안에는 swift의 dictionary가 들어있습니다(제가 만들었던 기준으로는요) 근데 그 dictionary에 있는 instance를 resolve를 하려하면 메모리 릭이 발생할 가능성이 존재했던거죠
하지만 DI Container안에 swinject의 Container가 있으면(dictionary역할을 하는) resolve할때 내부에 resolutionDepth가 있으니 이런 memory leak이 발생하지 않게 되는겁니다
결론적으로 swinject의 container자체는 싱글톤이 아니기에 싱글톤처럼 사용하려면 싱글톤객체를 만들어야하지만 대신 resolve할때 메모리릭을 방지할수있는 개선된 dictionary를 넣을 수 있다(이게 swinject의 container)정도로 결론을 내리게 되었습니다
컨테이너는 내부적인 로직을 통해 memory leak을 방지한다라고 말씀을 드렸었는데요
swinject의 container는 결국 싱글톤으로 만들면(싱글톤으로 안만들고 매번 객체를 넘겨줄 수도 있습니다 여기서는 싱글톤일때의 경우를 이야기하는겁니다) 싱글톤의 문제점인 생성된 후에(싱글톤은 생성될때는 thread safe합니다 싱글톤이 생성시점에서 thread safe한 이유가 궁금하다면?) thread safe하지 않을수있습니다
container에 접근하는경우는 register하는 경우와 resolve하는 경우가 있는데요
공식문서에 Container의 Thread Safety에 관한 글이 있습니다
아래부분을 해석하면 이렇게 해석할 수 있습니다
container는 thread-safe하지만 이는
resolve
에만 해당합니다.register
는 여전히 thread safe하지 않습니다. 따라서 registeration은 single thread에서만 실행되어야합니다
resolve할때는 다행히 thread-safe하다고 합니다 하지만 register는 thread-safe하지 않으니 단일스레드에서 하라고 하는데 이 의미에 대해서 한번 생각을 해봤습니다
우리가 보통의 경우에 container를 생성할때나 app delegate에서 DI할 객체를 전부 등록을 하고 사용할때는 app delegate이후에 다른 객체들에서 resolve하게 됩니다
사실 등록하는 시점만 한곳에서 해주게 되면 register할때 thread safe하지 않을 이유가 없습니다
register할때 resolve해야할때가 있지 않나요?
이 부분을 생각을 해보면 어떤 객체를 register할때 어떤 객체를 resolve해야 register가 가능하기 때문에 동일한 시점에 register와 resolve가 된다, 그렇기에 동시에 접근했을때 생기는 thread safe문제가 발생한다라고 보기 어렵다고 생각했습니다
어쨋든 resolve를 먼저해야 그 객체를 가지고 resgister한다는 확실한 순서가 존재하기때문이죠(serial queue처럼 이전 task가 끝나야 이후 task가 실행될수있는 방식으로 작동한다고 생각했습니다)
그렇기 때문에 register만 한 곳에서 해준다면 register도 thread-safe하지 않는 상황을 예방할 수 있겠다고 생각했습니다
그렇다면 swinject를 사용하게되면(register를 한곳에서만 한다는 가정을 추가하면) DI container를 싱글톤으로 만들더라도 내부에 dictionary의 역할을 하는 swinject의 container를 사용한다면 안전하게 사용할 수 있게되는구나 라는 결론을 내리게되었습니다
이렇게 3일동안 추가로 삽질했던 일기를 정리를 해봤습니다
정말 찜찜하기도했고 이걸 해결못하면 swinject를 쓰더라도 마음의 짐이었을수있겠다는 생각을 했는데
어떻게든 알아내고 생각을 정리하니 정말 후련하네요...
사실 너무나 유명한 라이브러리고 그렇기에 이런 문제를 해결하는 로직이있을거라고 생각은 했지만 실제로 보고 분석하니 다시금 대단하다는 생각이 드네요
실제 swift의 reference count를 관리하는게 아니라 내부에서 따로 rc를 관리하는 프로퍼티가있을거라고 생각을 못해봤는데 이렇게 관리를 해서 예상할수있는 메모리릭을 관리하는 방식이 참 신기하다는 생각이 들었습니다
아마도 이후에도 계속 이 이야기를 가지고 분석하고 공부를 해볼거같습니다
(그 말은 이 포스팅은 계속 업데이트가 되겠다는 말이겠군요...)
(9월 16일 업데이트 ⭐️완료⭐️)
틀린부분, 혹여나 다른의견이 있으시면 꼭 의견을 남겨주세요!!
그럼 20000!