본문 바로가기
Android/Hilt

[Android] Hilt 딥다이브

by 태크민 2025. 2. 22.

Hilt는 프로젝트에서 의존성 주입을 실행하는 상용구를 줄이는 Android용 의존성 주입 라이브러리입니다.

Hilt를 들어가기 전 의존성 주입(DI)를 사용하는 이유와 힐트의 기본형인 Dagger에 대해 알아보겠습니다.

 

의존성 주입이란?

특정 객체의 인스턴스가 필요한 경우 이를 직접 생성하지 않고, 외부에서 생성된 객체를 전달하는 기법입니다.

각 객체는 다른 객체의 생성에는 관여하지 않고 객체를 필요로 하는 부분과 독립된 별도의 모듈이 객체의 생성과 주입을 전달합니다.

 

예를들어서 Engine을 사용하는 Car 클래스가 있을경우

 

Car 클래스는 Engine 클래스에 의존하고 있습니다.

 

즉 Engine 클래스의 생성자가 변경되거나 파생 클래스가 생긴다면 Car 클래스 또한 수정해야 합니다.

 

그림으로 본다면 Car 클래스와 Engine 클래스는 밀접하게 연결되어 있습니다.

의존성 주입을 사용한다면 어떻게 변하는지 보겠습니다.

 

main함수에서 Engine 인스턴스를 생성한 후 이를 사용하여 Car 인스턴스를 구성합니다.

이제 Car 클래스는 Engine 클래스의 생성자가 변경되거나 파생클래스를 사용할 경우, Engine 인스턴스를 만들어주는 부분만 수정하고 Car 인스턴스에 주입시켜주면 되기때문에 Car 클래스와 Engine 클래스는 밀접하게 연결되어있지 않습니다.

 

그림으로 본다면 Car 클래스와 Engine 클래스는 밀접하게 연결되어있지 않습니다.

 

의존성 주입의 장점

1. 목적에 맞게 동작을 변경하기 쉽습니다. 
의존성 주입을 사용하면 특정 객체를 필요한 객체를 외부에서 전달받으므로, 이를 조작하면 필요에 따라 다른 동작을 하는 객체를 간편하게 생성할 수 있습니다.

 

2. 생성한 객체를 쉽게 재사용할 수 있습니다. 
의존성 주입을 사용하면 객체를 생성하는 작업을 특정 모듈에서 전담하게 되므로, 객체를 생성하는 방법과 이의 인스턴스를 효율적으로 관리할 수 있습니다.
위의 코드는 main문에서 Engine 인스턴스를 만들고 주입했지만, 특정 모듈에서 어떤 인스턴스를 만들어 주입한다면 다양한 곳에서 쉽게 해당 객체를 사용 가능합니다.

 

3. 객체를 생성하거나 사용할 때 발생할 수 있는 실수를 줄여줍니다.
같은 역할을 하는 객체를 각각 다른 곳에서 별도로 생성하도록 코드를 작성하는 경우, 해당 객체를 생성하는 모든 부분의 코드를 수정해야 하므로 작업이 복잡해지고 실수를 하기도 쉽습니다.

반면에. 의존성 주입을 사용하면 객체를 생성해주는 부분 한 곳만 변경하면 되므로 수정이 간편합니다.
또한, 해당 객체를 사용하는 모든 부분에 변경 결과가 일괄적으로 적용되므로, 변경할 부분을 누락하는 실수를 원천 차단할 수 있습니다.

 

4. 테스트에 용이합니다.

DI를 활용하면 특정 객체의 의존성을 외부에서 주입받을 수 있기 때문에, 테스트 시 실제 구현 객체 대신 Mock 객체 또는 Stub 객체를 주입하여 단위 테스트를 보다 쉽게 진행할 수 있습니다.

또한, 특정 객체에 대한 타입을 다양한 방식으로 변경하여 테스트할 수 있습니다. 예를 들어, 특정 클래스뿐만 아니라 해당 클래스의 자식 클래스도 주입할 수 있어 여러 객체 타입에 대한 테스트가 가능합니다.

 


대거(Dagger)

대거(Dagger)는 스퀘어에서 만든 최초의 라이브러리로, 자바 기반 프로젝트에서 의존성을 주입할 수 있게 도와줍니다.

대거 라이브러리에서 각 객체간 의존 관계는 어노테이션을 사용하여 정의합니다.
이렇게 정의한 의존 관계는 대거 라이브러리 내의 어노테이션 프로세서를 통해 문제가 없는지 분석 절차를 걸쳐 문제가 없다면 각 객체를 생성하는 코드를 만들어줍니다.

 

이처럼 의존관계를 검증하는 과정과 필요한 코드를 생성하는 과정이 모두 빌드 단계에서 일어나므로, 문제가 있으면 빌드 단계에 검출이 되므로 더 견고한 애플리케이션을 만들 수 있습니다.

