본문 바로가기
Android

[Android] DI(Dependencies Inject) 와 Hilt 라이브러리

by Glion 2024. 11. 22.
반응형

이 글은 Android Developers 공식 문서를 기반으로 내용을 정리한 글임.

 

공식 문서 Dependencies Inject 원문 글

 

Android의 종속 항목 삽입  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Android의 종속 항목 삽입 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 종속 항목 삽입(DI)은 프로그래

developer.android.com

 

종속 항목 삽입이란?


DI(Dependencies Inject) 라고 하는 기법으로서, 앱 아키텍처를 구성하는데 있어 중요함.

 

종속 항목 삽입을 통해

1. 코드 재사용 가능
2. 리펙터링 편의성
3. 테스트 편의성

의 이점을 누릴 수 있음

 

클래스에는 흔히 다른 클래스의 참조가 필요하다. 예를 들면 다음과 같이 ViewModel 에서 특정 동작을 수행하기 위해 Repository 객체가 필요한데 이와 같이 어떠한 클래스의 기능을 구현하는데 있어 필요한 클래스를 종속 항목 이라고 한다. 

// ViewModel
class MainViewModel (
    private val userRepository: UserRepository // 종속 항목
) : ViewModel() {
    fun getUser() {
        userRepository.getUser() // repository 의 getUser메소드를 사용하여 사용자 정보를 가져온다
    }
}

 

클래스가 필요한 객체를 얻는 3가지 방법은

1. 클래스가 필요한 종속항목을 구성한다. ViewModel 클래스 내에서 Repository 클래스에 대한 인스턴스를 생성, 초기화 하여 사용한다.
2. 다른 곳에서 객체를 가져온다. 일부 Android API 는 이러한 방식으로 작동한다. getSystemService() 나 Context getter 가 이에 해당한다.
3. 종속 항목을 생성자로 제공받는다. 클래스가 구성될때 필요한 종속항목을 제공받아 사용이 가능하다.

 

위의 3가지 방법 중 3번째 방법이 종속 항목 삽입이다. 이러한 방식을 사용하게 되면 클래스 객체가 자체적으로 종속 항목을 객체화 하여 사용하는 것이 아니라 클래스의 생성자로 받아 사용이 가능하다.

 

Android 에서 종속 항목을 삽입하는 두가지의 경우는

  1. 생성자 삽입(클래스의 종속 항목을 생성자에 전달하는 경우)
  2. 필드 삽입. Activity 나 Fragment 의 경우 생성자로 종속 항목을 삽입하는것이 불가능하기 때문에 이 경우 사용된다
    (필드 삽입 이용시 제공되는 종속 항목은 클래스가 생성된 후 객체화 된다.)

 

종속 항목 삽입의 필요성에 대한 이해


Android 권장 앱 아키텍처는 코드를 하나의 책임을 갖는 클래스로 세분화하여 관심사 분리가 될 수 있도록 권장하고 있다.  
이런 식으로 클래스를 나눌 경우 더 많은 클래스가 서로 연결되어야 하고 서로의 종속성을 충족시킬 수 밖에 없다.

 

예시

네트워크에서 사용자 정보를 가져오는 UserRepository 가 있고, UserViewModel 은 UserRepository 로부터 데이터를 가져오므로 ViewModel 은 UserRepository 에 종속되어있다.

 

일반적인 방법으로, 아래와 같이 생각할 수 있을것이다.

// ViewModel
class UserViewModel: ViewModel() {
    // Repository 객체 생성
    private val userRepository = UserRepository()
    fun getUserData() {
        userRepository.getUserData()
    }
}
// Repository
class UserRepository {
    fun getUserData() : String {
        // 네트워크 통신을 위한 Retrofit2 객체 생성
        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(GetUserService::class.java)
        return retrofit.userData()
    }
}

 

종속 항목 삽입을 사용하지 않으면 테스트가 어려워진다.

 

테스트를 위해 실제 API 통신하지 않고 모의로 구현된 Repository로 ViewModel 을 테스트 하고자 한다면?

UserRepository 를 구현한 모든 부분을 MockRepository 로 변경한 뒤 테스트를 진행한뒤 다시 UserRepository 로 변경해야 하는 아주 비효율적인 작업이 필요하다.

테스트를 위해 실제 작성한 ViewModel 코드를 변경해야 하기 때문이다.

 

따라서, ViewModel 이 어떠한 Repository 로 데이터를 가져올 것인가에 대한 부분을 어딘가로부터 제공받는다면

