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

Dependency Container 구현 (Swinject 파헤치기)

by Mintta 2023. 9. 14.

 

https://github.com/Team-LionHeart/LionHeart-iOS

 

GitHub - Team-LionHeart/LionHeart-iOS: 라이옹 🦁

라이옹 🦁. Contribute to Team-LionHeart/LionHeart-iOS development by creating an account on GitHub.

github.com

 


Dependency Container 구현 (Swinject 파헤치기)

이 글을 완성하기까지 3일이 걸렸던 것 같습니다. 글을 작성하는 중간에도 새로 깨닫는 사실들이 새로 생기고 그것이 또 다른 의문을 낳고 이런 과정이 계속 반복돼서 계속 수정.. 수정..에 걸쳐서 드디어 나름의 결론을 얻게 되어서 글을 마무리할 수 있게 되었습니다..!

(처음 이 글의 반이 정도되는 글도 있었는데 일단 다 날렸네요ㅎㅎ)

 

글의 주제는 제목을 통해서 아셨을 테고 그 배경부터 같이 한번 살펴보겠습니다.

 

저는 최근에 진행했던 프로젝트를 팀원들과 함께 리팩토링을 진행하고 있습니다.

 

싱글톤에 대한 결론: https://codingmon.tistory.com/59

여기서 얻은 결론을 통해서 결국 저희는 Service객체를 싱글톤이 아닌 DI를 하기로 결정했고, 그 과정에서 문제를 마주하게 됩니다.

의존성에 의존성에 의존성에 의존성에…

DI를 해주게 되면서 생성시에 필요한 객체들을 넣어주기 위한 코드가 이렇게 꼬리에 꼬리를 무는 형태로 깊은 depth가 생기게 됩니다.

보기에도 좋지 않지만, 무엇보다 의존성을 가지는 객체의 경우 저런 코드가 프로젝트에서 객체를 생성할때마다 계속해서 반복된다는 점이 가장 큰 문제입니다.

 

이것을 해결할 수 있는 방법으로 어떤 게 있을지 찾아보았고, Dependency Container가 등장합니다.

Dependency Container


일단 Dependency Container의 아이디어는 간단하다고 생각합니다.

 

객체를 생성할 때 해당 객체 타입을 Key로 가지고, 생성된 객체를 Value로 가지는 딕셔너리(Dictionary)를 만듭니다.

처음 객체를 생성하고 난 후에 딕셔너리에 넣어두면(register) 다음부터 해당 객체를 사용할 때는 딕셔너리에서 등록했을 당시의 Key값을 통해서 객체를 가져오는(resolve) 아이디어입니다.

 

var dependencies: [String: AnyObject] // [AuthService: AuthService()]

 

위와 같은 딕셔너리에 객체들을 담는 것이죠. (AnyObject는 추후에 변경됩니다)

이 때 Dependency Container는 싱글톤 객체로 구성합니다.

 

왜 그러냐.

 

객체를 단 하나로 제한함으로써 container의 여러 객체들이 의존성을 다룰 때 생길 수 있는 예측할 수 없는 행동들(있던 의존성을 자른다던가)을 막을 수 있기 때문입니다.

https://medium.com/sahibinden-technology/dependency-injection-container-in-swift-89392a309532

싱글톤을 피해 의존성 주입을 하는데, 결국 의존성 주입을 깔끔하게 위해서 다시 싱글톤을 쓰는 상황…ㅋㅋㅋㅋㅋ

이제 주요 커스텀 Dependency Container의 주요 메서드들을 살펴보겠습니다.

 

Resolve (저장해두었던 객체 꺼내기)

 

private func resolve<T>() -> T {
    let key = "\(T.self)"
    let weak = dependencies[key]

    precondition(weak != nil, "No Dependency found for \(key), Application must register a dependency before resolving it.")

    let dependency = weak!.value as? T

    precondition(dependency != nil, "Dependency is already deallocated by the system.")

    return dependency!
}

resolve는 Value값을 꺼내와서 generic parameter T로 형변환 시켜주어서 return시키는 메서드입니다. 여기에는 크리티컬한 이슈는 없기 때문에 가볍게 살펴보고 넘어가겠습니다.

Register (객체 저장)

private func register<T>(type: T.Type, _ dependency: T) {
    let key = "\(type)"
    dependencies[key] = dependency as AnyObject
}

 

코드로 함께 다시 살펴보겠습니다. dependency 인자에 들어가는 값은 객체입니다.

💡 객체를 받아서 해당 타입을 구해서 Key로 구하게끔 한다면 type파라미터를 받지 않아도 되지만, protocol타입을 Key값으로 저장하기 위해서 type파라미터를 받습니다.
LHDIContainer.register(type: SplashViewController.self, SplashViewController())

 

사용할 때에는 위와 같이 사용할 수 있습니다.

