먼저 DI 란 무엇일까요?
Dependency Injection의 약자로, 의존성 주입이라는 뜻을 가지고 있습니다.
그렇다면 의존성 + 주입 은 무슨 뜻을 가지고 있을까요?
먼저 의존성 에 관한 하나의 예시를 보겠습니다.
import Foundation
class A {
var name: String = "Im A"
func description() {
print("나는 A입니다")
}
}
class B {
var aClass: A = A()
func description() {
print(aClass.name)
}
}
현재 위의 코드에서는 B 클래스가 A 클래스에 의존하고 있다고 볼 수 있습니다. 왜 그렇게 볼 수 있을까요?
aClass 라는 저장 프로퍼티가 A 클래스를 참조하고 있는데, 이 경우에서 A 클래스의 내부가 변경된다면, B 클래스도 변경되어야 하는 경우가 생깁니다.
name 이라는 저장 프로퍼티의 이름 자체가 변경된다면 B 클래스에서도 사용하고 있는 aClass.name 이 변경되어야 하기 때문에 의존적이라고 할 수 있습니다.
즉 의존관계를 맺는다면 어떤 객체의 변화가 발생할 때, 영향을 받을 수 있습니다.
다음은 주입 에 대한 예시입니다.
주입이라는 말을 어렵게 생각할 수 있지만, 실제로 주입의 정의는 외부에서 값을 넣어준다는 간단한 개념입니다.
class A {
var bClass: B
init(bClass: B) {
self.bClass = bClass
}
}
var a = A(bClass : B()) // 외부에서 주입!!
예시에서 다음과 같이 B 인스턴스를 외부에서 주입하고 있습니다.
여기서 외부에서 주입하고 있다는 뜻은 A 객체 내부에서 값을 생성하고 있지 않다는 의미입니다.
여기까지가 의존성, 주입 개념입니다.
아쉽게도 이 개념만으로는 DI 에 대해서 이해했다고 보기 어렵습니다.
지금까지 얘기했던 DI 는 의존관계에서 큰 단점을 가지고 있습니다.
- 변화에 대응하기 어렵다.
- 결합도가 높다.
- 테스트가 어렵다.
해당 문제를 개선하기 위해서 우리는 의존성을 역전시켜야 할 필요가 있습니다.
🔥 Dependency Inversion (의존성 역전)
이것까지 적용했다면 그제서야 DI 를 적용했다고 얘기할 수 있게 됩니다.
그렇다면 어떻게 의존성을 역전시킬 수 있을까요?
Swift에서 일반적으로 사용하는 방법은 Protocol 입니다.
protocol Animal {
var name: String { get set }
}
class Cat: Animal {
var name: String = "Cat"
}
class Dog: Animal {
var name: String = "Dog"
}
class MyPet {
var animal: Animal
init(animal: Animal) { // 프로토콜 타입으로 선언
self.animal = animal
}
func change(animal: Animal) { // 메서드로 동물을 바꾸는 구현도 가능
self.animal = animal
}
func printName() {
print(animal.name)
}
}
var myPet = MyPet(animal: Dog())
여기서 어떤 부분이 의존성을 역전한걸까요?
이전과 달리, 프로토콜을 통해 업캐스팅을 해서 인스턴스를 보관합니다.
여기서 얻는 이점은 Dog, Cat 객체의 내부적인 구현에 대해서 알 필요가 없어집니다.
내부적인 구현에 대해서 제약을 받지 않기 때문에 MyPet 객체는 외부적인 의존 관계로부터 자유로워집니다.
이것을 느슨해진 의존관계라고 부릅니다.
또한 Dog 와 Cat 객체가 Animal 프로토콜에 의존하는 관계가 생기기 때문에 이것을 바로 의존성 역전이라고 부릅니다.

이렇게 의존성을 역전하게 되면 선언부만 의존하게 되어서 결합도가 낮아지고, 변화에 비교적 자유로워집니다. 필요한 경우 Mock 객체를 주입하여 테스트도 가능해집니다.
여기까지 오면 이제 DI 에 대해 모두 이야기했습니다.
그렇다면 이제 DI Container인데, 먼저 왜 DI Container를 사용할까요?
사이드 프로젝트를 하면서 마주했던 문제를 가져와봤습니다.

위의 예시 코드는 Coordinator 객체의 구현부이며, 화면 전환의 책임을 담당하고 있습니다.
화면 전환이 시작될 때 start() 메소드가 호출되고 Repository를 생성하고, ViewModel에 의존성을 주입하고 있습니다.
즉, 화면 전환을 담당하는 Coordinator 객체가 DataRepository 를 강하게 의존합니다.
아까 의존 관계에서의 문제점이 고스란히 이어집니다.
- 책임이 번진다.
- 테스트성이 저하된다.
- 의존한 객체의 변경에 영향을 받을 수 있다.
해당 문제를 해결하기 위해서 ViewModel 에서 직접 주입하도록 변경해봤습니다.
init(repository: DataRepositoryInterface = DataRepository())

