Android

Android DI - Hilt 톺아보기

show2888 2025. 7. 30. 18:25
반응형

Hilt는 Dagger 기반으로 컴파일 타임에 코드 생성 → 런타임 오버헤드 최소화, 타입 안정성을 확보한다.

 

Hilt vs Koin

  Hilt Koin
분류 Dagger 기반 컴파일 타임 DI 라이브러리 Kotlin DSL 기반 런타임 DI 라이브러리
설정/문법 어노테이션(@Inject, @Module, @Provides, @HiltViewModel) DSL 모듈(module { single { … } viewModel { … } })
성능 컴파일 시 그래프 생성 → 런타임 오버헤드↓ 런타임 해석/해결 → 오버헤드↑(대부분 앱에선 체감 미미)
안정성 타입 안전성↑(빌드 타임 검증) 런타임 에러로 뒤늦게 발견될 수 있음
빌드 시간 어노테이션 처리로 느려질 수 있음 빠름(어노테이션 처리 없음)
안드로이드 통합 Jetpack과 완전한 통합(ViewModel, WorkManager 등) 통합 가능하나 수동 설정이 더 필요
범위/스코프 컴포넌트/스코프 체계 표준화 single, factory 등 단순 스코프
대규모/멀티모듈 적합(대규모 그래프, 멀티바인딩, Qualifier 체계적) 단순·중소형에 적합(대규모에선 설계 관리가 필요)
테스트 팩토리/바인딩 기반으로 엄격한 테스트 용이 모듈 교체로 쉽게 목킹 가능
커뮤니티/표준성 Android 권장 DI 흐름의 표준 경량/속도 위주 팀에서 선호


Hilt의 핵심 명령어

진입점 & 대상

  • @HiltAndroidApp: Application에 부착. DI 그래프의 시작점.
  • @AndroidEntryPoint: Activity, Fragment, Service, BroadcastReceiver 등에 부착해 주입 가능하게 함.
  • @HiltViewModel: ViewModel 생성자에 주입 사용.

 

 

의존성 선언/제공

  • @Inject:
    • 생성자 주입: class Repo @Inject constructor(api: Api)
    • 필드 주입: @Inject lateinit var repo: Repo
    • 메서드 주입: @Inject fun setX(x: X)
  • @Module + @InstallIn(...): 모듈(제공자) 정의 및 어느 컴포넌트에 설치할지 지정.
  • @Provides: 생성자 주입이 어려운 객체 생성 방법 제공.
  • @Binds: 인터페이스 ↔ 구현체 바인딩(추상 함수, 구현 없이 시그니처만).
  • @BindsInstance: 컴포넌트 빌드 시 런타임 값(예: String, Config) 주입.

 

같은 타입 구분자

  • @Named("..."), @Qualifier(커스텀 애노테이션): 같은 타입 다수 바인딩 구분.

 

Android Context 구분

  • @ApplicationContext, @ActivityContext: 적절한 컨텍스트 주입.

 

스코프(범위)

  • @Singleton, @ActivityRetainedScoped, @ActivityScoped, @FragmentScoped,
  • @ViewModelScoped, @ServiceScoped: 컴포넌트 생명주기에 맞춰 인스턴스 재사용.

 

멀티 바인딩

  • @IntoSet, @IntoMap(+ @StringKey 등): 여러 바인딩을 Set/Map으로 합치기.

 

자동 주입이 어려운 곳

  • @EntryPoint + EntryPointAccessors: Custom View, 3rd-party 콜백 등 수동 접근.


작동 순서

1. 코드 생성

Dagger 컴파일러(+ Hilt 컴파일러)와 Gradle 플러그인을 통해 어노테이션 빌드 타임에 @Inject, @Module, @AndroidEntryPoint 등을 분석해 컴포넌트 트리, 팩토리(*_Factory), 멤버 인젝터(*_MembersInjector) 등 코드 생성.

 

컴포넌트 구현체

  • DaggerSingletonComponent, DaggerActivityRetainedComponent, DaggerActivityComponent, DaggerFragmentComponent, DaggerViewModelComponent 등
  • 각 컴포넌트는 상위 컴포넌트로부터 필요한 바인딩을 상속받고, 자신의 스코프 캐시를 가진다

 

팩토리

  • Foo_Factory @Inject constructor나 @Provides 메서드를 “호출 코드”로 풀어낸 클래스
  • get()이 호출되면 내부에서 필요한 의존성의 Provider.get()을 호출해 실제 객체를 만든다. 스코프가 있으면 DoubleCheck 같은 래퍼로 캐싱

 

