DI라고도 줄여쓰는 Dependency Injection는 다양한 우리 말 번역이 있지만, 이 글에서는 의존성 주입이라는 말로 사용하고자 한다.
A가 B를 의존한다
는 표현은 어떤 의미일까? 추상적인 표현이지만, 토비의 스프링에서는 다음과 같이 정의한다.
의존대상 B가 변하면, 그것이 A에 영향을 미친다.
- 이일민, 토비의 스프링 3.1, 에이콘(2012), p113
즉, B의 기능이 추가 또는 변경되거나 형식이 바뀌면 그 영향이 A에 미친다.
예를 들면, Car
라는 클래스는 Engine
이라는 클래스를 참조하고 있다. 이렇게 클래스를 필요로하는 것을 의존성(dependency)이라고 한다.
특정 클래스가 자신이 의존하고 있는 객체를 얻는 방법은 3가지가 있다.
Context
, getSystemService()
등에 해당한다.세 번째 방법이 바로 Dependency Injection 기법 중 하나이다
DI를 사용하지 않을 때
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
이 코드는 아래의 문제가 있다.
Car
와 Engine
은 결합(의존성)이 강하다.Car
는 Engine
의 자식 클래스를 사용하기가 쉽지 않아진다.Engine
을 직접 생성하면 Car
를 재사용할 때, Engine
을 상속한 Gas
나 Electric
이라는 클래스를 재사용하기가 쉽지 않다.DI 사용
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
Car
를 재사용할 수 있다. Car
클래스에게 다른 Engine
의 구현들을 전달할 수 있다. Engine
의 자식 클래스로 Electric
을 Car
에게 전달할 수 있다. Car
는 별다른 수정 없이 새로운 기능을 동작할 수 있다. Car
를 테스트하기 쉽다.FakeEngine
이라는 클래스를 작성하는 등 Engine
타입을 여러 방면으로 테스트해 볼 수 있다.DI 사용 시 단점으로는
안드로이드에서는 이 의존성 주입을 작성하는 두 가지 방법이 있다.
Constructor Injection (생성자를 통한 주입)
위에서 설명한 방법대로, 생성자 파라미터를 통해 의존성을 주입해주는 것이다.
Field Injection (setter를 통한 주입)
안드로이드에서는 Activity나 Fragment는 시스템이 생성하기 때문에 1번의 방법이 불가능하다.
참조가 필요한 클래스를 먼저 생성한 다음에 의존성을 주입하는 방법이다. 아래 코드와 같다.
class Car {
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
이전 예제에서는 특정 라이브러리에 의존하지 않고 의존성을 직접 생성하고 관리하고 제공했다.
이를 수동으로 관리하는 의존성 주입이라고 한다.
Car
의 예제에서는 의존성이 한 클래스에만 있었지만 더 많은 클래스에 의존성을 가지게 되면 관리가 힘들어질 수 있다.
수동 의존성 주입의 단점
큰 앱은 의존성이 필요한 클래스들을 연결하려면 불필요한 코드가 마구 생성되게 된다.여러 레이어를 가질 경우 최상위 객체를 가지기 위해서 그 아래 모든 계층의 객체가 필요하게 된다.
예를 들면 자동차를 만들려면 엔진, 변속기, 섀시 및 기타 부품이 필요할 수 있다. 엔진에는 실린더와 점화 플러그가 필요하다.
전달하기 전에 종속성을 생성할 수 없는 경우, lazy init과 같은 코드를 작성하고 수명을 직접 관리해야한다.
Dagger와 Hilt는 구글에서 만들긴 했지만 Dagger는 안드로이드 뿐만 아니라 자바 앱에서도 동작하도록 디자인되어있다.
Dagger는 의존성을 직접 관리하여 앱에서 쉽게 사용할 수 있도록 한다. Dagger1는 컴파일 타임에 의존성을 연결해서 리플렉션 기반으로 의존성을 주입하는 것에 대한 성능 이슈를 해결한다.
Hilt는 Dagger를 기반으로 빌드되어 Dagger를 Android 애플리케이션에 통합하는 표준 방법을 제공한다.
SingletonComponent, ActivityComponent, FragmentComponent등 안드로이드 컴포넌트 생명주기에 맞게 인스턴스를 관리하는 방법을 제공한다.
안드로이드에서 종속성 주입에는 Hilt를 사용하도록 권고하고 있다
Dagger와 관련하여 Hilt의 목표는 다음과 같다.
Android 운영체제는 많은 자체 프레임워크 클래스를 인스턴스화하므로 Android 앱에서 Dagger를 사용하려면 상당한 양의 상용구(보일러 플레이트) 코드를 작성해야 한다.
Hilt는 Android 애플리케이션에서 Dagger 사용과 관련된 상용구 코드를 줄일 수 있다.
Hilt는 자동으로 다음을 생성하고 제공합니다.
이제 Hilt에 대해 알아보겠습니다.
Hilt의 경우 프로젝트 레벨에 종속성을 추가적으로 해야한다
buildscript {
...
dependencies {
...
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1'
}
}
hilt-android-gradle-plugin 플러그인을 프로젝트의 루트(프로젝트 레벨) build.gradle 파일에 추가합니다.
그다음 app 레벨에서 또한 종속성을 추가한다
...
plugins {
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
...
}
dependencies {
implementation "com.google.dagger:hilt-android:2.38.1"
kapt "com.google.dagger:hilt-compiler:2.38.1"
}
참고: Hilt와 데이터 결합을 모두 사용하는 프로젝트에는 Android 스튜디오 4.0 이상이 필요합니다.
참고로 Hilt는 JAVA8을 사용하므로, project에서 java8을 사용한다고 아래와 같이 app레벨의 build.gradle에 추가해 줍니다.
android {
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
Hilt를 사용하는 모든 앱은 @HiltAndroidApp
으로 주석이 지정된 Application 클래스를 포함해야 합니다.
왜냐하면 애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여
Hilt의 코드 생성을 트리거해야 하기 때문이다.
@HiltAndroidApp
class ExampleApplication : Application() { ... }
위처럼 Application 클래스에 Hilt를 설정해서 애플리케이션 수준 구성요소를 사용할 수 있게 되면
Hilt는 @AndroidEntryPoint
주석이 있는 다른 클래스에 종속 항목을 제공할 수 있습니다.
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() { ... }
아래 클래스들에 @AndroidEntryPoint
annotation을 붙여주면,
Hilt가 해당 클래스에 Dependency를 제공해 줄 수 있는 Component를 생성해 줍니다.
Android 클래스에 관한 Hilt 지원에는 다음과 같은 예외가 적용됩니다.
- AppCompatActivity와 같은 ComponentActivity를 확장하는 활동만 지원
- androidx.Fragment를 확장하는 프래그먼트만 지원
->implementation "androidx.fragment:fragment-ktx:1.3.2"
추가해야함- 보존된 프래그먼트를 지원하지 않습니다.
@AndroidEntryPoint는 프로젝트의 각 Android 클래스에 관한 개별 Hilt 구성요소를 생성합니다.
이러한 구성요소는 구성요소 계층 구조에 설명된 대로 해당하는 각 상위 클래스에서 종속 항목을 받을 수 있습니다.
구성요소에서 종속 항목을 가져오려면 다음과 같이 @Inject
주석을 사용하여 필드 삽입을 실행합니다.
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {
@Inject lateinit var analytics: AnalyticsAdapter
...
}
클래스의 인스턴스를 제공하는 방법을 다음과 같이 @Inject
주석을 사용하여 Hilt에 알려주어 생성자 삽입을 실행합니다.
class AnalyticsAdapter @Inject constructor(
private val service: AnalyticsService
) { ... }
주석이 지정된 클래스 생성자의 매개변수service
는 클래스의 종속 항목입니다.
따라서 Hilt는 AnalyticsService 클래스의 인스턴스를 제공하는 방법도 알아야 합니다.
위에서 했던 것과 같이 @Inject Annotation을 붙이는 방법말고,
Module을 이용해서 Hilt에게 원하는 Dependency를 생성하는 방법을 알려줄 수 있습니다.
아래와 같이 생성자 삽입을 할 수 없는 상황에도 쓰입니다.
이럴 때는 Hilt 모듈을 사용하여 Hilt에 결합 정보를 제공할 수 있습니다.
Module클래스를 생성할 때 가장 먼저 할 것은 @Module
Annotation을 붙여주는 것 입니다.
그래야 Hilt가 여기가 Module이 있는 곳임을 알 수 있겠지요.
다음으로 @InstallIn
Annotation을 붙여줍니다.
@InstallIn(ActivityComponent::class)
는 해당 모듈이 acitivity에서 사용가능하다고 선언하다는 의미입니다.
다른 Component에서 사용가능하다로고 하려면 해당 Component의 이름을 넣어주면 됩니다.
component 참고
@Module
@InstallIn(ActivityComponent::class)
class AModule {
...
}
외부 라이브러리인 클래스(Retrofit, OkHttpClient, Room 등) 또는 빌더 패턴으로 인스턴스를 생성해야 하는 경우에 사용
주석이 달린 함수는 Hilt에 다음 정보를 제공합니다.
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {
@Singleton
@Provides
fun provideAnalyticsService(): AnalyticsService {
return Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(AnalyticsService::class.java)
}
}
위처럼 할 경우 AnalyticsService를 주입(inject)받는 클래스에 Hilt가 종속항목을 제공해줍니다.
class AnalyticsAdapter @Inject constructor( private val service: AnalyticsService ) { ... }
같은 타입을 반환해야 할 경우 각각 annotation class를 붙여 이름을 지정합니다.
@Qualifier
@Retention(AnnotationRetention.BINARY)
Annotation을 붙여서 구분할 Identifier라고 Hilt에 알려주구요.
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient
위처럼 Annotation을 생성한 다음 아래처럼 Annotation을 붙여서 구분해서 생성합니다.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@AuthInterceptorOkHttpClient
@Provides
fun provideAuthInterceptorOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@OtherInterceptorOkHttpClient
@Provides
fun provideOtherInterceptorOkHttpClient(
otherInterceptor: OtherInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(otherInterceptor)
.build()
}
}
interface타입의 객체를 어떻게 만드는지 Hilt에게 알려주기 위한 용도로 사용
주석이 지정된 함수는 Hilt에 다음 정보를 제공합니다.
다음과 같이 인터페이스와 이를 구현한 클래스가 있을 경우
interface AnalyticsService {
fun analyticsMethods()
}
class AnalyticsServiceImpl @Inject constructor(
...
) : AnalyticsService { ... }
아래와 같이 Module 클래스를 설정할 수 있습니다.
@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindAnalyticsService(
analyticsServiceImpl: AnalyticsServiceImpl
): AnalyticsService
}
위처럼 할 경우 AnalyticsService를 주입(inject)받는 클래스에 Hilt가 모듈을 따라 구현한 종속 항목을 제공해줍니다.
class AnalyticsAdapter @Inject constructor( private val service: AnalyticsService ) { ... }
애플리케이션 또는 활동의 Context 클래스가 필요할 수 있으므로
Hilt는 @ApplicationContext
및 @ActivityContext
한정자를 제공합니다.
class AnalyticsAdapter @Inject constructor(
@ApplicationContext private val applicationContext: Context,
@ActivityContext private val activityContext: Context,
) { ... }
activityContext의 경우는 다음과 같이 어노테이션이 없이 사용될 수 있습니다.
class AnalyticsAdapter @Inject constructor(
activity: FragmentActivity
) { ... }
Hilt는 컴포넌트(구성요소)들을 이용해서 필요한 Dependency를 클래스에 제공할 수 있게 해 줍니다.
@AndroidEntryPoint
annotation을 붙여주면
Hilt가 해당 클래스에 Dependency를 제공해 줄 수 있는 Component를 생성해 줍니다.
이전 예시 Hilt 모듈에서는 @InstallIn
으로 ActivityComponent를 사용하는 방법을 사용했습니다.
Hilt는 다음 구성요소를 제공합니다.
기본적으로 Hilt의 모든 결합은 범위가 지정되지 않는다.
앱이 결합을 요청할 때마다 Hilt는 필요한 유형의 새 인스턴스를 생성합니다.
그러나 Hilt는 결합을 특정 구성요소로 범위 지정할 수도 있습니다.
Hilt는 결합의 범위가 지정된 구성요소의 인스턴스마다 한 번만 범위가 지정된 결합을 생성하며 이 결합에 관한 모든 요청은 동일한 인스턴스를 공유합니다.
제공된 객체는 구성요소가 제거될 때까지 메모리에 남아 있기 때문에 결합의 범위를 그 구성요소로 지정하면 많은 비용이 들 수 있습니다. 따라서 애플리케이션에서 범위가 지정된 결합의 사용을 최소화하세요.
특정 범위 내에서 동일한 인스턴스를 사용해야 하는 내부 상태가 있는 결합 또는 동기화가 필요한 결합, 만드는 데 비용이 많이 들 것으로 측정된 결합에는 구성요소 범위 지정 결합을 사용하는 것이 적절합니다.
Provides에서 @Singleton
을 사용한 예제가 있습니다.
Hilt가 생성한 구성요소에는 다음과 같은 생명주기가 적용됩니다.
구성요소에 모듈을 설치하면 이 구성요소의 다른 결합 또는 구성요소 계층 구조에서
그 아래에 있는 하위 구성요소의 다른 결합의 종속 항목으로 설치된 모듈의 결합에 액세스할 수 있습니다.
Hilt가 지원하지 않는 클래스에 필드 삽입을 실행해야 할 수도 있습니다.
이러한 경우@AndroidEntryPoint
대신 @EntryPoint
주석을 사용하여 진입점을 만들 수 있습니다.
진입점은 Hilt가 관리하는 코드와 그렇지 않은 코드 사이의 경계입니다.
Hilt를 사용하여 일부 종속 항목을 가져오도록 하려면 원하는 결합 유형마다 @EntryPoint
로 주석이 지정된 인터페이스를 정의하고 한정자를 포함해야 합니다.
그리고 다음과 같이 @InstallIn
을 추가하여 진입점을 설치할 구성요소를 지정합니다.
class ExampleContentProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ExampleContentProviderEntryPoint {
fun analyticsService(): AnalyticsService
}
...
}
EntryPointAccessors
의 정적 메서드인 fromApplication
를 호출해서 객체를 가져옵니다.
EntryPointAccessors.fromApplication(application context
, 인터페이스
::class.java)
class ExampleContentProvider: ContentProvider() {
...
override fun query(...): Cursor {
val appContext = context?.applicationContext ?: throw IllegalStateException()
val hiltEntryPoint =
EntryPointAccessors.fromApplication(appContext, ExampleContentProviderEntryPoint::class.java)
val analyticsService = hiltEntryPoint.analyticsService()
...
}
}
https://medium.com/androiddevelopers/scoping-in-android-and-hilt-c2e5222317c0
https://developer88.tistory.com/349
https://tecoble.techcourse.co.kr/post/2021-04-27-dependency-injection/