안드로이드 Hilt 공부를 위한 첫 걸음

임현주·2022년 10월 19일
0
post-thumbnail

시작

앞으로 진행하는 프로젝트에 Hilt를 적용해보고 싶은 개발자가 옥수환님께서 발표하신 드로이드나이츠 2020 - Hilt와 함께 하는 안드로이드 의존성 주입 영상을 보고 정리한 글입니다. 혹시 잘못 이해하고 작성한 부분이나 오타에 대한 피드백을 주신다면 감사히 받겠습니다 🙇🏻‍♀️


의존성 주입 (Dependency Injection)

생성자 또는 메서드 등을 통해 외부로부터 생성된 객체를 전달받는 것

의존성 주입의 특징

  • 클래스 간 결합도를 느슨하게 한다.
  • 인터페이스 기반으로 설계되며, 코드를 유연하게 한다.
  • Stub 또는 Mock 객체를 사용하여 단위 테스트를 하기 더욱 쉬워진다.

안드로이드에서 의존성 주입이 어려운 이유

  • Android 클래스가 프레임워크에 의해 인스턴스화 됨
    ➡️ 개발자가 해당 클래스 내부에 생성자를 만들거나 생성자의 매개변수를 전달할 방법이 없다.
  • Factory를 API28부터 제공하지만 현실적이지 않음

Dagger2

자바와 안드로이드를 위한 강력하고 빠른 의존성 주입 프레임워크

  • 컴파일 타임에 어노테이션 프로세스를 사용하여 의존성 주입에 관련된 모든 코드 생성
    ➡️ 명확하고 디버깅 가능하며 리플렉션을 사용하거나 런타임에 바이트 코드를 생성하지 않는다.
  • 생명주기와 계층 별로 잘 정리된 오브젝트 그래프에서 객체들을 공유할 수 있는 방법을 제공한다.
  • 작은 라이브러리 크기

단점

  • 배우기 어렵고, 프로젝트 설정이 힘들다.
  • 간단한 프로그램을 만들 때는 번거롭다.
  • 같은 결과에 대한 다양한 방법이 존재한다.

개발자 중 49%가 DI 솔루션 개선을 요청함 😵


의존성 주입 프레임워크의 궁극적인 목표

  • 정확한 사용방법을 제안
  • 쉬운 설정 방법
  • 중요한 것들에 집중할 수 있도록 함
    • 프로젝트 빌드를 시작한 뒤에 사소한 문제로 인해 컴파일 에러 발생
      → 장점이자 단점, 너무 빈번해지면 개발자의 생산성을 저하시키는 요소가 된다.

Hilt 🗡

DI를 사용하는 표준적인 방법을 제공한다.
➡️ 중구난방이었던 Dagger의 사용법을 획일적이게 만든다는 것 ❗️


Hilt의 목표

  • Dagger 사용의 단순화
  • 표준화된 컴포넌트 세트와 스코프로 설정과 가독성 / 이해도 쉽게 만들기
  • 쉬운 방법으로 다양한 빌드 타입에 대해 다른 바인딩 제공

특징

  • Dagger2 기반의 라이브러리
  • 표준화된 Dagger2 사용법을 제시
  • 보일러 플레이트 코드 감소
    ➡️ Google IO 앱을 Hilt로 리팩토링한 결과, 의존성 주입 코드를 75% 삭제했다고 함 👀
  • 프로젝트 설정 간소화
  • 쉬운 모듈 탐색과 통합
  • 개선된 테스트 환경
  • Android Studio의 지원
    ➡️ 4.2 버전 이후 부터는 Hilt의 오브젝트 그래프를 가시화하여 볼 수 있음
  • AndroidX 라이브러리의 호환 (WorkManager, ViewModel 등)

Object graph

// @Inject : 의존성 주입을 받겠다.
class MemoRepository @Inject constructor(
    private val db: MemoDatabase
) {
    fun load(id: String) {...}
}
// @HiltAndroidApp : Hilt 사용시 반드시 선행 되어야 하는 부분, 모든 의존성 주입의 시작점
@HiltAndroidApp
class MemoApp : Application()

// @AndroidEntryPoint : 안드로이드 클래스에 추가,
// Activity 안에 선언 된 @Inject 어노테이션에 대해 의존성 주입 수행
@AndroidEntryPoint
class MemeActivity : AppCompatActivity() {

