[Android] 의존성 주입과 Hilt

이제일·2022년 8월 31일
1

Android

목록 보기
8/15
post-thumbnail

Dependency Injection

DI라고도 줄여쓰는 Dependency Injection는 다양한 우리 말 번역이 있지만, 이 글에서는 의존성 주입이라는 말로 사용하고자 한다.

A가 B를 의존한다는 표현은 어떤 의미일까? 추상적인 표현이지만, 토비의 스프링에서는 다음과 같이 정의한다.

의존대상 B가 변하면, 그것이 A에 영향을 미친다.

  • 이일민, 토비의 스프링 3.1, 에이콘(2012), p113

즉, B의 기능이 추가 또는 변경되거나 형식이 바뀌면 그 영향이 A에 미친다.

예를 들면, Car라는 클래스는 Engine이라는 클래스를 참조하고 있다. 이렇게 클래스를 필요로하는 것을 의존성(dependency)이라고 한다.

특정 클래스가 자신이 의존하고 있는 객체를 얻는 방법은 3가지가 있다.

  1. Car 클래스 안에서 Engine 인스턴스를 생성하여 초기화한다.
  2. 다른 곳에서 객체를 가져온다. Android 로 치면 Context, getSystemService() 등에 해당한다.
  3. 객체를 파라미터로 제공받는다. Car 의 생성자가 Engine 을 파라미터로 받는다.

세 번째 방법이 바로 Dependency Injection 기법 중 하나이다

DI를 사용하지 않을 때

class Car {
    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

이 코드는 아래의 문제가 있다.

  • CarEngine은 결합(의존성)이 강하다.
  • CarEngine의 자식 클래스를 사용하기가 쉽지 않아진다.
  • Engine을 직접 생성하면 Car를 재사용할 때, Engine을 상속한 GasElectric이라는 클래스를 재사용하기가 쉽지 않다.
  • 테스트를 어렵게 만든다. Engine 의 실제 인스턴스를 사용하기 때문에 다양한 시나리오를 고려하지 못한다. (Unit Test 불리)
  • Engine 의 생성자가 변경된 경우 Car 클래스에서도 수정이 이루어져야 한다.

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의 자식 클래스로 ElectricCar에게 전달할 수 있다. Car는 별다른 수정 없이 새로운 기능을 동작할 수 있다. 
  • Engine 의 생성자 등 구현이 변경되어도, Car 클래스를 수정하지 않아도 된다.
  • Car를 테스트하기 쉽다.FakeEngine이라는 클래스를 작성하는 등 Engine타입을 여러 방면으로 테스트해 볼 수 있다.

DI 사용 시 단점으로는

  • 간단한 프로그램을 만들 때는 번거로움
  • 코드의 가독성을 떨어뜨릴 수 있음

DI from Android

안드로이드에서는 이 의존성 주입을 작성하는 두 가지 방법이 있다. 

  • 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()
}

Dagger? Hilt?

이전 예제에서는 특정 라이브러리에 의존하지 않고 의존성을 직접 생성하고 관리하고 제공했다.
이를 수동으로 관리하는 의존성 주입이라고 한다.
Car의 예제에서는 의존성이 한 클래스에만 있었지만 더 많은 클래스에 의존성을 가지게 되면 관리가 힘들어질 수 있다. 

수동 의존성 주입의 단점

  • 큰 앱은 의존성이 필요한 클래스들을 연결하려면 불필요한 코드가 마구 생성되게 된다.여러 레이어를 가질 경우 최상위 객체를 가지기 위해서 그 아래 모든 계층의 객체가 필요하게 된다.
    예를 들면 자동차를 만들려면 엔진, 변속기, 섀시 및 기타 부품이 필요할 수 있다. 엔진에는 실린더와 점화 플러그가 필요하다. 

  • 전달하기 전에 종속성을 생성할 수 없는 경우, lazy init과 같은 코드를 작성하고 수명을 직접 관리해야한다.


Android에서는 이러한 의존성 주입을 자동으로 해주는 라이브러리로 `Dagger`와 `Hilt`가 있다.

Dagger

Dagger와 Hilt는 구글에서 만들긴 했지만 Dagger는 안드로이드 뿐만 아니라 자바 앱에서도 동작하도록 디자인되어있다.
Dagger는 의존성을 직접 관리하여 앱에서 쉽게 사용할 수 있도록 한다. Dagger1는 컴파일 타임에 의존성을 연결해서 리플렉션 기반으로 의존성을 주입하는 것에 대한 성능 이슈를 해결한다. 