이번에는 대거를 구성하는 핵심 요소들을 알아보겠습니다.

 

모듈

모듈은 필요한 객체를 제공하는 역할을 합니다.

모듈은 클래스 단위로 구성되며 이 클래스 내에 특정 객체를 반환하는 함수를 정의함으로써 모듈에서 제공하는 객체를 정의할 수 있습니다.

 

대거 라이브러리에서 모듈 클래스로 인식되게 하려면 @Module 어노테이션을 클래스에 추가해야하며 이 모듈에서 제공하는 객체를 정의한 함수에는 @Provides 어노테이션을 추가해야 합니다.

간단한 예를 통해 모듈을 정의하는 방법을 알아보겠습니다.

 

모듈에는 제공할 객체의 종류와 각 객체를 생성하는 코드를 작성합니다.

특정 객체를 생성할 때 다른 객체가 필요한 경우, 즉 의존 관계에 있는 객체가 있는 경우 객체를 생성하는 함수의 매개변수로 의존 관계에 있는 객체를 추가합니다.

 

컴포넌트

모듈이 객체를 제공하는 역할을 했다면, 컴포넌트는 모듈에서 제공받은 객체를 조합하여 필요한 곳에 주입하는 역할을 합니다.

하나의 컴포넌트는 여러개의 모듈을 조합할 수 있습니다. 

따라서 목적에 따라 각각 분리된 여러 모듈로부터 필요한 객체를 받아 사용할 수 있습니다.

 

대거의 컴포넌트는 @Component 어노테이션을 붙인 인터페이스로 선언하며, 이 어노테이션의 modules 프로퍼티를 통해 컴포넌트에 객체를 제공하는 모듈을 지정할 수 있습니다.

컴포넌트를 통해 객체를 전달받을 대상은 모듈과 유사하게 인터페이스 내 함수로 정의하며, 아무런 값을 반환하지 않고 객체를 전달받을 대상을 매개변수로 받는 형태로 정의합니다.

BurgerModule을 제공하는 컴포넌트인 FastFoodComponent의 예입니다.

@Component 어노테이션 내 modules 프로퍼티로 BurgerModule을 지정하고, Store 클래스에서 FastFoodComponent에서 제공하는 객체를 주입할 수 있도록 정의한 모습을 확인할 수 있습니다.

 

컴포넌트와 모듈, 그리고 각 모듈에서 제공하는 객체 간의 의존 관계는 그래프로 표시할 수 있으며, 이를 객체 그래프라고 부릅니다.

컴포넌트를 통해 객체를 주입하는 항목은 @Inject 어노테이션으로 표시합니다.

컴포넌트가 값을 주입하는 시점에 객체가 할당되므로 값을 주입받는 프로퍼티는 lateinit var로 선언해야 합니다.

컴포넌트에서, 객체를 주입받는 클래스를 정의한 후 프로젝트를 빌드하면, 대거는 객체를 주입할 때 사용할 수 있는 컴포넌트의 코드를 생성해줍니다.

대거가 생성해 주는 컴포넌트의 클래스 이름은 Dagger{컴포넌트 이름} 규칙을 따릅니다.

 


힐트(Hilt)

힐트는 Dagger를 기반으로 빌드되었으며 다음과 같은 목적을 가집니다.   

  • 보일러 플레이트 코드 감소 (객체 생성 방법과 주입 위치만 정의함)
  • 분리된 빌드 의존성
  • 환경 설정의 간소화
  • 개선된 테스트 환경
  • 표준화된 컴포넌트(안드로이드 클래스)

힐트는 대거 컴포넌트와 코드를 생성하여, Activity 및 Fragment와 같은 안드로이드 클래스에 필요한 의존성을 자동으로 주입합니다.

Hilt는 전이 클래스 경로를 기반으로 표준 안드로이드 대거 컴포넌트 세트를 생성합니다.

이를 위해서는 대거 모듈에 힐트 어노테이션(@InstallIn)을 표시하여, 어떤 컴포넌트에 포함시켜야하는지 Hilt에게 알려줘야 합니다.

안드로이드 프레임 워크 클래스에서 주입을 받기 위해서는, 또 다른 힐트 어노테이션 (@AndroidEntryPoint) 을 사용합니다. 이는 대거 주입 코드를 가지고 있는 base 클래스를 생성하고 상속할 수 있도록 합니다.

 

Hilt Module

Provides

클래스가 외부 라이브러리에서 제공되므로 생성자 삽입이 불가능한 경우(Retrofit, Room), @Provides 어노테이션을 이용해 인스턴스 삽입을 할 수 있습니다.

그렇다면 이 인스턴스 삽입이 어떻게 이루어지는지 알아보겠습니다.

 

