Android

안드로이드 [Kotlin] - 의존성 주입(DI) 알아보기 - Hilt

🤖 Play with Android 🤖 2022. 11. 7. 19:10
728x90


📌  들어가며

  • 의존성 주입은 객체지향 프로그래밍의 개념 중 하나이다. 
  • 이것을 이해하기 위해서는 객체지향 설계의 5대 원칙으로 불리는 SOLID 원칙에 대해 알아보아야 한다.
  • 바로 직전 포스팅에 SOLID 원칙에 대해 정리한 글이 있으니 꼭 읽어보고 오기 바란다.

https://jminie.tistory.com/179

 

OOP - SOLID 원칙

SOLID 원칙 단일 책임 원칙 (Single Responsibility Principle, SRP) 개방-폐쇄 원칙 (Open-Closed Principle, OCP) 리스코프 치환 원칙 (Liskov Substitution Principle, LSP) 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

jminie.tistory.com

 

 

 

 

📌  의존성 주입(Dependency Injection)

의존관계

  • 의존의 영향은 꼬리에 꼬리를 문 것처럼 전파되는 특징이 있다.
  • 그래서 위와 같은 의존관계가 있는 상태에서 Piston이 변경되게 되면 Engine도 변경되고 Car 역시 변경되어야 한다. 

 

순환 참조 (Circular Reference)

  • 프로그램을 짜다 보면 다음과 같이 의존이 순환되는 경우가 발생하기도 한다.
  • 이런 경우 Piston이 변경된 영향으로 다시 Piston 자신이 변경되는 문제가 발생한다.
  • 이를 순환 참조라고 하며 프로그램의 유지 보수를 어렵게 하기 때문에 피해야 한다.

 

 

이러한 순환참조를 깨는 법칙 중 하나가 SOLID 원칙 중 의존 역전 원칙이고 의존 역전 원칙을 코드로 실현하는 방법 중 하나가 의존성 주입이다.

 

 

 

 

📌 안드로이드 의존성 주입 라이브러리

개발자가 의존성 주입을 따로 구현하는 것은 번거로운 일이기 때문에 이러한 의존성 주입을 도와주는 여러 라이브러리들이 존재한다.

  • Dagger
    • 강력하고 빠른 의존성 주입 프레임워크로 많은 개발자들이 애용하고 있다.
    • 컴파일 타임에 DI 코드들을 생성해 런타임 성능이 좋다.
    • 리플렉션을 사용하지 않는다.
    • 하지만 러닝 커브가 높고 프로젝트 설정이 까다롭다는 단점이 있다.
  • Koin
    • 러닝 커브가 낮아 쉽고 빠르게 적용 가능하다.
    • Kotlin 개발 환경에 최적화가 되어 있다.
    • 하지만 런타임에 DI 코드를 생성해 런타임 성능이 좋지 않고 리플렉션을 사용한다는 단점이 있다.
  • Hilt
    • Dagger2 기반의 라이브러리이며 Dagger 보다 사용하기 쉽고 표준화된 사용법을 제시한다.
    • 프로젝트 설정이 간소화되었다.
    • 안드로이드 클래스에 최적화되어있다.
      • 이는 사실 장점이자 단점인데 프로젝트 규모에 따라 안드로이드 클래스 외의 곳에 의존성 주입을 해주어야 되는 경우가 생길 수 있다.
      • Dagger의 경우 안드로이드 클래스가 아니더라도 동등한 방법으로 Dagger를 사용할 수 있지만 Hilt는 부가적인 상용구가 필요하다.

 

 

 

📌 의존성 주입 방식별 구분

의존성 주입에는 여러 가지 방식이 존재한다. 코드와 함께 알아보자

class DieselEngine {
    val fuel = "diesel"
}

class Car {
    val engine = DieselEngine()
}
  • 위 코드를 보면 Car가 DieselEngine의 객체를 내부에서 직접 생성했다.
  • 즉 Car가 DieselEngine의 의존을 내부에서 직접 생성한 것이다.
  • 이렇게 되면 의존성 역전 원칙을 위반하게 되고 결과적으로 확장 폐쇄 원칙까지 위반하게 된다.

 

그러면 이 구조에 의존성을 주입해보자.

의존성 주입을 실현하는 방식은 아래와 같이 나누어 볼 수 있다.

  • 생성자 주입 방식
  • 메서드 주입 방식
  • 인터페이스를 통한 주입 방식

 

 

생성자 주입 방식

