안드로이드 DI에 대해 알아보자

timothy jeong·2022년 3월 3일
0

Android 개발 기록

목록 보기
6/8

[왜 DI 를 써야할까?]

A 라는 클래스에서 a,b,c 클래스의 기능을 이용할때 이를 A 는 a, b, c 에 의존하고 있다고 한다.

이때 a, b, c 가 구현체일 경우 구현 방식이 변경될때 굉장히 버거운 작업이 되지 않을까? a 를 주입받은 모든 클래스에서 오류가 발생할 수 있다. 하지만 A 와 a 를 매개하는 인터페이스를 중간에 두고, a 라는 구현체를 주입해준다고? 이러한 문제에서 자유로울 수 있다. 이러한 것을 가능케 하는것이 DI 이다.

또한 DI 는 이러한 구현체의 주입을 집중해서 관리할 수 있도록 해주어 관리가 한층 수월해지며, 중간에 인터페이스를 중계하도록 하기 때문에 테스트에서도 이점을 얻을 수 있다.

[Hilt]

검증되어있고, 가장 최신의 DI 기술은 Hilt 이다. Hilt 라이브러리는 구글에서 제공하는 것으로, Dagger 라이브러리를 기반으로 작성되었다.

Hilt는 클래스 생성시 필요한 의존성 주입 코드를 컴파일 타임에 자동으로 작성해줌으로써 반복되는 코드 작성량을 줄일 수 있고, 의존성 변경을 손쉽게 할 수 있도록 도와주어 테스트 코드 작성을 용이하게 해준다.

[기본적인 Hilt 사용]

Hilt 를 이용하기 위해서는 당연히 의존성을 설정해 줘야하고

// project 수준

buildscript {
    ext.hilt_version = "2.38.1"

    repositories {
        google()
        mavenCentral()
    }

    dependencies {
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
        }
}


// app수준 
plugins {
	id 'dagger.hilt.android.plugin'
}

dependencies {
	implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
    implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03"
    kapt "androidx.hilt:hilt-compiler:1.0.0"
}

Hilt를 이용해서 종속성을 주입하는 방법을 알아보자.

// Application() 클래스를 확장한 클래스에 @HiltAndroidApp 에노테이션을 붙여줘야한다. 
@HiltAndroidApp
class GlobalApp: Application()

// 그리고 의존성을 주입받은 activity 등은 @AndroidEntryPoint 에노테이션을 붙여줘야한다.
@AndroidEntryPoint
class MainActivity: AppCompatActivity() {

	// @Inject를 통해 주입된 의존성은 super.onCreate() 가 호출될때 초기화 되며 따라서 그 이후에 사용가능해진다.
	@Inject lateinit var player: MusicPlayer
    
    override onCreate(savedInstanceState: Bundle) {
    	super.onCreate(bundle)
        player.play("...")
    }
}

// MusicPlayer 라는 클래스에 별도의 설정을 해줌으로써 가능해진다. 
// 이때 constructor 에 들어가는 변수들은 생성자 주입이 된다고 하여 hilt 에서 관리한다. 
// 이와는 달리 activity 에서는 필드 주입 방식이 사용되었다.
class MusicPlayer @Inject constuctor() {
	fun play(id: String) {...}
}

[Hilt 사용의 제약과 Module]

위와 같은 방식으로 종속성을 주입하지 못하는 경우가 있는데, Interface 타입에 대한 주입, 그리고 외부 라이브러리의 클래스는 주입할 수 없다. 또한 빌더 패턴으로 생성해야하는 인스턴스는 생성자 삽입이 불가능하다. 클래스가Concrete 클래스가 아닌 Interface 에 의존하도록 하는 것이 테스트를 쉽게 만드는 핵심인데, 이러한 방법이 제한되는 것은 치명적이다.

이러한 경우에 Module 이라는 것을 이용한다. @Module 로 주석처리된 클래스는 Hilt에게 특정 타입의 인스턴스를 제공하는 방법을 Hilt 에게 알려준다.
즉, Interface 타입을 요청받았을때 어떤 인스턴스를 제공해야하는지 설정을 해두는 파일이라고 보면 될 것이다.

이 모듈 클래스에 @InstallIn 에노테이션을 지정하여 해당 의존성의 라이프 싸이클을 결정한다. 이렇게 생성된 모듈 클래스는 @Binds 과 @provides를 이용하여 해당 인터페이스에 어떤 인스턴스를 주입할지 지정한다.

@Binds