위 코드는 저희 안드로이드 온보딩에 현재 사용중인 코드입니다.

moshi (json converter)와 Retrofit객체의 경우 빌더패턴으로 사용되므로 @Provides 어노테이션을 이용해 인스턴스 삽입을 하고 있습니다.

 

내부 코드를 살펴보면 팩토리 객체가 생성되는 생성자에서 현재 모듈 정보를 받고

moshi를 제공하는 함수가 생성됩니다.

 

 

현재 코드는 Retrofit이 생성되고 제공하는 코드입니다.

생성자에서 moshi를 제공받아 Retrofit 팩토리를 만들고 retrofit을 제공하는 함수가 생성됩니다.

 

Binds

인터페이스는 생성자 삽입이 불가능합니다.
대신 Hilt 모듈 내에 @Binds로 주석이 지정된 추상 함수를 생성하여 결합 정보를 제공합니다.

@Binds 주석은 인터페이스의 인스턴스를 제공해야 할 때 사용할 구현을 Hilt에게 알려줍니다.

 

@Binds는 @Provides와 달리 추가적인 코드(Factory 클래스)가 생성되지 않는다는 장점이 있습니다.

@Provides를 사용할 경우, Dagger는 내부적으로 Factory 클래스를 생성하여 객체를 제공하지만, @Binds는 이미 생성 가능한 구현체가 있을 때, 바인딩 처리해서 추가적인 코드 생성 없이 효율적으로 인터페이스와 구현체를 연결합니다. 이로인해, 불필요한 코드 생성을 방지할 수 있습니다.

이러한 차이점 덕분에 @Binds를 사용하면 컴파일 시간 단축 및 성능 최적화에 유리합니다.

 


Hilt Application

Hilt를 사용하는 모든 앱은 @HiltAndroidApp이 달린 Application 클래스를 포함해야 합니다.

@HiltAndroidApp은 Hilt 컴포넌트의 코드 생성과 컴포넌트를 사용하는 Application의 기본 클래스를 생성하게됩니다.

코드 생성에는 모든 모듈에 대한 엑세스 권한이 필요하므로, Application 클래스를 컴파일하는 대상에는 전이 의존성에 모든 Dagger 모듈이 있어야 합니다.

전이 의존성이란
어떤 라이브러리를 추가하면 그 라이브러리의 의존성도 함께 의존하게 되는데
이를 전이 의존성이라고 합니다.

 

@AndroidEntryPoint가 달린 안드로이드 프레임워크 클래스와 마찬가지로 Application에도 멤버 주입이 됩니다.

이는 super.onCreate()가 호출된 후 Application의 필드에 의존성 주입이 이루어지는 것을 의미합니다. (생명주기에 맞게 대거를 사용한다면 Application의 onCreate에 의존성을 주입해야 합니다.)

예를들어 일반적인 Dagger 사용시 MyApplication이 MyBaseApplication을 상속하는 구조이면서 멤버 변수로 Bar를 가지고 있다고 가정해보겠습니다.

 

대거를 사용한 경우

 

힐트를 사용하면 다음과 같이 멤버 주입이 됩니다.

 


@AndroidEntryPoint

@AndroidEntryPoint어노테이션이 선언되면 안드로이드 클래스에 DI 컨테이너를 추가됩니다.
해당 어노테이션은 @HiltAndroidApp 의 설정 후 사용 가능합니다.

@HiltAndroidApp 어노테이션은 애플리케이션 클래스에 적용되어 Hilt의 DI 그래프를 초기화하고, 애플리케이션 범위의 의존성을 설정합니다. 이 어노테이션이 선언된 후, 앱 내의 다른 Android 컴포넌트에서 @AndroidEntryPoint를 사용하여 DI를 구현할 수 있습니다.

 

Dagger2 관점에서 보면

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

 

@AndroidEntryPoint를 사용할 수 있는 타입은 다음과 같습니다.   

  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

힐트와 ViewModel의 사용은 직접적으로 지원하지 않는 대신에 Jetpack Extension을 통해 지원합니다. 다음 예제는 어떻게 Activity에 어노테이션을 추가 할 수 있는지 보여줍니다.

다른 타입의 경우도 Activity와 동일한 방법으로 지원됩니다.

Activity에서 멤버 주입을 하기 위해서는 @AndroidEntryPoint를 추가 합니다.

 

@AndroidEntryPoint 주의 사항

안드로이드 클래스에서 의존성 주입 시, 상위(서브)컴포넌트에도 반드시 @AndroidEntryPoint를 선언해야한다는 점입니다.

Fragment에서 @AndroidEntryPoint를 사용했는데 만약 상위 액티비티에서 @AndroidEntryPoint를 마킹하지 않았다면 에러가 발생할 것입니다.


@InstallIn