class DieselEngine {
    val fuel = "diesel"
}

class Car(val engine: DieselEngine) {
}

fun main() {
    val dieselEngine = DieselEngine()
    val car = Car(dieselEngine)
}
  • 생성자 주입 방식은 다음과 같이 클래스를 초기화하는 시점에서 외부에서 작성한 DieselEngine 객체를 생성자로 주입하는 방식이다.

 

 

메서드 주입 방식

class DieselEngine {
    val fuel = "diesel"
}

class Car {
    val engine = null
    
    private fun setEngine(engine: DieselEngine) {
        this.engine = engine
    }
} 

fun main() {
    val dieselEngine = DieselEngine()
    val car = Car()
    car.setEngine(dieselEngine)
}
  • 메서드 주입 방식은 클래스 초기화가 끝난 뒤 특정한 시점에서 setEngine 함수를 동작시켜서 객체를 주입하는 방식이다.

 

 

인터페이스를 통한 주입 방식

interface DieselEngineInjector {
    fun inject(dieselEngine: DieselEngine)
}

class DieselEngine {
    val fuel = "diesel"
}

class Car: DieselEngineInjector {
    var engine = null
    
    override fun inject(dieselEngine: DieselEngine) {
        this.engine = engine
    }
}

fun main() {
    val dieselEngine = DieselEngine()
    val car = Car()
    car.inject(dieselEngine)
}
  • 인터페이스를 통한 주입 방식은 메서드 주입 방식과 유사한데 다만 인터페이스를 통해 의존성을 주입한다는 차이점이 있다.

 

 

주입방식 비교

생성자 주입 방식

  • 필요한 모든 의존 객체를 객체를 생성하는 시점에 준비 가능
  • 생성 시점에 의존 객체가 정상인지 아닌지 판정 가능

메서드와 인터페이스를 통한 주입 방식

  • 의존 객체가 나중에 생성되는 경우에 사용 가능
  • 메서드의 이름을 통해 어떤 의존 객체를 주입하는지 더 알기 쉽다.

 

 

+  서비스 로케이터 방식

  • 의존성 주입하는 방식 중에는 서비스 로케이터 방식이라는 것도 존재한다.
  • 서비스 로케이터 방식은 로케이터 클래스에 의존 객체를 모든 다음에 로케이터가 각 클래스에 의존 객체를 전달하는 방식이다.

서비스 로케이터 사용 방식

  • 메인 루틴에서 모든 의존 객체를 초기화하고 그것을 로케이터에 전달한다.
  • 로케이터는 모든 의존 객체를 가지도록 설계되어 있다.
  • 따라서 어떠한 클래스가 의존 객체가 필요하다면 로케이터를 통해서 주입받는다.

서비스 로케이터의 한계

  • 인터페이스 분리 원칙을 위반하게 된다.
    • 예를 들어 Car가 Diesel 엔진만 필요하여도, 서비스 로케이터가 가진 Gasoline 엔진에도 접근할 수 있게 된다.
  • 동일한 의존 객체를 여러 클래스에서 사용해야 할 경우, 제공 메서드를 각 객체 수만큼 준비해야 한다.
    • 같은 기능을 하는 중복된 코드가 늘어나게 된다.
  • 의존성에 문제가 있어도 컴파일 타임에 확인하는 것이 불가능하다.

 

 

 

 

📌 안드로이드 의존성 주입 라이브러리 (Hilt)

  • 2007년 Guice : 자바를 위한 의존성 주입 라이브러리, 느린 속도와 메모리 문제가 있었음
  • 2012년 Dagger v1 : 안드로이드 앱에 잘 적용되었기는 하지만 속도가 느리고 런타임에 의존성 주입을 처리하는 단점이 있었다.
  • 2016년 Dagger v2 : 구글은 Dagger v1을 Fork 하여 Dagger v2를 발표하였다. Dagger v1에 비하여 속도를 개선하였고 Depency Graph를 작성하는데 어노테이션을 사용함으로써 컴파일 타임에 오류를 잡아낼 수 있었다. 하지만 상당히 높은 러닝 커브가 단점으로 꼽힌다.
  • 2020년 Hilt : 구글은 여러 개발자들의 의견을 받아들여 Dagger를 더 쓰기 쉽게 해주는 Hilt를 발표하였다.

 

 

 

Hilt의 동작 구조

 

 

Component : 외부에서 클래스의 인스턴스를 생성하는 공간

