프로그래밍/Android

Google I/O Extended Seoul 2023: Dagger Hilt로 의존성 주입하기

Lou Park 2023. 7. 29. 20:08

 

https://speakerdeck.com/fornewid/dagger-hiltro-yijonseong-juibhagi

@네이버 웹툰 안성용님 발표자료를 글로 옮긴 것입니다.

의존성 주입이란?

의존성 주입은 하나의 객체가 다른 객체의 의존성을 제공하는 기법.

의존성 주입의 의도는 객체의 생성과 사용의 관심을 분리하는 것.

// 의존성 주입 X
class Car {
    private val engine: Engine = Engine()
    fun start() {
        engine.start()
    }
}

// 의존성 주입 예시 - 생성자에서 전달
class Car(private val engine: Engine) {
    fun start() {
        engine.start() 
    }
}

// 의존성 주입 예시 - 필드 주입
class Car {
    lateinit var engine: Engine
    fun start() {
        engine.start()
    }
}

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

의존성 주입이 항상 좋을까?

  • 객체를 생성하는 곳과 사용하는 곳이 분리되어 코드를 추적하기 어렵게 만들기도 한다.
  • 코드가 부분 부분 분리되어, 시스템 전체가 수행하는 작업을 파악하기 어렵다.
  • 사용하는 특정 DI 프레임워크에 의존하게 된다.

Hilt를 이용하여 Android 앱에 의존성 주입 패턴을 구현하되, 목적을 취할 만큼만 적당히 사용하는 방법을 찾아보는 것이 핵심.

 

Hilt 사용방법 알아보기

@Module
@InstallIn(SingletomComponent::class)
object ExampleModule {
    // unscoped
    @Provides
    fun providesBar(foo: Foo): Bar = BarImpl(foo)

    @Singleton
    @Provides
    fun providesFoo(): Foo = FooImpl()
}

Scope를 지정하면 해당 Component의 수명이 끝날때까지 유지되지만, Scope가 지정되지 않으면 매번 생성한다.

class BarImpl @Inject constructor(private val foo: Foo): Bar { ... }
class FooImpl @Inject constructor(): Foo { ... }

@Module
@InstallIn(SingletonComponent::class)
interface ExampleModule {
    @Binds
    fun binderBar(impl: BarImpl): Bar

    @Singleton
    @Binds
    fun bindsFoo(impl: FooImpl): Foo
}

Inject와 Binds를 이용해서 구현체를 인터페이스 형태로 제공할 수도 있다. Provides와는 달리, Binds는 Factory 클래스가 생성되지 않는다.

 

Thinking about WorkManager

WorkManager에서 의존성 주입하기

WorkManager는 @AndroidEntrypoint 대신 @HiltWorker 를 사용한다. 매개변수를 주입할때는 @AssistedInject / @Assisted 를 사용한다.

 

WorkManager를 다룰때 발생하는 문제점을 DI를 이용해 해결해보자.

 

문제1: WorkManager를 사용하려면 항상 Context가 필요하다.

따라서 사용할 수 있는 위치가 다소 제한될 수 있는데, DI를 이용해 Context를 주입하면 Context 의존성을 숨길 수 있다.

@Module
@InstallIn(SingletonComponent::class)
object TasksModule {
    @Singleton
    @Provides
    fun provideWorkManager(
        @ApplicationContext context: Context,
    ): WorkManager {
        return WorkManager.getInstance(context)
    }
}

@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val workManager: WorkManager
): ViewModel

Context를 고려하지 않고 사용하려는 곳에서 WorkManager로 주입받으면 된다.

 

문제2: 플랫폼 클래스에 의존성을 갖게된다.

Activity/ViewModel에서 WorkManager를 다루다보면 Worker, WorkManager들의 플랫폼 클래스에 의존성을 갖게 된다.

@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val workManager: WorkManager
): ViewModel {
    ...
    val request = OneTimeWorkRequestBuilder<ExampleWorker>()
    .addTab(ExampleWorker.TAG)
    .build()

    workManager.enqueue(request)
    ...
}

DI를 이용하여 OnetimeWorkRequest를 하는 부분을 따로 비즈니스로직으로 빼서,

@Module
@InstallIn(SingletonComponent::class)
interface TasksBindsModule {
    @Binds fun bindsExampleTasks(impl: ExampleTasksImpl): ExampleTasks
}

