💡 Intro
DI 미션 1단계에 이어 2, 3단계에 대한 글이다. DI 미션 1단계는 레아가 수업에서 라이브 코딩으로 다뤄주셔서 거지 딱다구리 같은 나의 코드들을 분리수거 해버리고 수업에서 진행했던 코드들을 되새김질 하며 많이 인용했다. 아니 거의 똑같이 구현했다.
2, 3단계는 그런 것도 없다. 그래서 또 힘들었고(이 양반은 같은 미션에서도 단계마다 어렵다고 하고 있네-_-), 리뷰를 받고 반영하면서 현재 구조가 문제가 많이 있음을 깨달았고, 구조를 한 번 뒤집어 엎었다.
한 번 들어가보자.
❗️ 만들면서 배우는 DI 2단계
2단계 요구 사항은 다음과 같다.
필드 주입
- ViewModel 내 필드 주입을 구현한다.
Annotation
다음 문제점을 해결한다.
- 의존성 주입이 필요한 필드와 그렇지 않은 필드를 구분할 수 없다.
- Annotation을 붙여서 필요한 요소에만 의존성을 주입한다.
- 내가 만든 의존성 라이브러리가 제대로 작동하는지 테스트 코드를 작성한다.
Recursive DI
- CartRepository가 다음과 같이 DAO 객체를 참조하도록 변경한다.
- CartProductEntity에는 createdAt 프로퍼티가 있어서 언제 장바구니에 상품이 담겼는지를 알 수 있다.
- CartProductViewHolder의 bind 함수에 다음 구문을 추가하여 뷰에서도 날짜 정보를 확인할 수 있도록 한다.
하나씩 파훼해보자.
1️⃣ Recursive DI : CartRepository가 DAO 객체를 참조하도록 변경한다.
원래 Repository들은 생성자를 호출하면서 넣어주는 값이 없었다. 그래서 뷰모델을 만들 때 필요한 Repository를 RepositoryContainer에 요청하고 없다면 createInstance해서 가져오는 방식으로 진행했다. 그런데 이번 미션에서는 상황이 달라졌다. RepositoryContainer 요청했는데 없다면 바로 createInstance를 바로 하는 것이 아니라 RepositoryContainer에 인스턴스를 요청한 클래스도 생성자를 호출하면서 넣어주는 값이 있는지 확인해야 한다.
즉, 재귀적인 의존성 주입이 필요해진다.
// Before
class DefaultCartRepository() : CartRepository { ... }
// After
class DefaultCartRepository(
private val dao: CartProductDao,
) : CartRepository {
...
}
일단 요구사항처럼 CartRepository 구현체가 CartProductDao를 참조하도록 변경한다. 이 때 CartProductDao는 Room DB를 사용하지만 이 글에서 그것은 중요한 것이 아니기 때문에 이렇게만 설명하고 넘어가도록 하겠다.
object Container {
private val repositories = mutableMapOf<KClass<*>, Any>()
fun getInstance(type: KClass<*>): Any? {
return repositories[type]
}
fun addInstance(type: KClass<*>, instance: Any) {
repositories[type] = instance
}
fun clear() {
repositories.clear()
}
}
먼저 원래 Container의 이름을 RepositoryContainer에서 Container로 수정했다. 1단계를 진행할 때 인자로 넣어줘야 하는 값이 Repository밖에 없었고 이는 곧 Container에서 관리 인스턴스들이 Repository들 뿐이라는 말과 같다. 그래서 이름을 RepositoryContainer로 지었는데, 2단계를 들어오면서 Dao도 저장될 것이고 앞으로도 다른 것들이 저장될 수도 있다는 생각에 Container로 선택했다.
그리고 다음에 설명을 하겠지만 Container에게 요청하는 클래스도 생성자로 호출할 때 인자를 필요로 한다면, Container에서 바로 인스턴스를 생성할 수 없고, 다르게 처리를 해줘야 하기 때문에 getInstance() 메서드의 반환값을 nullable하게 변경했다.
object Injector {
// klass의 인스턴스를 생성하여 반환한다
fun <T : Any> inject(klass: KClass<*>): T {
// 1. Container에 인자로 넘겨준 클래스의 인스턴스가 존재하는지 확인한다
val instance = Container.getInstance(klass)
// 2. Container에 존재하면 바로 반환한다
if (instance != null) {
return instance as T
}
// 3. Container에 존재하지 않으면 인스턴스를 생성한다
return createInstance(klass)
}
private fun <T> createInstance(klass: KClass<*>): T {
// 주생성자를 가져온다
val primaryConstructor = klass.primaryConstructor ?: throw NullPointerException("주 생성자가 없습니다.")
// 주생성자의 인자들을 인스턴스화 시킨다
// Container에 있는 경우 바로 가져오고 없다면 인스턴스를 생성한다
val insertedParameters = parameters.associateWith {
val type = it.type.jvmErasure
Container.getInstance(type) ?: inject(type)
}
return primaryConstructor.callBy(insertedParameters) as T
}
}
재귀적인 DI를 위한 Injector object를 만들었다.
inject 메서드는 파라미터로 넘어온 KClass를 인스턴스화 해서 반환하는 기능을 수행한다. 만약 파라미터로 넘어온 KClass가 Container에 존재한다면(이전에 만들어진 적이 있다면 Container에 저장하기 때문에 가져올 수 있다.) 가져와서 바로 반환해준다. 그렇지 않다면 아래 있는 createInstance 메서드를 실행하여 인스턴스를 생성한다.
createInstance 메서드는 KClass의 주생성자를 가져오고 주생성자에 들어가는 파라미터를 확인한다. 파라미터들을 하나씩 확인하면서 원하는 파라미터가 Container에 있는지 확인하고, 없다면 inject 메서드를 다시 실행시킨다. 이를 통해 생성자 파라미터로 들어오는 인스턴스들을 컨테이너에서 가져오거나, 재귀적으로 생성하여 채워넣고 결과적으로는 내가 요청했던 클래스가 반환된다.
여기서, 재귀라는 것은 눈에 보이지 않기 때문에 이해가 어려울 수 있으니 어떻게 돌아가는 것인지 봐보자.
아래 표(?)처럼 MainViewModel을 만들기 위해서는 ProductRepository와 CartRepository가 필요하고 CartRepository를 만들기 위해서는 CartDao가 필요하다.
inject(MainViewModel) → createInstance(MainViewModel)
→ inject(ProductRepository) → createInstance(ProductRepository) → ProductRepository 반환
→ inject(CartRepository) → createInstance(CartRepository) → Container.getInstance(CartDao) → CartRepository 반환
→ MainViewModel 반환!
그리고 Injector는 위처럼 재귀적으로 메서드들을 호출하여 원하는 인스턴스를 생성하여 반환해준다.
하지만 이러한 구조는 재귀의 가장 아래 있는 인스턴스가 Container에 미리 저장되어 있어야 하는 구조다. 다시 말하면 MainViewModel을 만들기 위해 ProductRepository, CartRepository가 필요하고, CartProductRepository를 만들기 위해 CartDao가 필요하기 때문에 Container에는 CartDao가 이미 저장되어 있는 상태여야만 재귀적으로 인스턴스를 생성할 수 있다.
2️⃣ Annotation : Annotation을 붙여서 필요한 요소에만 의존성을 주입한다.
annotation class Inject
@Inject라는 이름의 애노테이션 클래스를 만들어주었다. 이 애노테이션 클래스 자체가 직접 하는 일은 없다.
class MainViewModel(
@Inject private val productRepository: ProductRepository,
@Inject private val cartRepository: CartRepository,
) : ViewModel()
class CartViewModel(
@Inject private val cartRepository: CartRepository,
) : ViewModel()
ViewModel에 생성자에 들어가는 파라미터에 @Inject 애노테이션을 붙여주어 해당 파라미터에 의존성을 주입해줘야 한다고 명시해주었다. 실제로 주입해줄 때 @Inject 애노테이션이 붙은 파라미터들만 찾도록 로직을 구현하면 주입이 필요한 요소에만 의존성을 주입해주도록 구현할 수 있다.
아까 본 Injector를 다시 보자.
object Injector {
// klass의 인스턴스를 생성하여 반환한다
fun <T : Any> inject(klass: KClass<*>): T {
// 1. Container에 인자로 넘겨준 클래스의 인스턴스가 존재하는지 확인한다
val instance = Container.getInstance(klass)
// 2. 존재하면 바로 반환한다
if (instance != null) {
return instance as T
}
// 3. 존재하지 않으면 인스턴스를 생성한다
return createInstance(klass)
}
private fun <T> createInstance(klass: KClass<*>): T {
// 주생성자를 가져온다
val primaryConstructor =
klass.primaryConstructor ?: throw NullPointerException("주 생성자가 없습니다.")
// 인자들 중 ConstructorInject 어노테이션 붙은 인자들만 가져온다
val parameters =
primaryConstructor.parameters.filter { it.hasAnnotation<Inject>() }
// 주생성자의 인자들을 인스턴스화 시킨다
// Container에 있는 경우 바로 가져오고 없다면 인스턴스를 생성한다
val insertedParameters = parameters.associateWith {
val type = it.type.jvmErasure
Container.getInstance(type) ?: inject(type)
}
return primaryConstructor.callBy(insertedParameters) as T
}
}
전체코드는 위와 같은데 제거하고, 달라진 부분만 보면 다음과 같다.
// 인자들 중 ConstructorInject 어노테이션 붙은 인자들만 가져온다
val parameters = primaryConstructor.parameters.filter { it.hasAnnotation<Inject>() }
리플랙션을 사용하면 주생성자의 파라미터가 특정한 어노테이션을 가지고 있는지도 확인할 수 있다. (리플랙션이 흑마법이라고 불리는데는 다 이유가 있다.) 이것을 이용하여 모든 파라미터를 주입해주었던 기존 방식에서 @Inject 애노테이션이 붙은 파라미터들만 찾아 주입해줄 수 있도록 filter를 걸었다.
3️⃣ 필드 주입 : ViewModel 내 필드 주입을 구현한다.
이 부분은 어떤 것을 해야 하는 것인지, 무엇을 위한 요구사항인지 잘 이해하지 못했다. 그래서 이게 뭐지..? 뭘 원하는 것이지..? 어떻게 해야 하는가 많이 고민했다. 꽤나 오래 고민을 했지만 결과에는 그 고민을 많이 담지는 못했던 것 같다. (어쩌면 에잇 몰라! 하고 내버린 것일지도..)
class MainViewModel(
@Inject private val cartRepository: CartRepository,
) : ViewModel() {
lateinit var productRepository: ProductRepository
// MainActivity
private val viewModel by lazy {
ViewModelProvider(this, ViewModelFactory)[MainViewModel::class.java]
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
viewModel.productRepository = Injector.inject(ProductRepository::class)
...
}
그냥 MainViewModel의 productRepository를 lateinit var로 선언해두어 외부에서 변경 가능하도록 했고 그 당시의 나는 이것으로 충분하다고 생각했다. 하지만 어림도 없는 소리였다. 이에 대한 내용은 다음 글에서 추가로 설명하도록 하겠다.
❗️ 만들면서 배우는 DI 3단계
Qualifier
다음 문제점을 해결한다.
- 하나의 인터페이스의 여러 구현체가 DI 컨테이너에 등록된 경우, 어떤 의존성을 가져와야 할지 알 수 없다.
- 상황에 따라 개발자가 Room DB 의존성을 주입받을지, In-Memory 의존성을 주입받을지 선택할 수 있다.
모듈 분리
- 내가 만든 DI 라이브러리를 모듈로 분리한다.
3단계도 부숴보자. (어라 근데 지금보니까 큰 놈은 하나 뿐이었네.. 왜 그렇게 어려웠던걸까..?)
1️⃣ Qualifier : 하나의 인터페이스의 여러 구현체가 DI 컨테이너에 등록된 경우, 어떤 의존성을 가져와야 할지 알 수 없다.
1단계를 진행하고 받았던 리뷰이자 고민하고 있던 것이다. 어떻게 구현해야 할지 감이 오지 않았다. 마침 강의 자료에 아래와 같이 힌트로 Hilt의 구현 예시를 첨부해주셨고(힐트 자료를 힌트로..🥸) 좀 많이 참고했다.
위 자료에는 다양한 애노테이션이 있지만 내가 집중해서 본 애노테이션은 @InMemoryLogger, @DatabaseLogger다. 그리고 각각의 애노테이션이 정의된 곳을 보면 @Qualifier가 달려있는 것을 볼 수 있다.
그래서 아! @Qualifier가 달려있는 애노테이션이 있네? 애노테이션에 따라 구분을 할 수 있겠구나 하면서 힌트를 얻었다.
class InMemoryCartRepository() : CartRepository
class DatabaseCartRepository(
@Inject private val dao: CartProductDao,
) : CartRepository
Qualifier를 진행하기 앞서 Qualifier가 필요한 상황부터 만들어야 했고, 그것 또한 요구사항에 포함되어 있었다. CartRepository를 InMemory방식, Room Database를 사용하는 방식으로 구현하는 구현체를 각각 만들었다. 앞 단계의 DefaultCartRepository가 DatabaseCartRepository로 바뀌었고, InMemoryCartRepository를 추가로 만들었다. InMemoryCartRepository는 이름에서도 알 수 있듯이 데이터를 저장하는 곳이 메모리인 CartRepository 구현체다.(앱을 껐다켜면 데이터가 모두 날아간다.)
annotation class Qualifier(val name: String)
@Qualifier 클래스를 만들고, String을 갖고 있게 하고 hasAnnotation 혹은 findAnnotation 메서드를 통해 Qualifier를 찾은 뒤 이것의 name에 따라 구분하여 관리할 수 있겠다고 생각했다.
@Qualifier("InMemoryCartRepository")
class InMemoryCartRepository() : CartRepository
@Qualifier("DatabaseCartRepository")
class DatabaseCartRepository(
@Inject private val dao: CartProductDao,
) : CartRepository
이 애노테이션을 각각의 구현체에 붙였고, 클래스의 이름을 그대로 name에 넣어주었다.
object Container {
private val repositories = mutableMapOf<KClass<*>, Any>()
...
}
Container는 하나의 맵을 가지고 있고, 키 값으로는 KClass를, value로는 인스턴스를 갖는다.
앞에서 언급한 InMemoryCartRepository, DatabaseRepository 두 개의 클래스는 모두 CartRepository 인터페이스를 구현하고 있기 때문에 Container에 넣을 때 사용하는 키 값이 동일하다. 두 개의 인스턴스를 Container에 저장할 수 없기 때문에 Container에 저장하는 방봅 또한 적절하게 수정해야 했다.
object Container {
private val nonAnnotationMap = mutableMapOf<KClass<*>, Any>()
private val annotationMap = mutableMapOf<String, Any>()
const val defaultQualifier = ""
...
}
키 값에 대해 value를 인스턴스의 리스트로 관리하면 하나의 맵에서도 관리가 가능할 것 같았으나 맵 안에 리스트를 추가하는 것이 좋은 방법이라 생각하지 않아서 다른 방법을 찾아보았다. 그렇게 생각한 이유는 일단 List의 탐색을 위해서는 완전 탐색을 진행해야 하고, Map 안에 List의 자료구조를 넣는 것이 과연 적절한가? 였다. (원래 Map안에 Map을 넣으려다가 이건 진짜 아니라고 생각해서 접었다.)
Container에서 어떻게 관리할 것인가에 대한 지속적인 고민의 결과는 Qualifier 애노테이션이 붙은 클래스의 인스턴스들과 Qualifier 애노테이션이 붙지 않은 인스턴스들을 다른 맵에서 관리하는 방식이었다.
Container는 annotationMap, nonAnnotationMap을 갖고 있도록 했고, Qualifier 애노테이션이 붙지 않은 인스턴스들은 nonAnnotationMap에 저장하고, Qualifier 애노테이션이 붙은 인스턴스들은 annotationMap에 저장하도록 했다.
annotationMap에는 Qualifier 애노테이션이 붙은 인스턴스만 들어올 것이기 때문에 Qualifier가 가지고 있던 String인 name을 가져와 키 값으로 사용하고 value에 인스턴스를 넣어주었다.
object Container {
...
fun addInstance(type: KClass<*>, instance: Any) {
// 어노테이션이 있으면 Annotation맵에 저장
if (instance::class.hasAnnotation<Qualifier>()) {
val qualifier = instance::class.findAnnotation<Qualifier>()
val qualifierName = qualifier?.name ?: throw NullPointerException("Qualifier 이름이 없습니다.")
// getInstance를 가능하게 하기 위해 Qualifier가 있는 경우 Default 값 저장
nonAnnotationMap[type] = defaultQualifier
annotationMap[qualifierName] = instance
} else {
// 어노테이션이 없으면 nonAnnotationMap에 저장
nonAnnotationMap[type] = instance
}
}
...
}
그리고 인스턴스를 추가하는 로직은 다음과 같다.
가장 먼저 Qualifier 애노테이션이 붙어있는지 확인하고, 붙어있는 경우 nonAnnotationMap에 default 값을 저장하고 annotationMap에 Qualifier가 가지고 있는 name을 키 값으로, 인스턴스를 value로 하여 저장한다. nonAnnotationMap에 default 값을 저장하는 이유는 컨테이너에서 인스턴스를 가져오는 로직을 구상할 때 nonAnnotationMap 탐색 진행 후 default 값이 나오면 annotationMap을 탐색하도록 설계했기 때문이다.
글을 작성하면서 다시 생각해봤는데 다음에 getInstance에 대해 설명할테지만, nonAnnotationMap에 default 값을 저장할 필요가 없었던 것 같다.
object Container {
...
fun getInstance(type: KClass<*>): Any? {
return nonAnnotationMap[type]
}
fun getInstance(qualifier: Qualifier): Any? {
val qualifierName = qualifier.name
return annotationMap[qualifierName]
}
...
}
Container에서 값을 가져오는 메서드는 두 개다. 파라미터가 서로 다른데 KClass를 받는 것과 Qualifier를 받는 것으로 나누어져있다.
Injector에서 주입이 필요한 파라미터에 대해 Container에서 값을 요청할 때 애노테이션이 붙어있는지 확인하고 붙어있는 경우 Qualifier를, 붙어있지 않은 경우 KClass를 인자로 사용하도록 구현했다.
Container에서 getInstance 메서드가 추가되었으니 자연스럽게 Injector도 수정이 필요했다.
// Container에 있는 경우 바로 가져오고 없다면 인스턴스를 생성한다
val insertedParameters = parameters.associateWith {
val type = it.type.jvmErasure
// Qualifier 어노테이션 분기
if (it.hasAnnotation<Qualifier>()) {
Container.getInstance(it.findAnnotation<Qualifier>()!!) ?: inject(type)
} else {
Container.getInstance(type) ?: inject(type)
}
}
생성자의 파라미터에 Qualifier 애노테이션이 붙어있느냐 없느냐에 따라 인자가 다른 getInstance 메서드를 사용하도록 분기처리가 추가되었다.
class CartViewModel(
@Qualifier("DatabaseCartRepository")
@Inject
private val cartRepository: CartRepository,
) : ViewModel()
class MainViewModel(
@Qualifier("DatabaseCartRepository")
@Inject
private val cartRepository: CartRepository,
) : ViewModel()
viewModel을 자동적으로 주입하기 위해서는 위와 같이 @Qualifier 애노테이션이 적절하게 붙어있어야 하며, 아래처럼 Application이 시작할 때 Container에 필요한 데이터를 미리 넣어주어야 한다.
class ShoppingApplication : Application() {
override fun onCreate() {
super.onCreate()
injectDependency()
}
private fun injectDependency() {
val database = Room
.databaseBuilder(this, CartDatabase::class.java, "kkrong-database")
.build()
val cartProductDao = database.cartProductDao()
Container.addInstance(
CartProductDao::class,
cartProductDao,
)
Container.addInstance(
ProductRepository::class,
Injector.inject(DefaultProductRepository::class),
)
Container.addInstance(
CartRepository::class,
Injector.inject(DatabaseCartRepository::class),
)
Container.addInstance(
CartRepository::class,
Injector.inject(InMemoryCartRepository::class),
)
}
}
📚 정리
이러한 구조에서 어떤 문제가 있을지, 어떤 부분을 개선하면 좋을지 레아가 피드백을 남겨주셨다. 여기에 더 작성하기엔 글이 길어지기 때문에 새로운 글에 2, 3단계에 대한 피드백과 그 피드백을 반영한 방법에 대해 정리하려고 한다. 레아가 주신 피드백 하나 하나가 너무 도움이 되었고 내가 생각할 수 없던 부분들을 짚어주셔서 너무 감사했다. 덕분에 현재 구조는 문제가 많았음을 깨닫고 전체 구조를 바꾸는 계기가 되었다.