posted by Kyleslab 2024. 5. 3. 20:38

Dependency

  • 의존성 : A가 B를 의존한다. = A가 B를 참조한다.
  • 의존대상 B가 변하면, 그것이 A에 영향을 미친다.
    • B의 기능이 추가 또는 변경되거나 형식이 바뀌면 그 영향이 A에 미친다.

  • Car는 Engine이 없으면 동작하지 않는다.
    • Engine은 Car의 의존성
    • Car가 Engine에 의존한다. = Car가 Engine을 참조한다.
// Without DI

class Car {
    private val engine: Engine = Engine()
    ...
    ...
}
  • Car class는 engine instance 생성과 어떻게 구성하는지까지 책임을 지고 있다.
    • SRP(단일 책임 원칙) 위반
    • 하나의 Class에 하기에는 책임이 많다.
  • Car와 Engine은 타이트한 의존성을 갖는다.
    • Engine에 대한 하위 클래스등을 사용하기가 어렵다.
    • Gas나 Electric 타입의 차를 생성하려면 Car를 재사용하지 못한다.
    • 테스트가 어렵다.
      • Car는 실제 Engine인스턴스를 사용하므로 Test Double을 사용하여 Engine을 수정할 수 없다.

 

Dependency Injection

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}
 
fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}
  • Engine객체를 생성자의 인자로 의존성 주입함으로써 기존 Car클래스가 지닌 책임을 나눈다.
  • 그럼으로써 Car class의 실제 역할에만 집중하게 한다.

그래서 DI란?

  • 클래스가 내부에서 스스로 의존성을 생성하는 대신 외부에서 의존성을 부여하는 것이 의존성 주입(Dependency Injection)이다.

Dependency Injection의 장점

  • Good balance of loosely-coupled dependencies
  • Reusability of code
    • 클래스에서 의존성 생성을 제어하지 않기 때문에 어떤 구성과도 동작할 수 있다.
  • Ease of refactoring
    • Smaller-focused classes
    • Dependency가 API에 노출되어 검증가능한 요소가 되므로 객체 생성 타임 또는 컴파일 타임에 확인할 수 있습니다. 
  • Ease of testing
    • 클래스가 Dependency를 관리하지 않으므로 테스트 시 다양한 구현을 전달하여 다양한 TestCase를 만들어 테스트하는 것이 가능하다.

 

Reusability of code

fun main(args: Array<String>) {
    ...
    val electricCar = Car(ElectricEngine())
    val combustionCar = Car(CombustionEngine())
    ...
}
  • 어떤 엔진의 구현을 넘기더라도 Car를 재사용할 수 있다.

 

Ease of refactoring

class Car {
    // Very, very long source code
}
  • Car class 에서 engine관련 로직을 추출해서 다른 클래스로 만들고 의존성으로 바꿔도 된다.
class Car(
    private val engine: Engine,
    private val battery: Battery,
    private val airFilter: AirFilter,
    private val radiator: Radiator,
    private val wipers: List<Wiper>   
) {
    // Small source code
}
  • 코드 범위가 줄면서 더 단순해진다.
  • 클래스 작업의 인지 부하도 낮아졌다.
  • SRP(단일 책임 원칙)을 지키도록 만들기 쉬워진다.
  • 해당 작업을 계속 반복하면 코드도 줄어들고, Car가 해야할 일이 줄어든다.

Ease of testing

class CarTest {
    @Test
    fun `Car happy path`() {
        val car = Car(FakeEngine())
        ...
    }
 
    @Test
    fun `Car with failing Engine`() {
        val car = Car(FakeFailingEngine())
        ...
    }
}
  • 고장난 엔진을 단 자동차가 어떻게 작동하는 지 본다든지, 다양한 TestCase를 쉽게 만들 수 있다.

Manual dependency injection

Constructor Injection

class Car(private val engine: Engine) {
 
}
  • class의 dependency를 생성자로 전달한다. 

Field Injection (or Setter Injection)

class Car {
    lateinit var engine: Engine
 
    fun start() {
        engine.start()
    }
}
 
fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}
  • Activity나 Fragment와 같은 특정 Android component등은 시스템에서 인스턴스화하므로 생성자 삽입이 불가능하다.

Manual dependency injection's problem

Large amount of boilerplate code

class Car(
    private val engine: Engine,
    private val battery: Battery,
    private val airFilter: AirFilter,
    private val radiator: Radiator
) {
    ...
}
 
fun main(args: Array) {
    val cylinder = Cylinder()
    val engine = Engine(cylinder)
 
    val battery = Battery()
 
    val filterGrade = FilterGrade()
    val airFilter = AirFilter(filterGrade)
 
    val radiator = Radiator()
 
    val car = Car(engine, battery, airFilter, radiator)
}

 

  • 대규모 앱일수록 모든 종속 항목을 가져와 올바르게 연결하려면 대량의 보일러플레이트 코드가 필요할 수 있다.
  • 다중 레이어 아키텍처에서는 최상위 레이어의 객체를 생성하기 위해 아래의 모든 레이어의 모든 dependency가 필요하다.
  • 애플리케이션이 커지면 상용구 코드(예: 팩토리)를 많이 작성하게 되고 상용구 코드는 오류가 발생하기 쉽다.

Android에서 의존성 주입이 어려운 이유?

  • Android class(Activity, Fragment, Service)들이 Framework에 의해 인스턴스화 됨
    • 생성자에 접근할 수 없음
    • Factory를 API 28부터 제공하지만 현실적이지는 않음
    • Instance의 생명주기 관리도 직접해주어야 함

Hilt

Dagger와 Hilt

  • Dagger : 단검, 단도, 비수.
  • Hilt : (칼·단도의) 자루; (무기·도구의) 손잡이.
  • Hilt는 Dagger가 제공하는 컴파일 타임 정확성, 런타임 성능, 확장성 및 Android 스튜디오 지원의 이점을 누리기 위해 인기 있는 DI 라이브러리 Dagger를 기반으로 빌드되었습니다.

Hilt의 특징

  • Android에서 Dependency을 위한 Jetpack의 권장 라이브러리
  • 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리
  • Annotation을 통한 Boilerplate code 삭제
  • 컴파일 타임에 dependency를 연결하는 코드를 생성하는 정적 솔루션
    • 컴파일 타임의 의존성 체크
    • 생성된 코드는 명확하고 디버깅이 가능함
    • 성능상의 이점 - 리플렉션 사용X
  • Android Studio 지원

 

Hilt의 단점?

  • LearningCurve가 있음
  • 간단한 프로그램을 만들 때는 번거로움
  • 코드의 가독성을 떨어뜨릴 수 있음

Manual DI와 Hilt

Basics of manual dependency injection

class MemoRepository(
     private val database: MemoDatabase,
     private val remoteDataSource: RemoteDataSource
) {
    fun load(id: String){..}
 }
 
 
 class MemoActivity: Activity() {
 
      private lateinit var repository: MemoRepository
 
      override fun onCreate(savedInstanceState: Bundle?) {
          super.onCreate(savedInstanceState)
 
          val db = MemoDatabase.getInstance(context)
          val remoteDataSource = RemoteDataSource()
 
          val memoRepository = MemoRepository(db, remoteDataSource)
      }
  }
  • Boilerplate code가 많음
    • 다른 곳에서 MemoRepository을 만드려면 동일하게 코드가 중복될 수 있다.
  • Dependency 선언에 순서가 있다.
  • 객체를 재사용하기 어렵다. 

 

Managing dependencies with a container

// Container of objects shared across the whole app
class AppContainer {
 
   val db = MemoDatabase.getInstance(context)
   val remoteDataSource = RemoteDataSource()
 
   val memoRepository = MemoRepository(db, remoteDataSource)
}

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MemoApplication : Application() {
 
    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}
  • 이러한 종속 항목은 전체 애플리케이션에 걸쳐 사용되므로 모든 활동에서 사용할 수 있는 일반적인 위치, 즉 애플리케이션 클래스에 배치해야 합니다.
class MemoActivity: Activity() {
 
    private lateinit var memoRepository: MemoRepository
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
 