문제1: 딕셔너리의 강한 참조

여기서 한가지 문제가 있습니다. register를 할 시에 저희는 딕셔너리에 객체를 넣어주었습니다. 딕셔너리에 들어가는 Value값에 우리가 넣을 값은 클래스 객체입니다. 이 말은 즉, ‘클래스 객체를 직접적으로 Value로 가지고 있겠다’ 라는 말이 됩니다.

 

그리고 딕셔너리는 자신의 elements들을 ‘강한 참조’로 잡고 있습니다.

 

한번 확인해보겠습니다.

 

보시다시피 코드와 주석을 함께 살펴보면 딕셔너리가 element를 강한 참조로 가지고 있다는 사실을 확인할 수 있습니다.

(CFGetRetainCount에서 reference count가 1증가하기 때문에 1을 뺀 값으로 봐야합니다.)

딕셔너리가 element를 강한 참조로 가지고 있는데 그게 왜 문제가 되나요 ?

 

앞서 container는 싱글톤인 것을 이유와 함께 설명했습니다.

 

Container가 싱글톤이기에 container객체가 강한 참조로 가지게 되는 dictionary도 메모리에 항상 있게 됩니다. 이 때 dictionary또한 elements들을 강한 참조로 붙잡고 있습니다.

 

딕셔너리에 Value로 들어가는 것은 어떤 것이죠? 바로 객체입니다.

 

우리가 저장해두고 편하게 꺼내서 쓰고 싶은 모든 클래스 객체가 해당됩니다.

클래스 객체의 reference count는 딕셔너리에 의해서 1이 항상 증가된 상태겠죠.

 

예를 들어 ViewController를 저장해두었는데 navigation pop을 하면 ViewController는 메모리에서 할당 해제가 되어야합니다. 하지만 딕셔너리가 이를 강한 참조로 붙잡고 있게 된다면 ViewController의 deinit은 불리지 않게 됩니다.

 

이런 것을 방지하기 위해서 딕셔너리의 강한 참조를 약한 참조로 바꿔줄 필요가 있습니다.

딕셔너리의 강한 참조를 풀자

class Weak: Equatable {
    weak var value: AnyObject?

    init(value: AnyObject) {
        self.value = value
    }

    static func == (lhs: Weak, rhs: Weak) -> Bool {
        return lhs.value === rhs.value
    }
}

 

Weak라는 클래스를 만들어서 value를 weak var로 선언해주었습니다. 여기 value자리에 이제 객체가 들어가게 될 것 입니다.

객체를 바로 넣던 것을 Weak라는 클래스 한번 감싸는데, 이 때 weakly하게 객체를 들고 있게끔 한 것 입니다.

 

private var dependencies: [String: Weak] = []

private func register<T>(type: T.Type, _ dependency: T) {
    let key = "\(type)"
    dependencies[key] = Weak(value: dependency as AnyObject)
}

 

딕셔너리의 키 값도 바꿔주고, register할 때 Weak로 한번 감싸서 넣어주면 됩니다.

 

딕셔너리가 이제는 Weak를 강한 참초로 가지고 있고 Weak는 객체를 weakly하게 들고 있는 모습을 눈으로도 확인할 수 있었습니다.

앞서서 딕셔너리의 강한 참조 문제는 해결해보았습니다.

 

 

 

 

정말 그럴까 ?

한번 실행시켜보겠습니다.

좌절만 할 수 없으니 디버깅을 해봅니다.

register를 깜빡했던 것인가를 제일 먼저 확인해봅니다. 하지만 register도 잘되었습니다.

 

저희가 꺼낼려고했던 것은 SplashViewController입니다.

위에 딕셔너리의 빨간박스를 보면 등록까지 잘된 것을 확인할 수 있습니다. 하지만 아래 SplashViewController의 deinit lifecycle에 적어둔 프린트문이 출력되는 것도 보입니다.

 

들어갈 때는 잘들어갔는데, 꺼낼려고 하는 그 사이에 값이 사라져버린 것이죠..

이미 없어져버린 객체.

사실 범인은 찾기 쉬웠습니다. 바로 weak입니다. weak는 reference count를 증가시키지 않음으로써 순환 참조를 해결하기 위한 키워드입니다.

 

하지만 지금 제 상황에서는 아래와 같은 상황이 된 것이죠

 

weak var value = SplashViewController()

 

value변수는 weak로 선언된 변수이기에 reference count를 증가시키지 않습니다. 그러므로 SplashViewController는 생성과 동시에 바로 할당 해제 되어 value에 nil이 들어가게 됩니다.

 

이것도 모른채 잘들어갔다고 생각하고 resolve를 통해서 value값을 꺼낼려고 하면 이제 guard문에 걸려서 에러를 마주하게 된 것 입니다.