@Binds 주석은이 달린 함수를 추상 함수여야하며, 인터페이스의 인스턴스를 제공해야할 때 사용할 구현체를 Hilt에게 알려준다.
함수의 반환 타입은 어떤 인터페이스에 대한 인스턴스를 제공하는지 알려주어야 하며, 매개변수는 제공할 구현체에 대한 정보를 담고 있어야 한다.

@Module
@InstallIn(ServiceComponent::class)
abstract class ServiceModule {
    
    @Binds
    abstract fun bindLoginService(
        kakaologin: KaKaoLoginService): KakaoLoginInterface 
}

@Provides

외부 라이브러리에서 제공하는 클래스인 경우, 또는 빌더 패턴으로 인스턴스를 생성해야하는 경우(@Binds 는 추상 함수이므로 body를 갖지 못한다)

@Module
@InstallIn(SingletonComponent::class) {

    @Singleton
    @Provides
    fun injectNonAuthRetrofitApi(): NonAuthAPI {
        return Retrofit.Builder()
            .baseUrl(liveServer)
       		.addConverterFactory(GsonConverterFactory.create(gson))
            .build()
            .create(NonAuthAPI::class.java)
    }

    @Singleton
    @Provides
    fun injectNonAuthRepo(retrofit: NonAuthAPI): NonAuthRepoInterface = NonAuthRepo(retrofit)

}

동일한 타입을 서로 다른 구현체로 제공하기

OkHttpClient 를 제공하는데, Interceptor 를 이용하여 token 을 검사하고 refresh 해야하는 client 와 다른 기능이 필요한 Interceptor 가 있다고 하자, 위의 경우처럼 아예 retrofit api를 주입할때 서로 다른 client 를 삽입하는 방식으로 접근할 수도 있겠지만 한정자(@Qualifier)를 이용한 방법도 있다.

한정자 설정


@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient

각 한정자에 맞는 구현체 정보를 제공

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

@Inejct와 함께 한정자 지정된 한정자 에너테이션을 이용하여 생성자 주입과 필드 주입에서 모두 활용이 가능하다.

class ExampleServiceImpl @Inject constructor(
  @AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient
) : ...

// At field injection.
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() {

  @AuthInterceptorOkHttpClient
  @Inject lateinit var okHttpClient: OkHttpClient
}

Module 클래스 더 알아보기

Module 클래스는 InstallIn 에노테이션을 통해 종속성 주입 스코프를 결정할 수 있다. 주입된 종속성의 라이프 싸이클이 이 스코프에 의해 결정된다.
이들을 Component 라고 부른다.

  • SingltonComponent(Application)
    어플리케이션 생성 단계에서 종속항목의 인스턴스를 만들고 어플리케이션 전체에서 하나의 인스턴스를 공유한다. 어플리케이션이 종료될때 종속 항목이 사라진다. 이러한 패턴은 아래에서도 동일하게 적용된다.
  • ActivityRetainedComponent(ViewModel)
  • ViewModelComponent(ViewModel)
  • ActivityComponent(Activity)
  • FragmentComponent(Fragment)
  • ServiceComponent(Service)
  • ViewComponent(View)
  • ViewWithFragmentComponent(@WithFragmentBindings 주석이 지정된 View)

만약 SingltonComponent 에 모든 의존성을 설정해 놓으면 당장은 편하겠지만, 어플리케이션의 모든 활동에서 메모리에 공간을 차지하고 있기 때문에 앱 성능에 부정적인 영향을 끼칠 것이다. 적절한 범위를 설정하는 것이 필요한 이유다.

이러한 compnent들은 계층 구조를 가지고 있어서, 하위 compnent는 상위 compnent에 설정된 종속성을 주입받을 수 있다. 이러한 계층 구조를 이용하여 효율적으로 의존성 주입 스코프를 이용할 수 있을 것이다.

Hilt와 ContentProvider

일반적인 Class 에도 Hilt의 종속성 주입을 이용할 수 있다. 하지만 ContentProvider 를 확장한 클래스에 대해서는 종속성 주입을 제공하지 않는데, 이때는 @EntryPoint 주석을 사용하여 진입점을 만들면 종속성 주입을 이용할 수 있다. 이러한 설정은 원하는 종속성 주입 요소마다 되어야 한다.

class ExampleContentProvider : ContentProvider() {

  @EntryPoint
  @InstallIn(ApplicationComponent::class)
  interface ExampleContentProviderEntryPoint {
    fun analyticsService(): AnalyticsService
  }

  ...
}
profile
개발자

0개의 댓글