    @Inject lateinit var repository: MemoRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        repository.load("YHLQMDLG")
    }
}
// @InstallIn : 해당 컴포넌트의 모듈이 설치 되게 한다.
@InstallIn(ApplicationComponent::class)
@Module
object DataModule {

		// @Provides : 모듈 클래스 내에 데이터베이스 객체 생성
    @Provides
    fun provideMemoDB(@ApplicationContext context: Context) =
        Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
            .build()
}

  1. @HiltAndroidApp 를 통해 ApplicationComponent가 생성된다.
  2. @InstallIn 를 모듈 클래스에 추가하여 해당 컴포넌트의 모듈이 설치 되게 한다.
  3. @AndroidEntryPoint 를 Activity에 추가함으로써 ApplicationCompoenet의 하위 컴포넌트인 ActivityComponent가 생성되고 MemoRepository 객체를 주입 받을 수 있게 된다.

@HiltAndroidApp 없이 컴포넌트 생성하기

class MemoApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        val component = DaggerMemoComponent.builder()
            ...
            .build()
        component.inject(this)
    }
}

정의된 Component는 컴파일 타임에 Dagger라는 접두어가 붙은 Component 클래스를 생성하게 되고,
Application의 onCreate() 메소드에서 컴포넌트를 인스턴스화하는 것이 일반적인 형태였다.

하지만 @HiltAndroidApp를 사용하면?

@HiltAndroidApp // 위의 과정을 생략하고, @HiltAndroidApp만 추가하면 된다.
class MemoApplication : Application() {
    
    override fun onCreate() {
        super.onCreate() // 의존성 주입은 super.onCreate()에서 이루어짐 (bytecode 변환)
    }
}

@HiltAndroidApp : Hilt 코드 생성을 시작, 반드시 Application 클래스에 추가
해당 어노테이션 추가만으로 ApplicationComponent 코드를 생성 및 인스턴스화 하는 코드가 만들어진다.

컴포넌트를 인스턴스화 하는 부분은 상위 클래스의 onCreate() 에서 이루어진다.
➡️ 바이크 코드 변환 때문에 이러한 과정이 가능하다.

@HiltAndroidApp 이 붙은 MemoApplication 클래스는 컴파일 타임에 Hilt 접두어가 붙은 Hilt_MemoApplication 클래스를 생성한다. 생성된 Hilt 클래스는 Base 클래스가 상속한 클래스를 똑같이 상속하게 된다. 예제에서는 Base 클래스가 Application을 상속했기 때문에 생성된 Hilt_MemoApplication 클래스도 Application 클래스를 상속하고 있는 것을 확인할 수 있다. 생성된 Hilt 클래스는 컴포넌트의 인스턴스 및 의존성 주입 관련 코드들을 포함하고 있다.

Hilt_MemoApplication을 상속해야할 것 같지만 실제로는 그러지 않아도 된다❗️

MemoApplication 소스코드는 컴파일을 거쳐 바이트 코드를 산출하고, 그 이후에 Gradle 플러그인이 개입하여 바이트 코드를 조작하기 때문이다.

즉, MemoApplication 바이트 코드는 Hilt_MemoApplication를 상속하는 코드로 변환이 되는데 이는 개발자의 편의성을 위해 도모되었으며, 만약 바이트 코드 변환을 원하지 않는다면 Gradle 플러그인을 비활성화 시키면 된다.

@HiltAndroidApp(Application::class)
class MyApplication : Hilt_MyApplication()

만약 Gradle 플러그인을 사용하지 않는다면 @HiltAndroidApp 에 Application 클래스가 상속할 클래스를 명시하고, 실제로 상속하는 클래스는 생성된 Hilt_MyApplication 클래스가 되어야한다.


@AndroidEntryPoint

어노테이션이 추가된 안드로이드 클래스에 DI 컨테이너를 추가
@HiltAndroidApp 의 설정 후 사용 가능

Dagger2 관점에서 보면

  • @HiltAndroidApp → Component 생성
  • @AndroidEntryPoint → Subcomponent 생성

@AndroidEntryPoint를 지원하는 타입

  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