결과적으로 Coordinator 객체에서 DataRepository 의존성을 제거할 수 있습니다.
하지만 또 다른 문제가 발생했습니다. 이제는 ViewModel 에서 DataRepository 를 의존합니다.
그 과정에서 외부에서 값을 넣어주는 주입 의 의미도 함께 사라졌습니다.
그렇다면 어디서 DataRepository 를 생성해야 할까요?
우리는 이러한 책임 문제로부터의 하나의 해결책으로 DI Container를 사용합니다.
아까 DI 에서 알아봤었던 주입 을 기억하시나요? 주입 의 의미는 다음과 같았습니다.
외부에서 값을 넣어준다.
DI Container는 객체의 주입을 도와주는 컨테이너입니다. DI Container를 사용하여 객체나 레이어간의 책임 문제를 해결할 수 있도록 도와줍니다.
DI Container를 통해 어떻게 DataRepository 를 외부에서 주입하는지 보겠습니다.
public class DIContainer {
static let shared = DIContainer()
private init() {}
private var dictionaries: [String : Any] = [:]
public func register<T>(key: T.Type, value: T) {
dictionaries[String(describing: key)] = value
}
public func resolve<T>(key: T.Type) throws -> T {
guard let value = dictionaries[String(describing: key)] as? T else {
throw "\(String(describing: key) Failed Resolve"
}
return value
}
}
// SceneDelegate.swift
private func registerRepository() {
DIContainer.shared.register(key: DataRepository.self, value: DataRepository())
DIContainer.shared.register(key: MockDataRepository.self, value: MockDataRepository())
}
DIContainer 에 DataRepository 의 인스턴스를 저장해둡니다.
그런 다음에는 GraphViewModel 의 인스턴스를 DIContainer 에 저장합니다.
// SceneDelegate.swift
private func registerViewModel() {
let repository = try DIContainer.shared.resolve(key: DataRepository.self)
DIContainer.shared.register(key: GraphViewModel.self, value: GraphViewModel(repository: repository))
}
하지만 여기서 문제가 발생합니다. 어떤 문제가 발생할 수 있을까요?
DIContainer가 GraphViewModel을 계속해서 잡고 있어서 메모리에서 해제가 되지 않습니다.
DI Container 는 싱글톤 객체로 수명주기가 앱이 종료될 때까지 살아있게 됩니다. 그렇다면 DI Container 에서 저장하고 있는 GraphViewModel 도 GraphViewController 의 생명주기와 관계없이 앱이 종료될 때까지 계속해서 살아있게 됩니다. 이것은 곧 메모리 누수로 이어질 수 있습니다.
그렇기 때문에 DI Container 를 사용할 때는 메모리 누수가 발생하지 않는지 생각해봐야 합니다.
ViewModel 의 생성을 도와주는 팩토리 객체를 생성하여 DI Container 에 저장을 해두는 방법을 사용할 수 있습니다.
final class GraphViewModelFactory {
private let repository: DataRepositoryInterface
init(repository: DataRepositoryInterface) {
self.repository = repository
}
func createViewModel() -> GraphViewModel {
return GraphViewModel(repository: repository)
}
}
private func registerViewModel() {
let repository = try DIContainer.shared.resolve(key: DataRepository.self)
DIContainer.shared.register(key: GraphViewModelFactory.self, value: GraphViewModelFactory(repository: repository))
}
이런식으로 ViewModel 의 생성을 담당하는 객체를 따로 만들어서 책임을 분리할 수 있습니다.
이제는 Coordinator 다음과 같이 DataRepository 에 의존하지 않고 ViewModel 을 생성할 수 있게 되었습니다.

DI Container 는 주입 에 집중합니다. DI Container 를 쓴다고 해서 DI 에서 흔히 이야기하는 의존성 역전이 자동으로 적용되지 않습니다. 그리고 DI Container 를 사용했을 때 코드가 기하급수적으로 증가한다는 단점도 있습니다.
하지만 위에서 보다시피 외부에서 주입을 도와줌으로써 책임 문제를 해결하고 결합도를 낮출 수 있습니다.
그렇기 때문에 상황을 잘 저울질하여 적용할 필요가 있습니다. DI 도 마찬가지입니다. 분명한 이점이 있지만, 코드의 양이 크게 증가할 수도 있습니다.
현재 제가 사용한 DIContainer 도 오버 엔지니어링이라고 생각하지만, 또 그것이 개인 프로젝트의 맛이기에..
보다 자세한 예시 코드를 위해서 Github 링크를 남겨두겠습니다!
참고자료
https://velog.io/@heyksw/Swift-DI-%EC%99%80-Swinject
https://medium.com/@jang.wangsu/di-dependency-injection-%EC%9D%B4%EB%9E%80-1b12fdefec4f