(종속 항목 삽입), ViewModel 코드를 변경하지 않고 MockRepository 를 제공하여 테스트를 진행할 수 있다.

 

종속 항목 삽입을 사용하지 않으면 리펙토링이 어려워진다.

만약 Retrofit 객체 내 구성이 변경된다면?
헤더가 추가되거나, baseUrl 이 변경되거나 등등의 상황에서...
여러개의 Repository 가 있다면 모든 부분의 retrofit 객체를 변경해주어야 하는데...?

 

한곳에서 생성된 retrofit 객체를 Repository 가 제공받는다면(종속 항목 삽입) 이러한 변경에 유연하게 대처할 수 있을 것이다. 

 

종속 항목 삽입을 사용하지 않으면 코드 재사용이 어려워진다.

 

UserViewModel 에서는 UserRepository 타입을 사용하고 있다. 위에 설명한 테스트가 어려워진다 와 비슷한 맥락으로 이해했는데, 

// MockUserRepository 가 아래와 같다고 할때
class MockUserRepository: UserRepository() {
    fun getUserData() : String {
        return "Mock User Data Get!!"
    }
}

UserViewModel 은 UserRepository 객체를 직접 생성하여 사용하고 있기에, 다른 Repository(예를 들어 MockRepository) 를 사용하고자 한다면 코드 수정이 불가피하다.

 

UserViewModel 에서 Repository 객체를 제공받는다면(종속 항목 주입) 별도의 변경 없이 UserRepository 의 서브클래스인 MockUserRepository 를 주압히여 사용이 가능하다.

 

오해하고 있던 부분


지금까지 나는 Hilt나 Dagger, coin 등의 라이브러리를 사용하는 것만이 DI 를 구현하는 방법이라 인지하고 있었다.  
하지만 종속성 삽입이란

객체를 재사용하기 위해 필요한 항목(종속 항목)을 외부로부터 제공(주입) 받는 것

 

이고,  종속성 삽입을 구현하는 것은 라이브러리를 사용하지 않고도 가능하다는 것을 알았다.

 

이에 대한 자세한 내용은 이레 링크에서 확인 할 수 있다.

 

안드로이드 공식 DOCS 종속 항목 수동 삽입

 

종속 항목 수동 삽입  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 종속 항목 수동 삽입 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android의 권장 앱 아키텍처는 코드

developer.android.com

 

Hilt 란?


Android 에서 종속 항목을 삽입하기 위한 Jetpack 권장 라이브러리이다.  

Hilt 는 프로젝트의 모든 Android 클래스에 컨테이너를 제공, 수명 주기를 자동으로 관리함으로서 애플리케이션에서 DI 를 실행하는 표준 방법을 정의한다.  

Hilt 는 Dagger 라이브러리를 기반으로 빌드되었다.

 

Hilt 사용 방법


Hilt 는 프로젝트 내에서 종속 항목을 개발자가 수동으로 삽입을 실행하는 코드를 줄이는 Android 용 라이브러리이다.  

모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리해줌으로서 DI 를 사용하는 표준 방법을 제공해준다.

 

초기 설정(라이브러리 버전은 공식 홈페이지를 참고한다. 코드 내 적용된 버전은 11월22일 기준이다.)

 

1. build.gradle(project) 설정

plugins {  
    ...  
    id("com.google.dagger.hilt.android") version "2.51.1" apply false <-- 추가
}

 

 

2. sync now 실행

 

3. build.gradle(app) 설정

plugins {
    id("kotlin-kapt")  
    id("com.google.dagger.hilt.android")  
}
    
android {  
...  
}  
    
dependencies {  
    implementation("com.google.dagger:hilt-android:2.51.1")  
    kapt("com.google.dagger:hilt-android-compiler:2.51.1")  
}  
    
// Allow references to generated code  
kapt {  
    correctErrorTypes = true  
}

 

4. Hilt 는 자바 8 기능을 사용한다고 되어있다(프로젝트에 따라 Java17 을 사용하고 있다면, 17로 해도 상관없다)

 

build.gradle(app)

android {
...
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
}
kotlinOptions {
    jvmTarget = "1.8"
}

 

기본적인 사용법

 

Hilt 를 사용하는 모든 애플리케이션은 Application 클래스에 @HiltAndroidApp 을 지정해 주어야 한다.

 

이 어노테이션이 붙음으로서 애플리케이션 수준에서 종속 항목 컨테이너 역할을 하는 Hilt 의 코드를 생성할 수 있게 해준다.

@HiltAndroidApp
class ExampleApplication : Application() { ... }

 