Module : 클래스의 인스턴스들을 모아놓는 공간으로 Component에 의존성을 제공하는 역할을 한다.

 

Hilt의 동작 구조는 위와 같다

  • 우선은 Application 내부에 생명주기와 연동되는 Component라는 이름의 보관함을 만든다.
  • 그리고 그 Component 안에 의존 객체 인스턴스를 모아놓는 Module을 만들고 그 안에 의존 객체를 생성하게 된다.
  • 그리고 의존 객체들끼리 관계를 정의한 Dependency Graph를 만든 다음에 앱을 실행하는 중에 Activity 혹은 Fragment에서 의존 객체 요청이 오면 Graph를 참조하여 의존 객체를 Component를 통해 반환하게 된다.

 

 

Hilt의 구조

Component

Hilt는 현재 아래 안드로이드 클래스에 대해서만 의존성을 주입할 수 있다.

 

  • @HiltAndroidApp
    • Application
  • @HiltViewModel
    • ViewModel
  • @AndroidEntryPoint
    • Activity
    • Fragment
    • View
    • Service
    • BroadcastReceiver

위와 같이 클래스에 맞게 어노테이션을 붙여주면 Hilt가 각 구성요소에 상응하는 의존성을 보관하기 위한 Component를 작성하게 된다.

또한 이 Component 내부의 의존 객체는 다른 Component를 가진 쪽에 의존성을 주입할 수 있는 상태가 된다.

 

 

 

Scpoe

 

 

 

기본적으로 Hilt가 제공하는 의존 객체는 UnScoped 상태이다. 앱이 객체를 요청할 때마다 새로운 객체가 만들어진다는 말이다. 이를 방지하기 위하여 각 의존 객체에는 Scope라는 이름의 생명주기를 지정할 수 있는데 이 Scope의 범위를 나타내는 것이 위 그림의 Scope 어노테이션이다.

  • 의존 객체에 @Singleton을 붙이면 앱 전체에 하나의 객체만 만들어진다.
  • @ActivityScpoed가 붙으면 해당 Activity에서 하나의 객체만 만들어지게 된다.
  • 마찬가지로 @FragmentScpoed가 붙으면 해당 Fragment에서 하나의 객체만 만들어지게 된다.
  • 그리고 이때 Componet 종속 관계에 따라서 하위의 Component는 상위의 Scpoe를 가진 의존 객체에 접근할 수 있다.

 

 

 

의존성 주입

  • 의존 객체를 모아둔 Component를 만들었으면 이제 이 의존 객체를 주입해야 한다.
  • Component안에 의존 객체와 그 의존 객체를 주입받을 객체를 연결하는 행위를 바인딩(Binding) 한다고 한다.
  • Hilt에서는 의존성을 제공할 객체와 받을 객체의 양쪽에 @Inject 어노테이션을 붙임으로써 바인딩이 수행된다.
  • 다만 의존 객체를 주입받기 위해서는 Component가 있어야 하기 때문에 AndroidEntryPoint를 추가로 붙여주어야 한다.

 

Hilt에서는 필드(Field)생성자(Constructor)에 대해서 의존성 주입을 수행할 수 있다.

필드 주입 (Field Injection)

class AnalyticsAdapter @Inject constructor() { }

@AndriodEntryPoint
class ExampleActivity : AppCompatActivity() {
    
    @Inject 
    lateinit var analytics: AnalyticsAdapter
}

예를 들어 AnalyticsAdapter 객체를 ExampleActivity의 analytics 필드에 주입하는 상황을 가정해보자

  • 의존 객체인 AnalyticsAdapter에 @Inject 어노테이션을 붙여주고 (이때 constructor 키워드가 있어야 오류가 나지 않는다.)
  • 의존성을 주입받을 클래스에 @AndroidEntryPoint 어노테이션을 붙여준다.
  • 마지막으로 의존성을 제공받는 필드 즉 analytics에 @Inject 어노테이션을 붙여줌으로써 바인딩(Binding)을 완료한다.

 

생성자 주입 (Constructor Injection)

class AnalyticsAdapter @Inject constructor() { }

@AndriodEntryPoint
class ExampleActivity @Inject constructor(
    val analytics: AnalyticsAdapter
) : AppCompatActivity() { }