(ContentProvider는 까다로운 생명주기 때문에 지원하지 않기 때문에 다른 방법으로 의존성 주입이 가능하다.)


Hilt Component의 계층구조

  • Hilt는 이미 정의된 표준화된 컴포넌트 세트를 제공한다.
  • @AndroidEntryPoint 를 사용하여 해당 타입에 맞는 컴포넌트를 추가하게 된다.
  • 하위 컴포넌트는 상위 컴포넌트가 가지고 있는 의존성에 대해 접근할 수 있다. ➡️ 직계 수직 관계에서만 접근이 가능하다.

Hilt Component 특징

  • Dagger와 다르게 직접적으로 인스턴스화할 필요가 없다. (Hilt는 바이트 코드 변환을 사용하기 때문)
  • 각 생명주기와 기능에 알맞은 표준화된 컴포넌트 세트와 스코프를 제공한다.
  • 컴포넌트들은 계층으로 이루어져 있으며, 하위 컴포넌트는 상위 컴포넌트의 의존성에 접근할 수 있다. (=Subcomponent)

Hilt Scope

  • 표준화된 스코프를 제공한다.
  • 모듈 클래스에서 Scope 어노테이션을 사용하여 동일 인스턴스를 공유할 수 있다.
  • retained가 붙어있는 scope는 Configuration(language, orientation 등)에도 유지된다.

Scoped Binding

// No scope annotation
class MemoRepository @Inject constructor(
    private val db: MemoDatabase
) {
    fun load(id: String) { ... }
}

위 코드처럼 Scope를 지정해주지 않으면 MemoRepository를 다른 Activity에서 inject 하게 되면 매번 새로 생성하기 때문에 서로 다른 인스턴스가 되어버린다.

@Singleton
class MemoRepository @Inject constructor(
    private val db: MemoDatabase) 
{
    fun load(id: String) { ... }
}

모듈에서 사용되는 Scope 어노테이션은 반드시 InstallIn에 명시된 컴포넌트와 쌍을 이루는 Scope를 사용해야 한다.

두 Activity가 ApplicationComponent에 설치된 모듈로부터 동일한 MemoRepository 인스턴스를 주입받은 것을 확인할 수 있다.
이처럼 Hilt는 자원 공유를 쉽게 할 수 있도록 도와준다.


기본 컴포넌트 바인딩

컴포넌트는 기본적으로 아래와 같은 객체를 그래프에 바인딩한다.

Dagger의 @BindsInstance 을 사용하지 않아도 컴포넌트에 따라 Application, Activity, Context 등과 같은 인스턴스를 제공받을 수 있다.
대신 Context를 요청할 때는 @ApplicationContext 또는 @ActivityContext 를 사용하여 요청하는 Context의 종류를 명확히 해주어야 한다.


HiltModule

@InstallIn

  • Hilt가 생성하는 DI 컨테이너에 어떤 모듈을 사용할지 가리킨다.
  • 해당 모듈이 어떤 컴포넌트에 설치될 것인지 명시해야 하고, Hilt는 이를 보고 컴파일 타임에 관련 코드를 생성한다.
    ( 중요❗️- 올바르지 않은 컴포넌트 또는 스코프를 사용하면 컴파일 에러를 발생시킨다.)
@InstallIn(ActivityComponent::class)
@Module
object MyModule {
    ...
}

아래의 경우, ActivityComponent를 명시했기 때문에 당연히 ActivityComponent에 설치된다.

🤔 하지만 만약 AcitivtyComponent와 FragmentComponent 모두 MyModule이 필요하다면?

하위 컴포넌트는 상위 컴포넌트의 의존성에 Access 할 수 있기 때문에 상위 컴포넌트의 모듈을 설치하는 것을 고려할 수 있다.
ApplicationComponent의 모듈을 설치하면 모든 컴포넌트들이 의존성에 접근할 수 있다.

따라서, ApplicationComponent 혹은 AcitivityComponent에 설치하면 된다.

Hilt Module의 제약사항

@Module 클래스에 @InstallIn이 없으면 컴파일 에러를 발생시킨다. (모듈이 어느 컴포넌트에 설치되는지 명확하게 확인하기 위해 )

// @InstallIn 검사 비활성화
android {
    defaultConfig {
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["dagger.hilt.disableModulesHaveInstallInCheck":"true"]
            }
        }
    }
}

