Dagger Hilt로 ViewModel에 WorkManager 주입: Context·보일러플레이트 없이, 언제나 WorkQuery 반환
Source: Dev.to
WorkManager는 Android에서 연기 가능한, 보장된 백그라운드 작업을 수행하기에 적합한 도구입니다. 하지만 기본 설정을 그대로 사용하면 금방 보일러플레이트 코드가 늘어납니다. ViewModel 안에서 WorkManager.getInstance(context)를 호출하고, “Context를 잘못된 위치에 전달”하며, 코드베이스 전역에 흩어져 있는 옵저버들을 다시 등록하고, 큐에 넣은 작업의 상태를 일관되게 조회할 방법이 없습니다.
이 튜토리얼에서는 Dagger Hilt를 사용해 깔끔하고 주입 가능한 WorkManagerHandler를 만드는 방법을 보여줍니다. 이 핸들러는 Context 없이도 모든 ViewModel이 생성자 주입을 통해 받을 수 있는 단일 인터페이스이며, 호출마다 보장된 WorkQuery 콜백을 반환해 언제든지 관찰할 대상을 알 수 있게 합니다.
튜토리얼을 마치면 다음을 갖게 됩니다.
- Hilt를 통해 제공되는 WorkManager 싱글톤
- Hilt의
HiltWorkerFactory를 WorkManager 초기화 시 연결하는 커스텀Configuration.Provider - 작업 큐에 넣기, 체이닝, 쿼리 등록을 캡슐화한
WorkManagerHandler인터페이스와WorkManagerHandlerImpl WorkManagerHandler를 순수 생성자 의존성으로 선언한 ViewModel- 워커가 추가될 때마다 핸들러에 메서드 하나만 추가하면 되며, ViewModel은 작업이 어떻게 스케줄되는지 전혀 알 필요가 없습니다.
전제 조건: Dagger Hilt 기본 사용법, Jetpack WorkManager 개념, Kotlin 코루틴에 대한 기본 지식. Hilt가 이미 설정된 Android 프로젝트가 있다고 가정합니다.
Part 1: Application Setup and Custom Configuration.Provider
기본적으로 WorkManager는 자체 내부 팩토리를 사용해 자동으로 초기화됩니다. 문제는 Hilt가 주입한 워커는 HiltWorkerFactory를 통해 @Inject 생성자 의존성을 해결해야 한다는 점입니다. WorkManager가 스스로 초기화되면 워커는 Hilt 바인딩에 접근할 수 없습니다.
해결 방법은 자동 초기화를 비활성화하고 Configuration.Provider와 AndroidManifest.xml을 통해 수동으로 제어하는 것입니다.
1. 자동 초기화 비활성화
AndroidManifest.xml에서 WorkManager의 기본 이니셜라이저를 제거합니다:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" />
이렇게 하면 Jetpack Startup이 기본 WorkManager 설정을 건너뛰고, 직접 연결할 수 있게 됩니다.
2. Configuration.Provider를 Application 클래스에 구현하기
@HiltAndroidApp
class CustomApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
WorkManager.initialize(this, workManagerConfiguration)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
여기서 주목할 점 몇 가지:
@HiltAndroidApp은 Hilt 코드 생성을 트리거하고Application클래스를 주입 진입점으로 만듭니다. 따라서@Inject lateinit var workerFactory가 정상 동작합니다.HiltWorkerFactory는 Hilt와 WorkManager 사이의 브리지 역할을 합니다. WorkManager가 워커를 인스턴스화할 때 이 팩토리를 사용해@Inject된 의존성을 해결합니다.onCreate()에서WorkManager.initialize()를 수동으로 호출합니다. 필드 주입이 끝난 뒤이므로workerFactory는 사용 시점에 널이 아닙니다.
왜 이것이 중요한가?
워커 클래스에 @AssistedInject 혹은 @Inject 생성자를 사용하면 런타임에 조용히 실패합니다. WorkManager가 크래시를 일으키거나 찾을 수 없는 기본 생성자를 사용하게 됩니다. 이 부분을 먼저 올바르게 설정해야 이후에 구축하는 모든 것이 정상 동작합니다.
Part 2: Providing the WorkManager Singleton and Writing Your First CoroutineWorker
Application 클래스를 연결했으니, 이제 WorkManager 인스턴스를 Hilt를 통해 앱 전역에 제공하고, 실제로 주입 가능한 워커를 작성합니다.
1. WorkManager 싱글톤 제공
@Module
@InstallIn(SingletonComponent::class)
object WorkManagerModule {
@Provides
@Singleton
fun provideWorkManagerInstance(
@ApplicationContext context: Context
): WorkManager = WorkManager.getInstance(context)
}
주요 포인트:
@Singleton을 붙여 앱 전체에서 하나의 WorkManager 인스턴스를 공유합니다. WorkManager 자체도 내부적으로 싱글톤이므로, 일관된 래핑은 미묘한 상태 불일치를 방지합니다.@ApplicationContext는 Hilt가 제공하는 내장 Qualifier로, 별도 전달 없이 애플리케이션Context를 얻을 수 있습니다. 이번 튜토리얼에서 Context가 직접 등장하는 마지막 순간입니다.- 이 바인딩은 나중에
WorkManagerHandlerImpl에 주입됩니다. ViewModel은 WorkManager에 직접 접근하지 않습니다.
2. Hilt 주입이 가능한 첫 번째 CoroutineWorker 작성
일반 Worker 서브클래스는 @Inject 생성자를 통해 Hilt 주입을 받을 수 없습니다. WorkManager가 인스턴스 생성을 담당하기 때문이죠. 해결책은 @HiltWorker와 @AssistedInject를 함께 사용하는 것입니다.
@HiltWorker
class YourWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val someDependency: SomeDependency // Hilt가 주입
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = coroutineScope {
// 실제 작업 수행
Result.success()
}
}
핵심 설명:
@HiltWorker는 Hilt 코드 생성기가 해당 클래스를 위한 팩토리를 만들게 합니다. 없으면HiltWorkerFactory가 워커를 인식하지 못합니다.@AssistedInject는 런타임에context와workerParams를 WorkManager가 제공해야 함을 나타냅니다.@Assisted가 붙은 파라미터는 런타임 제공값이며, 그 외(someDependency)는 Hilt 그래프에서 해결됩니다.
워커가 준비되었습니다. 싱글톤도 그래프에 들어갔으니, 이제 ViewModel이 실제로 사용할 인터페이스를 만들 차례입니다.
Part 3: The WorkManagerHandler Interface and Its Implementation
패턴의 핵심은 바로 여기입니다. ViewModel이 WorkManager를 직접 호출하는 대신 WorkManagerHandler 인터페이스와 소통합니다. 구현체는 작업 큐에 넣기, 체이닝, 그리고 항상 WorkQuery를 반환해 호출자가 정확히 무엇을 관찰해야 하는지 알 수 있게 합니다.
1. 인터페이스 정의
interface WorkManagerHandler {
fun initializeYourWorker(
inputData: Data,
workQuery: (WorkQuery) -> Unit
)
fun observeWorkInfo(workQuery: WorkQuery): Flow<WorkInfo>
}
디자인 포인트:
- 모든 메서드는
workQuery: (WorkQuery) -> Unit콜백을 받습니다. 이것이 계약이며, 어떤 워커를 트리거하든 항상WorkQuery를 반환받아 ViewModel이 ID나 태그를 알 필요 없이 작업 상태를 관찰할 수 있습니다. Data는 WorkManager의 키‑값 입력 컨테이너입니다. 원시 타입 대신Data를 사용함으로써 핸들러는 입력 내용에 대해 알 필요가 없고, 단지 워커에 전달만 하면 됩니다.- 인터페이스 자체에는
Context,WorkManager, 라이프사이클 레퍼런스가 전혀 없습니다. 순수한 행동 계약만 정의합니다.
2. 인터페이스 구현
class WorkManagerHandlerImpl @Inject constructor(
private val workManager: WorkManager
) : WorkManagerHandler {
override fun initializeYourWorker(
inputData: Data,
workQuery: (WorkQuery) -> Unit
) {
val request = OneTimeWorkRequestBuilder<YourWorker>()
.setInputData(inputData)
.build()
workManager.enqueue(request)
val query = WorkQuery.fromIds(mutableListOf(request.id))
workQuery(query)
}
override fun observeWorkInfo(workQuery: WorkQuery): Flow<List<WorkInfo>> {
return workManager.getWorkInfosFlow(workQuery)
}
}
세부 설명:
@Inject생성자는WorkManager하나만을 파라미터로 받으며, Part 2에서 제공한 싱글톤이 자동 주입됩니다. 여기서는 Context가 전혀 필요 없습니다.- 작업을 큐에 넣은 직후
WorkQuery.fromIds()를 호출해 바로WorkQuery를 만든 뒤 콜백에 전달합니다. 이렇게 하면 ViewModel은 같은 호출 스택 안에서 쿼리를 받아 바로 관찰을 시작할 수 있습니다. observeWorkInfo는 전달받은WorkQuery를WorkManager의getWorkInfosFlow에 연결해Flow<List<WorkInfo>>를 반환합니다. ViewModel은 이 흐름을collect하거나asLiveData()등으로 변환해 UI에 바인딩하면 됩니다.