Hilt

Hilt는 Dagger를 기반으로 빌드되어 Dagger를 Android 애플리케이션에 통합하는 표준 방법을 제공한다.
SingletonComponent, ActivityComponent, FragmentComponent등 안드로이드 컴포넌트 생명주기에 맞게 인스턴스를 관리하는 방법을 제공한다. 

안드로이드에서 종속성 주입에는 Hilt를 사용하도록 권고하고 있다

Hilt 및 Dagger - 공식 문서

Dagger와 관련하여 Hilt의 목표는 다음과 같다.

  • Android 앱을 위한 Dagger 관련 인프라 간소화
  • 앱 간의 설정, 가독성 및 코드 공유를 용이하게 하기 위한 표준 구성요소 및 범위 세트 생성
  • 테스트, 디버그 또는 출시와 같은 다양한 빌드 유형에 서로 다른 결합을 프로비저닝하는 쉬운 방법 제공

Android 운영체제는 많은 자체 프레임워크 클래스를 인스턴스화하므로 Android 앱에서 Dagger를 사용하려면 상당한 양의 상용구(보일러 플레이트) 코드를 작성해야 한다.
Hilt는 Android 애플리케이션에서 Dagger 사용과 관련된 상용구 코드를 줄일 수 있다.
Hilt는 자동으로 다음을 생성하고 제공합니다.

  • 수동으로 생성해야 하는 Dagger와 Android 프레임워크 클래스를 통합하기 위한 구성요소
  • Hilt가 자동으로 생성하는 구성요소와 함께 사용할 범위 주석
  • Application 또는 Activity와 같은 Android 클래스를 나타내는 사전 정의된 결합
  • @ApplicationContext 및 @ActivityContext를 나타내는 사전 정의된 한정자

Start 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
    }
}

Application Class

Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 주석이 지정된 Application 클래스를 포함해야 합니다.

왜냐하면 애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여
Hilt의 코드 생성을 트리거해야 하기 때문이다.

@HiltAndroidApp
class ExampleApplication : Application() { ... }

Hilt 결합 정의

위처럼 Application 클래스에 Hilt를 설정해서 애플리케이션 수준 구성요소를 사용할 수 있게 되면
Hilt는 @AndroidEntryPoint 주석이 있는 다른 클래스에 종속 항목을 제공할 수 있습니다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() { ... }

아래 클래스들에 @AndroidEntryPoint annotation을 붙여주면,
Hilt가 해당 클래스에 Dependency를 제공해 줄 수 있는 Component를 생성해 줍니다.

  • Application(@HiltAndroidApp)
  • ViewModel(@HiltViewModel)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

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 클래스의 인스턴스를 제공하는 방법도 알아야 합니다.

Hilt 모듈

위에서 했던 것과 같이 @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 {
	...
}

Provides

외부 라이브러리인 클래스(Retrofit, OkHttpClient, Room 등) 또는 빌더 패턴으로 인스턴스를 생성해야 하는 경우에 사용

주석이 달린 함수는 Hilt에 다음 정보를 제공합니다.

  • 함수의 반환형은 함수가 어떤 유형의 인스턴스를 제공하는지 Hilt에 알려줍니다.
  • 함수 매개변수는 해당 유형의 종속 항목을 Hilt에 알려줍니다.
  • 함수 본문은 해당 유형의 인스턴스를 제공하는 방법을 Hilt에 알려줍니다. 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
) { ... }

같은 타입의 객체에 대한 Dependency

같은 타입을 반환해야 할 경우 각각 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()
  }
}

Binds

interface타입의 객체를 어떻게 만드는지 Hilt에게 알려주기 위한 용도로 사용

주석이 지정된 함수는 Hilt에 다음 정보를 제공합니다.

  • 함수 반환 유형은 함수가 어떤 인터페이스의 인스턴스를 제공하는지 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가 지원하지 않는 클래스에 종속 항목 삽입

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://lovestudycom.tistory.com/entry/Android-Hilt%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85

https://tecoble.techcourse.co.kr/post/2021-04-27-dependency-injection/

https://velog.io/@haero_kim/Android-DI-%EA%B0%9C%EB%85%90-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%97%86%EC%9D%B4-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0

profile
세상 제일 이제일

0개의 댓글