이번에는 AnalyticsAdapter 객체를 ExampleActivity의 생성자에 주입하는 상황을 가정해보자

  • 필드와 마찬가지로 의존 객체인 AnalyticsAdapter에 @Inject 어노테이션을 붙여주고 
  • 의존성을 주입받을 클래스에 @AndroidEntryPoint 어노테이션을 붙여준다.
  • 이번에는 ExampleActivity에 @Inject constructor를 붙여서 바인딩(Binding)을 완료한다. (마찬가지로 constructor 키워드가 있어야 오류가 나지 않는다.)

 

 

 

Module

의존 객체를 담는 클래스를 Hilt에서는 모듈(Module)이라는 이름으로 정의한다. Hilt의 용어를 따르면 Component 안에 모듈을 설치하고 그 모듈 내부의 의존 객체를 필요로 하는 곳에 주입하게 된다.

 

모듈의 정의

  • @Module
  • @InstallIn

모듈 안의 모든 의존 객체는 다른 곳에 주입할 수 있다. 하지만 이때 외부 라이브러리로부터 만들어지는 인스턴스와 인터페이스의 인스턴스는 Hilt가 생성하는 것이 아니기 때문에 그대로는 주입할 수 없다. 그래서 구글이 제공하는 것이 @provides와 @Binds 어노테이션이다.

 

모듈의 생성

  • @Provides
  • @Binds

 

@Provides

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {
    
    @Provies
    fun provideAnalyticsService(
        // Potential dependecies of this type
    ): AnalyticsService {
        return Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(AnalyticsService::class.java)
    }
}

"외부 라이브러리를 이용하여 객체를 생성해야 하는 경우"에는 @Provides를 이용하면 된다.

  • 위 예제에서는 AnalyticsModule 클래스는 @Module 어노테이션을 통해 Hilt의 모듈이 된다.
  • 이 모듈은 @InstallIn(ActivityComponent::class) 어노테이션을 통해 ActivityComponent 안에 설치되면서 스코프가 Activity로 설정되게 된다.
  • 모듈 안에는 AnalyticsService 타입을 반환하는 proivideAnalyticsService 함수가 존재하는데, 이 함수에 @Provides를 붙임으로써 AnalyticsModule이  AnalyticsService 의존성을 외부에 제공할 수 있게 된다.

 

 

@Binds

interface AnalyticsService {
    fun analyticsMethods()
}

// Constructor-injected, because Hilt needs to know 
// how to provide instances on AnalyticsServiceImpl, too
class AnalyticsServiceImpl @Inject constructor(...): AnalyticsService { ... }

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {
    
    @Binds
    abstract fun bindAnalyticsService(
        analyticsServiceImpl: AnalyticsServiceImpl
    ): AnalyticsService
}

"인터페이스를 의존 객체로 제공하는 경우" @Binds 어노테이션을 사용하면 된다

  • AnalyticsService 인터페이스가 존재하고 이 구현체인 AnalyticsServiceImpl 클래스가 존재한다.
  • AnalyticsServiceImpl 클래스에 @Inject constructor 키워드를 붙여주어 Hilt가 AnalyticsServiceImpl 인스턴스를 제공할 수 있도록 해준다.
  • AnalyticsModule 안에 bindAnalyticsService 함수의 매개변수로 AnalyticsServiceImpl를 받는다.
  • 그리고 bindAnalyticsService 함수가 AnalyticsService 인터페이스를 반환하도록 하고 @Binds 어노테이션을 붙여준다.
  • 이때 주의할 점은 모듈(Module)과 함수가 모두 abstract 측 추상 클래스와 추상 함수여야 한다.

@Binds는 파라미터는 하나만을 사용할 수 있으며 이 때 파라미터의 타입은 반환타입이 될 수 있는 것만 사용할 수 있다는 제한사항이 있다. @Binds는 그래서 사실상 제한된 조건하에서만 사용가능한 @Provides와 같다고 할 수 있다.

 

Hilt가 신경써야 할 부분이 적어지기 때문에 내부적으로 자동생성하는 코드 양이 감소한다는 특징이 있다.

 

 

 

다음 포스팅에서 Hilt를 실제 프로젝트에 적용하는 실습을 해보도록 하자.

https://jminie.tistory.com/182

 

안드로이드 [Kotlin] - 프로젝트에 의존성 주입(DI) 적용해보기 - Hilt

의존성 주입, 안드로이드에서의 의존성 주입, 그리고 안드로이드 의존성 주입 라이브러리인 Hilt에 대해서 지난번 포스팅에서 다뤘다. https://jminie.tistory.com/180 안드로이드 [Kotlin] - 의존성 주입(DI)

jminie.tistory.com