NEON의 이것저것

Hilt 라이브러리 정리 (ft. Annotation Processing) 본문

개발지식 정리/Kotlin

Hilt 라이브러리 정리 (ft. Annotation Processing)

Neoself 2025. 12. 4. 14:24

Android Developers에서는 Hilt를 아래와 같이 설명하고 있습니다.

종속 항목 수동 삽입을 실행하는 상용구를 줄이는 Android용 종속 항목 삽입(DI) 라이브러리

 

Hilt는 안드로이드 컴포넌트(Activity/Fragment/Compose NavHost/ ViewModel 등)의 생명주기에 맞춰 의존성 그래프를 자동 생성하고, Gradle 플러그인 및 Annotation processing을 통해 Factory 코드를 만들어 보일러 플레이트를 줄여줍니다.

Annotation Processing: 소스코드 상단의 어노테이션(ex. @Composable)을 컴파일 시점에 스캔하여, 추가 소스/ 클래스/메타 데이터를 자동 생성하는 컴파일 확장 단계.
JSR 269 표준(플러그인 API) 위에서 동작하며, Dagger/Hilt, Room, Glide 등이 이 메커니즘으로 팩토리/바인딩 코드를 생성

 

Hilt 라이브러리가 보일러 플레이트를 줄여주는 과정을 자세히 설명하기 위해서는 안드로이드 프로젝트가 빌드되는 과정부터 설명이 필요한데요.

 

빌드 파이프라인

1. Kotlin 컴파일 전 전처리: KAPT면 스텁 생성, KSP면 심벌 분석.
1.1 KAPT(Java annotation processor를 Kotlin에서 사용 가능하게 함)
      1. kotlinc가 프로세서가 읽을 수 있도록 Kotlin 소스를 Java 스텁으로 변환
      2. Javac가 스텁을 기반으로 Annotation Processor를 돌려 새 소스(주로 .java)를 생성하고, 그 생성물을 포함해 컴파일
      3. 이후 kotlinc가 원본 Kotlin 소스(+ 생성된 Kotlin 소스가 있다면 그것까지)를 컴파일
      4. 최종적으로 모두 .class 바이트코드로 합쳐짐
1.2 KSP
      1. kotlinc가 Kotlin 심벌을 직접 읽고, 프로세서가 Kotlin/Java 소스를 생성
      2. 생성된 Kotlin 소스(.kt)는 kotlinc가, 생성된 Java 소스가 있다면 Javac가 각각 컴파일
      3. 최종적으로 모두 .class 바이트코드로 합쳐짐

비교: KAPT는 스텁 생성 + Javac 라운드를 거쳐야 해 오버헤드가 있고, 증분/멀티플랫폼 제약이 있습니다. KSP는 스텁 없이 심벌을 직접 읽어 일반적으로 더 빠르고 증분/멀티플랫폼 지원이 낫습니다.

 

2. 이후 단계: 클래스 머징 → D8/DEX 변환 → 리소스 병합/패키징(APK/AAB) 등으로 빌드 마무리

 

여기서 Kapt 기준 2단계, KSP 기준 1단계에서 Hilt 라이브러리는 Factory 코드를 생성하게 됩니다.

// 실제 Hilt 라이브러리의 Annotation이 기입된 소스코드

@Singleton
class AuthManager @Inject constructor(
    @ApplicationContext private val context: Context
) {
	...
}

// 컴파일 단계에서 Annotation Processing으로 생성된 Factory 코드
@ScopeMetadata("javax.inject.Singleton")
@QualifierMetadata("dagger.hilt.android.qualifiers.ApplicationContext")
@DaggerGenerated
@Generated(
    value = "dagger.internal.codegen.ComponentProcessor",
    comments = "https://dagger.dev"
)
@SuppressWarnings({
    "unchecked",
    "rawtypes",
    "KotlinInternal",
    "KotlinInternalInJava"
})
public final class AuthManager_Factory implements Factory<AuthManager> {
  // @ApplicationContext로 한정된 Context를 Dagger가 공급
  private final Provider<Context> contextProvider;

