💡 Intro
어느덧 프로젝트에 집중하던 레벨 3이 지나고 레벨 4가 시작되었다. 이 글을 작성하기 시작한 오늘(9월 19일)을 기준으로 레벨 4가 시작한지 22일이나 되었다. 그런데 이제야 첫 글을 쓴다. 왜 벌써 22일이나 지난거지? 그리고 67일이 남았다. 시작할 때만 해도 9개월 남짓의 시간을 잘 버틸 수 있을까에 대해 걱정했는데, 이제는 끝이 다가오고 있음에 걱정하고 있다. 시간은 정말 빨리 지나간다.
무튼 각설하고 세상에서 제일 어렵고 힘들었던 레벨 4의 첫 미션에 대해 기술하려고 한다. 이놈의 주인장은 뭐 맨날 세상에서 제일 어렵다고 하냐라고 할 수 있겠으나 정말 어렵다. 주제는 “만들면서 배우는 DI” 무엇이 어려웠고, 어떤 요구사항을 만족하기 위해 어떤 고민을 했었는가에 대해 최대한 자세히 풀어나가려고 한다.
❓ DI?
DI는 Dependency Injection, 의존성 주입이다. 의존성 주입은 레벨 3부터 스멀스멀 나오기도 했던 말이다. 의존성 주입이 필요한데 어쩌구, 힐트는 금지기술 어쩌구, 수동 DI가 어쩌구… 그 당시 잠드로이드는 관심사가 아니었기에(모두가 그랬는지는 잘 모르겠지만 적어도 나는 아니었다.) 크게 신경쓰지 않고 프로젝트에 집중했다.
그런데 레벨 4를 시작하자마자 만난 관심없던 그 친구. 첫 수업이었나 어쨌든 어느 수업에서 레아가 레벨 3에서 수동 DI 찐하게 맛보셨죠? 하셨는데,, 예? 저는 초면인데요? 하면서 수업을 들었던 기억이 난다. 일단 DI는 무엇인가?
궁금한 점이 있었다. 의존성 주입이라는 것은 이미 레벨 2부터 진행했던 내용 같은데 그것과는 다른 것인가? 그렇다면 무엇이 다른 것인가? 결론부터 말하면 이전에 배웠던 것이 맞더라. 다만 적용하는 방식이 조금 달랐을 뿐이다.
공식문서에 나와있고 이해가 가장 쉬운 예를 들어서 의존성 주입에 대해 아주아주 간단하게 알아보자.
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
Car 클래스가 Engine을 가지고 있어야 하는 것은 자명하다. (그것이 현실 세계에서든 위 코드에서든 관계없이 말이다.)
현재 코드에서는 Car 클래스 안에서 엔진을 생성하고 있다. 이러한 방법은 Car 클래스가 엔진을 갖게하는 가장 쉬운 방법이지만 테스트도 하지 못하고 재사용도 할 수 없다. 프로그래밍에 정답은 없다지만 그럼에도 아주아주 오답에 가까운 코드일 수는 있겠다.
재사용 할 수 없다는 말을 조금 더 쉽게 풀어보면 다른 엔진이 생겼을 때 대응을 할 수 없다는 것이다. 다른 엔진을 갖는 Car클래스를 만들면 대응을 할 수 있겠으나 이 방법 자체가 재사용성이 떨어진다는 말의 반증이다. 만약 엔진이 100개가 된다면 Car클래스도 100개가 되어야 하는가? 이러한 구조는 조용히 접어 휴지통에 넣도록하자.
그러면 어떻게 바꿀 수 있을까?
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
외부에서 엔진을 생성하고 생성자에 주입해주는 방식을 생각할 수 있다. 이런 방식을 사용하면 100개의 엔진이 생기더라도 현재 존재하는 Car클래스 하나로 대응할 수 있다. (생성자에 다른 엔진을 넣어주면 되니)
이를 통해 Car클래스를 재사용할 수 있게 되었으며, 테스트가 가능한 구조가 되었다.
현재 예를 든 방법은 생성자에 파라미터를 추가하여 주입받는 방식이지만 아래 코드처럼 필드 주입도 의존성 주입 중 하나의 방법이 될 수 있다. (근데 필드 주입은 좀 별로인 것 같기도..?)
class Car {
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
(그리고 레벨 2에서 배운 것에 따르면 함수에 주입하는 방법도 있었지만 그렇구나로 넘어가도록 하자.)
결과적으로 DI는 코드를 테스트할 수 있도록 만들어주며, 코드의 재사용성을 높여준다.
그리고 이를 잘 구현해놓은 DI 라이브러리 Hilt 혹은 Koin이 있다. (하지만 나는 써본 적도, 들어본 적도 없다. 이번에 초면이다.)
🛞 바퀴의 재발명 & QnA 수업
레벨 4의 첫 수업으로 다시 돌아가보자. 첫 수업의 주제는 바퀴의 재발명이었고, 이미 만들어진 바퀴를 재발명하는 이유와 그를 통해 얻을 수 있는 이점, 단점에 대해 이야기했었다. 이렇게 끝나는 수업인줄 알았는데 아니었고 이어지는 수업은 너무 어려워 약간의 절망을 맛보았다ㅋㅋ. 여기서 바퀴는 Hilt를, 재발명은 만들면서 배우는 DI인 것이었다.
이야기를 하기전에 사족을 조금 붙여보자면 이번 미션은 정말 정말 많이 어려웠다. 안드로이드에서 DI에 대해 고민해본 적이 없으며, 아니 어디에서든 DI를 고민해본적은 없었고, Hilt나 Koin같은 의존성 주입 라이브러리를 사용해본적도 없기 때문이다. 서비스 로케이터니 의존성 주입이니 모든 것이 새로웠다. 반면 옆에는 의존성 주입 라이브러리를 사용해본 친구들도 있었고, 미션을 꽤나 빠르게 구현해서 제출하는 친구들도 많아서 자존감도 자신감도 바닥을 쳤었다. ‘나만 못하나?’, ‘내가 너무 쉽게 생각하나?’ 이런 생각들 때문에.
사실 이 미션을 뚝딱뚝딱 해내는 친구들보다는 어려워하는 친구들이 훨씬 더 많았고, 어려워하는 친구들을 위해 순서가 바뀐 QnA 수업에서 생각정리가 엄청 많이 됐다.
- 지금 만드는 의존성 주입 라이브러리를 완벽하게 구현하려는 욕심을 버려라.
- 힐트, 데거의 코드를 참고하는 것이 어떤 의미가 있는 것인가?
욕심을 부려서 힐트, 데거와 같이 이미 만들어져있는 코드들을 참고하는 것이 과연 나에게 도움이 될까? 그리고 참고하는 것이 과연 좋은 방법일까? 어렵게만 돌아가는 것이 아닐까? 어느것도 정답일지, 혹은 정답 자체가 없을 수도 있는 질문이다. 하지만 위 두 문장이 나에게는 많이 와닿았고, 비교적 조금은 가벼운 마음으로 미션에 임할 수 있게 되었고 미션을 즐길 수 있게 되었다.
나보다 잘난 사람들과 하는 비교는 좋은 자극제이며 동기부여겠지만 동시에 자신을 끝없는 나락으로 떨어뜨리는 것일 수도 있다.
비교는 과거의 나와만 하자.
나의 성장을 즐기자.
🦖 만들면서 배우는 DI 1단계
사족은 이쯤에서 접고 다시 미션의 이야기로 돌아가서 필수 요구 사항은 다음과 같다.
다음 문제점을 해결한다.
- ViewModel에서 참조하는 Repository가 정상적으로 주입되지 않는다.
- Repository를 참조하는 다른 객체가 생기면 주입 코드를 매번 만들어줘야 한다.
- ViewModel에 수동으로 주입되고 있는 의존성들을 자동으로 주입되도록 바꿔본다.
- 특정 ViewModel에서만이 아닌, 범용적으로 활용될 수 있는 자동 주입 로직을 작성한다. (MainViewModel, CartViewModel 모두 하나의 로직만 참조한다)
- 100개의 ViewModel이 생긴다고 가정했을 때, 자동 주입 로직 100개가 생기는 것이 아니다. 하나의 자동 주입 로직을 재사용할 수 있어야 한다.
- 장바구니에 접근할 때마다 매번 CartRepository 인스턴스를 새로 만들고 있다.
- 여러 번 인스턴스화할 필요 없는 객체는 최초 한 번만 인스턴스화한다. (이 단계에서는 너무 깊게 생각하지 말고 싱글 오브젝트로 구현해도 된다.)
어떻게 해결했는지 하나씩 풀어보도록 하겠다.
1️⃣ 장바구니에 접근할 때마다 매번 CartRepository 인스턴스를 새로 만들고 있다.
가장 쉬운 것부터 해결해나가보자.
이 단계에서는 너무 깊게 생각하지 말고 싱글 오브젝트로 구현해도 된다.
해결방법은 다양하겠으나 나는 요구사항에도 나와있는 것처럼 어렵게 생각하지 않고 간단하게 해결했다.
object RepositoryContainer {
private val repositories = mutableMapOf<KClass<*>, Any>()
fun getInstance(type: KClass<*>): Any {
return repositories[type] ?: type.createInstance()
}
fun addInstance(type: KClass<*>, instance: Any) {
repositories[type] = instance
}
}
RepositoryContainer라는 object를 만들었고 내부에서 Map 형태로 인스턴스를 관리했다. Map의 키 값으로 KClass를, value는 인스턴스를 넣어주었다. 이렇게하면 CartRepository, ProductRepository를 필요할 때마다 생성하는 것이 아니라 한 번 생성해두고 필요할 때마다 가져다 쓸 수 있게 된다.
외부에서 RepositoryContainer의 getInstance() 메서드를 사용할 때 알맞는 키 값이 들어오지 않아 Null이 반환될 수도 있는데, 이 때는 해당 타입에서 createInstance 메서드를 호출하여 인스턴스를 생성한 뒤에 반환해주도록 했다.
class ShoppingApplication : Application() {
override fun onCreate() {
super.onCreate()
injectRepository()
}
private fun injectRepository() {
RepositoryContainer.addInstance(
ProductRepository::class,
DefaultProductRepository(),
)
RepositoryContainer.addInstance(
CartRepository::class,
DefaultCartRepository(),
)
}
}
Application이 시작되는 시점에(꼭 이 시점이 아니어도 된다. ProductRepository와 CartRepository를 사용하기 전이면 된다.) RepositoryContainer에 인스턴스들을 추가해주는 코드가 있어야 한다.
2️⃣ ViewModel에서 참조하는 Repository가 정상적으로 주입되지 않는다.
미션에서 사용되는 뷰모델은 MainViewModel, CartViewModel이고, 코드는 다음과 같다.
class MainViewModel(
private val productRepository: ProductRepository,
private val cartRepository: CartRepository,
) : ViewModel()
class CartViewModel(
private val cartRepository: CartRepository,
) : ViewModel()
AAC 뷰모델을 사용하며 MainViewModel은 생성자로 ProductRepository와 CartRepository를, CartViewModel은 생성자로 CartRepository를 주입받는다. 여기서 ProductRepository, CartRepository는 모두 인터페이스다.
// MainActivity
private val viewModel by lazy { ViewModelProvider(this)[MainViewModel::class.java] }
// CartActivity
private val viewModel by lazy { ViewModelProvider(this)[CartViewModel::class.java] }
그리고 이 뷰모델을 사용하는 액티비티에서 뷰모델을 생성하는 방법은 위 코드와 같다. 하지만 이는 정상동작하지 않는다.
현재 작성된 위의 코드는 뷰모델이 생성자로 아무런 인자도 받지 않아도 되는 상황에서는 아무 문제없이 정상동작이 된다. 하지만 우리가 사용하려는 뷰모델은 각각 2개, 1개를 인자로 받고 있는 상황이다. 그래서 ViewModelProvider에게 내가 만들려는 뷰모델이 어떻게 생성되는지 방법을 알고 있는 ViewModelProvider.Factory를 넘겨줘야 한다.(뷰모델을 어떻게 생성하고 관리하는지까지 깊어지면 이 글의 논점에서 벗어날 수 있기 때문에 간단히 이렇게만 짚고 넘어가도록 하겠다.)
어떻게 하면 뷰모델에 Repository를 정상적으로 주입해줄 수 있을까?
간단하다. ViewModelProvider.Factory를 재정의해주면 된다.
val factory = MainViewModelFactory(DefaultProductRepository(), DefaultCartRepository())
class MainViewModelFactory(
private val productRepository: ProductRepository,
private val cartRepository: CartRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainViewModel(productRepository, cartRepository) as T
}
}
// MainActivity
private val viewModel by lazy { ViewModelProvider(this, factory)[MainViewModel::class.java] }
ViewModelProvider는 클래스이며, 인터페이스인 Factory를 가지고 있다.
Factory는 두 개의 create 메서드를 가지고 있으며 이 함수를 이용하여 넘겨받은 뷰모델 클래스를 생성하여 반환해준다. 따라서 ViewModelProvider.Factory를 만들면 create 메서드를 오버라이드 해줘야 하는 것이다.그리고 ViewModelProvider에게 Factory를 함께 넘겨줘서 내가 원하는 뷰모델 클래스와 이 뷰모델을 만드는 방법을 알려주고 만들어줘! 요청하는 것이다.
3️⃣ Repository를 참조하는 다른 객체가 생기면 주입 코드를 매번 만들어줘야 한다.
벌써 1단계 마지막 요구사항이다. 이 문제를 해결하기 위해서는 방금 만들었던 Factory를 생성하는 과정을 조금 손 봐줘야 한다.
방금 만든 Factory 형태는 여러 개의 뷰모델이 생기거나 다른 Repository를 참조하는 상황이 발생했을 때 각각에 대응하는 Factory 클래스를 정의해줘야 한다. 굉장히 번거로운 일이 아닐 수 없다.
ViewModel에 수동으로 주입되고 있는 의존성들을 자동으로 주입되도록 바꾸기 위해 리플렉션이라는 기능을 사용한다. 리플렉션은 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API를 말하며 코틀린 리플렉션도 존재한다. 여기서도 본질에 집중하기 위해 이 정도의 설명만 하고 넘어가도록 하겠다.
inline fun <reified T : ViewModel> viewModelInject(): ViewModelProvider.Factory {
val parameters = T::class
.primaryConstructor
?.parameters
?: throw NullPointerException("주 생성자가 없습니다.")
val instances = parameters.map {
val type: KClass<*> = it.type.jvmErasure
RepositoryContainer.getInstance(type)
}
return viewModelFactory {
initializer {
T::class.primaryConstructor!!.call(*instances.toTypedArray())
}
}
}
뷰모델에 의존성을 자동으로 주입해주기 위해 viewModelInject 메서드를 만들었고 반환 값은 Factory다. 그래서 ViewModelProvider의 인자 중 factory 위치에 이 함수를 넣어주면 알맞는 뷰모델을 반환 받을 수 있다.
과정을 조금 더 자세하게 살펴보면 다음과 같다.
ViewModel 클래스 리플랙션 → 주 생성자를 가져온다. → 주 생성자의 파라미터들을 받아온다. → RepositoryContainer에서 주 생성자의 타입을 넘겨주어 싱글턴으로 관리하고 있는 인스턴스를 반환받는다. → ViewModel의 주 생성자에 파라미터를 넣어 인스턴스를 생성한다.
받아온 파라미터들의 KClass를 가져오고 RepositoryContainer에서 KClass(키 값)를 이용하여 인스턴스들을 가져온다.
주생성자를 call하고 이 때 파라미터들을 가변인자로 넣어주어 Factory에 ViewModel을 생성하는 방법을 알려준다.
// MainActivity
private val viewModel by lazy { ViewModelProvider(this, viewModelInject<MainViewModel>())[MainViewModel::class.java] }
// CartActivity
private val viewModel by lazy { ViewModelProvider(this, viewModelInject<CartViewModel>())[CartViewModel::class.java] }
그러면 위에서 만들었던 MainViewModelFactory, CartViewModelFactory 대신 viewModelInject<ViewModelClass>()메서드를 이용하여 생성자로 Repository들을 받는 뷰모델에 자동으로 의존성을 주입해주고 반환해주는 팩토리를 만들어낼 수 있다.
📚 정리
만들면서 배우는 DI 1단계에 대한 내용을 정리했다. 미션은 총 5단계로 이루어져 있으며 각 단계별로 필수 요구 사항과 선택 요구 사항이 존재한다. 그리고 그 중 1단계의 필수 요구 사항에 대해 정리하고 해결 방법을 알아보았다. 글이 많이 길어질 수도 있을 것 같아서 각 단계 별로 나누어 글을 작성하려고 한다. (절대 글 개수 늘리려고 그러는 거 아님. 진짜 아님. 글이 길어지면 읽는데 루즈해지니까 그런거임.)
현재 구조에서는 해결할 수 없는 문제가 있다.
1. 인터페이스를 구현하는 클래스는 여러 개가 될 수도 있다. RepositoryContainer의 키 값이 KClass 인데, 하나의 인터페이스를 구현하는 클래스가 3개가 된다면? 현재 상황에서는 RepositoryContainer에 세 개의 구현체 인스턴스가 들어갈 수 없는 구조다.
2. RepositoryContainer에 저장되는 모든 인스턴스들이 싱글톤으로 관리되며 한 번 저장되면 애플리케이션이 끝날 때까지 계속 저장된다. 즉 각 컴포넌트의 생명주기를 따라가는 것이 아니라 모두 애플리케이션의 생명주기를 따르는 구조다.
이런 것들은 단계를 밟아 나가면서 해결할 수 있을 것이라고 생각했고, 레아도 벌써부터 고민할 필요는 없다고 하셨으니 일단은 가지고 가려고 한다.
[크롱] 1단계 자동 DI 미션 제출합니다 by krrong · Pull Request #17 · woowacourse/android-di
안녕하세요 빅스! 첫 리뷰어로 같은 팀인 빅스가 되어 신기하네요 혹시라도 이해가 안되는 부분이 있거나 바꾸면 더 좋을 것 같은 부분이 있다면 가감없이 짚어주면 좋을 것 같아요🙃! 리뷰 잘
github.com