힐트의 모듈은 표준 대거 모듈로 @InstallIn이라는 추가적인 어노테이션을 갖습니다.

@InstallIn은 Hilt의 표준 컴포넌트들 중 어떤 컴포넌트에 모듈을 설치할지 결정합니다.

Hilt 컴포넌트가 생성될 때 모듈들은 추가된 @InstallIn과 함께 알맞은 컴포넌트 또는 서브 컴포넌트에 설치됩니다.

대거와 같이 컴포넌트에 모듈을 설치하면 해당 모듈에 바인딩된 의존성은 컴포넌트 내 다른 바인딩 또는 다른 하위 컴포넌트의 바인딩이 접근하는 것을 허용합니다.

바인딩 된 의존성에 @AndroidEntryPoint 클래스가 접근하는 것 또한 가능합니다.

해당 컴포넌트에 대한 바인딩 스코프를 지정할 수도 있습니다.

 

@InstallIn 사용하기

모듈에서 @InstallIn 어노테이션을 추가하는 것으로 Hilt 컴포넌트에 모듈이 설치됩니다.

@InstallIn 어노테이션에 어떤 컴포넌트가 모듈이 설치될 적당한 Hilt 컴포넌트인지 명시해야합니다.

예를 들어, 애플리케이션 스코프에서 어떤 바인딩이든 사용할 수 있게 모듈을 설치하려면

SingletonComponent를 사용해야 합니다.

@InstallIn이 달린 모듈의 바인딩에 스코프가 지정될 때 반드시 모듈이 설치되는
컴포넌트의 스코프와 일치해야 합니다. 
예를들면 @InstallIn(ActivityComponent.class) 모듈은
내부에서 @ActivityScope만 사용할 수 있습니다.


모듈이 설치되는 과정

모듈의 설치는 컴파일 타임에 @InstallIn을 체크하여 적절한 컴포넌트에 설치합니다.

모듈 설치 프로세스는 다음과 같습니다.

  1. 컴파일 타임, 애노테이션 처리
  2. @Module 탐색
  3. @InstallIn에서 설치될 컴포넌트 탐색
  4. 해당 컴포넌트에 설치


컴포넌트 계층

기존 사용하던 대거(Dagger)와 다르게 힐트(Hilt) 사용자는 컴포넌트를 직접 정의하거나 인스턴스화 할 필요가 없어졌습니다.

대신에 힐트는 이미 정의된 컴포넌트를 통해 생성되는 클래스들을 제공하고 있습니다.

Hilt는 안드로이드 Application의 다양한 생명주기에 자동으로 통합되는 내장 컴포넌트 세트해당 스코프 어노테이션과 함께 제공합니다.

 

구성요소 계층 구조

다이어그램은 표준 Hilt 계층을 보여주고 있습니다.

 

각 컴포넌트 위에 달린 어노테이션은 컴포넌트 바인딩의 생명주기를 지정하는 데 사용됩니다.

각 컴포넌트 아래에 있는 화살표는 하위 컴포넌트를 가르키고 있습니다.

보통 하위 컴포넌트의 바인딩은 상위 컴포넌트의 바인딩이 가지고 있는 의존성들을 가질 수 있습니다.

 

컴포넌트 멤버 주입

앞에서 다룬 @AndroidEntryPoint 섹션에서는 안드로이드 클래스가 멤버 주입을 하는 방법을 다뤘습니다. Hilt 컴포넌트들은 각각 안드로이드 클래스에 맞는 의존성을 주입을 해야 할 의무가 있습니다.

다음 표는 안드로이드 클래스에 적합한 Hilt 컴포넌트를 보여줍니다.

 

ActivityRetainedComponent vs ActivityComponent

ActivityRetainedComponent와 ActivityComponent는 둘다 Activity의 수명을 갖는다는 공통점이 있습니다.

하지만 ActivityRetainedComponent는 ViewModel 특성처럼 Activity의 configuration change(디바이스 화면전환 등) 시에는 파괴되지 않고 유지하돼, AcitiyComponent는 onDestory()와 함께 파괴됩니다.

ActivityRetainendComponent vs ViewModelComponent

AcitivtyRetainendComponent와 ViewModelComponent는 디바이스 화면 전환시에도 유지하는 점에서 공통점을 같습니다.

하지만 생명주기에 대해서 차이점이 있습니다. ActivityRetainendComponent는 Activity의 Lifecycle을 갖으며, ViewModelComponent는 ViewModel의 LifecyCycle을 갖습니다.

또한, ActivityRetainendScope는 ViewModelScope보다 상위 Scope를 갖기 때문에, 하나의 인스턴스를 여러 ViewModel에서 공유할 수 있습니다. 반면, @ViewModelScoped 유형을 사용하면 각각의 ViewModel은 별도의 인스턴스를 수신합니다.

