Back to list

#04. Hilt와 의존성 주입

목차15

개요

사실 지금까지 Hilt를 사옹하면서, 대충 의존성 주입 간편하게 도와주는 친구 정도로만 생각하고 있었다. 실제로 어떻게 동작하는지, 어떤 기능들이 있는지는 신경쓰지 않고 코드 복붙하고 AI한테 만들어달라 했으면 다 해줬으니까. 하지만 실제 코드 베이스를 하나씩 따라가며 살펴보니, 생각보다 여러 중요한 개념들을 간과하고 있었다는 생각이 들었다.


멘탈 모델

IoC와 DI

소프트웨어 공학이나 여러 프레임워크를 공부하다보면, 항상 IoCDI라는 말을 듣게 된다.

  • IoC (제어 역전의 원칙)

    • 어떤 모듈이 다른 모듈의 기능을 필요로 할 때(의존성을 가질 때), 자기가 직접 만드는 것이 아니라 이미 만들어진 모듈을 사용
  • DI (의존성 주입)

    • IoC 원칙을 지키는 가장 대표적인 방식
    • 이미 만들어진 모듈을 생성자나 필드 등으로 주입하는 방식이다.

문제는, 이 의존 관계가 깊어질수록 수동으로 작성해야 할 코드가 점점 길어진다는 점이다:

val repo = SomeRepositoryImpl(SomeApi(authClient), someDao)
val useCase = GetSomeListUseCase(repo)
val vm = SomeListViewModel(savedState, useCase)

Hilt가 하는 일은, 이처럼 손으로 짜야하는 의존성 조립 코드를 빌드 타임에 자동으로 생성하는 것이다. 사용자는 필요한 모듈이나 클래스를, Annotation을 통해 적절하게 Hilt에게 알려주면 된다.


Hilt의 동작

의존성 그래프

의존 관계는 방향 그래프(DAG)이다. 즉 어떤 노드를 만들려면 자식 노드가 있어야하고, 최종적으로 그래프의 ‘잎’ (혹은 ‘말단’) 노드부터 생성되게 된다.

위의 SomeXXX 사례를 다시 살펴보면, ViewModel을 만들기 위해 UseCase가 필요하고, 이 UseCase는 데이터를 가져올 Repository가 필요하며, Repository는 실제 Data source로부터 데이터를 가져올 수 있는 API와 DAO가 필요하다.

만약 Hilt가 이 의존성 관계를 안다면, 사용자 대신에 이러한 의존성들을 자동으로 생성하고 주입해줄 수 있다.

주요 Annotation

Hilt를 사용하기 위해서는 다음과 같은 Annotation을 사용해야 한다.

  • @HiltAndroidApp

    • Application 클래스에 붙이는 Annotation으로, Hilt의 DI 컨테이너를 생성한다.
    • 그래프의 ‘시작’을 정의하며, 앱 당 정확히 하나만 존재할 수 있다.
  • @AndroidEntryPoint

    • Activity, Fragment, Service 등, Hilt가 직접 객체를 생성할 수 없는 파일들에 붙이는 Annotation
    • 의존성 주입 지점(Injection Site)을 표시한다.
  • @HiltViewModel

    • ViewModel에 붙이는 Annotation
  • @Inject

    • 생성자나 필드에 붙이는 Annotation
    • 누가 객체를 만드느냐에 따라 사용 방법이 달라진다.
      • Hilt가 객체를 만들 때: 생성자에 붙인다 → 이 의존성을 주입해서 만들어라
      • 사용자가 객체를 만들 때: 필드에 붙인다 → 필요한 의존성으로 이 필드를 채워라
  • @Module

    • Interface의 경우, 여러 구현체를 가질 수 있다.
    • 실제 의존성 주입이 일어날 때 Hilt가 어떤 구현체를 사용할지 바인딩(binding)이 필요한데, 이 바인딩을 담당하는 모듈 클래스에 붙인다.
  • @Binds

    • 인터페이스와 구현체를 바인딩하기 위해 사용
  • @Provides

    • 간혹 객체 생성을 Hilt가 할 수 없는 경우가 있다 (특수한 생성 로직, 혹은 외부 라이브러리의 객체 생성 등)
    • 이때 Hilt는 @Provides를 사용하여, 필요한 객체의 생성 메서드(팩토리)를 호출할 수 있다.

주입 방식

“누가 객체를 만드느냐”에 따라, 의존성의 주입 방식이 달라진다.

Hilt가 만든 객체프레임워크(Android)가 만든 객체
예시UseCase, RepositoryImplActivity / Fragment / Service
방식생성자 주입 (@Inject constructor)필드 주입 (@AndroidEntryPoint + @Inject lateinit)
이유Hilt가 생성자 호출 → 인자로 의존성 공급Android가 직접 생성 → 사후에 필드 채움

간단하게 생각해보면, Hilt가 직접 객체를 생성할 수 있느냐 없느냐를 생각해보면 된다. 아래와 같은 경우, Hilt가 직접 생성자를 호출하여 객체를 생성할 수 있으므로 생성자에 @Inject를 붙여주면 된다.

class GetThemeUseCase @Inject constructor(
    private val preferenceRepository: PreferenceRepository
) { ... }