이렇게 생성된 Hilt 구성 요소는 Application 객체의 수명 주기에 연결되어 관련된 종속 항목을 제공할 수 있게 해주는 시작점이다.

 

이렇게 Application 클래스에 Hilt 설정하면 @AndroidEntityPoint 가 붙은 다른 Android 클래스에 종속 항목을 제공할 수 있게 된다.

 

아래의 Android 클래스에 @AndroidEntityPoint 를 붙여 종속 항목을 제공할 수 있다.

  • Activity, Fragment, View, Service, BroadcastReceiver (@AndroidEntityPoint) 사용
  • ViewModel (@HiltViewModel)

Activity 클래스에 @AndroidEntityPoint 를 붙이게 되면, Activity 가 소유하는 Fragment 에도 동일한 어노테이션을 붙여주어야 한다.

 

위와 같이 Hilt 구성요소를 생성했다면, 이제 @Inject 를 사용하여 Hilt 구성요소로부터 종속 항목을 가져올 수 있다.

 

필요한 필드를 삽입하여 사용할 수도 있고, 클래스 생성자에 삽입할 수도 있다.  

필드나 생성자에 종속항목의 객체를 제공하기 위해서는 Hilt 에게 필요한 종속 항목의 객체를 제공하는 방법을 알려주어야 한다. 이때는 종속 항목이 될(제공 될) 객체에 ...@Inject constructor()... 를 붙여주어야 한다.

여기까지 코드로 정리하면 아래와 같다.

 

1. MainActivity 필드에 종속항목 SampleClass 를 삽입받고 싶어함.
@AndroidEntryPoint <-- Activity 를 Hilt 의 구성요소로 생성
class MainActivity : AppCompatActivity() {

    private lateinit var navController: NavController
    @Inject lateinit var mSampleClass: SampleClass <- SampleClass 종속 항목을 필드에 삽입 받겠다고 선언.
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        .
        .
        .
        mSampleClass.show() <-- lateinit 변수인 mSampleClass 를 따로 초기화하지 않아도, Hilt 에 의해 객체가 제공되어 사용이 가능함
    }
}

2. 종속 항목이 될 SampleClass
class SampleClass @Inject constructor() { <-- @Inject constructor() 를 사용하여 종속 항목의 객체라는 것을 알려줌
    init {
        LogUtil.d("샘플 클래스 생성됨")
    }

    fun show() {
        LogUtil.d("show 함수 실행됨")
    }
}

 

주의할 점 은 Hilt 를 통해 필드에 종속항목을 삽입받고자 할때, 필드는 private 일 수 없다. Hilt 컨테이너에서도 삽입할 필드에 접근이 되어야 하기 때문.  
(생성자는 상관이 없다. 생성자에 private 이 붙는건 그 클래스 내에서만 사용하겠다는 의미이지, 클래스 생성자에 접근을 못한다는것이 아니기 때문) 

생성자에 제공하는 경우도 비슷하다. 간단하게 ViewModel 에서 Repository 를 삽입 받는다고 할때 위의 코드를 이용하면

// ViewModel
@HiltViewModel <-- ViewModel 을 Hilt 구성요소로 지정
class UserViewModel @Inject constructor( <-- 생성자에 종속 항목을 삽입받겠다는 의미
    private val userRepository: UserRepository
) : ViewModel {
    fun getUserData() {
        userRepository.getUserData()
    }
}

// Repository
class UserRepository @Inject constructor() { <-- @Inject constructor() 를 사용하여 종속 항목의 객체라는 것을 알려줌

    fun getUserData() : String {
        return "User Data"
    }
}

 

Hilt 컨테이너 자체에서 종속 항목을 삽입할 수 없는경우 - Hilt 모듈 사용

 

Hilt를 통해 종속 항목을 기본적으로 제공할 수 없는 상황도 존재한다. 

  1. 첫번째는 생성자에 인터페이스를 삽입할 수 없다.
  2. 두번째는 외부 라이브러리의 클래스와 같이 소유하지 않은 유형도 삽입할 수 없다.

이런 경우, Hilt 모듈을 사용하여 Hilt 에 결합 정보를 제공해 줄 수 있다.

 

Hilt 모듈이란 @Module 로 지정된 클래스이며 @InstallIn 도 함께 지정하여 모듈을 사용하거나 설치할 Android Class 를 Hilt 에 알려주어야 한다.

 

@InstallIn 을 사용하여 아래와 같은 Hilt 구성요소에 종속 항목을 삽입하라고 알려줄 수 있다.

