|

Hilt EntryPoint 정리

Hilt EntryPoint 정리 대표 이미지
자동 주입 경계 바깥에서 최소한으로 그래프에 들어갈 때 EntryPoint가 필요하다

Hilt EntryPoint는 평소에 자주 쓰는 기본 도구가 아닙니다. 결론부터 말하면 Hilt가 직접 생성하지 않는 객체에서 그래프에 최소한으로 접근해야 할 때 쓰는 예외 처리 도구에 가깝습니다.

그래서 잘 쓰면 ContentProvider나 라이브러리 생성 객체 같은 막힌 지점을 풀 수 있지만, 너무 쉽게 쓰기 시작하면 constructor injection과 component 경계라는 Hilt의 장점이 흐려질 수 있습니다.


기본값은 자동 주입

Activity, Fragment, ViewModel처럼 Hilt가 lifecycle과 생성 흐름을 잘 아는 지점에서는 보통 @AndroidEntryPoint와 일반 주입 패턴이 기본값입니다. 이쪽이 더 짧고 더 안전하고, 테스트와 scope 해석도 자연스럽습니다.

@AndroidEntryPoint
class DetailActivity : AppCompatActivity() {
    @Inject lateinit var analytics: AnalyticsService
}

즉 EntryPoint는 기본 주입을 대체하는 기능이 아니라, 기본 주입이 바로 닿지 않는 경계를 다루는 보조 도구라고 보는 편이 맞습니다.


무엇이 다를까

Dagger 공식 문서는 entry point를 “Dagger가 직접 주입할 수 없는 코드가 graph에 진입하는 boundary”로 설명합니다. 안드로이드 실무 말로 바꾸면, Hilt가 만든 객체는 아닌데 안에서 Hilt binding이 꼭 필요한 상황이라고 이해하면 편합니다.

Hilt 자동 주입이 막힌 지점에서 EntryPoint 판단 흐름도
누가 객체를 생성하는지부터 보면 EntryPoint가 필요한지 더 쉽게 보인다

언제 필요할까

  1. Hilt가 직접 생성하지 않는 객체일 때
  2. 라이브러리가 대신 new 하는 객체일 때
  3. 너무 이른 초기화 시점이라 일반 주입 흐름과 어긋날 때
  4. @AndroidEntryPoint를 바로 붙일 수 없는 경계일 때

대표 예시는 ContentProvider, App Startup initializer, 일부 서드파티 callback 객체, 라이브러리 내부 생성 클래스 같은 경우입니다. 핵심은 “자동 주입이 불가능한 예외 지점인가”를 먼저 보는 것입니다.

Hilt EntryPoint 핵심 카드
기본값은 자동 주입이고 EntryPoint는 막힌 경계에서만 최소로 쓰는 편이 안전하다

예시: ContentProvider

ContentProvider는 framework가 비교적 이른 시점에 생성하므로 EntryPoint 설명에서 자주 등장합니다. 이 경우 필요한 binding만 가진 interface를 만들고, 적절한 component holder에서 꺼내옵니다.

@EntryPoint
@InstallIn(SingletonComponent::class)
interface AnalyticsEntryPoint {
    fun analyticsService(): AnalyticsService
}

class TrackingContentProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        val appContext = context?.applicationContext ?: return false
        val entryPoint = EntryPointAccessors.fromApplication(
            appContext,
            AnalyticsEntryPoint::class.java,
        )
        val analytics = entryPoint.analyticsService()
        analytics.log("provider_created")
        return true
    }

    override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
    override fun getType(uri: Uri): String? = null
    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

이 패턴의 핵심은 세 단계입니다. 필요한 binding만 가진 interface를 만들고, @InstallIn으로 component를 맞추고, EntryPointAccessors로 올바른 holder에서 꺼내오는 것입니다.


component 매칭이 중요한 이유