멤버 인젝터(MembersInjector)

  • 인스턴스를 받은 뒤 instance.field = provider.get() 또는 instance.setX(provider.get())를 호출해 멤버에 값을 채워 넣는다.
  • 상속 구조가 있으면 상위 타입 → 현재 타입 순서로 처리

 

안드로이드 컴포넌트용 베이스 클래스

  • Hilt_MyActivity, Hilt_MyFragment, Hilt_MyApp 등 내부적으로 Hilt Gradle 플러그인이 바이트코드를 변환해 @AndroidEntryPoint 클래스가 실제로는 Hilt_* 베이스 클래스를 상속하도록 만든다. 개발자는 AppCompatActivity를 상속했다고 적지만, 빌드 결과물에선 Hilt_MyActivity가 중간에 껴서 주입 타이밍을 확보합니다.
  • 이 베이스 클래스들은 생명주기에 맞춰 inject()를 호출하는 로직을 포함합니다.

 

ViewModel 팩토리 및 멀티바인딩 맵

  • HiltViewModelFactory와 Map<Class<out ViewModel>, Provider<ViewModel>> 바인딩
  • @HiltViewModel 클래스는 키-프로바이더 맵으로 등록되고, 팩토리가 해당 키로 올바른 뷰모델을 생성자로 주입한다. SavedStateHandle도 자동 연결

2. 런타임 초기화

Application 시작

  • @HiltAndroidApp이 붙은 Application은 생성된 Hilt_MyApp을 통해 컴포넌트 매니저를 초기화하고, 최상위 그래프인 SingletonComponent를 구성
  • 이때 @InstallIn(SingletonComponent::class) 모듈의 바인딩이 준비되고, @Singleton 스코프 캐시가 설정

 

스코프 컴포넌트 생성

  • SingletonComponent: 앱 전체 수명
  • ActivityRetainedComponent: 액티비티 재생성 사이에도 유지(주로 ViewModel 계층).
  • ActivityComponent, FragmentComponent, ViewModelComponent, ServiceComponent: 각 생명주기 동안 유지.
  • 각 컴포넌트는 필요해지는 시점에 생성되고, 수명이 끝날 때 정리
  • 정책은 빌드타임에 정해지더라도 초기화는 런타임에 동작

3. 주입

@AndroidEntryPoint가 붙은 Activity/Fragment 등에서 생명주기 초기에 inject() 자동 호출

@HiltViewModel은 전용 Factory가 생성자 주입으로 만들고 관리

 

생성자 주입

// 개발 코드
class Engine @Inject constructor(logger: Logger)

// 생성 코드
class Engine_Factory(
  private val loggerProvider: Provider<Logger>
) : Factory<Engine> {
  override fun get(): Engine = Engine(loggerProvider.get())
}

 

필드 주입

// 개발 코드
class Car {
  @Inject lateinit var engine: Engine
}

// 생성 코드
class Car_MembersInjector(
  private val engineProvider: Provider<Engine>
) : MembersInjector<Car> {
  override fun injectMembers(instance: Car) {
    instance.engine = engineProvider.get()
  }
}

 

메서드 주입

// 개발 코드
class Car {
  private lateinit var engine: Engine
  @Inject fun setEngine(engine: Engine) { this.engine = engine }
}

// 생성 코드
class Car_MembersInjector(
  private val engineProvider: Provider<Engine>
) : MembersInjector<Car> {
  override fun injectMembers(instance: Car) {
    instance.setEngine(engineProvider.get())
  }
}

 

주입 타이밍

  • Activity: onCreate 직전
  • Fragment: onAttach 시점
  • ViewModel: ViewModelProvider가 @HiltViewModel 생성자 주입으로 생성
  • Service/Receiver: 각 생명주기 초기 지점
  • Custom View 등: EntryPoint로 직접 가져와 사용

 

++ 좀 더 파헤치기

 

의존성주입의 원리를 이해하기 위해선 Component, Provider, Factory 에 대해서 알아야 한다.

  • Provider : 필요할 때 get()으로 T를 꺼내주는 지연 공급자 (캐시/지연을 담당)
  • Component : 수명과 스코프를 가진 DI 컨테이너, 그래프를 보관 및 관리
  • *_Factory : T를 “어떻게 만들지”를 아는 생성기 (캐시는 하지 않음)


Q. Provider란 무엇인가?

