|

Hilt scope 쉽게 이해하기

Hilt scope 대표 이미지
scope는 주입 위치보다 객체 lifetime을 먼저 보면 훨씬 덜 헷갈린다

Hilt scope가 헷갈리는 가장 큰 이유는 annotation 이름만 보고 외우려 하기 때문입니다. 결론부터 말하면 scope는 객체가 얼마나 오래 살아야 하는지 정하는 lifetime 규칙으로 보면 훨씬 이해가 쉬워집니다.

이번 글에서는 많은 안드로이드 개발자가 가장 자주 헷갈리는 세 범위만 집중해서 보겠습니다. Singleton, ActivityRetained, ViewModel입니다. 셋 다 자주 보이지만, 살아남는 범위가 다르고 어울리는 책임도 다릅니다.

Singleton, ActivityRetained, ViewModel scope 차이를 요약한 카드
셋의 차이는 결국 lifetime과 공유 범위 차이로 정리된다

먼저 큰 그림: Hilt는 lifecycle에 붙은 component 계층이다

dagger.dev 문서는 Hilt가 미리 정의된 component hierarchy를 제공하고, 각 component가 Android lifecycle에 연결된다고 설명합니다. 즉 scope를 이해하려면 annotation 자체보다 어떤 component에 묶이는지를 먼저 봐야 합니다.

이 관점에서 보면 질문이 바뀝니다. “이 객체를 어디서 주입하지?”가 아니라 “이 객체를 언제까지 같은 인스턴스로 유지해야 하지?”가 됩니다.

Hilt scope lifetime 흐름도
Application, Activity 재생성, ViewModel 파괴 시점에 따라 lifetime이 갈린다

SingletonComponent: 앱 프로세스 동안 유지되는 범위

SingletonComponent는 Application#onCreate에서 만들어지고 프로세스가 종료될 때까지 살아 있습니다. 그래서 앱 전체에서 오래 써야 하는 객체에 어울립니다.

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

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

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

대표적으로 HTTP client, Retrofit, database, shared preferences wrapper처럼 앱 전역에서 재사용하는 인프라 객체가 여기에 잘 맞습니다.

하지만 “어디서든 쓰이니까 Singleton”이라고 생각하면 금방 과해집니다. 화면별 상태, 현재 선택된 탭, 일시적인 입력값 같은 것은 전역 수명이 필요하지 않습니다.


ActivityRetainedScoped: 회전해도 유지되지만 앱 전체는 아닌 범위

이 범위가 가장 많이 헷갈립니다. ActivityRetainedComponent는 첫 onCreate부터 마지막 onDestroy까지 유지되고, configuration change를 가로질러 살아남습니다.

쉽게 말해 화면 회전으로 Activity 인스턴스가 다시 만들어져도, 같은 logical activity 흐름 안에서는 계속 유지되는 범위라고 보면 됩니다.

@ActivityRetainedScoped
class SessionDraftRepository @Inject constructor(
    private val api: DraftApi
) {
    private var cachedDraft: Draft? = null

    fun saveTemp(draft: Draft) {
        cachedDraft = draft
    }

    fun getTemp(): Draft? = cachedDraft
}

예를 들어 한 Activity 아래 여러 Fragment가 같은 임시 편집 상태를 함께 보고 있고, 회전해도 그 임시 상태를 유지하고 싶다면 ActivityRetainedScoped가 잘 맞을 수 있습니다.

단, 이것이 “Activity view 객체와 함께 움직이는 범위”는 아닙니다. 오히려 view는 다시 만들어져도 논리적 화면 흐름은 유지하고 싶은 경우를 떠올리면 맞습니다.


ViewModelScoped: 특정 ViewModel 수명 안에서만 유지되는 범위

ViewModelComponent는 ViewModel created부터 ViewModel destroyed까지 살아 있습니다. 공식 문서도 이 component의 기본 바인딩으로 SavedStateHandle을 제공한다고 설명합니다.

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val loadProfileUseCase: LoadProfileUseCase,
    private val profileFormatter: ProfileFormatter
) : ViewModel()

@ViewModelScoped
class ProfileFormatter @Inject constructor() {
    fun toUi(user: User): ProfileUiModel = ProfileUiModel(
        displayName = user.name.trim(),
        bio = user.bio.orEmpty()
    )
}

이 범위는 특정 ViewModel 안에서만 공유되면 충분한 객체에 잘 맞습니다. 예를 들어 특정 화면 전용 formatter, editor state helper, 화면 전용 validation helper 같은 것입니다.

같은 Activity 아래 다른 ViewModel까지 공유할 필요가 없다면 ActivityRetained보다 ViewModelScoped가 더 좁고 안전합니다.


Hilt scope 차이를 한 번에 정리하면

  • 앱 전체에서 오래 공유해야 한다 → Singleton
  • 화면 회전 이후에도 같은 Activity 흐름에서 여러 ViewModel/Fragment가 같이 볼 수 있어야 한다 → ActivityRetained
  • 특정 ViewModel 안에서만 유지되면 충분하다 → ViewModelScoped