EntryPoint는 단순 getter가 아니라 component 경계 문제입니다. application holder에서 꺼낼 것인지, activity holder에서 꺼낼 것인지에 따라 수명과 scope 해석이 달라집니다. 필요한 생명주기보다 너무 긴 component에서 객체를 꺼내 쓰면 구조가 금방 흐려질 수 있습니다.

  • application context에서 꺼낼 때는 보통 SingletonComponent 감각이 자연스럽다
  • activity/fragment/view holder는 해당 holder가 품는 component 경계를 먼저 봐야 한다
  • 지금 들고 있는 객체가 어떤 component holder 역할을 하는지 먼저 파악해야 한다

라이브러리 객체에서는

어떤 클래스는 @Inject constructor를 붙이고 싶어도 실제 생성은 외부 라이브러리가 가져갑니다. 이런 경우 EntryPoint를 using class 옆에 정의해 두면 왜 그래프 접근이 필요한지 의도가 분명해집니다.

class ExternalCallbackHandler {

    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface ExternalCallbackEntryPoint {
        fun analyticsService(): AnalyticsService
        fun logger(): AppLogger
    }

    fun handle(context: Context) {
        val entryPoint = EntryPointAccessors.fromApplication(
            context.applicationContext,
            ExternalCallbackEntryPoint::class.java,
        )

        entryPoint.logger().debug("callback started")
        entryPoint.analyticsService().log("callback_handled")
    }
}

Dagger 공식 문서도 EntryPoint interface는 binding 정의 위치보다 “그 binding이 왜 필요한 using class” 쪽에 두는 편이 더 낫다고 설명합니다. 그래야 interface가 작은 목적 단위로 유지되기 쉽습니다.


남용하면 생기는 문제

Hilt EntryPoint 자주 하는 실수 카드
막힌 곳을 푸는 도구가 어디서든 graph를 직접 여는 습관으로 바뀌면 구조가 거칠어진다
  1. constructor injection으로 될 코드를 굳이 EntryPoint로 푼다
  2. EntryPoint interface가 비대해져 service locator처럼 변한다
  3. application에서 꺼낸 객체를 activity 범위처럼 착각한다
  4. 공식 통합 경로가 있는데 우회 접근부터 선택한다

특히 메서드가 계속 늘어나는 EntryPoint interface는 경고 신호입니다. 그 순간부터는 예외적 우회 통로가 아니라 작은 service locator처럼 굴기 시작할 수 있습니다.


빠르게 고르는 기준

  1. 이 객체를 Hilt가 직접 생성하지 않는가
  2. 그런데 Hilt binding이 꼭 필요한가
  3. 지원 component 구조로 옮기기 어려운가
  4. 필요한 binding 수를 작게 제한할 수 있는가

이 질문에 대부분 “예”라면 EntryPoint가 후보입니다. 반대로 일반 component에 둘 수 있거나 constructor injection으로 충분하다면 기본 주입 쪽이 더 좋은 선택일 가능성이 큽니다.



예외 도구라는 감각이 중요하다

Hilt EntryPoint는 기본 주입을 대체하는 도구가 아니라, 자동 주입 경계 바깥에서 최소한으로 그래프에 들어가는 예외 도구라고 보는 편이 맞습니다.

그래서 잘 쓰면 막힌 지점을 풀 수 있지만, 습관처럼 쓰면 작은 service locator처럼 변할 수 있습니다.

마무리

Hilt EntryPoint는 DI를 더 많이 쓰기 위한 도구가 아니라, Hilt 자동 주입 바깥에서 정말 필요한 만큼만 그래프에 들어가기 위한 예외 처리 도구에 가깝습니다. 그래서 ContentProvider나 라이브러리 생성 객체처럼 막힌 지점에서는 유용하지만, 기본값처럼 남용하면 Hilt의 장점인 명확한 생성 경계와 scope 감각이 흐려질 수 있습니다.

함께 보면 좋은 글로는 Hilt scope 쉽게 이해하기, 안드로이드 UDF는 왜 중요할까, 안드로이드 single activity 구조는 왜 많아졌을까가 있습니다. 공식 기준은 Dagger Hilt Entry Points, Hilt on Android 문서를 같이 보면 더 분명합니다.

함께보면 좋은 글