Provider<T>란 필요한 순간에 T를 꺼내는 지연 공급자로서, Hilt/Dagger는 컴파일 타임에 생성한 컴포넌트 구현체 안에 모든 바인딩을 Provider 필드로 보관하고, 실제 객체가 필요할 때 get()을 호출해 꺼내면서 생성 타이밍을 책임진다.

import javax.inject.Provider

interface Provider<T> {
    fun get(): T
}


Q. 왜 Provider를 써야하는가?

  1. 지연 생성: 안 쓰면 만들지 않는다. 초기 부하↓
  2. 스코프 캐싱: @Singleton 등은 최초 1회 생성→캐시 재사용을 보장
  3. 초기화 순서/순환 의존 해결: 한쪽을 Provider<Foo>로 받아 생성 시점 지연으로 고리를 끊는다.
  4. 멀티바인딩 집계: @IntoSet/@IntoMap 바인딩을 Set/Map Provider로 모으기 쉽다.
Lazy<T> vs Provider<T>
Provider<T> : 필요할 때마다 만들거나(무스코프) 한 번만 만들어 재사용(스코프), 여러 번 get() 가능
Lazy<T> : 최초 한 번만 지연 생성하고 이후에는 그 하나를 돌려준다. 한 번만 늦게 만들고 계속 재사용이 목적일 때 적합

 

Q. Provider는 계속 get()을 요청하는데 어떻게 스코프 캐싱을 할까?

Dagger 내부 유틸에는 DoubleCheck 라는 Wrapper Provider가 존재한다. DoubleCheck는 Provider를 감싸 최초 1회만 생성하고 이후 같은 인스턴스를 반환한다. 주로 @Singleton, @ActivityScoped스코프 바인딩에서 사용된다.

// 개념화한 형태 (실제 소스와 다를 수 있지만 아이디어는 동일)
class DoubleCheck<T>(
    private var provider: Provider<T>   // 원래 객체 만드는 방법
) : Provider<T> {

    @Volatile private var instance: Any? = UNINITIALIZED

    override fun get(): T {
        var result = instance
        if (result === UNINITIALIZED) {          // 1차 체크 (락 없이 빠르게)
            synchronized(this) {                 // 잠금
                result = instance
                if (result === UNINITIALIZED) {  // 2차 체크
                    result = provider.get()      // 실제 생성
                    instance = result            // 캐시에 저장
                    provider = null              // 원 Provider GC 가능
                }
            }
        }
        @Suppress("UNCHECKED_CAST")
        return result as T
    }

    private object UNINITIALIZED
}

 


만약 스코프가 있으면 DoubleCheck첫 호출에만 factory.get() 을 호출하고, 이후에는 캐시된 인스턴스를 반환

 

Q. 컴포넌트란 무엇인가

특정 수명(lifecycle)을 가진 DI 컨테이너로서 역할은 다음과 같다.

    1. 모듈(@Module)과 바인딩을 장착한다. 즉, @InstallIn으로 어느 컴포넌트의 그래프에 이 바인딩들을 넣을지 지정한다
      ex) @SingletonSingletonComponent , @ActivityRetainedScopedActivityRetainedComponentt
    2. 필요한 순간에 Provider.get() 을 통해 객체를 만들고(또는 캐시에서 꺼내) 주입한다.
    3. 스코프 캐시를 관리한다(예: @Singleton은 앱 프로세스 동안 1개).

부모 → 자식 방향으로 자식은 부모 바인딩을 참조할 수 있어서 자식 컴포넌트가 부모 Provider를 참조해 배선된다. 

// 개념 스케치
class DaggerViewModelComponent(parent: DaggerActivityRetainedComponent) {
    private val repoProvider: Provider<UserRepository> =
        UserRepositoryImpl_Factory(parent.apiProvider, parent.userDaoProvider)
}

 

단계별 코드

더보기

1단계. 개발자가 작성하는 코드

// NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build()

    @Provides @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://api.example.com")
            .client(client)
            .build()

    @Provides @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService =
        retrofit.create(ApiService::class.java)
}

// DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides @Singleton
    fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
        Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()

    @Provides
    fun provideUserDao(db: AppDatabase): UserDao = db.userDao()
}

// RepositoryModule.kt
interface UserRepository {
    suspend fun loadUser(id: Long): User
}

class UserRepositoryImpl @Inject constructor(
    private val api: ApiService,
    private val dao: UserDao
) : UserRepository {
    override suspend fun loadUser(id: Long): User {
        val local = dao.find(id)
        return local ?: api.getUser(id).also { dao.insert(it) }
    }
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}

 

