
Hilt scope가 헷갈리는 가장 큰 이유는 annotation 이름만 보고 외우려 하기 때문입니다. 결론부터 말하면 scope는 객체가 얼마나 오래 살아야 하는지 정하는 lifetime 규칙으로 보면 훨씬 이해가 쉬워집니다.
이번 글에서는 많은 안드로이드 개발자가 가장 자주 헷갈리는 세 범위만 집중해서 보겠습니다. Singleton, ActivityRetained, ViewModel입니다. 셋 다 자주 보이지만, 살아남는 범위가 다르고 어울리는 책임도 다릅니다.

먼저 큰 그림: Hilt는 lifecycle에 붙은 component 계층이다
dagger.dev 문서는 Hilt가 미리 정의된 component hierarchy를 제공하고, 각 component가 Android lifecycle에 연결된다고 설명합니다. 즉 scope를 이해하려면 annotation 자체보다 어떤 component에 묶이는지를 먼저 봐야 합니다.
이 관점에서 보면 질문이 바뀝니다. “이 객체를 어디서 주입하지?”가 아니라 “이 객체를 언제까지 같은 인스턴스로 유지해야 하지?”가 됩니다.

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가 더 자연스러울 수 있습니다.

실무 예시로 보면 더 쉬워진다
아래 예시는 많이 나오는 판단 문제입니다.
- Retrofit / Room / 토큰 저장소 → 앱 전역 재사용이라 Singleton 쪽이 자연스럽다.
- 한 Activity 안에서 여러 Fragment가 공유하는 임시 편집 상태 → 회전 후에도 살아야 하면 ActivityRetained를 검토할 수 있다.
- 특정 화면 전용 formatter, validator, mapper → 그 ViewModel 안에서만 쓰면 ViewModelScoped가 더 맞다.
- 검색 화면의 UI state holder → 보통은 ViewModel 자체가 들고 있으면 되고, 굳이 별도 scoped 객체를 만들지 않아도 된다.
- 현재 로그인 사용자 세션 → 앱 전체에서 참조한다면 Singleton 계층에서 다루는 편이 자연스럽다.
여기서 중요한 포인트는 모든 것을 scope로 해결하려 하지 않는 것입니다. 실제로는 unscoped가 더 맞는 helper도 많습니다. 공식 문서도 Hilt binding은 기본적으로 unscoped이며, scope는 correctness가 필요할 때만 신중히 쓰라고 설명합니다.
자주 하는 오해 4가지
- 오해 1. module을 InstallIn 했으니 자동으로 다 scoped 된다 → 아니다. scope annotation을 붙인 binding만 scoped 된다.
- 오해 2. ActivityRetainedScoped는 Activity view 객체와 수명이 같다 → 아니다. 회전 같은 configuration change를 넘겨 유지된다.
- 오해 3. ViewModelScoped면 앱의 모든 ViewModel이 공유한다 → 아니다. 특정 ViewModelComponent 안에서만 공유된다.
- 오해 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을 어디까지 보장할지 결정하는 규칙입니다.