만약 Dagger를 사용하는 프로젝트를 Hilt로 마이그레이션 해야 하는 경우, build.gradle(Module) 파일에 해당 내용을 추가해야 한다.

@EntryPoint

Hilt가 지원하지 않는 클래스에서 의존성이 필요한 경우 사용
(ex. ContentProvier, DFM, Dagger를 사용하지 않는 3rd-party 라이브러리 등)

  • @EntryPoint 는 인터페이스에서만 사용할 수 있다.
  • @InstallIn 을 사용해서 어떤 컴포넌트에 접근할 것인지 명시해야 한다.
  • EntryPoints 클래스의 정적 메서드를 통해 해당 컴포넌트 그래프에 접근할 수 있다.
// EntryPoint 생성하기
@EntryPoint
@InstallIn(ApplicationComponent::class)
intreface FooBarInterface {
    fun getBar: Bar
}

// EntryPoint로 접근하기
val bar = EntryPoints.get(
    application,
    FooBarInterface::class.java
).getBar()

@EntryPoint 예제 1 - ContentProvider

@AndroidEntryPoint 를 지원하지 않기 때문에 @EntryPoint 를 사용해야 한다.

@EntryPoint
@InstallIn(ApplicationComponent::class)
interface *MemoEntryPoint* {
    fun getRepository(): MemoRepository
}

MemoRepository를 제공받기 위해 MemoEntryPoint라는 인터페이스를 작성한다.

class MemoProvider: ContentProvider() {
    override fun query() {
        val entryPoint = EntryPointAccessors.fromApplication(context, MemoEntryPoint::class.java)
        val repository = entryPoint.getRepository()
    }
}

ContentProvider에서 ApplicationComponent에 있는 MemoRepository에 접근하기 위해 작성된 코드이다.

EntryPointAccessors

  • EntryPoints를 감싼 Util성 클래스
  • EntryPoint 를 쉽게 가져올 수 있도록 도와준다.

@EntryPoint 예제 2 - DFM

프로젝트를 DFM(Dynamic Featur Module) 구성하는 경우 기본 App 모듈에 의존하게 된다.

기본 App 모듈은 DFM을 참조할 수 없기 때문에 컴파일 타임에서 SubComponent 구조로 그래프를 생성하는 것이 불가능하다. 하지만 컴포넌트를 상속하는 구조인 Dagger의 Component dependency 기능을 사용하면 DFM에서 ApplicationComponent를 상속하는 컴포넌트를 생성할 수 있게 된다.

@Component(dependencies = [MemoEntryPoint::class])
interface MemoEditComponent {
    fun inject(activity: MemoEditActivity)
    
    @Component.Builder
    interface Builder {
        fun context(@BindInstance context: Context): Builder
        fun dependencies(entryPoint: MemoEntryPoint): Builder
        fun build(): MemoEditComponent
    }
}

먼저 DFM 쪽에 Dagger 컴포넌트 인터페이스를 정의한다. EntryPoint를 의존하는 MemoEditComponent를 만들면 EntryPoint가 제공하는 MemoRepository를 MemoEditComponent가 접근할 수 있게 된다.

class MemoEditActivity: AppCompatActivity() {

    @Inject
    lateinit var repository: MemoRepository
    
    ovrride fun onCreate(savedInstanceState: Bundle?) {
        DaggerMemoEditComponent.builder().context(this)
            .dependencies(EntryPointsAccessors.fromApplication(applicationContext, MemoEntryPoint::class.java))
            .build()
            .inject(this)
         
    super.onCreate(savedInstanceState)
    }
}

EntryPointsAccessors를 통해 ApplicationContext의 일부인 MemoEntryPoint를 받아 DaggerMemoEditComponent를 인스턴스화하고, 마침내 MemoEditActivity에 MemoRepository를 멤버 주입하는 것을 확인할 수 있다.


그 외 Hilt에 대한 내용들

AndroidX Extensions

Hilt는 Jetpack 라이브러리와 함께 사용할 수 있도록 확장(extension) 라이브러리를 제공한다.
현재 지원하는 Jetpack 컴포넌트로는 ViewModel과 WorkManager가 있다.


💉 Hilt로 ViewModel 주입하기

