0. Dagger Hilt
Android Framework는 각종 컴포넌트 간 의존성이 상당히 강하다. 클래스간 의존도를 낮추기 위해서는 의존성 주입(Dependency Injection)을 사용할 수 있다. 객체 생성 시 클래스 간 의존성이 생기게 되는데 이 때, 객체의 생성을 클래스 내부에서 하는 것이 아니라 클래스 외부에서 객체를 생성하여 주입시키는 의존성 주입의 디자인 패턴을 이용할 수 있는 것이다.
이전에는 개인이나 회사별로 수동 또는 별도 의존성 주입 솔루션을 직접 만들어 쓰기도 하였다. Java나 Kotlin 진영에서 널리 쓰이고 있는 라이브러리를 쓰기도 했다. Android Framework는 자바 언어를 지원하는 것부터 시작했기 때문에 Guice를 사용하기도 했고 구글에서 안드로이드 전용 DI 라이브러리인 Dagger 라이브러리가 나오기도 했으며, 코틀린을 지원하면서 Koin 또한 등장하게 되었다. 인스턴스를 클래스 외부에서 주입하기 위해서는(클래스간 의존도를 낮추기 위해서는) 인스턴스에 대한 생명주기의 관리도 필요한데, 이러한 라이브러리들이 자동으로 관리해주기도 하였다. 이후 Dagger Hilt가 등장하면서 리플렉션과 같은 단점을 보완하고 표준 컴포넌트를 제공함으로써 초기 DI 환경 구축 비용을 크게 절감할 수 있게 되었다. 또한 Jetpack과 AAC 라이브러리를 통해서 Hilt 의존성 주입을 비교적 간편하게 구현할 수 있도록 지원해주고 있기 때문에 현재까지 안드로이드 DI 환경 구축에 굉장히 유용한 라이브러리로 사용되고 있다.
@HiltAndroidApp
@HiltAndroidApp 는 컴파일 타임 시 표준 컴포넌트 빌딩에 필요한 클래스들을 초기화한다. 따라서 Hilt 셋업을 위해서 @HiltAndroidApp 어노테이션을 ApplicationClass에 반드시 추가해야하는, 필수적으로 요구되는 과정이다. 이 어노테이션으로 의존성 주입의 시작점을 지정한다.
@HiltAndroidApp
class App : Application()
@Inject annotation
@Inject 어노테이션은 field annotation과 생성자 annotation이 있는데, 모두 의존성 주입을 요청한다는 의미이다.
@Inject 어노테이션을 사용하여 의존성을 주입 받으려는 변수에 객체를 주입할 수 있다. 즉 @Inject 어노테이션이 붙은 변수는 의존성을 주입받는 포인트를 선언한다는 의미이다.
@HiltAndroidApp
class App: Application() {
@Inject
lateinit var myName: MyName
override fun onCreate() {
super.onCreate()
Log.d("TAG", $myName)
}
}
만약 Log를 super.onCreate() 위로 위치한다면 어떻게 될까? UninitializedPropertyAccessException 에러가 발생할 것이다. super.onCreate() 시점에 의존성 주입이 발생하기 때문이다. 때문이 이후에 호출하여 의존성 주입을 보장받을 수 있다.
AndroidEntryPoint
의존성을 주입할 Android 클래스에 @AndroidEntryPoint 어노테이션을 추가한다. Hilt에서는 객체를 주입할 Android 클래스에 @AndroidEntryPoint어노테이션을 추가하는 것만으로도 자동으로 생명주기에 따라 적절한 시점에 Hilt 요소로 인스턴스화 되어 처리된다. 이 어노테이션으로 의존성 주입의 시작점을 나타낸다. @AndroidEntryPoint을 추가할 수 있는 Android 클래스는 아래와 같다.
- Activity
- Fragment
- View
- Service
- BroadcastReceiver
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate()
}
}
@Inject 어노테이션을 사용하여 의존성을 주입 받으려는 변수에 객체를 주입할 수 있다. Hilt는 컴파일 타임에 의존성을 주입하며, 런타임에 객체를 생성하여 제공한다.
주의해야할 점은, 안드로이드 클래스에서 의존성 주입시, 상위 (서브)컴포넌트에도 반드시 @AndroidEntryPoint를 선언해야한다. 예를 들어, Fragment에 마킹했다면 Activity에도 마킹해야한다. 의존성을 주입받는 방법에 대해서는 알아봤으니, 이제 의존성을 생성하는 방법에 대해 알아보자.
의존성 생성 방법
@Inject constructor
클래스의 생성자에서 @Inject 어노테이션을 사용하여 의존성을 생성하는 방법이다. 생성자 annotation을 가지고 있는 Foo 클래스경우, Foo라는 의존성을 컴포넌트에 바인딩하겠다, 의존성을 추가하겠다라는 의미이다.
이처럼 생성자 constructor에 @Inject 어노테이션으로 의존성 인스턴스를 생성하고, 생성자의 파라미터로 의존성을 주입받을 수도 있다. 간단한 객체나 데이터 클래스에 유용하다.
class Foo @Inject constructor(private val myName: MyName) { ..
}
Hilt 모듈 (@Module, @Provides, @Binds)
@Module
모듈의 의미를 먼저 생각해보자. 하나의 소스파일에 모든 코드를 작성하지 않고 기능별로 모듈을 구성하는데, 이러한 모듈들은 합쳐서 하나의 실행가능한 프로그램을 만들기 위함이다. 이 의미를 생각하며 Hilt에서 제공하는 @Module에 대해 알아보자.
위에서 살펴본 생성자 annotation을 대신, Module을 통한 주입을 이용해 Hilt에게 원하는 Dependency를 생성하는 방법을 알려줄 수 있다. 인터페이스, 외부 라이브러리의 클래스와 같이 생성자 삽입을 할 수 없는 상황에 쓰인다. 이처럼 개발자가 생성자를 만들고 삽입할 수 없는 경우에는 Hilt 모듈을 사용하여 의존성을 생성할 수 있다.
위의 클래스, 인터페이스들은 Module을 통해 객체화하는 방법을 명시하는데, @Module, @InstallIn 어노테이션을 사용한다.
@Module
- @Module 애노테이션은 클래스에 적용된다.
- Hilt에게 해당 클래스가 의존성을 제공하는 모듈임을 알린다.
- 모듈은 의존성 제공 메서드를 포함하며, 이 메서드들은 주입할 객체를 생성하는 역할을 한다.
- Hilt는 @Module로 정의된 클래스를 스캔하여 의존성을 주입할 수 있는 방법을 학습한다.
@InstallIn
- 이 모듈 안 객체들이 어디에서 생성될 것인지를 의미힌다
- 둘 이상의 생성 지점을 써줄 수도 있다. (@InstallIn(ApplicationComponent::class, ViewComponent::class))
@Module은 의존성 인스턴스를 제공하는 방법을 Hilt 에게 알려주는 역할을 한다.
이러한 Module에 @InstallIn(component) 어노테이션을 지정하여 어떤 컴포넌트에 install할지를 반드시 정해주어야 한다. @InstallIn 에 사용되는 Hilt 컴포넌트들은 각자의 생명주기를 갖고 있으며 해당 모듈들이 이 컴포넌트의 생명주기에 맞춰 그대로 따라가게 된다. @InstallIn 안에 들어가는 Hilt 표준 컴포넌트들은 아래에서 이어서 작성하겠다.
모듈이 설치되는 과정을 보자.
컴파일 타임에 컴포넌트가 설치된다. 어노테이션 프로세싱에 의해 @Moduel 이 마킹된 클래스를 탐색하게 되고, 해당 클래스의 @Installin 이 있는지 확인하고 설치될 컴포넌트 탐색한다. 해당 내용을 참조해서 해당 컴포넌트에 모듈이 설치된다.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
...
}
@Provides
- @Provides 어노테이션은 메서드에 적용된다.
- 이 메서드가 의존성을 제공하는 메서드임을 Hilt에게 알린다.
- @Provides가 붙은 메서드는 객체를 생성하거나 반환하는 역할을 하며, Hilt가 이를 통해 필요한 의존성을 주입한다.
provideApiService 라는 함수를 통해 ApiService 의존성이 SingletonComponent에 바인딩 될 것이다. 또한 @Singleton 으로 정의하게 되면 생성된 ApiService 의존성을 Component에 저장하고 있기 때문에 함수 호출은 단 한 번만 발생한다. 생명주기에 맞춰가기 때문에, SingletonComponent 경우 앱이 완전히 종료되기전까지 메모리에 남아있게 된다.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val BASE_URL = "BASE_URL"
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.client(okHttpClient)
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
class RemoteDataSource @Inject constructor(private val apiService: ApiService) {
//ApiService를 외부(Module)에서 주입받고, RemoteDataSource 또한 @Inject constructor로 외부에 주입이 가능해짐
suspend fun get(
query: String,
): Response<FooClass> = apiService.get(query)
}
ApiService를 외부(Module)에서 주입받고, RemoteDataSource 또한 @Inject constructor로 외부에 주입이 가능해진다.
Hilt에서는 미리 정의 된 한정자를 제공해준다. 예를들어, Context가 필요한 경우에 간편하게 사용할 수 있도록 아래와 같은 한정자를 제공한다.
@ApplicationContext
@ActivityContext
@Module
@InstallIn(SingletonComponent::class)
object FooModule {
@Provides
@Singleton
fun provideFoo(@ApplicationContext context: Context): Something {
//context 사용
...
}
}
어떻게 의존성 코드가 주입되는지 생성된 generate 자바코드를 보자.
@Override
public void injectMembers(App instance) {
injectMyName(instance, myNameProvider.get())
}
@InjectedFieldSignature("kr.co...App.myName")
public static void injectMyName(App instance, myName myName) {
instance.myName = myName
}
Dagger와 달리 Hilt는 표준컴넌트 조차도 소스코드 내에서 인스턴스화하지 않는다. 이는 지난 편인 Hilt의 내부 작동방식에서 작성했듯이 바이트코드 변조 덕분이다.
@Binds
- @Binds는 추상 메서드에 사용된다. 추상 메서드는 하나의 매개변수를 가지며, 이는 구현체 타입이어야 한다.
- 인터페이스와 그 구현체를 연결(바인딩)하는 데 사용된다.
- @Provides와 달리, 직접 객체를 생성하는 코드가 포함되지 않는다.
인터페이스를 구현하는 클래스의 인스턴스를 제공할 때는 @Binds 어노테이션을 사용할 수 있다. @Binds를 사용하면 인터페이스를 구현체에 바인딩하여, Hilt가 인터페이스 타입의 의존성을 주입할 때 해당 구현체를 주입하도록 한다.
이 방법은 @Provides보다 간결하며, 인터페이스와 그 구현체 간의 결합도를 낮출 수 있다.
인터페이스를 구현하는 클래스의 인스턴스를 제공할 때, 꼭 @Binds 를 사용하지 않아도 된다. 실제로 바인딩 하지 않았을 경우 뜨는 에러 메세지의 경우 @Provides 메소드를 제공하라고 알려준다.
@Binds는 구현체에 복잡한 로직이 없고, 단순히 인터페이스와 그 구현체를 연결할 때 사용하면 더욱 간결함을 유지할 수 있다. @Provides는 복잡한 로직이 필요하거나, 생성 과정에서 여러 의존성이 필요한 경우 사용하는 것이 더욱 적절하겠다.
@Module
@InstallIn(SingletonComponent::class)
abstract class NetworkModule {
@Binds
@Singleton
abstract fun bindApiService(impl: ApiServiceImpl): ApiService
}
interface ApiService {
fun fetchData(): Data
}
class ApiServiceImpl @Inject constructor() : ApiService {
override fun fetchData(): Data {
// Implementation code
}
}
Component hierachy
현재 @InstallIn(component) 에 사용되는 컴포넌트들은 다음과 같이 제공하고 있다.
기존의 Dagger2는 개발자가 직접 필요한 component들을 작성하고 상속 관계를 정의했다면, Hilt 내부적으로 컴포넌트들의 생명주기를 자동으로 관리해주기 때문에 개발자가 DI 환경을 구축하는데 수고를 최소화 해주고 있다. 또한 Hilt 내부적으로 제공하는 component들의 전반적인 라이프 사이클 또한 자동으로 관리해주기 때문에 사용자가 초기 DI 환경을 구축하는데 드는 비용을 최소화하고 있다. 다음은 Hilt에서 제공하는 표준 component hierarchy 이다.
Hilt component | Injector for | Created at | Destroyed at | Scope | |
SingletonComponent | Application | Application#onCreate() | Application#onDestroy() | @Singleton | |
ActivityRetainedComponent | 해당 없음 | Activity#onCreate() | Activity#onDestroy() | @ActivityRetainedScoped | |
ViewModelComponent | ViewModel | ViewModel created | ViewModel destroyed | @ViewModelScoped | |
ActivityComponent | Activity | Activity#onCreate() | Activity#onDestroy() | @ActivityScoped | |
FragmentComponent | Fragment | Fragment#onAttach() | Fragment#onDestroy() | @FragmentScoped | |
ViewComponent | View | View#super() | View destroyed | @ViewScoped | |
ViewWithFragmentComponent | @WithFragmentBindings 가 붙은 View | View#super() | View destroyed | @ViewScoped | |
ServiceComponent | Service | Service#onCreate() | Service#onDestroy() | @ServiceScoped |
각 컴포넌트들은 생성 시점부터 파괴되기 전까지 Injection이 가능하고, 각 컴포넌트마다 자신만의 생명주기를 갖는다.
Hilt 컴포넌트가 언제 생성되고 언제 소멸되는지 알 수 있다. Hilt 컴포넌트가 인스턴스화 되기 이전에 의존성을 참조하게 되면 크래시가 날 수 있기에 컴포넌트의 생성과 소멸 주기를 알고 있어야 한다.
- SingletonComponent : Application의 생명주기를 갖는다. Application이 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
- ActivityRetainedComponent : Activity의 생명주기를 갖는다. 다만, Activity의 Configuration Change(디바이스 화면전환 등...) 시에는 파괴되지 않고 유지된다.
- ViewModelComponent : Jetpack ViewModel의 생명주기를 갖는다.
- ActivityComponent : Activity의 생명주기를 갖는다. Activity가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
- FragmentComponent : Fragment의 생명주기를 갖는다. Fragment가 Activity에 붙는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
- ViewComponent : View의 생명주기를 갖는다. View가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
- ViewWithFragmentComponent : Fragment의 View 생명주기를 갖는다. View가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
- ServiceComponent : Service의 생명주기를 갖는다. Service가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴된다.
컴포넌트 계층 이미지에서 볼 수 있는 각 컴포넌트들의 호칭위에 스코프 어노테이션은 해당 컴포넌트의 라이프사이클에 대한 의존성 범위를 지정할 때 사용한다. 화살표의 밑은 하위컴포넌트이다. 일반적으로 하위컴포너트는 상위컴포넌트에 접근할 수 있다. 하지만 역방향으로 하위의 의존성에는 접근할 수 없다. 예를 들어 Activity보다 Fragment 생명이 더 짧기에 역방향 참조하면 메모리 누수가 있을 수 있다. 이러한 의존관계는 컴파일 타임에 체크하게 되고, 문제가 생기면 빌드를 중단한다.
Component와 SubComponent의 차이점
Dagger에서는 여러 컴포넌트를 정의하고 인스턴스화하는게 가능하지만, Hilt에서는 표준컴포넌트를 사용하고 계층을 이루고 있다. 엄밀히 따지면 @SingleTon 컴포넌트만 컴포넌트이고 나머지는 모두 SubComponent이다. Hilt에서 SubCompoennt 인스턴스화 하는 방법은 안드로이드 클래스에 @AndroidEntryPoint를 선언하는 것이다.
참고자료
'🗡️Hilt' 카테고리의 다른 글
[Hilt] 타입과 의존성 (0) | 2024.08.27 |
---|---|
[Hilt] Hilt 내부 동작 원리 (0) | 2024.04.10 |