        val appContainer = (application as MemoApplication).appContainer
        memoRepository = appContainer.memoRepository
    }
  • AppContainer를 직접 관리해야하고, 모든 종속 항목의 인스턴스를 수동으로 만들어야 합니다.
  • 여전히 Boilerplate code가 많다. 

 

Dependency 추가

// Root/build.gradle

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


// app/build.gradle

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
 
android {
    ...
}
 
dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}

Hilt Application class

@HiltAndroidApp
class MemoApplication : Application() { ... }
  • @HiltAndroidApp은 애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여 Hilt의 코드 생성을 트리거합니다.

 

Android 클래스에 종속 항목 삽입

  • @AndroidEntryPoint: @Inject annotation이 달린 변수에 의존성 주입을 수행한다.
@AndroidEntryPoint
class MemoActivity : AppCompatActivity() {
 
  @Inject lateinit var memoRepository: MemoRepository
  ...
}
  • @Inject 주석을 사용하여 필드 삽입
    • Hilt가 삽입한 필드는 private일 수 없다. 

Hilt bindings 정의

  • Binding: Hilt에게 dependency의 instance를 제공하는 방법을 알려주는 것

생성자 삽입

class MemoRepository @Inject constructor(
  private val database: MemoDatabase,
  private val remoteDataSource: RemoteDataSource
) { ... }
  • 클래스의 생성자에서 @Inject 주석을 사용하여 클래스의 인스턴스를 제공하는 방법을 Hilt에 알려준다.
  • Annotation이 지정된 클래스 생성자의 매개변수는 그 클래스의 dependency 항목이다.
    • 따라서 Hilt는 database의 instance를 제공하는 방법도 알아야한다. 

 

Hilt Module

  • @Module로 주석이 지정된 클래스
  • 이 모듈은 특정 유형의 instance를 생성하는 방법을 Hilt에 알려준다.
    • 생성자 주입을 사용할 수 없는 경우에서 사용한다.
      • 예를 들어 interface는 생성자가 없으므로 생성자 주입을 사용할 수 없다. 내가 작성하지 않은 클래스(Room 등)를 사용할 때에도 생성자 주입을 사용할 수 없다. 이런 경우에는 Hilt Module을 이용하여 Binding 정보를 제공해야 한다.
  • @InstallIn 주석을 지정하여 각 모듈을 사용하거나 설치할 Android 클래스를 Hilt에 알려야 한다.
    • 예를 들어 앱 전체에서 사용할 모듈이라면 application 범위에, 특정 fragment에서만 사용한다면 fragment 범위에 모듈을 설치해야 한다.
@Module
@InstallIn(ApplicationComponent::class)
object DataModule {
 
  @Provides
  fun provideMemoDB(
     @ApplicationContext context: Context)
  ) = Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
}
  • 클래스가 외부 라이브러리에서 제공되므로 클래스를 소유하지 않은 경우 또는 빌더 패턴으로 instance를 생성해야 하는 경우에도 생성자 삽입이 불가능하다.

주요 Annotation들

@HiltAndroidApp

  • Hilt 코드 생성을 시작하는 시작점, 반드시 Application 클래스에 추가해야 한다.
@HiltAndroidApp
class MemoApplication : Application() {
 
   override fun onCreate() {
      super.onCreate() // 의존성 주입은 super.onCreate()에서 이뤄짐 (bytecode 변환)
   }
}

 

  • 생성된 Hilt_MemoApplication 클래스를 개발자가 직접 상속할 필요없다.

 

  • Bytecode를 수정하지 않고 직접 상속하도록 수정할 수 도 있다.

 

