[Android] Hilt에 대해서 알아보자!

WonseokOh·2022년 11월 16일
1

Android

목록 보기
16/16
post-thumbnail

  안드로이드 개발에서 DI(의존성 주입) 기법은 많이 사용되는 기술로 DI를 통해 아키텍처를 위한 기반을 마련할 수 있습니다. DI에 대표적인 라이브러리로 Dagger2, Hilt가 존재하고 이와 비슷한 Service Locator 패턴으로 구현된 Koin 라이브러리도 존재합니다. 이 글에서는 Hilt에 대해서 설명드리고, Dagger2는 추후 작성하도록 하겠습니다. Koin에 대해서 깊게 알고 싶으실 경우에 아래 링크 참고해주세요.


의존성 주입(DI)이란?

  Hilt 라이브러리를 설명하기 전에 의존성 주입을 왜 하는지에 대한 이해가 선행되어야 합니다. 참고로 이 글의 모든 코드는 구글 공식문서에서 가져왔습니다.


의존성 주입의 필요성

  일반적인 클래스에서는 다른 클래스의 참조가 필요한 경우가 많습니다. 아래 예시처럼 Car라는 클래스에는 Engine 클래스 참조가 필요하며 직접 인스턴스를 생성하고 있습니다.

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

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

이와 같이 참조를 구성한 경우는 의존성 주입을 사용하지 않는 예시로 몇 가지 문제점을 야기시킵니다.

  • Car 클래스와 Engine 클래스는 강하게 결합되어 있어서 다른 서브 클래스나 인터페이스를 사용할 수 없습니다.
  • 또한, Engine 클래스의 변경으로 인해 Car 클래스도 변경을 해야 하는 변경의 전이 현상이 일어나게 됩니다.
  • Engine 인스턴스를 직접 생성하기에 Test Stub, Fake, Mock 등 테스트 더블을 사용하여 테스트 코드를 작성할 수 없습니다.

  위 코드처럼 클래스 내 다른 클래스의 참조를 직접 생성하여 얻는 방법도 있지만 다른 방법도 존재합니다. 하나는 전역 클래스에서 참조가 필요한 객체들을 가져오는 방법과 다른 하나는 매개변수를 통해 제공받는 방법입니다. 매개변수를 통해 객체를 제공받는 기법을 "의존성 주입(DI)"이라고 합니다.

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

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

main 함수에서는 Engine 객체를 생성한 후 Car를 생성함과 동시에 매개변수로 생성했던 Engine 객체를 주입시킵니다. 이처럼 의존성 주입(DI)을 통한 방법을 적용시에 이전에 제기되었던 문제들을 해결할 수 있습니다.

  • Engine의 다양한 구현체들을 Car 객체에 주입시킴으로써 새로운 서브 클래스 또는 인터페이스들을 전달할 수 있습니다.
  • Car 객체는 Engine과 결합도를 약하게 하여 Engine 객체의 변경이 필요할지라도 Car 클래스는 영향을 받지 않게 됩니다. 따라서 Car 클래스는 변경의 전이 현상도 일어나지 않고 관심사 분리를 통한 독립적인 개발을 수행할 수 있습니다.
  • Engine의 Fake 객체나 Mock 객체를 주입시킴으로써 손쉽게 테스트 코드를 작성할 수 있습니다.

의존성 주입 방법

의존성 주입의 방법은 크게 2가지로 생성자 주입과 필드 주입이 있습니다.

  • 생성자 주입 : 생성자의 매개변수를 통해 의존성 주입
  • 필드 주입 : setter와 같은 메소드를 통한 필드 주입
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 프레임워크 클래스들은 직접 생성할 수 없기에 필연적으로 필드 주입을 해야합니다. 위 코드처럼 필드 주입은 주입될 대상 클래스를 먼저 생성한 후에 주입할 수 있습니다.


Service Locator

  위의 의존성 주입을 수동으로 하는 방법의 대안으로 나타난 방법이 Service Locator(서비스 로케이터) 입니다. 서비스 로케이터는 사실 디자인 패턴 중 하나로 주입시킬 의존성을 미리 생성하고 저장한 후에 요청에 따라 제공하는 패턴입니다.

object ServiceLocator {
    fun getEngine(): Engine = Engine()
}

