SWInject를 구현해서 사용하자!

kio·2023년 6월 15일
0

Project

목록 보기
2/2
post-thumbnail

왜 import 안하고 구현해서 사용한거지?

우선 우리프로젝트는 처음에 완전히 외부 라이브러리를 사용하지 않는 것을 목표로 했다.
(외부 라이브러리라는 말은 조금 그렇지만 애플에서 만든 MusicKit, MapKit은 사용했다. 즉 애플이 만들지 않은 것은 사용하지 않기로 한것이다.)

이걸 기반으로 Clean Architecture를 적용했다. 그러다 보니 특정 한 곳에서 동시 의존성을 주입하는 것이 좋다고 생각했다.


왜 의존성을 한 곳에서 주입해야 된 건데...

이 말에 앞서서 의존성 주입에 대해 얘기를 해야할 것 같다.

class Dog {
	var name: String 

	init(){
		self.name = "kio"
	}
}

let myDog = Dog()
print(myDog.name)

이렇게 되면 우리는 어려움을 겪을 수 있다.
Dog의 이름을 바꾸기 위해서 내부에 코드를 변경해야한다. ( 기본값을 변경하려면 )
하지만 인스턴스를 만들 때 class 외부에서 프로퍼티들을 초기화하게 된다면

class Dog {
	var name: String 

	init(name: String){
		self.name = name
	}
}

let myDog = Dog(name: "kio")
print(myDog.name)

class의 변경없이도 충분히 가능하다.
이렇게 class 외부에서 class가 의존관계를 맺게하는 것을 의존성 주입이라고 한다.
여기서 한단계 더 나아가서 SOLIDDIP 즉, 의존성 역전 법칙을 얘기해보자

만약 말고도 Cat을 만든다고 하면 우리는 class가 어떤 프로퍼티를 가져야하는지 모른다.
또 Cat은 Dog와 달라서 안에 구현체의 내용은 다를 수 있다. 하지만 기본적으로 Pet이라는 것은 변함이 없다.
즉 변하기 쉬운것과 변하지 않는 것을 나누고, 변하지 않는 것은 추상화 시키고 class가 높은 추상화 레벨에 의존하는 것이 DIP이다.

protocol Pet {
	var name: String { get }
}

class Dog: Pet {
	var name: String 

	init(name: String){
		self.name = name
	}
}

class Cat: Pet {
	var name: String 

	init(name: String){
		self.name = name
	}
}

이렇게 의존성주입DIP에 대해 알아 봤다.
그럼 왜 이것들을 한곳에서 관리하는 것이 좋을까?

1. 교체가 쉽다.

해당 프로토콜에 맞는 인스턴스에 대한 교체가 한 곳에서 일어나게 되면 모두 같이 적용되기 때문에 교체가 편하다.

2. 테스트가 용이하다.

실제 테스트환경이라기 보다 새로 개발한 모듈을 실험하기 편하다.


이러한 이유로 SWInject가 필요했다.

1. SWInject? 느낌이..

느낌이 뭔가 static type dictionary 같다는 느낌이 들었다.
다양한 기능이 있지만, key가 프로토콜 그 자체이고, value가 인스턴스인 static dictionay라는 느낌이 있었다.
또 우리는 SwiftUI를 사용하고 있었기 때문에 Binding이나 State처럼 특정 View에 다른 View가 한 변수에 의존하고 있어서 View를 외부에서 의존성 주입하기 힘들었다. (방법이 있나요? 아니면 보통 어떻게 하시나요?...)

그래서 View들은 build라는 함수를 사용해서 특정 class에서 단일 인스턴스를 생성해서 넘겨주는 방식을 사용했습니다.

쨋든 Domain이나 Repository에 있는 interface들을 dictionary 처럼 다루면 SWInject를 사용하는 것과 똑같다고 생각했다.

나중에 알게 된것

public final class Container {
    internal var services = [ServiceKey: ServiceEntryProtocol]()
    ...

	 @discardableResult
    // swiftlint:disable:next identifier_name
    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
    }

	...
}

실제로도 비슷하게 구현되어 있더라구여... 괜히 뿌듯... 물론 생각도 못한 부분이 있어지만...

구현

우선 Type Dictionary의 구현이 1순위였습니다.

struct TypeDictionary {
    private var dictionary: [ObjectIdentifier: (Any)] = [:]
        
    subscript<T>(key: T.Type) -> Any? {
        get {
            return dictionary[ObjectIdentifier(key)]
        }
        set {
            dictionary[ObjectIdentifier(key)] = newValue
        }
    }
}

우선 프로토콜의 키 값으로 쓰기 위해서 각 프로토콜의 ObjectIdentifier를 사용해야 되겠거니 했다.

이것을 메타타입 조차도 unique아이디가 있기때문에 충분히 가능하다고 생각했다.

그 다음부터는 쉽다.

final class MappinDIContainer {

    var resolver = Resolver()
    
    struct Resolver {
        
        var DIDictionary = TypeDictionary()
        
        func resolve<T>(_ type: T.Type) -> T {
            return DIDictionary[type.self] as! T
        }
        
    }
    
    func register<T>(_ type: T.Type, _ completion: @escaping (Resolver) -> T) {
        let result = completion(self.resolver)
        
        resolver.DIDictionary[type.self] = result
    }
}

생각했던 것 ..

저기 resolve 함수를 보면 강제로 타입캐스팅을 하고있다.
저기서 많은 고민을 했다.

return 타입을 옵셔널로 해야하나?

이 문제도 오래 고민했다. 외부에서 DI를 했는데 그곳을 사용하는 곳에서 옵셔널이다.....
해당 인스턴스가 있다는 가정을 한다는 것부터 이상했다. 당연히 해당 interface는 주입이 되어있는게 기본으로 가야하는거 아닌가....
그렇다고 강제로 하기엔 앱이 죽을 수도 있는데...
만약 내가 프로젝트의 규모가 컸다면 다음과 같은 방법을 사용했을 것 같다.

주입시에 반드시 dummy 인스턴스를 받는다
위와 같은 방법을 쓸것 같다. 그렇다고 옵셔널은 앱에 영향을 줄거 같기 때문에 dummy instance를 받는 것이 맞는 것 같다.
상당히 귀찮긴 하지만 이게 최선의 방법이었을것 같다. 반드시 넣었다고 할 순 없으니...

내부에서 캐스팅하는 것이 맞을까?

처음엔 싱글톤 패턴처럼 만들려고했지만 SWInject는 실제로 그렇게 되어있지 않기 때문에 private init은 포기했다. 그 때 클라이언트도 쉽게 사용하기 위해선 내부 캐스팅이 맞다고 생각했고, 여전히 주입은 클라이언트가 했으니 잘못된다면 클라이언트의 책임이 되야된다고 생각했다.(근데 여기서 클라이언트도 나다...)

느낀점..

항상 open source liabrary에 대한 동경이 있었다. 나도 엄청 유명한 open source를 만들어 보고 싶다? 라는 꿈이 있었어서 유명한 open source는 까볼려고 노력한다. 이번에 우연히 적용하게 되었지만 급해서 나중에 공부하긴 했지만 이렇게 적용되니 신기했고 DI를 이번 기회에 해본것 같아 좋았다.
무엇보다 DI의 장점을 느끼게 될 수 있는다는 점이 가장 큰 수확이 아닐까 한다..

0개의 댓글