따라서, ActivityRetainedComponent는 여러 ViewModel에서 동일한 객체를 공유해야 할 때 적합하며, ViewModelComponent는 특정 ViewModel 내부에서만 필요한 의존성을 관리하는 데 적절합니다.

 

 

컴포넌트의 수명

컴포넌트의 수명은 다음 두가지 관점에서 볼 때 바인딩의 수명과 관련되기 때문에 중요합니다.   

  • 컴포넌트가 생성되고 종료될 때, 해당 스코프 어노테이션이 지정된 바인딩 또한 수명을 함께합니다.
  • 컴포넌트 수명은 멤버 주입된 값들이 사용할 수 있는 시기를 나타냅니다.

컴포넌트의 수명은 일반적으로 안드로이드 클래스에 대응하는 인스턴스 생명과 소멸을 따라갑니다.

 

다음은 스코프 어노테이션과 각 컴포넌트에 맞는 수명을 목록을 보여주는 표입니다.

 

스코프 바인딩 vs 비 스코프 바인딩

기본적으로 모든 대거의 바인딩은 스코프 어노테이션이 없는 비 스코프 바인딩입니다.

이는 각 바인딩이 요청될 때마다 대거는 새로운 인스턴스를 생성하는 것을 의미 합니다.

그러나 대거는 컴포넌트에 스코프 어노테이션을 지정할 수 있습니다.

스코프가 지정된 컴포넌트에서 해당 스코프 바인딩은 컴포넌트 인스턴스당 한번만 생성되고,

해당 바인딩에 대한 모든 요청에 동일한 인스턴스 제공합니다.

일반적으로 오해하는 부분이 @FragmentScoped가 지정된 바인딩이
모든 Fragment 인스턴스에 대해 동일한 바인딩 인스턴스를 공유할 것이라고 생각하는 점입니다.
하지만 실제로 그렇지는 않고 각 Fragment 인스턴스는
새로운 Fragment 컴포넌트 인스턴스를 얻기 때문에 각기 다른 Fragment 인스턴스는
각자만의 스코프 된 바인딩을 얻게 됩니다.

 

모듈에서 스코프 어노테이션 사용하기

모듈에서 바인딩에 대해 비슷한 방법으로 스코프 어노테이션을 사용 가능합니다.

 

스코프 어노테이션이 지정된 바인딩 선언만 해당 컴포넌트와 수명을 함께하여 각 바인딩 요청들에 대해 동일한 인스턴스를 제공합니다.

 

스코프 어노테이션은 언제 사용할까?

바인딩에 대해 스코프 어노테이션을 지정하는 것은 코드 생성 크기 그리고 런타임 성능에 영향을 미치므로

가능한 스코프 어노테이션을 조금만 사용하는 것이 좋습니다.

@Module
@InstallIn(SingletonComponent::class)
internal object FooModule {
    @Singleton
    @Provides
    fun provideBar(): Bar {...}
}

 

다음 코드를 보면 의존성에 @Singleton이 붙었습니다.
이렇게 되면 인스턴스를 컴포넌트 내부에 저장해두기 때문에 함수 호출 시 한번만 이뤄지게 됩니다. 해당 인스턴스는 모듈(SingletonComponent)과 생명주기를 공유하기 때문에 애플리케이션 종료될때까지 해당 인스턴스는 메모리에 남아있게 될 것입니다.

그렇기에 @Singleton는 일종의 메모리 누수 효과를 불러일으킬 수 있기에 잘 써야합니다.

 

그렇다면 스코프 어노테이션은 언제 사용하는 것이 적절할까요?
동일한 인스턴스를 보장해야 할 만큼 코드의 정확성이 중요한 경우 스코프 어노테이션을 적용하는 것이 좋습니다.

 

반면, 단순히 성능 최적화를 위해 스코프 어노테이션을 고려하고 있다면, 먼저 성능이 실제로 문제가 되는지 확인하는 것이 중요합니다. 만약 성능 개선이 목적이라면, 표준 Hilt 컴포넌트 스코프 어노테이션 대신 @Reusable을 사용하는 것이 더 적절할 수 있습니다.

@Module
@InstallIn(SingletonComponent::class)
internal object FooModule {
    @Reusable
    @Provides
    fun provideBar(): Bar {...}
}

 

@Resuable은 이미 생성한 인스턴스가 존재하면 싱글톤처럼 재 사용하고, 없을 경우 인스턴스를 새로 생성해서 사용하게 도와줍니다.

하지만, Non-Scope 이기 때문에, GC에 의해서 메모리에서 해제될 수도 있으며 @Singleton 처럼 항상 같은 인스턴스를 제공한다는 보장이 없습니다.

 


Entry point란?