class Car {
    private val engine = ServiceLocator.getEngine()

    fun start() {
        engine.start()
    }
}

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

위 코드처럼 ServiceLocator 클래스에 주입시킬 객체인 Engine을 생성하는 메소드가 존재합니다. 해당 메소드를 호출하여 Car 클래스에서도 사용할 수 있도록 할 수 있습니다. 이와 같은 서비스 로케이터로 의존성을 주입할 수 있지만 기존에 하는 방식과는 차이가 명백히 존재합니다.

  1. 생성자 주입과 같은 방법으로 의존성을 주입하는 것보다 서비스 로케이터로 구현하는 것이 테스트 코드 작성을 어렵게 만듭니다. 모든 테스트는 공통의 ServiceLocator와 상호작용하기에 외부로부터 영향을 받을 가능성이 높아집니다.

  2. ServiceLocator는 런타임에 의존성을 주입하기 때문에 주입되는 대상을 변경을 하게 되면 참조실패로 런타임 에러 또는 테스트 실패가 발생할 수 있습니다.

  3. 객체가 전체 앱 기간이 아닌 특정 보유 기간을 지정하려고 하면 관리하는게 더 어렵습니다.


Hilt 사용

  Hilt는 Android에서 종속 항목을 삽입하기 위한 Jetpack 권장 라이브러리로 Hilt가 적용된 프로젝트에서 모든 Android 클래스별 맞춤 컨테이너를 제공하고 수명 주기를 자동 관리함으로써 DI를 실행하는 표준 방법을 정의합니다. 또한, Hilt는 Dagger 기반으로 빌드되고 Dagger보다 조금 더 편리하게 사용하고자 만들어진 Wrapper 라이브러리로도 볼 수 있습니다.


종속 항목 추가

종속 항목 추가는 라이브러리 버전이 항시 바뀔 수 있으므로 공식문서를 참고하는 편이 좋습니다. 😘


Application 클래스에 Hilt 적용

  Hilt를 사용하는 모든 앱에서는 @HiltAndroidApp Annotation을 Application 클래스에 지정을 해야 합니다. @HiltAndroidApp는 애플리케이션 수준의 컨테이너를 제공하고 그 외 Hilt 코드 생성을 트리거합니다.

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

애노테이션 프로세서를 통해 생성된 이 Hilt 구성요소는 Application 클래스와 생명주기가 연결되어 있으며 관련된 종속항목들을 제공합니다. 그리고 이 구성요소는 가장 상위 클래스에 연결되어 있기에 하위 클래스에도 종속항목들을 액세스 할 수 있습니다.


Android 클래스에 Hilt 적용

  Application에 Hilt 적용 이후에는 다른 Android 클래스에서도 Hilt를 적용 가능하며 @AndroidEntryPoint와 같은 다른 애노테이션이 지정된 구성요소에서도 동일하게 종속 항목을 제공할 수 있습니다.

@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() { ... }

아래는 Hilt가 지원하는 클래스들의 목록입니다.

  • Application ( @HiltAndoridApp )
  • ViewModel ( @HiltViewModel )
  • Activity ( @AndroidEntryPoint )
  • Fragment ( @AndroidEntryPoint )
  • View
  • Service
  • BroadcastReceiver

액티비티에 @AndroidEntryPoint 애노테이션을 지정을 하였다면 액티비티에서 호스팅하는 프래그먼트에서도 애노테이션을 지정한다는 사실도 잊으면 안됩니다.


종속 항목 주입

  @AndroidEntryPoint와 같이 Android 클래스에 각각 애노테이션을 지정하면 관련 Hilt 구성요소를 생성합니다. Application도 동일하게 Application 생명주기와 연결된 구성요소를 생성하였고 가장 상위 요소이기에 다른 Android 클래스의 구성요소도 동일한 종속 항목을 제공할 수 있습니다.

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

구성요소에서 종속 항목을 가져오기 위해서는 @Inject 애노테이션만 지정을 하면 필드 삽입을 실행하게 됩니다. 필드 삽입 사용 시에는 절대로 private 할 수 없습니다. 유의해주세요!


