💡Intro
2, 3단계 리뷰를 받은 뒤에 프로젝트의 구조를 대대적으로 수정하게 되었고, 생각보다 내용이 길어져 리뷰 반영에 대해서만 다루려고 한다. 레벨 1, 2와는 다르게 레벨 4는 크루들이 서로의 코드를 리뷰해준다. 좀 엉망인 코드긴 하지만 빨리 제출한 편에 속했고, 덕분에 레아가 리뷰를 해주셨다. 그래서 아마 이에 대한 반영 내용이 주가 될 것 같다. 미션에 허덕이는 것이 싫어서 더 고민하지 않고 제출했었는데, 이렇게 득이 되어 돌아와 기분이 아주 좋다.
내 딴에는 열심히 생각하고 괜찮은 것 같은데? 라고 생각하면서 완성한 구조이지만 문제가 있다는 피드백을 받아 개인적으로는 마음이 조금 아프긴 하다. 😂 (문제가 있다고 직접적으로 언급하시지는 않았지만 임시방편인 느낌이라는 부분에서 문제가 있지만 말을 골라서 해주신게 아닌가… 생각했다.)
그 덕분에 구조를 뒤바꾸게 되었고 시간이 많이 들여 새벽까지 잠을 못자면서 했지만 그만큼 많이 느끼고 배웠다. 역시 코치님의 리뷰는 보약이다. 씹고 먹기엔 쓰지만 결국 몸에는 좋은.
🪨 레아의 리뷰 : 깨지고 부숴져라.
1️⃣ 모듈
일단 먼저 가벼운 리뷰부터 시작해서 몸을 풀어보자.
레아가 첨부해주신 링크로 들어가면 안드로이드 공식문서의 코드 스타일 가이드가 나온다. 그리고 여기서 다음과 같이 이야기한다.
오호.. 바로 반영했다. (다시 생각해보니까 의미있게 krrongdi라고 붙일걸 그랬다는 생각이 드네)
2️⃣ @Qualifier 개선하기 - 1
코멘트를 달아주신 곳에서도 보이지만 원래 구현 방식은 다음과 같았다.
@Qualifier("InMemoryCartRepository")
class InMemoryCartRepository() : CartRepository
@Qualifier("DatabaseCartRepository")
class DatabaseCartRepository(
@Inject private val dao: CartProductDao,
) : CartRepository
CartRepository 인터페이스를 구현하는 두 개의 구현체 위에 애노테이션이 달려있고, @Qualifier 애노테이션은 name이라는 값을 String 형태로 가지고 있다.
레아는 구현체 선언부에서 Qualifier를 선언하는 것도 좋지만 이는 구현체가 직접 DI로직을 참조해야 하며, 사용하는 DI 라이브러리가 변경된다면 구현체도 수정되어야 한다고 말씀해주셨다.
실제로 힐트를 배운 지금 시점에서 생각해보면 힐트는 Provides 방식을 사용하는 경우 인터페이스의 구현체는 변경점이 없고 Binds 방식을 사용한다고 하더라도 @Inject 애노테이션만 붙일뿐이다. @Inject 애노테이션이 붙어 구현체에 변경이 생기는 것은 마찬가지가 아니냐고 말할 수 있겠지만 힐트가 사용하는 @Inject 애노테이션은 아래 첨부한 사진과 같이 javax에 있는 애노테이션이다.
즉, 의존성 라이브러리(힐트)가 가지고 있는 자체 애노테이션이 아니라 javax에 있는 애노테이션을 가져와 사용하는 것이고, 힐트를 사용하다가 다른 의존성 주입 라이브러리로 바꿔야 하는 상황이 와도 @Inject 애노테이션이 문제가 되지는 않으리라 생각한다. 내부 로직이 어떻게 구성되어 있느냐에 따라 다르겠지만.
3️⃣ @Qualifier 개선하기 - 2
그리고 나에게 달린 리뷰는 아니었지만 나에게도 해당하는 리뷰도 있었다. Qualifier를 참조하는 구분자가 꼭 String일 필요가 없다는 것이었다. 이 리뷰를 보기 전까지는 현재 구조에서 어떤 문제가 발생할 수 있는지 깊이 고민해보지는 않았다. 이것 저것을 모두 고려하면서 구현을 하기에는 능력이 부족해서 조금씩 수정하는 것을 목표로 했기 때문이다.
이 리뷰를 보고 내가 생각한 문제는 다음과 같다. String에 의존적이기 때문에 오타에 취약하다. 현재 내 구조는 사용하는 곳과 구현체에 달아주는 Qualifier의 애노테이션의 String으로 같은 값을 찾아오기 때문에 한 글자라도 다르면 주입이 불가능하다.
그리고 이러한 문제는 컴파일 시점에 문제가 생겼음을 인식할 수 없도록 하며, 런타임에 예상치 못한 이유로 갑자기 앱이 터져버릴 가능성이 있다. 그래서 다음과 같이 수정하였다.
annotation class Qualifier
가장 먼저 Qualifier에 name을 삭제했다.
@Qualifier
annotation class Database
@Qualifier
annotation class InMemory
그리고 두 개의 애노테이션 클래스를 생성하고 Qualifier 애노테이션을 붙여 구분이 필요한 애노테이션 클래스임을 명시했다. 이렇게 한 이유는 애노테이션 클래스위에 애노테이션을 달 수 있었고, 애노테이션 클래스를 리플렉션을 통해 어떤 애노테이션 클래스가 달려있는지 알 수 있기 때문이다.
class DatabaseCartRepository(
@Inject private val dao: CartProductDao,
) : CartRepository
class InMemoryCartRepository() : CartRepository
그러면 구현체 위에 붙어있던 애노테이션을 붙여주지 않아도 된다. 대신 이 구현체를 사용하는 곳에서 변경점이 생긴다.
class MainViewModel(
@Inject
private val productRepository: ProductRepository,
@Database
@Inject
private val cartRepository: CartRepository,
) : ViewModel()
class CartViewModel(
@Database
@Inject
private val cartRepository: CartRepository,
) : ViewModel()
뷰모델의 생성자에서 주입이 필요한 프로터피 앞에 @Inject 애노테이션을 붙여 이 프로퍼티는 주입이 필요하다! 라고 명시했다.
그리고 앞에서 새로 정의해준 애노테이션(@Database, @InMemory 두 가지이지만 위에서는 @Database만을 사용했다.)을 사용하여 어떤 구현체를 주입받고 싶은지를 명시했다.
자동으로 주입해주는 과정을 간단하게 살펴보면 다음과 같다.
주입할 클래스의 주 생성자를 찾는다. → @Inject 가 붙은 파라미터를 찾는다. → @Qualifier가 붙어있는지 찾는다. → KClass와 @Qualifier(null일 수 있다.)로 키 값을 만들어 컨테이너에서 인스턴스를 찾아 주입해준다.
논리적인 로직은 많이 어렵지 않지만 이렇게 구현하는 것은 어렵다. 난 어려웠다.
4️⃣ 인스턴스 저장 방법 개선
현재 Container는 다음과 같이 생겼다.
object Container {
private val nonAnnotationMap = mutableMapOf<KClass<*>, Any>()
private val annotationMap = mutableMapOf<String, Any>()
}
Qualifier 애노테이션이 달려있지 않으면 nonAnnotationMap에, Qualifier 애노테이션이 달려있으면 annotationMap 에 저장한다. annotationMap은 Qualifier가 가지고 있는 name을 키 값으로 하고, 해당 클래스의 인스턴스를 value로 저장한다.
이 방법에 대해 다음과 같은 리뷰가 달렸다.
이게 끝.판.왕.이다.
이 리뷰를 보고 음.. 현재 구조는 잘못되었구나. 다른 구조로 바꿔야겠다고 생각했다.
구조를 바꿔야겠다는 것은 느껴졌는데 어떻게 바꿔야 좋은 구조일지 혹은 당장 돌아가는 코드가 아닐지는 여전히 어려운 상태였다. 그래서 가장 먼저 시작한 것은 레아가 힌트로 주신 의존성 주입 타입을 관리하는 객체를 만들어보는 것이었다.
data class DependencyType(val klass: KClass<*>, val annotation: Annotation?)
KClass를 키 값으로, 애노테이션을 value로 갖는 DependencyType 클래스를 만들었다. 그리고 이 클래스를 Container에서 Map의 형태로 인스턴스를 관리할 때 키 값으로 사용한다.
data class로 만든 이유는 이 클래스를 Map의 키 값으로 사용할 것이기 때문에 동등성 비교가 가능해야 한다고 생각했다. data class로 만듦으로써 이를 쉽게 해결할 수 있을 것 같았다.
그리고 annotation이 nullable하게 만드는 것도 또 하나의 방법이었는데, 단순히 @Qualifier 애노테이션의 유무로 저장소를 분리하지 않을 수 있다. 지금까지 nullable하도록 만드는 것은 지양해야겠다고 생각했는데, 이번 미션을 통해 적재적소에 잘 사용하면 좋은 방법일 수 있겠구나라고 생각했다.
그러면 Container의 코드도 다시 다음처럼 간단하게 유지할 수 있다.
object Container {
private val dependency = mutableMapOf<DependencyType, Any>()
fun getInstance(dependencyType: DependencyType): Any? {
return dependency[dependencyType]
}
fun addInstance(type: KClass<*>, instance: Any, annotation: Annotation?) {
dependency[DependencyType(type, annotation)] = instance
}
fun clear() {
dependency.clear()
}
}
🔥 대격변
당장 돌아가는 코드를 만들기 위한 임시 방편처럼 보여요.
라는 말이 나에게는 많이 꽂혔다. 임시 방편…
어떻게 보면 그랬을 수도 있겠다. 이 미션을 진행할 때 나는 어떻게든 되면 된다! 라는 생각으로 진행했다. 지금 가고 있는 이 길이 옳은가 옳지 않은가는 도착해봐야 아는 것이라고 생각했고, 도착한 시점에 리뷰를 통해 옳지 않은 길이었구나 판단이 섰다.
그리고 왔던 길을 되돌아 가 다른 길을 찾아야겠다고 생각했다. 그래서 처음부터 다시 생각했다.
다른 크루들의 코드도 많이 살펴보았고, 강의자료에 힌트로 주신 것도 여러 번 봤다. 그리고 이미 잘 만들어져있는 바퀴인 힐트에 대해서도 조금씩 알아보았다.
힐트는 모듈이라는 개념이 있다. 그 당시에 내가 이해한 모듈이라는 개념은 주입을 필요로 하는 것들(클래스 혹은 인터페이스가 되겠다)에 대해 각각의 인스턴스들을 어떻게 생성하는지 알려주는 ProvideFunction을 정의해 놓은 것이다.
이러한 방법들이 있다는 것을 깨닫고 나도 모듈을 만들어 필요한 인스턴스들을 어떻게 생성하는지를 정의해주고 Injector 클래스가 모듈을 한바퀴 돌며 인스턴스들을 생성하고 가지고 있어야겠다. 라는 방법을 생각했다. 사실 처음부터 이렇게 명확한 방향을 잡지는 못했다. 처음에는 추상적이었지만 조금씩 구현하며 이러한 결과에 도달한 것 같다. 그리고 잘 모르는 영역이기 때문에 그게 당연하지 않을까?
2, 3단계를 제출하는 시점에 요구사항을 만족하기 위해 내가 만든 DI 라이브러리를 모듈로 분리해둔 상태지만 그런 것은 최대한 고려하지 않고 작성할 것이다.
모듈을 살펴보자.
interface Module
모듈도 여러 모듈이 생길 수 있겠다고 생각했고 추상화 해두었다.
class DefaultModule(private val context: Context) : Module {
fun provideCartProductDao(): CartProductDao {
val database = Room
.databaseBuilder(context, CartDatabase::class.java, "kkrong-database")
.build()
return database.cartProductDao()
}
@InMemory
fun provideInMemoryCartRepository(): CartRepository {
return InMemoryCartRepository()
}
@Database
fun provideDatabaseCartRepository(cartProductDao: CartProductDao): CartRepository {
return DatabaseCartRepository(cartProductDao)
}
fun provideProductRepository(): ProductRepository {
return DefaultProductRepository()
}
}
Module을 구현하는 구현체인 DefaultModule은 앞에서 이야기한대로 원하는 인스턴스들을 어떻게 생성해주는지를 정의하는 ProvideFunction을 가지고 있다. 현재 내가 앱을 동작시키면서 필요한 인스턴스들은 ProductRepository, CartProductDao, CartRepository다.
ProductRepository는 구현체가 DefaultProductRepository 밖에 없기 때문에 Qualifier와 같은 애노테이션이 필요하지 않다. 그래서 바로 DefaultProductRepository를 생성하여 반환해주는 함수를 작성해주면 된다.
CartProductDao는 Room을 사용하기 때문에 ApplicationContext가 필요하다. 그래서 DefaultModule을 생성할 때 생성자 인자로 context를 받아오고, Dao를 생성할 때 사용한다.
함수를 호출하는 시점에 applicationContext를 주입해줘도 되지 않느냐라고 이야기할 수도 있는데, 그렇게 해도 된다고 생각한다. 다만 나는 Context는 예기치 못한 메모리 릭이 발생할 수 있기 때문에 최대한 다른 클래스에게 전달해주는 것이 좋지 않다고 생각한다. 뒤에서도 이야기 하겠지만 현재 나의 구조는 Injector 클래스가 addModule() 메서드를 호출하고 인자로 Module을 받아 Module에 있는 모든 메서드를 실행시켜 전역으로 존재하는 컨테이너에 저장한다. 이런 구조를 가지고 있기 때문에 Module은 Injector가 사용하고 난 뒤에는 참조하지 않기에 GC가 수거해갈 것이라고 생각되고, 이를 통해 Context에 대한 참조도 안전하게 제거되지 않을까 생각한다.
어쨋든 다시 주된 내용으로 돌아가보면 CartRepository는 InMemoryCartRepository, DatabaseCartRepository 2개의 구현체가 존재한다. 두 개의 메서드의 반환 값이 동일하기 때문에 자동으로 의존성을 주입해주려고 할 때 어떤 것을 선택할지 알 수 없다. 메서드의 파라미터에 들어가는 값의 유무로 차이를 구분할 수 있다고 할 수 있겠지만 이는 운좋게 가능한 하나의 케이스일 뿐이고, 만약 파라미터에 들어가는 값이 둘 다 없는 경우라면 구분할 수 없을 것이다.
그래서 이 때 앞에서 만들어둔 Qualifier가 붙은 애노테이션을 적절하게 붙여 구분할 수 있도록 구현할 수 있다.
object Injector {
// 인자로 받은 모듈에 있는 메서드를 인스턴스화 하여 Container에 저장한다
fun addModule(module: Module) {
val kFunctions = module::class.declaredFunctions
kFunctions.forEach { kFunction ->
createOrAdd(module, kFunction)
}
}
...
}
Injector가 가지고 있는 모듈을 추가하는 addModule 메서드를 보면 모듈에 있는 메서드를 모두 순회하면서 createOrAdd() 메서드를 호출한다.
createOrAdd() 메서드 역시 Injector가 가지고 있는 메서드인데, 코드가 지저분해서 어떤 역할을 하는 메서드인지 간단하게 설명하면, 모듈에 있는 함수를 실행시켜 인스턴스를 Container에 저장하는 역할을 한다. 이 때 애노테이션을 확인하기도 하고, 생성자에 파라미터가 필요하다면 재귀적으로 주입해주는 것도 내부적으로 진행한다.
앞선 리뷰를 제출할 때에도 Container에 인스턴스들을 추가해주기 위해 Application에서 진행해줘야 하는 작업들이 있었고, 그에 대한 코드는 다음과 같다.
// Before
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),
)
}
}
Container에 어떤 값을 추가해줄 것인지 명시적으로 적어줬어야 했는데, 이를 모듈에 모두 정의해두었기 때문에 코드는 다음과 같이 간결해진다.
// After
class ShoppingApplication : Application() {
override fun onCreate() {
super.onCreate()
injectModule()
}
private fun injectModule() {
Injector.addModule(DefaultModule(applicationContext))
}
}
Wow! 매우매우매우매우매우 간결해졌다. 대부분의 코드가 모듈로 옮겨졌지만.
📚 정리
나름의 고민을 통해 2, 3단계를 진행했고, 리뷰를 받아 전체적인 구조를 수정했다. 처음부터 좋은 방향으로 나아갔더라면 시간도 덜 걸리고 고생도 덜 했겠으나, 돌아가는 길에서도 많은 경험을 했다. 현재 구조에서는 어떤 문제가 발생할 수 있고, 이를 해결하기 위해서는 어떤 구조로 변경하는게 더 좋은지, 다른 크루들과 힐트의 구조를 살펴보며 왜 이런 구조로 만들어져 있을지 생각해보는 계기가 되었다. 새벽 5시 30분에 미션을 제출하고 정말 고생을 많이 했지만 이 경험이 나를 더 성장하도록 만들어준 것 같다.
우테코에 들어오지 않았다면 돌아가면 장땡! 이라는 마인드를 아직도 가지고 있었을 것 같다. 하지만 더 깔끔한 코드, 더 좋은 구조로 만들기 위해 고민하는 나를 보며 많이 성장했다고 생각한다.
사실 지금의 구조도 레아가 보신다면 리뷰할거리가 많은 빈틈 많은 구조일 수도 있다. 그럼에도 이전 구조보다 훨씬 안정된, 간결한 구조라고 생각한다.
다음은 조금 더 어려웠던 4단계다.