Entry point(진입점)은 Dagger를 사용하여 의존성 주입을 할 수 없는 코드에서 제공된 Dagger 객체를 얻을 수 있는 방법.

 

Entry point는 언제 필요할까요?

Dagger를 적용하지 않는 라이브러리를 인터페이싱하거나 Hilt에서 지원하지 않는 안드로이드 구성요소가 Dagger 객체에 접근이 필요할 때 Entry point가 필요합니다.

 

앞서 말했듯이 Activity, Fragment, ViewModel 등은 기본적으로 Hilt에서 @AndroidEntryPoint를 지원하여 해당 어노테이션을 선언 시 DI 컨테이너를 자동으로 만들어줍니다.따라서, Hilt가 관리하는 Android 컴포넌트이기 때문에 내부에서는 자동으로 의존성을 주입할 수 있습니다. 

하지만 Hilt가 직접 관리하지 않는 객체(예: ContentProvider, App Startup Library 등) 에서는 @Inject 또는 @HiltViewModel 같은 방식으로 직접 주입할 수 없습니다. Hilt가 직접 관리하지 않는 일반 클래스에서는 Hilt가 @Inject를 찾질 못합니다. 이럴 때 @EntryPoint를 사용하면 Hilt의 의존성 컨테이너에서 직접 객체를 수동으로 가져올 수 있습니다.

 

<Hilt가 지원하는 Android 클래스>

  • Application(@HiltAndroidApp을 사용하여)
  • ViewModel(@HiltViewModel을 사용하여)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

EntryPoint 생성하기

ContentProvider등과 같은 Hilt가 직접 관리하지 않는 시스템 컴포넌트 내부에서는 일반적인 @Inject 방식으로 의존성을 주입할 수 없습니다.

이럴 때 @EntryPoint를 활용합니다.

Entry point를 생성하기 위해서는 각 바인딩 타입에 대한 접근 가능한 메서드를 사용하여

인터페이스를 정의 @EntryPoint 어노테이션을 추가해야 합니다.

그런 다음 @InstallIn을 추가하여 Entry point가 설치될 컴포넌트 지정합니다.

class MyContentProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        val context = context ?: return false
        val entryPoint = EntryPointAccessors.fromApplication(context, MyProviderEntryPoint::class.java)
        val repository = entryPoint.myRepository()

        repository.doSomething()
        return true
    }
   ..
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyProviderEntryPoint {
    fun myRepository(): MyRepository
}

 

ContentProvider는 Hilt의 관리 대상이 아니므로, @Inject를 직접 사용할 수 없습니다.

대신, EntryPointAccessors.fromApplication(context, MyProviderEntryPoint::class.java) 를 통해 Hilt의 객체를 가져옴

 

그냥 @Module로 @Provides를 사용하여 ContentProvider에 주입하면 안될까요?

@Module은 Hilt가 관리하는 클래스 내부에서는 잘 동작하지만,
Hilt가 관리하지 않는 시스템 클래스(ContentProvider 등)에서는 주입이 자동으로 이루어지지 않습니다.

이런 경우 @EntryPoint를 사용하여 Hilt의 DI 컨테이너에서 객체를 직접 가져와야 합니다.

 


HiltViewModel

@HiltViewModel 어노테이션은 Hilt를 사용하여 ViewModel에 의존성을 주입할 수 있도록 도와주는 기능입니다.

Hilt 2.31에서 나왔고 기존의 ViewModel은 @Assisted와 ViewModelInject 을 사용하여 DI를 했는데 이제는 @Inject와 @HiltViewModel 를 활용하면 됩니다.

@AndroidEntryPoint 어노테이션이 있는 액티비티나 프래그먼트에서 이 Hilt가 적용된 ViewModel 인스턴스를 얻으려면 ViewModelProvider 나 kt-extensions 인 by viewmodels() 을 사용하면 됩니다.

 

<과거>

class HakunaViewModel @ViewModelInject constructor(
  private val bar: Bar,
  @Assisted private val savedStateHandle: SavedStateHandle
) : ViewModel() {
  // ... //
}

 

<현재>

@HiltViewModel
class HakunaViewModel @Inject constructor(
  private val bar: Bar,
  val savedStateHandle: SavedStateHandle
) : ViewModel() {
  // ... //
}

 


Dagger-Hilt Keyword

Inject

의존성 주입을 요청하는 부분으로, Inject Annotation 으로 주입을 요청하면 연결된 Component가 Module로부터 객체를 생성하여 건네줍니다.

Component

연결된 Module을 이용하여 의존성 객체를 생성하고 Inject로 요청받은 인스턴스에 생성한 객체를 주입합니다.
의존성 주입 요청을 받고 주입하는 주된 역할을 합니다.

SubComponent

Component는 계층관계를 만들 수 있으며, SubComponent는 Inner Class 방식의 하위 계층 Component 이며, SubComponent는 그래프를 생성합니다.

