RIEN😚
이상한 나라의 개발자
RIEN😚
전체 방문자
오늘
어제
  • 분류 전체보기 (125)
    • Algorithm (68)
      • 알고리즘 (0)
      • Baekjoon (8)
      • 프로그래머스 (55)
      • HackerRank (5)
    • Android (30)
      • Project (1)
      • Error (2)
      • Studio (1)
      • Android (26)
    • Kotlin (6)
    • CS (4)
      • 네트워크 (2)
      • 데이터베이스 (2)
    • Front End (5)
      • React (1)
      • VUE (3)
      • Project (0)
      • 기타 (1)
    • 기록 (11)
      • 회고록 (6)
      • TIL (5)

블로그 메뉴

  • Github🔥
  • 포트폴리오🌹

공지사항

인기 글

티스토리

250x250
반응형
hELLO · Designed By 정상우.
RIEN😚

이상한 나라의 개발자

Android/Android

[Hilt] Component와 Scope 무엇이 다른지 디버깅으로 다 찍어보자!

2023. 2. 11. 23:20
728x90
반응형

Hilt를 공부하면서 가장 헷갈리면서도 정보를 찾기 힘들었던 것이 바로

Component와 Scope의 차이점이었습니다.

 

다른 분들이 올려주신 여러 자료들을 보아도 잘 이해가 되지 않아..😭

그냥 안드로이드 스튜디오에서 여러 경우의 수를 다 디버깅 해보면서 저 나름대로 차이점을

정리해보았어요.ㅎㅎ

 

안드로이드를 독학하고 있기 때문에 잘못된 점이 있을 수 있습니다.😌

만약 잘못 정리한 부분이 있다면 꼭 댓글로 말씀 부탁드립니다! 🔥

 

테스트를 위해 작성한 프로젝트의 구조는 아래와 같습니다.

 

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에 속하기 때문에 동일한 인스턴스를 제공해줍니다. 🤗


[정리]

1. Scope 어노테이션

- 접근 제한 + 싱글톤

 

2. @InstallIn + Component

- Module 접근 제한

 

3. @Module 내 Scope 어노테이션

- 싱글톤

 

제한된 메모리로 인해 life time 관리가 필요한 안드로이드에서 이러한 Component와 Scope를 적절히 지정하는 것은 중요합니다.🔥

 

이론적으로는 이렇게 알아보았는데,

실제 어플리케이션에서는 어떻게 반영할지는 직접 해봐야 알 듯 하네요. 😭

 

현재 진행중인 토이 프로젝트의 기획을 마무리하고 개발을 진행할 때,

이러한 부분과 관련된 기획도 선행하면 좋을거 같네요. 🤗

 

감사합니다. 😌

 

반응형

'Android > Android' 카테고리의 다른 글

[Android/Compose] Compose에서 Paging3 적용기  (0) 2023.03.03
[공식문서 열어보기] AlarmManger로 정확한 시간에 알림 띄워주자  (0) 2023.02.13
[Android:Codelab] Dependency Injection and Test Doubles - 5  (0) 2023.01.26
[Android:Codelab] Dependency Injection and Test Doubles - 4  (0) 2023.01.25
[Android:Codelab] Dependency Injection and Test Doubles - 3  (0) 2023.01.25
    'Android/Android' 카테고리의 다른 글
    • [Android/Compose] Compose에서 Paging3 적용기
    • [공식문서 열어보기] AlarmManger로 정확한 시간에 알림 띄워주자
    • [Android:Codelab] Dependency Injection and Test Doubles - 5
    • [Android:Codelab] Dependency Injection and Test Doubles - 4
    RIEN😚
    RIEN😚
    안드로이드 / 코틀린 독학으로 취업하자!

    티스토리툴바