그렇게 해결책을 찾기 위해 고민을 하던 중 한가지 생각을 하게 됩니다.

 

swinject도 이런 문제가 있지 않았을까 ? 그들은 어떻게 해결한거지 ? 거기에 정답이 있지 않을까”

 

그렇게 멀고 swinject로부터 해결책을 훔치기 위해 험난한 여행을 떠나게 됩니다.

내놔..해결책

 

Swinject로부터 해결책 훔치기

이야기하기에 앞서 swinject이야기를 해야할 것 같습니다.

Swift에서 DI를 위한 프레임워크로 대표주자 중 하나로 Swinject가 있습니다.

swinject의 코드 중 일부를 보면..

 

internal var services = [ServiceKey: ServiceEntryProtocol]()
public func _register<Service, Arguments>(
    _ serviceType: Service.Type,
    factory: @escaping (Arguments) -> Any,
    name: String? = nil,
    option: ServiceKeyOption? = nil
) -> ServiceEntry<Service> {
    let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self, name: name, option: option)
    let entry = ServiceEntry(
        serviceType: serviceType,
        argumentsType: Arguments.self,
        factory: factory,
        objectScope: defaultObjectScope
    )
    entry.container = self
    services[key] = entry

    behaviors.forEach { $0.container(self, didRegisterType: serviceType, toService: entry, withName: name) }

    return entry
}

 

딱 봤을 때 위에서 설명드린 개념과 큰 틀은 다르지 않은 것을 확인할 수 있었습니다.

그렇다면 분명 여기에도 우리와 같은 고민이 있었을 것이라고 생각하게 됩니다.

타겟1. 딕셔너리의 강한 참조를 막기 위한 로직

그렇게 swinject코드를 탐방하던 중 아래와 같은 코드를 보게 됩니다.

분홍색 박스가 쳐진곳들을 보면 우리와 동일하게 딕셔너리의 형태와 Weak구조체를 가지고 있는 것을 확인할 수 있었습니다.

그리고 넣어줄 때에도 동일하게 Weak구조체를 만들어서 넣어준 후에 value에 Any타입의 인스턴스(객체)를 넣어주는 것을 확인할 수 있었습니다.

Weak구조체 또한 weak var로 객체를 가지고 있는 것을 확인할 수 있었습니다.

여기까지는 저희와 완전히 동일한 상황이다! 라고 봐도 무방할 것 같습니다

(훨씬 더 많은 추상화가 되어있겠지만 단순 register할 때의 최종 로직자체만 봤을 때요!)

 

그런데 한가지 신기한점이 있습니다.

 

바로 초록색 박스로 표시해둔 지점입니다.

 

self.instance = instance

instance라는 프로퍼티를 두고, 해당 변수에 인자로 넘겨받은 객체를 할당 해줍니다.

하지만 실제로 value에 저장하는 객체는 self.instance가 아닌 인자로 넘겨받는 instance객체입니다.

프로퍼티에 할당만 할 뿐, 해당 프로퍼티를 따로 활용하지는 않고 있는 겁니다.

이 때 깨달을 수 있었습니다.

 

“self.instance에 할당만 해줌으로써 reference count를 1 증가시켜서 weak var에 들어가더라도 할당해제가 되지 않게끔 해주는구나…!”

 

그리고..

 

“Swinject도 우리와 동일한 문제가 있었구나..!”

 

Swinject또한 마찬가지로 register를 하면서 weak var에 객체를 넣게되면 객체가 바로 nil이 된다라는 것을 알고 그 문제를 위와 같은 방법으로 해결한 것이었습니다.

 

곧바로 적용해봅니다.

 

instance: Any? 하나의 프로퍼티가 아닌 배열로 만든 이유는 처음에 동일하게 하나의 프로퍼티로 해보니, 처음 에러보다 더 뒤쪽에서 resolve를 했을 때 동일한 nil에러가 나와서 살펴봤더니 저희는 싱글톤이기 때문에 해당 값이 register를 할 때마다 새로 덮어씌워졌었기에 모든 객체에 대한 참조를 유지하기 위해서 배열에 담았습니다.

 

한번 다시 실행시켜보겠습니다.

성공

이번에는 deinit이 어디에도 뜨지 않고 잘 넘어가고 완벽히 돌아갑니다..!

한가지 해결책을 훔치는 데 성공합니다..! 하지만 여기서 끝이 날 일이 없죠.

 

한가지 의문이 남습니다.

 

딕셔너리의 value인 Weak구조체의 weak var로 선언된 변수에 넣기 위해서 강제로 rc를 1을 증가시켰다면 결국 해당 객체의 rc는 1로 항상 남을 테고, 메모리에서 할당해제 되지 않는건 똑같지 않나..??
이걸 어떻게 할당해제 시켜주는거지??🤔🤔🤔

 

 

2. 객체를 weak하게 관리하기 위한 별도의 로직

