sunflower 클론코딩 중 MainApplication.kt 파일에서 @HiltAndroidApp
Annotation을 발견했는데, 이것이 Hilt와 연관이 있다는 것을 알게 되었다.
이번 포스팅에서 이 Hilt라는 것이 무엇인지 알아보려고 했으나... 그 전에 DI부터 공부해야 Hilt를 이해할 수 있을 것 같아 먼저 DI에 대해 알아보려고 한다.
class Garden {
var plant: Plant
init {
plant = Plant()
}
fun storeGarden() {
System.out.println("Planting it in the gardne!")
}
}
위의 코드를 보면, Garden
클래스는 init 블럭에서 의존성을 가지는 Plant
를 인스턴스화한다. 즉, Garden
은 이 function의 객체에 의존적이다.
그렇다면, 왜 이러한 의존성이 위험한걸까?
우리가 init
블럭에서 클래스의 dependencies들을 인스턴스화 할 때, 해당 클래스가 그것의 dependencies와 tightly coupled되어 있게 된다. 위의 상황에서는 Garden
이 Plant
와 tightly coupled된 상태이다. 그래서 이런 경우에 우리가 새로운 Garden
인스턴스를 만들기 위해서는 Plant객체 또한 새로 만들어야하는 것이 불가피하다. 하나의 모듈이 바뀌면 의존하는 다른 모듈도 수정해야하고, 유연하게 사용할 수 없기 때문에 재사용성이 떨어진다.
객체의 initializer block에서 dependencies를 인스턴스화 하는 것을 지양하는 또다른 이유 중 하나는 unit test때문이다. 의존성이 있으면 테스트하고자 하는 해당 클래스만 테스트하는 것이 어렵다. unit test는 다른 모듈로부터 독립적으로 이루어지는 것이 목적인데 의존성이 있다면 이를 지킬 수 없기 때문이다.
위의 위험성을 통해, 클래스 안에서 새롭게 만드는 것보다 외부 소스로부터 의존성을 건네 주는 것이 중요하게 되었고, 이것이 Dependency Injection이다.
Android는 context의 영향을 많이 받는다. 즉, Activity, Fragment 내에서 선언되고 사용되는 Instance들은 Activity, Fragment의 영향을 받는다. 인스턴스 생성 시 내부 환경의 영향을 받는다면, 같은 인스턴스라도 다른 환경에서 다르게 동작할 수 있다. 하지만 항상 같은 환경에서만 Instance를 생성하고, Activity나 Fragment에서는 생성된 Instance를 받아서 사용만 한다면 내부 환경과 상관없이 같은 동작을 하며, 범용적으로 재사용 할 수 있다.
이런 개념을 Dependency Injection이라고 한다.
Constructor Injection
클래스의 종속 항목을 생성자에 전달하는 방식이다. 간단한 예를 들어보자.
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
위의 예시에서 Engine
이라는 객체는 Car
클래스 안에서 초기화된다.
이것이 DI를 적용하지 않은 대표적인 예시이다. 이렇게 코드를 작성했다면 Car
와 Engine
은 강하게 커플링 되어 있고, 만약 Car
클래스를 사용하는 무수히 많은 클래스 중에 어떤 것에서 기본 Engine 유형이 아니라 다른 Engine을 사용하고자 한다면, 그것들을 일일이 수정해줘야 하는 비효율적인 과정이 필요해진다.
이를 해결하는 방법 중 하나는 Car
의 각 인스턴스는 초기화 시 자체 Engine
객체를 구성하는 대신 Engine
객체를 생성자의 매개변수로 받도록 하는 것이다.
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
위의 코드에서는 main
함수에서 Car
을 사용하고 Car
는 Engine
에 대해 dependency를 가지므로, 앱은 Engine
인스턴스를 생성한 후 이를 사용하여 Car
인스턴스를 구성한다. 이렇게 코드를 구성하면, Engine
의 유형이 달라져도 Car
자체를 수정할 필요없이 재사용하여 다양한 Engine
에 대해 Car
를 재사용 할 수 있다. 예를 들면, ElectricEngine
이라는 Engine
서브클래스를 정의하여 이 서브클래스의 인스턴스를 전달만 하면 되는 것이다.
Field Injection
Activity, Fragment와 같은 특정 Android 프레임워크 클래스는 시스템에서 인스턴스화하므로 생성자 삽입이 불가능하다. Field Injection을 사용하면 dependencies는 클래스가 생성된 후 인스턴스화된다.
class Car {
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
이렇게 Engine
의 서브클래스를 Car
클래스의 parameter로 넘져주지 않고, 밖에서 해당 멤버변수에 직접 값을 넣는 형태이다.
실제 서비스에는 위의 예시보다 더 많은 클래스와 의존성들이 존재하는데, 기존의 manual DI 방식은 좀 더 복잡하고 까다로워지고 몇 가지 문제점을 야기할 수 있다.
이러한 문제를 해결하기 위해 DI 프레임워크나 라이브러리가 존재하고, 대표적인 것이 Dagger2, Koin, Hilt이다. 이에 대해서는 추후에 포스팅으로 자세히 다루겠다.