그러나 Activity, Fragment, Service 등은 Android OS가 직접 생성하기 때문에, Hilt가 직접 객체를 생성할 수 없다. 따라서 @AndroidEntryPoint를 사용해서 Hilt 그래프에 등록한 다음, 의존성 주입이 필요한 지점에 @Inject lateinit var를 사용한다:

@AndroidEntryPoint
class MainActivity {

    @Inject lateinit var getThemeUseCase: GetThemeUseCase
    ...
}

@HiltViewModel

ViewModel은 조금 특별한 취급을 받는다.

여기에서, 가장 초반에 “프레임워크에서 ViewModel을 제공하기 때문에 ViewModel의 인스턴스를 만들려면 특수한 메커니즘이 필요”하다고 언급하고 있다.

위에서 언급된 @AndroidEntryPoint는 화면이 파괴될 때 함께 소멸하는 Component의 생명주기를 따른다. 즉, 구성 변경이 발생할 때 메모리에서 파괴되었다가 재생성되며, 이 과정에서 주입된 객체들도 일제히 파괴되고 새롭게 주입되도록 설계되어있다.

ViewModel은 구성 변경(Configuration changes)에서도 유지되어야 하므로 별도의 생명주기를 갖게 된다. 그런데 ViewModel이 자신보다 생명주기가 짧은 ActivityComponent 등에 의존하게 된다면, 참조중인 Activity가 파괴된 후에도 ViewModel이 살아남을 시 메모리 누수를 야기할 수 있다. 때문에 Hilt에서는 ViewModel을 위한 별도의 ViewModelComponent를 제공하며, 이는 Activity가 완전히 종료될 때 까지 유지되는 ActivityRetainedComponent를 기반으로 한다.

요약하자면, ViewModel이 가지는 특수한 수명 주기와 관리 주체 때문에, Hilt는 ViewModel을 위한 별도의 Component를 제공하며 @HiltViewModel이라는 창구를 만들어둔 것이다.

@HiltViewModel
class ExampleViewModel @Inject constructor(
  private val savedStateHandle: SavedStateHandle,
  private val repository: ExampleRepository
) : ViewModel() {
  ...
}

Inject, Binds, Provides

세 Annotation은, Hilt가 의존성을 ‘어떻게 주입하느냐’에 따라 다르게 사용된다.

  1. @Inject constructor

Hilt가 직접 객체를 생성할 수 있는 경우 사용한다.

  1. @Module & @Binds

인터페이스 → 구현체 바인딩을 위해 사용한다.

예를들어 동일한 ArticleRepository의 구현체이더라도, 개발자는 테스트를 위한 MockArticleRepository와 실제 서버로부터 데이터를 가져오는 RemoteArticleRepository 등, 여러 구현체를 둘 수 있다.

한 인터페이스에 여러 구현체가 붙을 수 있으므로, 어딘가에서는 Hilt가 누구를 주입할지를 명시해줘야 한다.

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindArticleRepository(repo: RemoteArticleRepository): ArticleRepository
}
  1. @Provides

대표적으로 두 경우에 사용한다:

  • 서드파티 라이브러리에서 사용되는 객체의 경우
  • 단순 constructor가 아닌, 복잡한 생성 로직이나 설정이 필요한 경우

결과적으로, @Provides는 객체 생성을 위한 Factory 메서드를 호출하며, 따라서 주입하고자 하는 객체를 return 해줘야 한다:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .build()
    }
}

위 예시에서, 실제 OkHttpClient의 실제 생성 로직은 OkHttpClient.Builder()를 통해 이루어지며, Hilt는 @Provides를 사용하여 필요한 객체의 생성 메서드(팩토리)만 호출한 것이다.

  1. 정리

보다 간단하게 정리하자면,

  • 내가 소유한 구체 클래스 + 단순 @Inject 생성자? = 모듈 불필요
  • 인터페이스 → impl 매핑 (impl은 @Inject 가능)? = @Binds
  • 서드파티 클래스 OR 생성 로직 필요 (= 생성자에 @Inject 불가)? = @Provides

DAG 구성

아래 시나리오는 내가 사용하는 안드로이드 프로젝트 템플릿에서 가져온 것이다. 구체적인 코드보다는 흐름 위주로 정리하였다.

구조도

스크린샷 2026-06-09 150043

시나리오

A. @Inject constructor

UseCase는 복잡한 생성 로직이 필요하지 않은 구체 클래스이므로, Hilt가 직접 생성할 수 있다. 따라서 주입되는 Repository 구현체가 @Inject로 명시되어있다면, 단순히 @Inject constructor를 사용하면 된다.

B. @Module & @Binds

Repository는 인터페이스이므로, 구현체가 여러개일 수 있다. 따라서 Repository Module에서 사용할 구현체를 @Binds를 통해 묶어주었다. 이때, 각 구현체는 @Inject Constructor로 Hilt에서 생성한다.

C. @Provides

나는 주로 Ktor를 사용하는데, Ktor Client는 Ktor-Client-Core 라이브러리에서 제공하는 Builder를 통해 생성된다. 이 Builder는 라이브러리에서 관리하며, 사용자가 원하는 설정이 반영되는 복잡한 생성 로직을 포함하므로 Hilt가 직접 생성할 수 없다. 따라서 @Provides를 사용하여 Ktor Client의 생성 메서드(팩토리)를 호출한다.

Comments

링크가 복사되었습니다