swinject만의 reference count, resolutionDepth🥷

의문을 가지고 다시 swinject를 돌아다니다 문득 눈걸음을 멈추게 된 지점이 있습니다.

바로 resolutionDepth 라는 Int변수를 +1 혹은 -1 시켜주는 로직입니다.

그리고 resolutionDepth가 0이 된 순간에 graphResolutionCompleted()메서드를 호출하는데, 이는 instancenil로 바꿔버리는, 즉 할당 해제와 같은 역할을 수행하는 작업을 합니다.

 

이 때부터 어느정도 확신이 들기 시작했고, fatalError의 에러 메시지를 보면 해당 resolutionDepth가 maxResolutionDepth인 200을 넘어가게 되면 이는 순환 참조가 일어난 것이다! 라고 합니다.

 

이런 단서들을 보아 아래와 같이 생각합니다.

“swinject에서는 reference count와 비슷한 역할을 하는 resolutionDepth라는 것을 두어 해당 변수를 통해서 별도로 할당 해제를 관리하는 구나 !”

그렇다면 이 메서드들을 사용하는 곳은 단연코 incrementregister, decrementresolve겠거니 하고 registerresolve메서드를 찾으러 또 떠납니다.

3. register, resolve에서의 resolutionDepth

Swinject - register메서드

?.?

 

register에 있을거라 생각했던 incrementResolutionDepth가 보이지 않았습니다.

 

제 생각으로는 register를 하게 되면 결국 최종적으로 register하고자 하는 객체는 Weak구조체의 weak var변수로 들어가게 됩니다. weak로 선언했기에 rc는 당연히 늘어나지 않습니다. resolutionDepth의 역할도 rc와 비슷하다면 resolutionDepth 또한 값을 1 증가시킬 필요가 전혀 없는 것이라 생각했습니다.

swinject - resolve메서드

resolve에는 decrement를 쓰고 있습니다. 하지만 increment도 같이 쓰고 있는 모습을 볼 수 있었습니다.

여기서는 두가지 추측이 존재했습니다.

1. resolution에 클로저가 들어가고 클로저는 capture를 하기 때문에, 이에 따라 rc도 증가하고 클로저가 종료되는 시점에 rc는 줄어듭니다. 따라서 rc와 resolutionDepth를 synchronize시켜주기 위해서 똑같이 +1 그리고 defer를 통해 종료 직전에 -1을 시켜준다! 라는 추측
2. resolve를 하게되면서 얻게 된 객체는 외부에 변수에 할당되면서 reference count가 증가하게 되면서 이제 더 이상 container에서 관리를 해줄 필요가 없기 때문에 다시 -1을 시켜주면서 0이되면 딕셔너리에 nil을 넣어 할당해주게 되고, 이 후 resolve되어서 나간 객체는 이제 정상적으로 온전히 rc에 의해서만 관리될 것이기 때문에 자연스럽게 메모리에서 할당해제 될 것이다! 라는 추측

 

이렇게 swinject의 register / resolve에서의 주요 로직들을 살펴보았고, 내부 동작의 흐름을 어느정도는 이해할 수 있었습니다.


3일 동안 길고 길었던 삽질이 그래도 막을 내렸습니다.

 

처음에는 프로젝트에 우리만의 DI Container를 만들어서 적용해보는 것이 목표였으나 구현을 하면 할수록 벽에 계속 부딫혀서 swinject로 가야겠다 생각은 하고 있었지만, 그 내부 로직도 잘 모른채 쓴다면 그저 코드를 가져다 쓰는 것이라 생각했습니다.

그래도 내부를 들여다보면서 swinject에 대한 이해를 높일 수 있었고, 그래도 지금은 swinject를 쓰더라도 근거를 가지고 쓸 수 있을 것 같아 마음은 놓입니다.

 

다만 좀 신기했던 점은 swinject는 정말 큰 테크회사들도 사용하는 라이브러리라고 알고 있었는데, 제가 이해한 바로는! 이렇게 resolutionDepth같은 Int변수를 직접 handling하면서 그에 따른 작업을 수행한다 점이였습니다. 이렇게 수동으로 관리를 해줄려면 정말 한치의 실수도 없고 모든 예외 상황을 고려했어야할텐데, 그 과정이 정말 힘들지 않았을까 라는 생각도 들고, 그걸 결국 해내서 저런 라이브러리를 만들어냈다는 것이 정말 대단한 것 같습니다.

 

Ref


https://medium.com/sahibinden-technology/dependency-injection-container-in-swift-89392a309532

https://medium.com/@juliusfischer/dependency-injection-in-ios-with-swinject-f5ffb019006d

https://medium.com/@jang.wangsu/di-dependency-injection-이란-1b12fdefec4f

https://www.youtube.com/watch?v=h2FBZcLBeq0

댓글