Inject로 주입을 요청 받으면 SubComponent 에서 먼저 의존성을 검색하고, 없으면 부모로 올라가면서 검색합니다.

Module

Component 에 연결되어 의존성 객체를 생성하며, 생성 후 Scope에 따라서 관리 또한 하게 됩니다.

Scope

생성된 객체의 Lifecycle 의 범위이며, Activity, Fragment 등등의 생명주기에 맞추어 사용합니다.

Module 에서 Scope를 보면서 객체를 관리합니다.

 


Hilt의 전체 프로세스

Hilt의 전체 프로세스는 아래와 같습니다.

  1. Hilt 애노테이션이 포함된 소스 코드
  2. 애노테이션 프로세싱
  3. 생성된 소스코드
  4. 컴파일
  5. 바이트코드 산출
  6. 바이트코드 변조
  7. D8 컴파일 이후 APK/AAB 패키징

Hilt는 애노테이션을 기반으로 동작합니다.

Annotation Processor를 통해 어노테이션을 읽어 컴파일 타임에 의존성 그래프에 문제가 없는지 확인하고 의존성 주입에 필요한 소스코드를 생성합니다.

그렇게 생성된 코드들로 애플리케이션 시작(Application#onCreate) 시 Hilt의 의존성 그래프가 생성되고, 컴포넌트의 생명주기에 맞춰 의존성을 주입됩니다.

 

그렇다면, 애노테이션 프로세서는 뭐고 어떻게 이루어질까요?

 

Annotaion Procesor란?

Annotation Processor란, 영단어 말 그대로 애노테이션 처리기로, 컴파일 타임에 애노테이션을 스캔하고, 소스코드를 검사 또는 생성합니다.

빌드를 다 하고 나서 특정 라이브러리나 플러그인에 의해 프로젝트에 자동으로 코드가 생성되는 것을 본 적이 있을 것입니다. 이것이 Annotation Processing입니다.


하나의 애노테이션을 처리하는 과정을 round라고 하며 몇차례의 round를 거치며 Anotation Processor가 전체 코드를 스캔하고 처리하게 됩니다.

애노테이션 프로세싱 라운드에서 소스 코드 생성이 가능합니다.

  • 의존성 그래프 생성
  • 자동으로 Dagger 기반의 보일러플레이트 코드를 생성 (대거 컴포넌트)

그렇게 생성된 코드는 일반적으로 Build 폴더 하위에 위치하게 되며, 프로젝트 폴더 구조를 확인해보면 generated라고 되어있는 것들을 확인할 수 있습니다.

 

Hilt 애노테이션 처리 요약

  1. Hilt 애노테이션을 사용하여 필요한 소스코드 자동 생성
  2. 컴파일 타임에 의존성 그래프에 이상이 없는지 확인
  3. 생성된 소스코드를 기반으로 동작하므로 리플렉션을 사용하지 않아도 됨

Hilt는 전용 애노테이션을 사용해서 컴파일 타임에 애노테이션을 적절히 처리합니다.
이 과정에서 object 그래프에 문제가 발생한다면 예외를 발생시키고 빌드를 중단시킵니다.

 

이렇게 에노테이션 프로세싱이 끝나면, 생성된 코드를 바탕으로 컴파일을 하고 바이트코드를 산출하게 됩니다.

그리고 바이트 코드 변조 작업을하게 되는데요.

왜 바이트 코드 변조가 필요할까요?

 

바이트코드 변조

바이트코드란, 자바 소스코드가 컴파일을 거쳐 나온 결과물입니다.
자바 소스코드(*.java) -> 자바 컴파일러 -> 자바 바이트코드(*.class)

안드로이드 앱 빌드 과정 (추상화)

  • 소스코드 -> 컴파일러 -> 바이트코드 -> D8 -> Dex -> APK/AAB

여기서 바이트코드 변조가 바이트코드 바로 다음에 이뤄집니다.
바이트코드 변조를 도대체 왜 하는 것일까요?

바이트코드 변조 예시

@HiltAndroidApp
public class MemoApplication extends Application()

 

Hilt를 사용할 때, 바이트코드 변조 기능이 필수는 아닙니다. 하지만, 소스코드를 해치지 않으면서 편리하게 의존성 주입을 할 수 있도록 바이트코드 변조가 도와줍니다.

  • App -> 컴파일러 -> Hilt_App

 

위 코드가 컴파일러를 거치면 다음과 같이 Hilt_MemoApplication 클래스가 생성됩니다.
의존성 주입을 하기 위해 이 Hilt_MemoApplication이라는 클래스를 반드시 참조해야합니다.

그렇다면 소스코드는 다음과 같이 변해야합니다.

@HiltAndroidApp
public class App extends Hilt_MemoApplication()

 

그렇다면 실 코드에서 Hilt_MemoApplication을 상속해야할까요?

정답은 "NO" 입니다.

MemoApplication클래스는 실제로 바이트 코드 변조를 통해 자동으로 Hilt_MemoApplication을 상속하는 코드로 변환이 됩니다.

 

MemoApplication 클래스는 Hilt_MemoApplication 를 상속하고, 컴파일러는 App 클래스를 컴파일할 때 Hilt가 생성한 코드들을 포함하게 됩니다.

 

만약 이러한 과정을 개발자가 수동으로 Hilt_MemoApplication을 상속하려고 했을 때는 어떤 문제가 발생할까요?
Hilt_MemoApplication을 상속했을 때, 해당 클래스가 생성되기 이전이었다면, 아마 Hilt_MemoApplication을 찾지 못한다는 에러가 발생할 것입니다.

그렇기에 그러한 불편함들을 Hilt의 바이트코드 변조가 해결해줍니다.

 


Dagger의 의존성을 주입하는 Flow

의존성을 주입하는 Flow는 아래와 같이 동작합니다.

@Inject → Sub Component → Module

 

부연 설명하면, 아래와 같이 정리할 수 있습니다.

 

1. 현재 Scope 확인: @Inject를 통해 요청된 객체가 현재 Component 또는 Subcomponent의 Scope 내에서 이미 생성된 인스턴스인지 확인합니다. Scope(예: @Singleton, @ActivityScoped 등) 내에 인스턴스가 존재하면 기존 인스턴스를 반환합니다.

@ActivityScoped
class UserRepository @Inject constructor()

 

2. Module 확인: Scope 내에 인스턴스가 존재하지 않으면, 해당 객체를 제공할 수 있는 Module이 있는지 확인합니다. Module에서 제공하는 경우 해당 인스턴스를 반환합니다.

@Module
@InstallIn(ActivityComponent::class)
object AppModule {

    @ActivityScoped
    @Provides
    fun provideRepository(): UserRepository {
        return UserRepositoryImpl()
    }
}

 

3. 부모 Component 탐색: Subcomponent에서 해당 타입을 찾을 수 없는 경우, 부모 Component로 올라가면서 같은 과정을 반복합니다.

 

4. 새 인스턴스 생성: 부모 Component에서도 찾을 수 없다면, 생성자를 통해 새로운 인스턴스를 만들어 반환합니다. 단, Scope 어노테이션이 없으면 매번 새로운 인스턴스가 생성됩니다.

 

참고자료

https://brunch.co.kr/@purpledev/44

 

안드로이드 Hilt 딥 다이브

DI와 Dagger 그리고 Hilt 에 대해서 | 안녕하세요 저희는 서비스 개발팀에서 안드로이드 개발을 하고있는 두루와 스티븐입니다. 오늘은 Dagger와 Hilt에 대해 깊게 조사해본 것을 소개하겠습니다. Hilt

brunch.co.kr

https://small-stepping.tistory.com/1119

 

[Hilt] AndroidEntryPoint의 이해와 Hilt 모듈, 바인딩

AndroidEntryPoint의 이해Dagger에서 말하는 Component는 의존성을 관리하는 컨테이너를 말한다. SubComponent는 어떤 컴포넌트의 하위에 속하는 컴포넌트를 일컫는다. SubComponent의 하위에 있는 또다른 컴포

small-stepping.tistory.com

https://small-stepping.tistory.com/1114

 

[Hilt] Hilt 내부 동작의 이해

애노테이션은 여러가지 속성을 가질 수 있다. 정의된 애노테이션은 클래스에 마킹할 수 있다. 뿐만아니라 필드 메서드 파라미터 등에서도 선택적으로 사용가능하다. 안드로이드에

small-stepping.tistory.com

https://youngest-programming.tistory.com/356

 

[안드로이드] DI Dagger2 @Singleton vs @Resuable 차이점 정리

[2021-04-14 업데이트] [참고] https://proandroiddev.com/dagger-2-check-singlecheck-doublecheck-scopes-4ee48fc31736 Dagger 2 : Check — SingleCheck — DoubleCheck … Scopes This article is a part of the “Dagger 2 Android: Defeat the Dahaka” seri

youngest-programming.tistory.com

https://f2janyway.github.io/android/hilt2/

 

[안드로이드] Hilt 개념 설명 및 사용법[2]

이전 글 에서 Hilt의 기본에 대해서 알아봤다.

f2janyway.github.io

https://hyperconnect.github.io/2020/07/28/android-dagger-hilt.html

 

Dagger Hilt로 안드로이드 의존성 주입 시작하기

Dagger Hilt에 대해 알아보고 안드로이드 프로젝트에 적용하는 방법을 소개합니다.

hyperconnect.github.io