@AndroidEntryPoint

  • @Inject가 붙은 변수에 의존성을 주입하는 역할을 한다.
  • 각 Android 클래스에 관한 개별 Hilt 구성요소를 생성
    • @HiltAndroidApp → Component 생성
    • @AndroidEntryPoint → Subcomponent 생성
  • @AndroidEntryPoint를 지원하는 타입
    • Activity
    • Fragment
    • View
    • Service
    • BroadcastReceiver
  • Component
    • Component는 각 객체의 생명주기에 연결 되고 이와 관련된 의존성을 제공합니다.
    • Hilt 2.28.2 버전부터 @ApplicationComponent 어노테이션이 @SingletonComponent 을 상속받는 형식으로 변경되었고, @ApplicationComponent 가 추후 릴리즈에서 제거된다고 알렸다.

 

  •  
  •  Component Hierarchy
    • Hilt는 이미 정의된 표준화된 component set와 scope를 제공한다.
    • 하위 component는 상위 component가 가지고 있는 의존성에 접근할 수 있다.

  • Component Lifetime
    • 해당 Android 클래스의 수명 주기에 따라 생성된 Component의 인스턴스를 자동으로 만들고 제거합니다. 

Scope와 @InstallIn

  • 범위가 지정되지 않음 (unscoped) - 어노테이션이 지정되지 않았을 때, 인스턴스 주입이 필요할 때마다 항상 새로운 인스턴스를 생성한다.
  • 커스텀 범위가 지정됨 (Custom scoped) - @Singleton 어노테이션과 같이 앱 컨테이너 등의 지정된 컴포넌트 범위동안 같은 인스턴스를 제공한다.
  • Module에서 Scope 지정은 @InstallIn으로 지정한 Component의 범위와 일치해야 합니다.

@Singleton
class MemoRepository @Inject constructor(
  private val database: Database,
  private val remoteDataSource: RemoteDataSource
) { ... }

 

 

@InstallIn

  • 어떤 Component에 이 모듈을 설치할 지 가리킨다.
  • Module의 필수 요소
  • Module에서 Scope 지정은 @InstallIn으로 지정한 Component의 범위와 일치해야 합니다.
  • Hilt에 항상 동일한 데이터베이스 인스턴스를 제공하도록 하려면 @Provides provideMemoDB함수에 @Singleton 어노테이션을 추가한다.
@Module
@InstallIn(ApplicationComponent::class)     // 이곳과
object DataModule {
 
  @Provides
  @Singleton    // 이곳의 범위가 일치해야 한다.
  fun provideMemoDB(
     @ApplicationContext context: Context)
  ) = Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
}

@EntryPoint

  • Hilt가 지원하지 않는 클래스에서 의존성이 필요한 경우 사용
  • Example) ContentProvider, DynamicFeatureModule, Dagger를 사용하지 않는 3rd-party 라이브러리 등등
  • 특징
    • @EntryPoint는 인터페이스에서만 사용
    • @InstallIn이 반드시 함께 있어야 함
    • EntryPoints 클래스의 정적 메서드를 통해 그래프에 접근
// EntryPoint 생성하기
@EntryPoint
@IntallIn(ApplicationComponent::class)
interface FooBarInterface {
    fun getBar(): Bar
}
 
// EntryPoint로 접근하기
val bar = EntryPoints.get(
   applicationContenxt,
   FooBarInterface::class.java
).getBar()

Hilt의 사전 정의된 qualifier 

  • @ApplicationContext 또는 @ActivityContext를 사용하여 적절한 Context를 제공 받을 수 있다.
class AnalyticsServiceImpl @Inject constructor(
  @ApplicationContext context: Context
) : AnalyticsService { ... }


class AnalyticsAdapter @Inject constructor(
  @ActivityContext context: Context
) { ... }

 

AndroidX Extension

  • Hilt에는 다른 Jetpack 라이브러리의 클래스를 제공하기 위한 extension이 있다.
    • ViewModel
    • WorkManager
...
dependencies {
  ...
  implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha01'
  // When using Kotlin.
  kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
  // When using Java.
  annotationProcessor 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
}

class ExampleViewModel @ViewModelInject constructor(
  private val repository: ExampleRepository,
  @Assisted private val savedStateHandle: SavedStateHandle
) : ViewModel() {
  ...
}

 

  • Component 인스턴스화가 끝난 뒤에 변경될 수 있는 동적인 매개변수인 SavedStateHandle에 @Assisted annotation 을 붙임으로써 해결된다.
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {
  private val exampleViewModel: ExampleViewModel by viewModels()
  ...
}

 

Reference