종속 항목 정의

  위에서 Hilt를 통한 종속성을 제공받는 방법은 2가지라고 소개하였습니다. 생성자 주입과 필드 주입입니다. 이런 종속성을 제공하기 위해서는 Hilt에 종속 항목의 인스턴스를 제공하는 방법을 알려주어야 합니다.

class AnalyticsAdapter @Inject constructor(
	private val service: AnalyticsService
) { ... }

@Inject라는 애노테이션을 지정하면 Hilt에 종속 대상을 알려주게 되고 여기서 AnalyticsService가 추가적인 종속 항목으로 있습니다. 따라서 Hilt는 AnalyticsService 인스턴스를 생성하는 방법을 알려주어야 합니다.


Module 정의

  위에서는 생성자를 통해서 종속 항목을 정의하는 방법을 배웠습니다. 하지만 Android 개발하면서 Retrofit, Room과 같은 외부 라이브러리를 사용할 때 생성자를 수정할 수 없기 때문에 위의 방법을 사용할 수 없습니다. 이런 경우에 @Module 애노테이션을 지정한 모듈 클래스를 이용하여 종속 항목들을 정의할 수 있습니다.

@Binds를 사용하여 인터페이스 구현체 정의

모듈 내에 종속성을 정의하는 방법은 크게 2가지가 있는데 @Binds를 활용하는 것이 하나의 방법입니다. 인터페이스는 생성자 삽입해서 제공받을 수 없는데 @Binds를 사용하면 가능해집니다.

interface AnalyticsService {
  fun analyticsMethods()
}

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

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

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

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var service: AnalyticsService
    
    // ...
 }

@Binds 애노테이션은 인터페이스의 구현체를 제공하기 위해서 사용되며 인터페이스와 인스턴스를 바인딩 해주는 역할을 하고 있습니다. 따라서 해당 모듈에서 제공해주는 의존성은 인터페이스만 선언하더라도 구현체가 삽입되는 것을 확인할 수 있습니다.


@Provides를 사용하여 인스턴스 제공

인터페이스 외에도 외부 라이브러리의 클래스를 사용할 경우에도 직접 생성자로 정의할 수 없습니다. 안드로이드 개발에 많이 사용되는 Retrofit, Room 과 같은 클래스만 봐도 빌더 패턴을 사용하여 정의하기에 생성자를 통한 정의가 불가능합니다.

@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)
  }
}

위 코드의 provideAnalyticsService가 제공하려는 인스턴스 생성하는 방법을 정의하고 있습니다. 반환 값이 제공하려는 인스턴스이며 함수 매개변수는 인스턴스 생성 시에 필요한 종속 항목이라고 생각하시면 됩니다. 즉, 매개변수 또한 @Inject를 사용하여 정의를 하던 @Provides, @Binds를 통해 Hilt에게 생성방법을 알려주어야 합니다.


@Binds, @Provides 성능 Tip!!

@Binds를 사용한 예제와 @Provides를 사용한 예제에서 모듈 클래스 정의가 다르다는 것을 알 수 있습니다. @Binds는 추상클래스로 정의하였고 @Provides는 object 키워드를 사용한 싱글톤 클래스로 정의하였습니다. 이렇게 차이가 있는 이유는 성능상 이점을 가져가기 위함입니다.

가장 먼저 @Binds로 정의가 될 수 있다면 @Provides 보다는 @Binds를 권장합니다.

@DaggerGenerated
@SuppressWarnings({
    "unchecked",
    "rawtypes"
})
public final class Module_ProvideAlarmDaoFactory implements Factory<AlarmDao> {
  private final Module module;

  private final Provider<AlarmDatabase> dbProvider;

  public Module_ProvideAlarmDaoFactory(Module module, Provider<AlarmDatabase> dbProvider) {
    this.module = module;
    this.dbProvider = dbProvider;
  }

  @Override
  public AlarmDao get() {
    return provideAlarmDao(module, dbProvider.get());
  }
  
  ...
}