기존의 Dagger로 ViewModel을 주입하는 것은 상당히 까다로운 작업이었다. 컴포넌트 인스턴스화 끝난 후에 변경될 수 있는 동적인 매개변수 SavedStateHandle을 Dagger의 그래프에 포함시키는 것은 거의 불가능하다.

하지만 이 부분은 Square 사에서 만든 AssistedInject 라이브러리를 통해 해결이 가능하며 Hilt에도 이 기능이 기본적으로 포함되어 있다.
ViewModel을 주입하는 것을 Dagger와 AssistedInject로 직접 구현하려면 매우 복잡하지만 Hilt는 이를 단순화 시켜준다.

class MemoViewModel @ViewModelInject constructor(
    private val repository: MemoRepository,
    @Assisted private val savedStateHandle: SavedStateHandle
): ViewModel() { ... }

@AndroidEntryPoint
class MemoActivity: AppCompatActivity() {
    pirvate val viewModel: MemoViewModel by viewModels()
}
  1. ViewModel 생성자에 @ViewModelInject을 붙인다.
  2. ViewModel 생성자 매개변수인 savedStateHandle은 @Assisted를 추가적으로 덧붙인다.
    ➡️ 내부적으로 SavedStateHandle을 가져오는 Factory를 통해 동적인 주입이 가능해진다.
  3. MemoViewModel을 인스턴스화한다.

💉 Hilt로 Worker 주입하기

WorkManager의 Worker도 ViewModel과 동일한 방식으로 동작한다.
다만 @WorkerInject을 사용하는 것만 다르다.

class ExampleWorker @WorkerInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    workDependency: WorkerDependency
): Worker(appContext, workerParams) { ... }

@HiltAndroidApp
class ExampleApplication: Application(), Configuration.Provider {

    @Inject lateinit var workerFactory: HiltWorkerFactory
    
    override fun getWorkManagerConfiguration() = 
        Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
    
}
  1. Worker를 정의한다.
  2. Application 클래스에 Configuration.Provider 인터페이스 구현한다.
    (Hilt가 제공하는 HiltWorkerFactory를 주입받아 WorkManager를 설정한다.)

Custom Component

  • 표준 Hilt 컴포넌트 이외에 새로운 컴포넌트를 만드는 것
  • 커스텀 컴포넌트를 사용하면 복잡하고 이해하기 어려워지기 때문에 꼭 필요한 경우만 사용한다.

Hilt는 표준화된 컴포넌트를 가지고 있다. 하지만 간혹 컴포넌트 객체의 수명이 맞지 않거나 특정 기능을 필요로 하는 상황들이 생기기 마련이다. 이런 경우에는 커스텀 컴포넌트를 정의할 필요가 있다. 커스텀 컴포넌트의 생성은 그래프를 복잡하게 만들기 때문에 생성하기 전에 논리적으로 반드시 필요한 것인지 생각해야 한다.

커스텀 컴포넌트를 정의하는 것은 Dagger의 컴포넌트를 정의하는 것과 크게 다를 것이 없다. 다른 점은 DefineComponent를 사용한다는 것이다.

사용하는 Annotation

  • @DefineComponent
  • @DefineComponent.Builder

제약조건

  • 반드시 ApplicationComponent의 하위 계층의 컴포넌트로 만들어야 한다.
  • 표준 컴포넌트 계층 사이에 추가할 수 없다.

ex) ActivityComponent와 FragmentComponent 사이에 추가될 수 없다.


Hilt의 설계 철학

단일체로 된 컴포넌트
예를 들어, 각 Activity는 분리된 Component 인스턴스를 가지지만 정의된 컴포넌트 클래스는 하나로 공용된다.

Monolithic components 특징

  • Single binding key space
    특정 바인딩이 어디로부터 왔는지 추적하기 쉬워지고 코드량도 줄어든다.
  • 간단한 설정
    모듈이 설치될 수 있는 부분을 줄이기 때문에 설정 및 테스트가 간단해진다.
  • 생성되는 코드를 줄인다.
    모듈이 여러 SubComponent에 사용되면서 반복적으로 생기게 된다.
    ➡️ Activity, Fragment, View 등을 통해 급격하게 늘어날 수 있다.
profile
🐰 피드백은 언제나 환영합니다

0개의 댓글