Component
Component는 의존성 객체를 생성하고, 의존성 주입을 관리하는 컨테이너입니다.
즉, 생명주기(Scope)에 맞춰 의존성을 관리하고 제공 하는 역할을 합니다.
컴포넌트는 보통 @SingletonComponent, @ActivityComponent, **@FragmentComponent**와 같이 특정 라이프사이클에 맞는 범위에 따라 정의되며,
Hilt Component는 표준 Android 구성요소, Activity, Fragment, View, ViewModel, Service에서 사용할 수 있는 Component를 제공하고 있습니다.
자동으로 생명주기 및 계층구조와 같은 세부적인 설정을 자동으로 구현합니다.
컴포넌트는 다음과 같은 계층 구조를 가집니다. 이러한 계층구조에서 화살표는 하위 컴포넌트를 의미하며, 하위 컴포넌트 바인딩은 상위 컴포넌트 바인딩의 종속성을 가질 수 있습니다.
Scope
의존성 객체가 어떤 생명 주기를 가지는지, 즉 객체가 어디서, 언제 재사용될지 관리하는 메커니즘입니다.
즉, 특정 객체가 어떤 생명 주기 동안 유지될지를 결정합니다.
스코프를 지정하지 않으면 기본적으로 매번 새로 생성됩니다.
Hilt에서 Scope Anotation을 사용하면, 객체의 수명을 해당 구성요소의 수명으로 제한할 수 있습니다.
아래 예시를 살펴보 Componet와 Scope를 각각 설정해줬는데, 왜 이렇게 해줘야할까요?
@InstallIn(ActivityComponent::class) // Activity의 생명주기에 맞춰 의존성을 제공
@Module
object NetworkModule {
@Provides
@ActivityScoped // Activity 범위에서 하나의 인스턴스를 공유
fun provideNetworkService(): NetworkService {
return NetworkServiceImpl()
}
}
만일 컴포넌트만 선언하고 Scope를 선언하지 않는다면?
아래와 같이 답변 드릴 수 있을 것 같습니다.
- @InstallIn(ActivityComponent::class)만 사용하면 해당 의존성은 Activity 생명 주기 동안 제공되지만, 각 호출마다 인스턴스가 새로 생성될 수 있습니다. 즉, 컴포넌트의 역할은 생명 주기 내에서 의존성 주입이 이루어지도록 하는 것입니다.
- @ActivityScoped를 사용하면, 같은 Activity 내에서 동일한 인스턴스가 재사용되도록 보장할 수 있습니다. 즉, 같은 Activity 내에서는 여러 번 주입되어도 하나의 인스턴스를 공유하게 됩니다.
따라서 스코프를 명시적으로 지정하지 않으면 의존성이 매번 새로 생성될 수 있으므로 의존성의 재사용을 원한다면 @ActivityScoped 같은 스코프 어노테이션을 추가하는 것이 좋은 방식입니다.
테스트 코드를 통해 더 알아 봅시다.
테스트를 위해 작성한 프로젝트의 구조는 아래와 같습니다.
1. Component는 반드시 Module 또는 EntryPoint와 함께 사용해야 한다.
가장 먼저 일반 클래스에 Component를 사용해보자!
@InstallIn(ViewModelComponent::class)
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
!! 에러가 출력되고 있습니다. 😱
때문에 일반 타입에서는 Scope annotation만을 사용할 수 있고, @InstallIn + Component는 @Module과 함께 사용하는 것을 알 수 있습니다. 👍🏻
2. Scope Annotation 1 : Component
그럼 먼저 Module이 아닌 별도의 타입들에 Scope annotation을 사용할 때의 동작에 대해 알아보겠습니다.
Hilt에 대한 안드로이드 공식문서를 보면 Component들은 위와 같이 계층 구조를 가진다는 것을 알 수 있습니다.
🌹 Component란! 각각의 Component에 해당하는 라이프사이클에 맞게 생성되고 파괴되는 인스턴스들의 집합(?)이라고 이야기할 수 있을거 같습니다.
예를 들어, ViewModel에서 아래와 같이 ActivityScope로 지정된 UseCase를 사용하고 있다고 합시다.
ActivityScoped로 지정된 UseCase는 ActivityComponent에 생성되며, 이 ActivityComponent는 Activity의 라이프사이클에 맞게 생성되고 파괴될 수 있습니다.
하지만, 알다싶이 Activity의 생명주기는 ViewModel의 생명주기보다 짧을 수 있기 때문에
ViewModel에서 주입받은 testUseCase가 언제나 메모리상에 존재하고 있다는 것을 보장해주지 못합니다.
ViewModel은 계속 살아있지만, Configuration Change로 인해 Activity가 파괴되었을 때, ActivityComponent 내에 존재하는 UseCase도 함께 파괴될 수 있습니다.
이러한 문제점을 방지하기 위해 호출하는 쪽(여기서는 ViewModel)와 동일하거나 더 긴 라이프사이클을 가진 Component의 타입만 사용할 수 있도록 하는 듯 합니다.
위 예제를 코드로 구현하면 아래와 같습니다.👇🏻
@ActivityScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
여기서 알 수 있는 점은 의존성을 주입받을 때, 자신을 포함한 자신보다 상위에 있는 Scope 타입만 주입받을 수 있다는 점입니다.🤗
👇🏻 요렇게!
🌹 즉, ViewModel에서는 ViewModelScoped, ActivityRetainedScoped, Singleton으로 범위가 지정된 타입만 사용할 수 있습니다.
이는 의존성 주입으로 인해 생긴 tree에서도 동일하게 적용됩니다.
예를 들어, 아래와 같은 구조로 되어있다고 하자
이를 코드로 표현하면
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@ViewModelScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
이와 같으며, 예상하셨듯이 동일한 에러가 발생해 컴파일이 되지 않습니다. 🤗
📌 정리
Scope 어노테이션으로 인해 자신을 포함한 상위 Scope의 타입만 주입받을 수 있습니다. 🍿
3. Scope Annotation 2 : Singleton
이 하나만 기억하고 있으면 됩니다.
🌹 동일한 Scope에서는 매번 새로운 인스턴스, 상위 Scope에서는 동일한 인스턴스
여기서는 테스트를 아래와 같이 수행하였습니다.
3.1 서로 다른 두 viewModel에서 ViewModelScope로 지정된 UseCase를 주입받아보자
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
@HiltViewModel
class OtherViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
3.1.1 MainViewModel에서
viewModel | @20812 |
useCase | @20814 |
3.1.2 OtherViewModel에서
viewModel | @20819 |
useCase | @20821 |
ViewModel에서 동일한 Scope인 ViewModelScope로 지정된 UseCase를 주입받았으므로, 두 ViewModel은 다른 UseCase 인스턴스를 받아 사용하고 있습니다. 🤗
이는 앞에서도 말했듯이 ViewModelScoped로 지정된 UseCase는 ViewModelComponent 내에 존재하게 되고, 각 ViewModelComponent는 자신을 호출한 ViewModel의 라이프 사이클에 맞추어 동작하기 때문입니다.
👇🏻 요렇게?
그렇다면, 만약 UseCase가 상위 Scope인 AcitivtyRetainedScope였다면?
3.2 서로 다른 두 viewModel에서 ActivityRetainedScope로 지정된 UseCase를 주입받아보자
// 위 코드에서 UseCase 부분만 다르다.
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
3.2.1 MainViewModel에서
viewModel | @20814 |
useCase | @20816 |
3.2.2 OtherViewModel에서
viewModel | @20821 |
useCase | @20816 |
ViewModel에서 상위 Scope인 ActivityRetainedScope로 지정된 UseCase를 주입받아 사용하고 있으므로,
두 ViewModel에서 동일한 인스턴스의 UseCase를 사용하고 있는 것을 알 수 있습니다. 🔥
동일한 Activity에서 호출되는 MainViewModel과 OtherViewModel이 동일한 ActivityRetainedComponent를 바라보고 있기 때문입니다.
👇🏻 요렇게?
📌 정리
동일한 Scope라면 매번 새로운 인스턴스, 상위 Scope라면 동일한 Instance를 제공
4. @Module & @InstallIn & Component
이번에는 Repository를 Module로 만들어서 Scope와 어떻게 다른지 확인해보겠습니다!
저는 아래 이미지와 같다고 이해를 하였습니다. 😭
앞서 살펴보았듯이 Scope Annotation에서는
- 동일한 Scope 또는 상위 Scope로만 접근 가능
- 상위 Scope에서는 동일한 Instance(Singleton)를 받아옴
이 2가지 역할을 담당하고 있었습니다.
각 역할이 @Module에서는 2가지로 나누어 지게 됩니다.
동일한 Scope 또는 상위 Scope로만 접근이 가능하도록 하는 것은 Component가
Singleton으로 Instance를 제공하는 것은 Scope 어노테이션이 담당하고 있습니다.
예제를 보면서 하나씩 확인해보도록 하겠습니다.
4.1 @ViewModelScoped UseCase & SingletonComponent + Not Singleton Scoped Repository
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.1.1 MainViewModel에서 호출된 TestUseCase
useCase | @20812 |
repository | @20815 |
4.1.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20825 |
repository | @20826 |
ViewModelScope보다 상위 Scope인 SingletonComponent로 지정되어 있지만,
매번 다른 인스턴스를 제공하는 것을 볼 수 있습니다.
여기서 알 수 있듯이, Module에서의 @InstallIn(SingletonComponent::class)는 접근 제한 기능만 제공할 뿐, 객체를 Singleton으로 제공하지는 않습니다.
🌹 여기서 바로 Scope라는 영어 의미를 다시 생각해보면 쉽게 이해할 수 있습니다.
Scoped 어노테이션을 통해 해당 타입을 모듈이 생성한 Component에 포획되면 매번 동일한 Instance를 제공해주는 것이 아닐까 해요.
이런 느낌으로..
4.2 @ActivityRetainedScoped UseCase & ViewModelComponent + Not ViewModel Scoped Repository
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
위와 같이 ActivityRetainedScope에서 하위 Scope인 ViewModelComponent로 지정된 Repository를 사용하려고 하면 에러가 발생합니다.
4.3 @ViewModelScoped UseCase & SingletonComponent + Singleton Scoped Repository
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Singleton
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.3.1 MainViewModel에서 호출된 TestUseCase
useCase | @20814 |
repository | @20817 |
4.3.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20827 |
repository | @20817 |
@Singleton 어노테이션으로 메서드를 지정해주니, 동일한 인스턴스를 제공해주고 있는 것을 알 수 있습니다. 🔥👍🏻
📌 정리
Module에서 접근 제한은 @InstallIn + Component로, Singleton 객체는 Scope 어노테이션으로!
🌹 여기서 주의해야 할 점은 Scope 어노테이션은 해당 모듈의 Component와 동일해야 합니다.
👇🏻 참고!
Application | SingletonComponent | @Singleton |
Activity | ActivityRetainedComponent | @ActivityRetainedScoped |
ViewModel | ViewModelComponent | @ViewModelScoped |
Activity | ActivityComponent | @ActivityScoped |
Fragment | FragmentComponent | @FragmentScoped |
View | ViewComponent | @ViewScoped |
View annotated with @WithFragmentBindings | ViewWithFragmentComponent | @ViewScoped |
Service | ServiceComponent | @ServiceScoped |
예를 들어, ActivityRetainedComponent로 지정된 모듈 내에서 @Singleton 어노테이션을 사용한다면
👇🏻 요러한 에러와 만날 수 있습니다.
4.4 @ViewModelScoped UseCase & ViewModelComponent + ViewModel Scoped Repository
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@ViewModelScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.4.1 MainViewModel에서 호출된 TestUseCase
useCase | @20813 |
repository | @20816 |
4.4.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20826 |
repository | @20827 |
ViewModelComponent 내에서 Scope 어노테이션을 지정했지만 서로 다른 인스턴스를 제공하는 것을 알 수 있습니다.
이전에도 말했든 ViewModelScoped로 지정된 UseCase와 Repository는 ViewModelComponent에 속하게 되고, 이는 각 ViewModel 라이프사이클에 의해 관리되고 있기 때문입니다.
4.5 @ActivityRetainedScoped UseCase & ActivityRetainedComponent + ActivityRetainedScoped Repository
@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class RepositoryModule {
@ActivityRetainedScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.5.1 MainViewModel에서 호출된 TestUseCase
useCase | @20811 |
repository | @20814 |
4.5.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20811 |
repository | @20814 |
위와 같이 ViewModelScope 상위에 위치한 ActivityRetainedScope로 지정된 UseCase와 Repository는 ViewModel 라이프 사이클보다 오래 살아있는 ActivityReatinedComponent에 속하기 때문에 동일한 인스턴스를 제공해줍니다. 🤗
추가로...
Dagger 공식 홈페이지에서는 다음과 같이 Scope Annotation 사용을 설명합니다.
Scoping a binding has a cost on both the generated code size and its runtime performance so use scoping sparingly. The general rule for determining if a binding should be scoped is to only scope the binding if it’s required for the correctness of the code. If you think a binding should be scoped for purely performance reasons, first verify that the performance is an issue, and if it is consider using @Reusable instead of a component scope.
(스코프를 지정하는 것은 내부적으로 많은 코드를 만들어내고, 비용을 많이 증가시킵니다. 대부분의 경우에서 사용하지 말고, 특수한 목적이 있는 경우에서만 사용해야합니다.)
또한 스코프를 설정한 컴포넌트가 해제되기 전에는 인스턴스가 GC에 수집되지 않습니다. Hilt Scope를 잘못사용하면 메모리 누수를 발생시키고 더나아가 성능 저하가 발생합니다.
참고자료
'Android > Hilt' 카테고리의 다른 글
[Hilt] Hilt vs Koin (0) | 2024.11.11 |
---|---|
[Hilt] 프로젝트에 의존성 주입(DI) 적용해보기 - Hilt + Retrofit (0) | 2023.06.27 |
[Hilt] Dagger Hilt로 안드로이드 의존성 주입하기 (0) | 2023.06.25 |