Hilt는 애노테이션 프로세서를 사용하여 빌드타임에 필요한 인스턴스를 생성하게 됩니다. 이 때 @Provides로 정의된 메소드 하나마다 내부적으로 팩토리 클래스를 생성합니다. @Binds는 인스턴스가 필요한 곳에 직접 생성을 하지만 @Provides는 팩토리 클래스를 생성하고 제공받기 때문에 오버헤드가 더 발생하게 됩니다.

  추가적으로 @Provides에서도 팩토리 클래스가 생성이 안되게 하는 방법은 처음 예시처럼 object로 정의를 하면 가능합니다. 정리를 하자면 정의하려는 인스턴스에 따라 모듈 클래스를 아래와 같이 사용하시면 됩니다.

  • Only @Provides : object
  • Only @Binds : interface or abstract class(abstract fun으로 정의)
  • @Provides and @Binds : interface로 정의, @Provides는 companion object 내에서 구현
@Module
@InstallIn(ActivityComponent::class)
interface Module{
	@Binds 
    fun bindService(serviceImpl: ServiceImpl) : Service // 부가 코드 생성 skip
    
    companion ojbect{
    	@Provides
        fun provideExample(): Example = Example()	// 새로운 팩토리 클래스 생성 X
    }
}

Hilt 사전에 정의된 Quantifier

Hilt는 이미 정의된 Quantifier(한정자)를 제공합니다. 안드로이드 개발에 필수적인 Context 클래스 또한 Hilt에서 제공하기에 한정자만 추가하면 자동으로 Context가 삽입되는 것을 알 수 있습니다.

class AnalyticsAdapter @Inject constructor(
    @ActivityContext private val context: Context,
    private val service: AnalyticsService
) { ... }

Component 정의

  Hilt를 사용하기전에 Dagger를 사용해봤던 분들이라면 Component 정의가 번거롭고 의존성을 제공해주려는 클래스마다 함수를 생성한다는 것을 알고 있을 것입니다. Hilt 라이브러리가 개발된 이유는 다양하게 있겠지만 Dagger에서의 Component를 자동으로 생성해주면서 Wrapper 역할을 해주기 위함도 있습니다.

@Module 예시코드에서 모듈 클래스를 생성한 후 @InstallIn 애노테이션을 사용하여 종속성을 제공해야하는 컴포넌트들을 정의하게 됩니다. Dagger에서 Component의 역할은 위에서 정의했던 모듈과 인스턴스 객체 관계를 나타내는 오브젝트 그래프를 생성하고 Hilt에서도 동일하게 사용됩니다. 하지만 Hilt에서는 직접 정의할 필요없이 컴포넌트들이 이미 다 정의되어 있어 종속성이 유지되는 기간과 범위만 잘 인지하고 Android 클래스에 삽입해서 사용하면 됩니다.


Hilt 구성요소 대상 클래스 생성 위치 소멸 위치 범위
@SingleComponent Application Application
onCreate()
Application
onDestroy()
@Singleton
@ActivityRetainedComponent N/A Activity
onCreate()
Activity
onDestroy()
@ActivityRetainedScoped
@ViewModelComponent ViewModel ViewModel
created
ViewModel
destroyed
@ViewModelScoped
@ActivityComponent Activity Activity
onCreate()
Activity
onDestroy()
@ActivityScoped
@FragmentComponent Fragment Fragment
onCreate()
Fragment
onDestroy()
@FragmentScoped
@ViewComponent View View super() View destroyed @ViewScoped
@ServiceComponent Service Service onCreate() Service onDestory() @ServiceScoped

Hilt vs Dagger

추후에 Dagger에 대해 글을 작성하려고 하지만 간단하게 비교를 해보도록 하겠습니다.

  • Hilt에서는 Dagger의 여러 상용 코드를 간소화
  • Dagger의 바인딩 오브젝트 그래프를 생성하는 컴포넌트를 Hilt에서는 자동 생성과 범위를 지정
  • @HiltAndroidApp, @AndroidEntryPoint와 같이 Android 클래스를 나타내는 사전 정의된 바인딩 애노테이션 지원
  • @ApplicationContext, @Application과 같은 사전에 정의된 다수의 애노테이션 지원

정리

  의존성 주입의 대표적인 라이브러리인 Hilt에 대해서 알아보았습니다. Dagger에 대해 먼저 사용해보고 Hilt를 다시 본다면 어떤 점에서 Dagger보다 더 편리하게 사용한지를 알 수 있습니다. 추후에 Dagger 관련 블로그 작성할 예정입니다. 언제나 댓글 및 피드백은 환영입니다!! ❤️


참고

profile
"Effort never betrays"

0개의 댓글