interface ExampleTasks {
    fun execute()
}

class ExampleTasksImpl @Inject constructor(
    private val workManager: WorkManager,
): ExampleTasks {
    override fun execute() {
        val request = OneTimeWorkRequestBuilder<ExampleWorker>()
            .addTab(ExampleWorker.TAG)
            .build()

        workManager.enqueue(request)
    }
}

플랫폼 클래스에 대한 의존성을 깔끔히 숨길 수 있게된다.

@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val exampleTasks: ExampleTasks
): ViewModel {
    ...
    exampleTasks.execute()
    ...
}

비슷하게, Worker내에서도 Worker의 동작과 비즈니스로직이 뒤섞여있을 경우, 비즈니스 로직들을 UseCase로 뽑아내서 Worker의 역할을 최소화하면서 이해하기 쉬운 코드를 만들 수 있다.

 

Thinking about Coroutines

DI를 이용해 CoroutineDispatcher를 주입할때, CoroutineDispatcher라는 같은 자료형을 사용하고 있기때문에 특정한 Dispatcher를 사용하고 싶을때 문제가 발생한다.

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

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

@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
    @DefaultDispatcher
    @Provides
    fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @IoDispatcher
    @Provides
    fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

}

Qualifier를 이용하면된다. 이렇게 자료형(Type)만으로 식별하기에 충분하지 않을때 Qualifier가 사용된다.

class Example @Inject constructor(
    @DefaultDispatcher private val dispatcher: CoroutineDispatcher
_

이렇게 사용할 수 있다.

Application 범위에서 CoroutineScope를 사용하는 방법

  • GlobalScope
    • 테스트가 어려움
  • ProcessLifecycleOwner
    • App Process에 대한 Lifecycle Owner임
    • 생명주기가 필요한 경우 사용하면된다 → App이 Foreground/Background인지 체크가 가능하다.
    • 테스트가 어려움
  • Custom CoroutineScope
    • 소개할 방법, Application 범위의 CoroutineScope를 직접 생성한다.

Custom CoroutineScope

@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
    @ApplicationScope
    @Singleton
    @Provides
    fun prividesApplicationCoroutineScope(
        @MainImmediateDispatcher mainImmediateDispatchers: CoroutineDispatcher
    ): CoroutineScope = CoroutineScope(context = SupervisorJob() + mainImmediateDispatcher)

    @MainImmediateDispatcher
    @Provides
    fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
}

위 예시에서는 Main.immediate를 주입했으나,

  • Application 등 UI 로직에 사용한다면 순서보장이 되는 Main.immediate를,
  • Background 작업에 주로 사용한다면 Dispatcher 전환이 적도록 Default를 사용할 수 있다.

서비스에서 Coroutine 사용하기

androidx.lifecycle:lifecycle-service에서 제공하는 LifecycleService를 이용하면 Lifecycle을 고려한 Coroutine Scope를 사용할 수 있다.

class ExampleService: LifecycleService() {
    fun foo() {
        lifecycleScope.launch {
            // do...
        }
}

 

Thinking about EntryPoint

Hilt가 제공하지 않는 구성요소에도 EntryPoint를 이용하여 의존성 주입이 가능하다.

Compose에서 GrandChildren Composable에서 “Bar”를 사용하고 싶다면, 직접 GrandParents부터 쭉쭉 내려주거나, Composition Local을 이용하여 해결할 수 있다.

@AndroidEntrypoint
class ExampleActivity: Activity() {
    @Inject lateinit var bar: Bar

    override fun onCreate(savedInstantceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CompositionLocalProvider(LocalBar provides bar) {...}
        }
    }
} 

이런 그림으로 사용할 수 있는데, Entrypoint로도 해결할 수 있다.

@Composable private fun rememberBar(): Bar {
    val context = LocalContext.current
    return remember {
        val entrypoint = EntryPointAccessors.fromApplication(
            context.applicationContext,
            ExampleEntrypoint::class.java
        )
        entryPoint.providesBar()
    }
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface ExampleEntryPoint {
    fun providesBar(): Bar
}

LocalContext를 이용해 Application 범위의 의존성을 주입하면

Activity 단에서 Bar를 주입 받을 필요가 없이 GrandChildren에서 바로 참조할 수가 있다.

@Composable fun GrandChildren() {
    val bar: Bar = rememberBar()
}