Hilt 구성 요소 주입 대상
SingletonComponent Application
ActivityRetainedComponent N/A
ViewModelComponent ViewModel
ActivityComponent Activity
FragmentComponent Fragment
ViewComponent View
ViewWithFragmentComponent @WithFragmentBindings 주석이 지정된 View
ServiceComponent Service

 

해당 Android 생명주기에 따라 생성된 Hilt 구성요소의 객체를 자동으로 만들고, 제거하게 된다.

 

생성된 Hilt 구성 요소 생성 위치 소멸 위치
SingleComponent Application#onCreate() Application 소멸 시
ActivityRetainedComponent Activity#onCreate() Activity#onDestroy()
ViewModelComponent ViewModel 생성 시 ViewModel 소멸 시
ActivityComponent Activity#onCreate() Activity#onDestroy()
FragmentConponent Fragment#onAttach() Fragment#onDestroy()
ViewComponent View#super() View 소멸 시
ViewWithFragmentComponent View#super() View 소멸 시
ServiceComponent Service#onCreate() Service#onDestroy()

 

Hilt 모듈에 종속 항목에 대한 정보를 제공하게 되면, Hilt 구성요소로 만든 Android 클래스 내에서 사용이 가능하다.   

 

아래는, Hilt 가 생성하는 구성요소의 계층 구조이다.

Hilt 가 생성하는 구성요소의 계층 구조

 

1. 생성자에 인터페이스 객체 삽입

 

Repository 패턴을 따를떄, Repository 는 Interface 이고 이를 구현하는 RepositoryImpl 을 만들어 사용하곤 한다. 실제 ViewModel 에서는 생성자로 Repository 를 삽입받아야 하는데, 주로 이러한 경우가 해당된다.

 

생성자에 인터페이스 객체를 삽입하려면 추상 클래스로 만든 Hilt 모듈에 @Binds 를 붙여 사용한다.

@Module
@InstallIn(ViewModelComponent::class) // ViewModel Hilt 요소에 종속항목을 삽입하라고 선언
abstract class UserModule {
    @Binds
    abstract fun bindsUserRepository(
        userRepositoryImpl: UserRepositoryImpl
    ) : UserRepository
}

 

여기서 abstract fun bindsUserRepository() 의 매개변수는 **제공할 Interface 의 구현체 를 hilt 에 알려주고,

Return 은 실제 종속 항목으로 제공할 Interface 이다.

 

2. 소유하지 않은 유형에 대한 삽입

 

클래스가 외부 라이브러리에서 제공되어 직접적으로 해당 클래스를 소유하지 않은 경우

(Retrofit, OkHttpClient, Room DB) 혹은 build() 형식과 같은 빌더 패턴으로 객체를 생성해야 하는 경우에도 생성자 삽입이 불가능하다.  

일반적으로 앱을 만들때 API 를 통해 서버로부터 데이터를 가져오거나, 내부 DB 에 저장된 데이터를 가져오는 경우가 많은데 해당 객체를 Repository 에 제공할떄 이 방법을 사용해야 할 것이다.

대부분 위의 예시와 동일하나, @Provides 를 붙인다는 점과, object 로 생성한다는 점이 다르다.

@Module
@InstallIn(ActivityComponent::class) // Activity Hilt 요소에 종속항목을 삽입하라고 선언
object ApiModule {

  @Provides
  fun provideApiService(
  ): ApiService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(ApiService::class.java)
  }
}

 

왜 @Binds 는 추상클래스로 모듈을 생성하고, @Provides 는 object 로 모듈을 생성하는가?

 

@Binds 를 사용하는 경우를 보면, 추상 함수 bindUserRepository 의 본문이 존재하지 않는다. 별도의 구현이 필요하지 않을 뿐더러, Hilt 에게  인터페이스를 주입하는데, 그 인터페이스 의 구현은 이거야  라고 알려주는 역할만 한다.

인터페이스를 구체적인 구현에 바인딩 하는 의미로 볼 수 있는데, 이 경우 Hilt 모듈은 추상 클래스 로 구성한다.

@Provides 를 사용하는 위의 예시의 경우는 ApiService 종속 항목에 대한 구성을 Retrofit.Builder() 를 통해 수행해야 한다. ApiService 자체를 직접 주입할 수 없고 무언가의 구성이 필요하다.

Hilt 에게 이 종속 항목을 제공해주는데, 그걸 위해서 ~~~ 한 방법으로 구성해야 해.  라고 알려주는 것이며 객체를 초기화하여 반환한다.  
(ApiService 는 interface 이지만, Retrofit 객체의 .create 를 통해 제공한 ApiService Interface 를 구현하는 클래스를 동적으로 생성한다. 즉 Interface 를 구현한 클래스를 반환하는 것)

