[Android] 의존성 주입과 Hilt

강승구·2023년 11월 14일

의존성이란?

의존성이란 한 클래스가 다른 클래스를 참고하고 있다는 뜻이다.

예를 들면, Car라는 클래스는 Engine이라는 클래스를 참조하고 있다. 이렇게 클래스가 참조를 필요로 하는 다른 클래스를 의존성(dependency)이라고 한다.
즉, B의 기능이 추가 또는 변경되거나 형식이 바뀌면 그 영향이 A에 미친다.

즉 A가 B에 의존한다는 것은 의존대상 B가 변하면, 그것이 A에 영향을 미친다는 것을 의미한다.

아래 코드에서 Car 클래스는 내부에 Engine을 구성하고 있다. 그러므로 Car와 Engine은 긴밀하게 연결되어 있고 이로 인해 테스트 및 테스트 더블을 사용하여 수정을 할 수 없게 된다.

class Car{
	private val engine = Engine()
    
    fun start(){
    	engine.start()
    }
}

fun main(args: Array){
	val car = Car()
    car.start()
}

위의 예제와 같이 클래스가 많지 않다면 개발자가 직접 연결을 해줄 수도 있지만 클래스의 수가 많아지고 여려곳에서 연결을 해야한다면 일일히 객체를 만들어주는것이 번거로워진다.


의존성 주입을 통한 문제 해결

의존성 주입이란 객체의 생성과 사용의 관심을 분리하는 것이다.
클래스와 클래스간에 관계를 맺을 때, 이를 내부에서 직접 생성하는 것이 아닌, 외부에서 주입을 함으로써 관계를 맺게 만드는 것을 의미한다.

위 코드에서 발생했던 문제는, Engin 객체를 생성하고 이를 사용하여 Car 생성자 매개변수로 Engine 객체를 전달함으로써 해결이 가능하다.

class Car(private val engine: Engine){
	fun start(){
     	engine.start()
    }
}

fun main(args: Array){
    val engine = Engine()
    val car = Car(engine)
    car.start()

}

이로 생기는 장점은 다음과 같다.

1. Car의 재사용 가능
Engine의 다양한 구현을 Car에 전달할 수 있다. ex) Engine, EletroicEngine, GasEngine

2. 테스트 용이성
의존성 주입을 사용하면 객체 간의 관계가 명시적으로 드러나기 때문에 코드의 가독성이 높아진다. 또한 의존성이 명시적으로 주입되므로 코드의 수정이나 유지보수가 더욱 쉬워진다는 장점이 있다.


자동 의존성 주입

이전 예시에서는 라이브러리를 사용하지 않고 다양한 클래스의 의존성을 직접 생성, 제공 및 관리를 했는데 이를 직접 의존성 주입 혹은 수동 의존성 주입이라고 한다.

Car 예제에서는 의존성이 하나만 존재했지만 의존성을 필요로하는 클래스가 많아지면, 수동으로 의존성을 삽입하는 작업은 비용이 커질 수 있다.

또한 수동 의존성 주입에는 다음과 같은 문제들이 있다.

1. 보일러 플레이트의 증가
대규모 앱의 경우 모든 의존성 주입을 가져와 올바르게 연결하려면 대량의 보일러 플레이트가 요구된다. 다중 레이어 아키텍처에서는 최상위 레이어의 객체를 생성하려면 그 아래에 있는 레이어의 모든 의존성을 제공해야 한다.

2. 수명 관리의 복잡성
수동 의존성 주입 방식에서는 의존성을 주입하기 전에 객체를 즉시 생성할 수 없는 경우, Lazy Initialization을 구현해야 한다. 이는 필요할 때까지 객체 생성을 미루는 방식인데, 이를 위해 별도의 관리 코드가 필요하다.

안드로이드에서는 의존성을 자동으로 주입하기 위한 여러 라이브러리가 존재하는데 그 중 대표적인 한가지는 Dagger이다. 하지만 Dagger는 많은 Annotation 과 보일러 플레이트로 인해서 러닝커브가 높다는 평을 받아 사용하기 쉽지 않다.