@HiltViewModel
class MyViewModel @Inject constructor(
    private val repo: UserRepository,
    // SavedStateHandle은 Hilt가 자동 주입
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    // ...
}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    // Hilt가 제공하는 ViewModelFactory를 통해 생성자 주입된 VM 획득
    private val vm: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
    }
}

 

2단계. 빌드 타임에 생성되는 개념 코드

// UserRepositoryImpl_Factory (생성자 주입 대상)
class UserRepositoryImpl_Factory(
    private val apiProvider: Provider<ApiService>,
    private val daoProvider: Provider<UserDao>
) : Factory<UserRepositoryImpl> {
    override fun get(): UserRepositoryImpl {
        return UserRepositoryImpl(apiProvider.get(), daoProvider.get())
    }
}

// NetworkModule_ProvideRetrofitFactory (Provides 변환)
class NetworkModule_ProvideRetrofitFactory(
    private val clientProvider: Provider<OkHttpClient>
) : Factory<Retrofit> {
    override fun get(): Retrofit {
        return NetworkModule.provideRetrofit(clientProvider.get())
    }
}
// MainActivity_MembersInjector (예시)
class MainActivity_MembersInjector(
    private val someDepProvider: Provider<SomeDep>
) : MembersInjector<MainActivity> {
    override fun injectMembers(instance: MainActivity) {
        instance.someDep = someDepProvider.get()
    }
}
// MyViewModel_Factory (개념)
class MyViewModel_Factory(
    private val repoProvider: Provider<UserRepository>,
    private val savedStateHandleProvider: Provider<SavedStateHandle>
) : Factory<MyViewModel> {
    override fun get(): MyViewModel {
        return MyViewModel(repoProvider.get(), savedStateHandleProvider.get())
    }
}

 

3단계. 컴포넌트 내부의 Provider 배선

// DaggerSingletonComponent
class DaggerSingletonComponent : SingletonComponent {

    // Provider 필드들
    private val contextProvider: Provider<Context>
    private val okHttpProvider: Provider<OkHttpClient>
    private val retrofitProvider: Provider<Retrofit>
    private val apiServiceProvider: Provider<ApiService>
    private val dbProvider: Provider<AppDatabase>
    private val userDaoProvider: Provider<UserDao>
    private val userRepositoryProvider: Provider<UserRepository>

    init {
        // ApplicationContext 등은 별도 Entry로 제공
        contextProvider = ApplicationContextModule_ProvideContextFactory.create()

        okHttpProvider = DoubleCheck.provider(
            NetworkModule_ProvideOkHttpFactory.create()
        )

        retrofitProvider = DoubleCheck.provider(
            NetworkModule_ProvideRetrofitFactory(okHttpProvider)
        )

        apiServiceProvider = DoubleCheck.provider(
            NetworkModule_ProvideApiServiceFactory(retrofitProvider)
        )

        dbProvider = DoubleCheck.provider(
            DatabaseModule_ProvideDbFactory(contextProvider)
        )

        userDaoProvider = DatabaseModule_ProvideUserDaoFactory(dbProvider)

        // @Binds 바인딩은 구현체 Factory를 인터페이스로 매핑
        val repoImplProvider: Provider<UserRepositoryImpl> =
            UserRepositoryImpl_Factory(apiServiceProvider, userDaoProvider)

        userRepositoryProvider = DoubleCheck.provider(
            RepositoryModule_BindUserRepositoryFactory(repoImplProvider)
        )
    }

    // 하위 컴포넌트들이 참조할 수 있도록 getter 제공 가능
    fun userRepository(): UserRepository = userRepositoryProvider.get()
}
// DaggerViewModelComponent
class DaggerViewModelComponent(
    private val appComponent: DaggerSingletonComponent
) : ViewModelComponent {

    private val savedStateHandleProvider: Provider<SavedStateHandle> =
        SavedStateHandle_Factory.create()

    private val myViewModelProvider: Provider<MyViewModel> =
        MyViewModel_Factory(appComponent.userRepositoryProvider, savedStateHandleProvider)

    fun getMyViewModel(): MyViewModel = myViewModelProvider.get()
}

 

 

반응형

'Android' 카테고리의 다른 글

Navigation3 알아보기  (0) 2025.10.21
Android Build-logic & Version Catalog  (0) 2025.09.03
Android DI(의존성 주입) - 개요  (1) 2025.07.30
Camera2 API 개념부터 구현까지  (0) 2023.04.06
Android Paging3 개념정리 및 사용기  (0) 2022.07.06