[Android] Hilt 파헤치기

yuuuzzzinzzzang·2024년 3월 23일
1
post-thumbnail

Hilt를 사용해온 이유

나는 의존성 주입을 위해 Hilt를 많이 사용해왔는데, Hilt와 함께 많이 사용되는 Koin과 한 번 비교를 해보려고 한다.

🥊 Koin🥊 Hilt
러닝 커브 ↓러닝 커브 ↑
런타임 안정성 ↓런타임 안정성 ↑
퍼포먼스 ↓퍼포먼스 ↑

🗣️ 정리하자면

Koin은 Hilt에 비해 러닝커브가 낮다.

하지만 Koin은 Hilt처럼 컴파일타임에 의존성 주입을 검증하지 않고, 런타임에 인스턴스를 동적으로 주입하기 때문에 런타임 안정성이 낮아지고 실사용 중 크래시가 발생할 가능성이 높아 앱 퍼포먼스가 낮아진다.

개발이 계속 진행되면서 프로젝트는 점점 커지고 DI는 점점 더 많은 곳에서 사용하게 된다.
그렇기 때문에 컴파일 타임에 검증을 미리 마치고 문제를 해결할 수 있다는 것은 아주 큰 비용 절감으로 다가온다 !

Hilt 알아보기

Annotation(@)

Hilt는 애노테이션을 기반으로 동작한다.

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

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

이는 바이트 코드 변조를 통해 보일러 플레이트 코드를 줄일 수 있다.

컴파일시 생성되는 힐트 클래스는 주입과 관련된 코드를 포함된다. 그렇다면 실 코드에서 MemoApplication이 의존성 주입을 사용하기 위해 Hilt_MemoApplication을 상속해야 할까?

→ 🙅 아니다. MemoApplication 클래스는 바이트 코드 변조를 통해 자동으로 Hilt_MemoApplication을 상속하는 코드로 변환이 된다.

@HiltAndroidApp

  • Hilt를 사용하려면 반드시 @HiltAndroidApp 애노테이션이 지정된 어플리케이션 클래스를 생성해야 한다.
  • 앱에 의존성 주입이 가능하도록 컴파일 단계에서 의존성 주입에 필요한 구성 요소들을 초기화하고 관련 코드 생성을 트리거하게 된다.
  • 앱의 생명주기와 밀접하게 연결된 앱 컨테이너(Application container)를 생성한다.

@AndroidEntryPoint

@AndroidEntryPoint
class MyActivity: AppCompatActivity() { ... }
  • 위에서 @HiltAndroidApp을 통해 Application 클래스에 힐트가 설정되고 어플리케이션 레벨의 컴포넌트가 사용가능하게 되면, @AndroidEntryPoint 애노테이션을 가진 안드로이드 클래스에 의존성을 제공할 수 있게 된다.
  • 이를 통해 해당 안드로이드 클래스의 라이프 사이클을 따르는 개별적인 힐트 컴포넌트를 생성하고, 필요한 인스턴스를 주입한다.
  • Activity, Fragment, View, Service 등에 사용이 가능하다.
  • Application은 위에서 설명했듯 @HiltAndroidApp으로, ViewModel은 @HiltViewModel을 통해 사용 가능하다.

Hilt 컴포넌트

힐트 컴포넌트는 대응되는 안드로이드 클래스에 바인딩을 제공해주는 역할을 한다.

ex) Activity는 ActivityComponent로부터 바인딩을 제공받는다.

위 사진은 컴포넌트간 계층 구조이며, 하위 컴포넌트는 상위 컴포넌트의 바인딩에는 접근할 수 있지만 그 반대는 불가하다.

컴포넌트 생명주기

바인딩을 제공하는 안드로이드 클래스의 생명주기에 맞게 컴포넌트를 만들고 파괴한다.

컴포넌트 Scope

  • 컴포넌트의 scope와 바인딩의 scope는 별개이다.
  • 기본적으로 모든 바인딩은 UnScoped 되어 있다. 즉, 디폴트로는 해당 바인딩이 요청될 때마다 힐트는 항상 새로운 인스턴스를 만들어 제공한다는 뜻이다.
  • 별도로 Scope를 지정해주면 해당하는 HiltComponent의 수명동안 같은 인스턴스를 공유해 바인딩이 요청될 때마다 같은 인스턴스를 제공할 수 있다.