  public AuthManager_Factory(Provider<Context> contextProvider) {
    this.contextProvider = contextProvider;
  }

  @Override
  public AuthManager get() {
    // contextProvider.get()으로 실제 Context를 받아 newInstance를 호출
    return newInstance(contextProvider.get());
  }

  public static AuthManager_Factory create(Provider<Context> contextProvider) {
    return new AuthManager_Factory(contextProvider);
  }

  public static AuthManager newInstance(Context context) {
    // AuthManager의 @Inject constructor(@ApplicationContext Context)를 그대로 사용
    return new AuthManager(context);
  }
}

위 예시는 Hilt 라이브러리의 @Singleton 어노테이션을 코드에 기입할 때, 컴파일 과정 자동생성되는 코드입니다.

이 Factory 코드는 AuthManager 생성자 호출을 감싸며, Dagger가 DI 그래프를 조립할 때 AuthManager의 생성자에 필요한 Context를 주입해 새 인스턴스를 생성하는 역할을 수행합니다. 이로써, 런타임에 리플렉션 없이 빠른 객체 공급이 가능해집니다.

 

 

Hilt/ Dagger로 설계되는 의존성 방향 중 하나의 예시로 아래와 같은 상황 및 코드가 있습니다.

 

0. 의존성 방향 

@HiltAndroidApp → 모듈(Repo/UseCase/Network) → @HiltViewModel → Compose hiltViewModel()/EntryPoint

 

1. 루트 그래프 선언

// 루트 그래프 선언
@HiltAndroidApp
class ExampleApplication : Application() {
    // Inject 어노테이션을 통해 클래스를 접근하여 루트 그래프에서 사용할 수 있도록 의존성 설계
    @Inject lateinit var exampleManager: ExampleManager

    override fun onCreate() {
        super.onCreate()
        exampleManager.exampleFunc() 
    }
}

 

2. 의존성 정의 모듈

abstract class RepositoryModule {
    // 도메인 인터페이스를 @Binds @SingleTon으로 구현체에 연결
    @Binds
    @Singleton
    abstract fun bindExampleRepository(
        exampleRepositoryImpl: ExampleRepositoryImpl
    ): ExampleRepository

    ...
}

 

3. 화면/상태 계층 주입

// @HiltViewModel + @Inject constructor로 상위 클래스를 주입받아 사용
@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val exampleManager: ExampleManager
) {
    fun test() {
        exampleManager.exampleFunc()
    }
    ...
}
@Composable
fun ExampleView(
    // Compose 네비게이션에서 hiltViewModel()로 주입된 ViewModel 사용
    viewModel: ExampleViewModel = hiltViewModel()
) {
    ...

 

4. EntryPoint

@EntryPoint
@InstallIn(SingletonComponent::class)
interface AuthManagerEntryPoint {
    fun authManager(): AuthManager
}

// EntryPoint를 통해 불러올 수 있도록 인터페이스를 사전에 정의
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PopupManagerEntryPoint {
    fun popupManager(): PopupManager
}

@Composable
fun HumaniaView(humaniaViewModel: HumaniaViewModel) {
    val toastManager: ToastManager = humaniaViewModel.toastManager

    val context = LocalContext.current
    // EntryPointAccessors.fromApplication으로 상위 Manager 클래스들을 꺼내, UI 컨테이너에 연결
    val popupManager: PopupManager = remember {
        EntryPointAccessors.fromApplication(context, PopupManagerEntryPoint::class.java).popupManager()
    }
    val authManager = EntryPointAccessors.fromApplication(context, AuthManagerEntryPoint::class.java).authManager()
    val navController = rememberNavController()
    
    ...
    // 컴포저블 정의 섹션
    Box {
        ...
        PopupContainer(popupManager = popupManager)
    }


루트 Compose에서 EntryPointAccessors.fromApplication으로 ToastManager, PopupManager, AuthManager 등을 꺼내 UI 컨테이너에 연결합니다.

 

감사합니다.