이러한 경우, Hilt 모듈은 object(싱글톤) 으로 구성한다.

 

기타


기본적인 Context 객체를 제공하는 어노테이션이 존재한다. 별도의 전달 없이도 아래의 어노테이션을 생성자에 추가하여 사용이 가능하다.

 

@ApplicationContext

사용 예시

class RepositoryImpl @Inject constructor(@applicationContext private val context: Context)

 

@ActivityContext

사용 예시

class RepositoryImpl @Inject constructor(@ActivityContext private val context: Context)

 

 

동일한 내용에 대해 여러 결합을 제공하는 방법을 소개한다.

 

종속 항목과 동일한 유형의 다양한 구현을 제공하는 Hilt 가 필요한 경우이다.

 

이 경우, 한정자를 사용한다.

아래의 예시는 동일한 Interface 를 구현한 두개의 RepositorytImpl 이 있다고 했을때, 한정자를 사용하여 다르게 구현한 것이다

 

1. 먼저, 다음과 같이 @Binds 또는 @Provides 메서드에 주석을 지정하는 데 사용할 한정자를 어노테이션으로 정의한다.

// FakeLogin 와 FakeCommon 한정자 지정
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FakeLogin
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FakeCommon

 

2. 정의한 한정자를 통해 동일한 유형의 다른 구현체를 제공할 수 있도록 한다. @FakeLogin 어노테이션이 붙은 FakeRepository 의 경우 FakeLoginRepositoryImpl 이 들어갈 것이고, @FakeCommon 어노테이션이 붙은 경우 FakeCommonRepositoryImpl 이 들어갈 것이다.

@FakeLogin
@Binds
abstract fun bindsFakeLogin(
    fakeLoginRepositoryImpl: FakeLoginRepositoryImpl
) : FakeRepository
@FakeCommon
@Binds
abstract fun bindsFakeCommon(
    fakeCommonRepositoryImpl: FakeCommonRepositoryImpl
) : FakeRepository

 

3. FakeRepository 와 구현한 FakeLoginRepositoryImpl, FakeCommonRepositoryImpl 은 다음과 같다.

interface FakeRepository {
    fun fakeAction(): String
}
class FakeLoginRepositoryImpl @Inject constructor() : FakeRepository{
    override fun fakeAction() : String{
        return "Fake Action In FakeLoginRepositoryImpl"
    }
}
class FakeCommonRepositoryImpl @Inject constructor() : FakeRepository {
    override fun fakeAction() : String{
        return "Fake Action In FakeCommonRepositoryImpl"
    }
}

 

4. MainActivity 에서 필드 주입으로 동일한 Repository 를 다른 어노테이션을 붙여 주입한다. 실제 사용해보면 같은 Repository 의 fakeAction 이지만 다른 RepositoryImpl 이 바인딩되어있다.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@FakeModule.FakeLogin
@Inject lateinit var fakeRepositoryLogin: FakeRepository
@FakeModule.FakeCommon
@Inject lateinit var fakeRepositoryCommon: FakeRepository
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        LogUtil.d(fakeRepositoryLogin.fakeAction())
        LogUtil.d(fakeRepositoryCommon.fakeAction())
    }
}

 

결과

한정자를 사용하여 동일 항목에 여러 결합 제공

 

만약, 한정자를 붙이지 않고 구성하게 되면 에러가 발생한다.(진행중인 프로젝트에서 테스트해본 관계로 패키지명은 비공개 처리한다)

한정자를 붙이지 않고 동일 항목에 여러 결합을 제공 할 경우 발생하는 오류

error: [Dagger/DuplicateBindings] com.example.패키지명.경로.FakeRepository is bound multiple times:

 

Hilt 의 입장에서 동일한 종속 항목을 제공하라고 명령했는데 어떨때 어떤 Impl 로 구성해야 하는지 모르기 때문이다.

또한 이러한 오류를 방지하기 위해 한정자를 종속항목 유형에 추가한다면, 해당 종속항목을 제공하는 가능한 모든 방법에 한정자를 추가해주는것이 오류를 방지할 수 있다.

 

이런 방법은 테스트하거나, 여러개의 Room DB 를 사용하거나, OkHttpClient 가 서로 다르게 구성된 Retrofit 객체를 생성해야 할 경우 유용하게 사용이 가능할것 같다.

반응형