// 매 요청시 새로운 인스턴스 생성
class UnScopedFooBinding @Inject constructor() {
   ...
}

// 스코프 애노테이션이 있음
// 해당하는 Hilt 컴포넌트의 수명동안 매 요청에 동일 인스턴스를 반환
@ActivityScoped
class ScopedFooBinding @Inject constructor() {
   ...
}

@Inject

@Inject를 통해 의존성을 주입할 수 있고, 다음과 같은 두 가지 방법이 있다.

생성자 주입(Constructor Injection)

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

바인딩(binding): 의존성을 컴포넌트에 추가해 객체를 제공하는 방법을 알려주는 것 또는 의존성 그 자체

  • 생성자에서 @Inject를 통해 해당 클래스(AnalyticsAdapter)를 만들기 위해 AnalyticsService의 의존성을 힐트 컴포넌트에 요청하고 주입을 받는다.
  • 주입 받음과 동시에 주입 받는 대상 클래스(AnalyticsAdapter)도 컴포넌트에 바인딩이 된다. (해당 클래스의 인스턴스를 제공하는 방법을 알려준다.)
  • 두 가지를 다 수행하는 것이 특징 !

필드 주입(Field Injection)

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

   @Inject lateinit var adapter: AnalyticsAdapter,
   ...
}
  • 일반적으로 안드로이드 클래스에서는 생성자 주입을 할 수 없기 때문에 필드 주입을 이용한다.
  • @AndroidEntryPoint 주석이 붙은 클래스에서 @Inject 어노테이션이 사용된 필드에 값을 할당해준다.

메서드 주입

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

   @Inject
   fun setBar(bar: Bar) {
      ...
   }
}
  • 잘 활용되는 방식은 아님
  • 파라미터로 사용되는 Bar의 의존성을 주입받는다.

모듈

@Module

  • 모듈은 Hilt에게 특정 객체를 만드는 방법을 알려주는 클래스이다.
  • @Module 어노테이션을 달아준다.

@InstallIn

  • 해당 모듈이 어떤 범위에서 사용될 것인지 지정해주기 위해 @InstallIn 어노테이션을 사용한다. → 달아주지 않으면 컴파일 타임에 검사될 때 걸린다. @Module & @InstallIn 은 세트 느낌
    ex) 앱 전체에서 사용할 모듈이면 application 범위에, 어떤 activity에서만 사용할 거라면 activity 범위로 지정해 설치해야 한다.

모듈 설치 과정

컴파일 타임에 애노테이션 처리 → @Module 탐색 → @InstallIn에서 설치될 컴포넌트 탐색 → 해당 컴포넌트에 설치

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

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

모듈 내 바인딩에 Scope 지정하기

// If AnalyticsService is an interface.
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {

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

// If you don't own AnalyticsService.
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {

  @Singleton
  @Provides
  fun provideAnalyticsService(): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}
  • 바인딩의 Scope는 모듈이 설치되는 컴포넌트의 Scope와 매칭되는 Scope여야 한다. ex) SingletonComponent이면 @Singleton / ActivityComponent이면 @ActivityScoped
  • 위에서 “컴포넌트의 scope와 바인딩의 scope는 별개이다”라고 설명했던 것 처럼, 모듈이 SingletonComponent에 설치되었다고 모듈의 바인딩까지 같은 scope 처리 되는 것은 아니다. 바인딩의 scope를 지정하고 싶다면 따로 어노테이션을 붙여주어야 한다. ex) 위 코드에서 바인딩에 @Singleton을 붙여주지 않았다면, 모듈의 인스턴스는 앱 수명주기 내에서 동일한 하나로 존재하지만 모듈 안의 바인딩은 인스턴스를 매번 새로 만든다.
  • provideAnalyticsService() 또는 provideAnalyticsService()가 한 번 불리고 나면, AnalyticsService 인스턴스를 컴포넌트 내부에 저장해두기 때문에, 앱이 끝날 때 까지 메모리에 남아있는다. (메모리 누수를 유발할 수 있기 때문에 사용시 고려가 필요함)

Default Bindings

Android componentDefault bindings
SingletonComponentApplication
ActivityRetainedComponentApplication
ViewModelComponentSavedStateHandle
ActivityComponentApplication, Activity
FragmentComponentApplication, Activity, Fragment
ViewComponentApplication, Activity, View
ViewWithFragmentComponentApplication, Activity, Fragment, View
ServiceComponentApplication, Service
@Module
@InstallIn(SingletonComponent:class)
object FooModule {
   