따라서 Dagger 기반의 컴파일 타임에 코드를 생성하는 의존성 주입 라이브러리 인 Hilt가 등장하게 되었다.
Hilt는 Android에서 의존성 주입을 위한 Jetpack의 권장 라이브러리로, 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 실행하는 표준 방법을 정의한다.

또한 Dagger2보다 표준화된 사용법을 제시하고 보일러 플레이트 코드를 감소시킨다. 실제로 구글 샘플 앱을 Dagger에서 Hilt로 바꾼 결과, 코드가 반으로 줄었다고 한다.

더불어 Hilt는 컴파일 시점에 주입할 코드를 생성하기 때문에 컴파일 타임에 오류를 잡아낼 수 있어 런타임 안정성이 높아진다는 장점이 있다.


Hilt 알아보기

Hilt를 사용하기 위해서는 우선 다음과 같은 초기 설정 과정이 필요하다.

1. project 수준의 gradle에 classpath 추가

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:$version'
    }
}

2. app 수준의 gradleplugindependency 추가

...
plugins {
  id 'kotlin-kapt'
  id 'dagger.hilt.android.plugin'
}

android {
    ...
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }    
}

dependencies {
    implementation "com.google.dagger:hilt-android:2.38.1"
    kapt "com.google.dagger:hilt-compiler:2.38.1"
}

Hilt는 어노테이션을 기반으로 동작한다.
Annotation Processor를 통해 어노테이션을 읽어 컴파일 타임에 의존성 그래프에 문제가 없는지 확인하고 의존성 주입에 필요한 소스코드를 생성한다.

그렇게 생성된 코드들은 런타임에 실행되면서 의존성 주입이 가능해진다.

1. @HiltAndroidApp

Hilt를 사용하는 모든 앱은 application 클래스에 @HiltAndoidApp 이라는 어노테이션을 적용해야 한다. @HiltAndroidApp은 적용된 application 클래스를 포함한 Hilt의 코드 생성을 트리거 시킨다.

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

2. @AndroidEntryPoint

Activity, Fragment, View, Service, BroadcastReceiver 같은 Android Component에 사용할 수 있는 어노테이션으로, 이를 적용한 컴포넌트 내에서 @Inject가 달린 필드에 의존성 주입을 한다. Application 클래스에 대해서는 @HiltAndroidApp이 대신하고 있다.

만약 Fragment에 @AndroidEntryPoint를 지정했을 경우 그 Fragment를 사용하는 Activity에도 @AndroidEntryPoint를 지정해야한다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {

  @Inject lateinit var analytics: AnalyticsAdapter
  ...
}
3. @EntryPoint

위에서 @AndroidEntryPoint에서 커버할 수 있는 컴포넌트외에서 의존성 주입이 필요한 경우가 있는데 (ContentProvider 등) 이때 @EntryPoint를 이용할 수 있다.

@EntryPoint
@InstallIn(SingletonComponent::class)
interface FooBarInterface {
  @Foo fun getBar(): Bar
}

4. @Module

인터페이스나, 빌더 패턴을 사용한 경우, 외부 라이브러리 클래스 등등 생성자를 사용할 수 없는 Class를 주입해야 할 경우 @Module에다 객체 생성방법을 정의해서 @Inject가 가능하도록 만들 수 있다.

5. @InstallIn

@Module이나 @EntryPoint 어노테이션과 함께 사용해야하며, @InstallIn을 이용해서 모듈을 어느 컴포넌트에 설치할지 정할 수 있다.

아래 예제는 ActivityComponent에 설치하는 Analytics Module의 예시코드다.

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {

  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

6. @Binds

위 예시코드에 @Binds가 들어간김에 이것도 같이 얘기할텐데...인터페이스의 구현체를 제공해야할 때 사용할 수 있다. Repository 인터페이스를 구현하고 이를 따르는 구현체 RepositoryImpl를 많이 사용하는데 그 패턴맞다.

모듈에 정의한 후에는 아래 코드처럼 구현체의 생성자에서 @Inject를 시켜주면 된다.

class AnalyticsServiceImpl @Inject constructor(
...
) : AnalyticsService { ... }

7. @Provides

Retrofit이나 Room DB와 같이 빌더패턴을 사용해서 인스턴스를 생성해야할때 @Provides를 사용하면 된다.

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}
profile
강승구

0개의 댓글