즉 좁은 쪽에서 넓은 쪽으로 올리는 기준은 “편해서”가 아니라 “정말 그만큼 오래 살아야 하는가”입니다.

예를 들어 특정 화면의 입력 검증 helper를 Singleton으로 올리면 재사용보다 책임 범위가 먼저 커집니다. 반대로 같은 Activity 안 여러 화면이 함께 보는 임시 편집 상태라면 ViewModelScoped보다 ActivityRetained가 더 자연스러울 수 있습니다.

Hilt scope 선택 기준 카드
어떤 scope를 고를지 고민될 때는 shared range와 lifetime을 먼저 본다

실무 예시로 보면 더 쉬워진다

아래 예시는 많이 나오는 판단 문제입니다.

  1. Retrofit / Room / 토큰 저장소 → 앱 전역 재사용이라 Singleton 쪽이 자연스럽다.
  2. 한 Activity 안에서 여러 Fragment가 공유하는 임시 편집 상태 → 회전 후에도 살아야 하면 ActivityRetained를 검토할 수 있다.
  3. 특정 화면 전용 formatter, validator, mapper → 그 ViewModel 안에서만 쓰면 ViewModelScoped가 더 맞다.
  4. 검색 화면의 UI state holder → 보통은 ViewModel 자체가 들고 있으면 되고, 굳이 별도 scoped 객체를 만들지 않아도 된다.
  5. 현재 로그인 사용자 세션 → 앱 전체에서 참조한다면 Singleton 계층에서 다루는 편이 자연스럽다.

여기서 중요한 포인트는 모든 것을 scope로 해결하려 하지 않는 것입니다. 실제로는 unscoped가 더 맞는 helper도 많습니다. 공식 문서도 Hilt binding은 기본적으로 unscoped이며, scope는 correctness가 필요할 때만 신중히 쓰라고 설명합니다.


자주 하는 오해 4가지

  1. 오해 1. module을 InstallIn 했으니 자동으로 다 scoped 된다 → 아니다. scope annotation을 붙인 binding만 scoped 된다.
  2. 오해 2. ActivityRetainedScoped는 Activity view 객체와 수명이 같다 → 아니다. 회전 같은 configuration change를 넘겨 유지된다.
  3. 오해 3. ViewModelScoped면 앱의 모든 ViewModel이 공유한다 → 아니다. 특정 ViewModelComponent 안에서만 공유된다.
  4. 오해 4. 성능 때문에 일단 Singleton으로 두는 편이 낫다 → 아니다. 수명과 정합성이 먼저다.

특히 네 번째 오해는 꼭 경계해야 합니다. Singleton은 편해 보이지만, 전역 공유가 불필요한 상태를 너무 오래 살려 두면 테스트도 어려워지고 책임 경계도 흐려집니다.


빠르게 고르는 체크리스트

  • 프로세스 전체에서 같은 인스턴스를 써야 하나? → Singleton 검토
  • 회전 이후에도 같은 activity 흐름에서 살아야 하나? → ActivityRetained 검토
  • 특정 ViewModel 안에서만 의미가 있나? → ViewModelScoped 검토
  • 사실 매번 새로 만들어도 문제 없나? → unscoped가 더 단순할 수 있다
  • scope를 붙이는 이유를 lifetime 한 문장으로 설명할 수 있나? 설명이 안 되면 너무 넓게 잡았을 가능성이 있다


scope를 너무 넓게 잡으면 왜 위험할까

scope를 넓게 잡을수록 생성 비용은 줄어든다고 느낄 수 있지만, 실제로는 상태 공유가 의도보다 넓어지고 테스트 경계가 흐려질 수 있습니다. 특히 화면 전용 helper를 전역으로 올리면 이후 수정 파급이 커집니다.

그래서 Hilt scope 판단은 성능 최적화보다 정합성 최적화에 가깝습니다. 정말 그 객체가 그 수명만큼 살아야 하는지 먼저 설명할 수 있어야 합니다.

정리

Hilt scope를 외울 때 annotation 이름만 보면 계속 헷갈립니다. 대신 이 객체가 언제 만들어지고 언제 사라져야 하는가를 먼저 보면 Singleton, ActivityRetained, ViewModel의 차이가 훨씬 선명해집니다.

Singleton은 앱 전역, ActivityRetained는 회전을 넘는 activity 흐름, ViewModelScoped는 특정 ViewModel 안쪽이라고 정리하면 대부분의 판단이 풀립니다.

관련해서 같이 보면 좋은 글은 안드로이드에서 ViewModel이 왜 필요한가, UI 상태를 어디에 둬야 할까, 안드로이드 UDF란 무엇일까입니다.

외부 기준으로는 Hilt Components, Dependency injection with Hilt, ViewModel overview를 같이 보면 이해가 가장 단단해집니다.

한 문장으로 끝내면 이렇습니다. Hilt scope는 주입 위치를 고르는 표식이 아니라, 객체 lifetime을 어디까지 보장할지 결정하는 규칙입니다.

함께보면 좋은 글