   @Provides
   fun provideBar(app: Application): Bar {
      ...
   }
}
  • 힐트 컴포넌트는 디폴트의 기본 바인딩이 제공된다.
  • 힐트 컴포넌트에는 기본적으로 제공되는 디폴트 바인딩이 있다.
  • 위 코드 처럼, 모듈에서 설치된 SingletonComponent에 따라 제공되는 기본 바인딩인 Application 인스턴스를 주입받고 사용할 수 있다.
  • 또한 아래 처럼 @ApplicationContext 또는 @ActivityContext를 사용해 디폴트 바인딩에 맞는 Context를 제공받을 수 있다.
class AnalyticsServiceImpl @Inject constructor(
  @ApplicationContext context: Context
) : AnalyticsService { ... }

// The Application binding is available without qualifiers.
class AnalyticsServiceImpl @Inject constructor(
  application: Application
) : AnalyticsService { ... }
class AnalyticsAdapter @Inject constructor(
  @ActivityContext context: Context
) { ... }

// The Activity binding is available without qualifiers.
class AnalyticsAdapter @Inject constructor(
  activity: FragmentActivity
) { ... }

바인딩 방법

@Inject를 통한 생성자 바인딩

class AnalyticsAdapter @Inject constuctor(
   private val service: AnalyticsService,
) { ... }
  • 위에서 이미 나왔던 내용이죵 ?
  • 의존성을 요청할 때에도 사용되지만, 해당 클래스의 의존성도 함께 컴포넌트에 바인딩된다.

@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)
  }
}
  • 외부 라이브러리(Retrofit, OkHttpClient, Room Databases 등..) 또는 빌더 패턴을 통해 인스턴스를 만들어야 하는 경우 생성자 주입 사용이 불가능하다.
  • 이런 경우, @Provides를 통해 인스턴스를 만드는 방법을 Hilt에게 알려줄 수 있다(바인딩할 수 있다).
    • 함수의 리턴 타입 → 함수가 제공할 인스턴스의 타입
    • 함수의 파라미터 → 어떤 타입의 의존성을 가지는지
    • 함수의 바디 → 어떻게 인스턴스를 제공하는지. (이 인스턴스가 필요할 때마다 함수 바디를 실행시킨다.)

@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
}
  • 위와 같이 인터페이스의 경우에도 생성자 주입 사용이 불가능하다.
  • @Binds 애노테이션이 달린 abstract 함수를 통해 인스턴스를 만드는 방법을 알려줄 수 있다. (바인딩이 abstract 함수이기 때문에 포함하는 모듈 또한 abstract 클래스여야 한다.)
    • 함수의 리턴 타입 → 함수가 제공할 인스턴스의 타입(캐스팅 가능한 상위 타입)
    • 함수의 파라미터 → 제공할 구현체(implementation) 반드시 하나를 가져야 한다.

중복 바인딩 구분하기

  • Hilt는 타입으로 의존성을 구분하기 때문에 동일한 타입이 한 컴포넌트에 중복으로 바인딩되어 있으면 어떤 의존성을 전달해야 할지 혼란이 생겨 컴파일 타임에 중복 바인딩 에러가 발생한다.

@Qualifier

  • @Qualifier를 이용하면 이러한 중복 바인딩이 가능하다. 타입이 같은 바인딩이 여러개 존재해도 Qualifier를 통해 구분할 수 있다.
  • 아래처럼 커스텀한 Qualifier를 정의해서 사용하면 된다.
    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class AuthInterceptorOkHttpClient
    
    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class OtherInterceptorOkHttpClient
    • @Retention 은 어노테이션의 Scope을 지정해 어느 시점까지 어노테이션의 메모리를 가져갈 지 설정하는 역할을 한다.
    • 흠.. @Retention(AnnotationRetention.BINARY)는 왜 붙여주는 건가 생각해봤는데(정확하지 않음), 따로 이걸 지정해주지 않으면 디폴트로 ‘Retention(AnnotationRetention.RUNTIME)’이 지정된다고 한다. 힐트는 런타임 시 종속 항목을 연결하는 리플렉션 기반이 아니기 때문에 따로 리플렉션을 통해 접근하지 않는 BINARY로 설정을 하는 것 같다.
    • 중복 바인딩 되는 것이 두개 존재할 때, 하나만 달아줘도 되지만 구글에서는 가능한 모든 바인딩에 커스텀 Qualifier를 만들어 달아주는 것을 권장한다. 헷갈려서 오류를 야기할 수도 있고, 명시적으로 달아주는 것이 잘못된 의존성 주입을 막을 수 있기 때문에 !
  • 위에서 선언한 어노테이션 클래스를 이용해 아래처럼 어노테이션을 붙여준다.
    @Module
    @InstallIn(SingletonComponent::class)
    object NetworkModule {
    
      @AuthInterceptorOkHttpClient
      @Provides
      fun provideAuthInterceptorOkHttpClient(
        authInterceptor: AuthInterceptor
      ): OkHttpClient {
          return OkHttpClient.Builder()
                   .addInterceptor(authInterceptor)
                   .build()
      }
    
      @OtherInterceptorOkHttpClient
      @Provides
      fun provideOtherInterceptorOkHttpClient(
        otherInterceptor: OtherInterceptor
      ): OkHttpClient {
          return OkHttpClient.Builder()
                   .addInterceptor(otherInterceptor)
                   .build()
      }
    }
    • OkHttpClient를 리턴하는 바인딩이 두개 선언되어 중복 바인딩이 일어났음에도 불구하고, 커스텀 Quialifier를 사용했기 때문에 의존성 그래프가 그려질 때 두개가 구분이 된다.
  • 위에서는 Quialifier를 통해 바인딩을 했고, 아래 코드처럼 클라이언트 쪽에서 특정 타입을 주입받을 수도 있다.Quialifier에 맞는 필드 또는 파라미터에 어노테이션을 붙여서 특정 타입을 주입할 수도 있다.
    // As a dependency of another class.
    @Module
    @InstallIn(ActivityComponent::class)
    object AnalyticsModule {
    
      @Provides
      fun provideAnalyticsService(
        @AuthInterceptorOkHttpClient okHttpClient: OkHttpClient
      ): AnalyticsService {
          return Retrofit.Builder()
                   .baseUrl("https://example.com")
                   .client(okHttpClient)
                   .build()
                   .create(AnalyticsService::class.java)
      }
    }
    
    // As a dependency of a constructor-injected class.
    class ExampleServiceImpl @Inject constructor(
      @AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient
    ) : ...
    
    // At field injection.
    @AndroidEntryPoint
    class ExampleActivity: AppCompatActivity() {
    
      @AuthInterceptorOkHttpClient
      @Inject lateinit var okHttpClient: OkHttpClient
    }

@Named

  • Named 애노테이션으로도 해결이 가능하다!
    @InstallIn(SingletonComponent::class)
    object FooModule {
       
       @Named("Bar1")
       @Provides
       fun provideBar1: Bar {
          return Bar()
       }
    }
  • 문자열을 유니크한 아이디 값으로 갖도록 설정해 이걸로 중복 바인딩을 구분할 수 있다.
  • 따로 커스텀 애노테이션 클래스를 만들어주지 않아도 되므로 더 간단한듯 ?!

느낀점

취준때부터 Hilt는 나에게 굉장히 이해하기 어려운 벽같은게 느껴지는 개념이었다. 근데 이번 기회에 강의도 들어보고 공식문서도 뒤져보면서 어느정도 힐트랑 친해진 것 같아 뿌듯하다 🙂 전엔 Hilt 관련해서 쓰면서도 '아 제발 오류 나지마라 너..'이러면서 운빨코딩한 적이 많았던 것 같은데, 이젠 케이스에 맞게 잘 쓰고 오류가 나더라도 이유를 빠르게 파악하고 해결할 수 있을 것 같은 느낌..! 암튼 이 글 쓰는데 굉장히 오랜 시간이 걸렸고 아직도 엄청 빠삭하게 나는 힐트로 책을 낼 수도 있다 !!! 이런 느낌은 아닌 듯하여 틀린 부분이 있다면 댓글로.. 남겨주세요.. 🤗

참고

[드로이드나이츠 2020] 옥수환 - Hilt와 함께 하는 안드로이드 의존성 주입
Dependency injection with Hilt
뱅크샐러드 안드로이드 앱에서 Koin 걷어내고 Hilt로 마이그레이션하기

profile
yuuuzzzin의 개발 블로그

0개의 댓글