[Jetpack] WorkManager

이동건·2023년 6월 9일
1

jetpack

목록 보기
3/3
post-thumbnail

안드로이드에서 백그라운드 작업을 처리하는 방법에 대해 공부하는 중이다. 진행중인 프로젝트에 백그라운드 네트워크 작업 요청 및 재처리 로직을 구현하고자 WorkManager를 도입하였다. 공식문서를 기반으로 공부한 내용을 바탕으로 글을 작성하였다.

https://developer.android.com/topic/libraries/architecture/workmanager?hl=ko

WorkManager는 지속적인 작업에 권장되는 솔루션이다. 앱이 다시 시작되거나 시스템이 재부팅될 때 작업이 예약된 채로 남아있으면 그 작업은 유지된다.

WorkManager로 처리하는 작업에는 크게 3가지 유형이 있다.

  • 즉시 (Immediate) : 즉시 시작하고 곧 완료해야 하는 작업, 신속하게 처리될 수 있음
  • 장기 실행 (Long Running) : 더 오래(10분 이상) 실행될 수 있는 작업
  • 지연 가능 (Deferrable) : 나중에 시작하며 주기적으로 실행될 수 있는 예약된 작업

WorkManager가 항상 최선은 아니다!

WorkManager는 앱 프로세스가 사라지더라도 안전하게 종료될 수 있는 진행 중인 백그라운드 작업을 위한 것이 아닙니다. 즉각적인 실행이 필요한 모든 작업을 위한 일반적인 솔루션도 아닙니다.

사용자가 현재 보고있는 UI를 빠르게 변경해야 하는 작업은 WorkManager가 아닌 코루틴을 사용해야 한다! 코루틴은 지속적인 작업이 아닌 곳에 사용한다.


WorkManager 이점

작업 제약 조건

작업 제약 조건을 사용하여 작업을 실행하는데 최적인 조건을 선언적으로 정의한다. 최적의 조건이 충족될 때 까지 작업이 지연되도록 한다. 예를 들어 기기가 무제한 네트워크에 있을 때 또는 배터리가 충분할 때만 실행한다.

다음 코드는 기기가 충전중이고 Wi-Fi에 연결되어 있을 때만 실행되는 작업 요청을 빌드한다.

 val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .build()
    
 val myWorkRequest: WorkRequest =
     OneTimeWorkRequestBuilder<MyWork>()
        .setConstraints(constraints)
        .build()

작업 실행 중에 제약 조건이 충족되지 않는 경우 WorkManager에서 작업자를 중지한다. 그런 다음 모든 제약 조건이 충족될 때 작업을 다시 시도한다.

강력한 예약 관리

한 번 또는 반복적으로 실행할 작업을 예약할 수 있다. 작업에 태그 및 이름을 지정하여 고유 작업 및 대체 가능한 작업을 예약하고 작업 그룹을 함께 모니터링하거나 취소할 수 있다.

예약된 작업은 내부적으로 관리되는 SQLite 데이터베이스에 저장되며 WorkManager에서 기기를 재부팅해도 작업이 유지되고 다시 예약되도록 보장한다.

또한 WorkManager는 절전 기능을 사용하고 권장사항(ex. 잠자기 모드)을 준수하므로 배터리 소모를 걱정하지 않아도된다.

신속 처리 작업

WorkManager를 사용하여 백그라운드에서 즉시 실행할 작업을 예약할 수 있다. 사용자에게 중요하고 몇 분 내에 완료되는 작업에는 setExpedited()를 호출하여 사용 가능하다.

val request = OneTimeWorkRequestBuilder()
        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
        .build()
    
    WorkManager.getInstance(context)
        .enqueue(request)

유연한 재시도 정책

경우에 따라 작업이 실패하기도 하는데 WorkManager는 구성가능한 지수 백오프 정책을 비롯해 유연한 재시도 정책을 제공한다.

    
val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
    .setBackoffCriteria(
       BackoffPolicy.LINEAR,
       OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
       TimeUnit.MILLISECONDS)
    .build()
    

작업 체이닝

복잡한 관련 작업의 경우 직관적인 인터페이스를 사용하여 개별 작업을 함께 체이닝 하면 순차적으로 실행할 작업과 동시에 실행할 작업을 제어할 수 있다.

 val continuation = WorkManager.getInstance(context)
     .beginUniqueWork(
     	 Constants.IMAGE_MANIPULATION_WORK_NAME,
         ExistingWorkPolicy.REPLACE,
         OneTimeWorkRequest.from(CleanupWorker::class.java)
     ).then(OneTimeWorkRequest.from(WaterColorFilterWorker::class.java))
      .then(OneTimeWorkRequest.from(GrayScaleFilterWorker::class.java))
      .then(OneTimeWorkRequest.from(BlurEffectFilterWorker::class.java))
      .then(
          if (save) {
              workRequest<SaveImageToGalleryWorker>(tag = Constants.TAG_OUTPUT)
          } else /* upload */ {
              workRequest<UploadWorker>(tag = Constants.TAG_OUTPUT)
          }
      )

내장 스레딩 상호 운용성

코루틴 및 RxJava와 원활하게 통합되며 자체 비동기 API를 연결 할 수 있는 유연성을 제공한다.

WorkManager를 통해 예약된 작업 내에서 코루틴 사용 가능

안정적인 작업에 WorkManager 사용

WorkManager는 사용자가 화면을 벗어나 이동하거나, 앱이 종료되거나, 기기가 다시 시작되더라도 안정적으로 실행되어야 하는 작업을 대상으로 설계되었다.

ex) 백엔드 서비스에 로그 또는 분석 전송

주기적으로 서버와 애플리케이션 데이터 동기화


WorkManager 시작하기

작업 정의

작업을 WorkManager에 추가하기 위해선 먼저 작업 클래스를 정의해야 한다. 이 작업 클래스는 Worker 혹은 CoroutineWorker 클래스를 상속하여 생성할 수 있다.

class UploadWorker(appContext: Context, workerParams: WorkerParameters):
       Worker(appContext, workerParams) {
   override fun doWork(): Result {

       // Do the work here--in this case, upload the images.
       uploadImages()

       // Indicate whether the work finished successfully with the Result
       return Result.success()
   }
}

doWork()라는 함수를 오버라이드 하여 수행할 작업을 적어준다. 기본적으로 이 메서드는 WorkManager에서 제공하는 백그라운드 스레드에서 비동기적으로 실행된다.

WorkRequest 만들기

작업을 정의하고 나면 이를 실행시키기 위해 WorkRequest를 만들어야 한다. 이 작업 요청은 위에서 알아본 제약조건과 함께 다양하게 생성할 수 있다. 작업 요청을 다양한 방식으로 정의하는 것은 공식문서를 참고하면 된다.

https://developer.android.com/topic/libraries/architecture/workmanager/how-to/define-work?hl=ko

val uploadWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<UploadWorker>()
       .build()

일회성 작업을 예약하기 위해 OneTimeWorkRequestBuilder를 사용하였다. 만약 업로드 작업이 네트워크 연결중일 때만 이루어지도록 하려면 다음과 같이 제약사항을 걸 수 있다.

val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

val uploadWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<UploadWorker>()
			 .setConstraints(constraints)
       .build()

시스템에 작업요청 추가

작업 요청을 만들었다면, 마지막으로 WorkManager에 추가해야 한다. 이 작업은 enque() 메서드를 통해 이루어 진다.

WorkManager.getInstance(myContext)
    .enqueue(uploadWorkRequest)

해당 작업이 실행되는 정확한 시간은 WorkRequest에 사용된 제약조건과 시스템 최적화에 따라 달라진다. WorkManager는 이러한 제한사항에 따라 최상의 상태로 작동하게 설계되었다.


Hilt와 함께 사용

채팅 메시지를 네트워크 DB로 보내는 작업을 WorkManager를 통해 구현하고자 하였다. 로컬 DB에 먼저 저장 하여 사용자에게 메시지가 전송 중임을 보여주고, 네트워크 요청이 완료(작업이 완료)되면 전송이 완료되었음을 나타내고자 하였다. 또한 이 과정에서 작업자 클래스에 의존성 주입을 하여 데이터 소스의 메서드를 호출하였다.

작업자 클래스에서 의존성 주입을 하기 위해서는 @HiltWorker를 사용해야 한다. HiltWorker를 사용하지 않고 필드 주입을 통한 의존성 주입도 시도해보았으나 오류를 발생하였다. (HiltWorker말곤 답이 없는 것 같다 ㅠㅠ )

gradle 설정

HiltWorker를 사용하기 위해선 사전 작업이 필요하다. 먼저 gradle에 다음 의존성을 추가한다.

implementation("androidx.hilt:hilt-work:1.0.0")
kapt("androidx.hilt:hilt-compiler:1.0.0")

주의해야 할 것은 기존에 Dagger Hilt에도 Hilt Compiler 관련 의존성을 kapt로 추가한 적이 있어 (Hilt를 기존부터 사용하고 있으면 추가되어 있을 것이다), 위 코드에서 hilt-compiler가 적용되어 있는 줄 알고 지나갔는데 이름이 둘이 다르다!!

com.google.dagger:hilt-android-compiler
androidx.hilt:hilt-compiler

꼭 추가하자! 추가 안하면 작업을 실행할 때 마다 오류가 발생한다.

HiltWorkerFactory

WorkManager는 기본적으로 앱이 시작될 때 적절한 옵션을 사용하여 자동으로 구성된다. 그러나 WorkManager에 Hilt를 적용하기 위해 커스텀 구성을 만들었으므로 기본 초기화를 제거해줘야 한다. 제거해주지 않으면 WorkManager가 동작하지 않는다.

먼저 AndroidManifest에 다음과 같이 추가한다.

<provider
      android:name="androidx.startup.InitializationProvider"
      android:authorities="${applicationId}.androidx-startup"
      android:exported="false"
      tools:node="merge">
      <!-- If you are using androidx.startup to initialize other components -->
      <meta-data
         android:name="androidx.work.WorkManagerInitializer"
         android:value="androidx.startup"
         tools:node="remove" />
</provider>

여기서 $applicationId 는 진짜 그대로 적어야 한다. 진짜 applicationId(흔히 패키지명)을 적는 것이 아니다.

그 후, Application 클래스에 HiltWorkerFactory를 의존성 주입해주고, Configuration.Provider를 구현하여 WorkManagerConfigurationHiltWorkerFactory를 통해 생성된 값으로 오버라이드해준다.

@HiltAndroidApp
class LunchVoteApplication : Application(), Configuration.Provider{

    @Inject lateinit var workerFactory: HiltWorkerFactory

    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        }
    }

    override fun getWorkManagerConfiguration(): Configuration
        = Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

작업자 클래스

작업자 클래스에 @HiltWorker 어노테이션을 선언해준다. 또한 생성자 주입을 런타임 시에 주입하도록 해주는 @AssistedInject을 constructor 키워드 옆에 사용해준다.

@HiltWorker
class SendChatWorker @AssistedInject constructor(
    @Assisted private val context: Context,
    @Assisted private val workerParams: WorkerParameters,
    @Dispatcher(IO) private val dispatcher: CoroutineDispatcher,
    private val remoteDataSource: LoungeRemoteDataSource,
    private val localDataSource: LoungeLocalDataSource
): CoroutineWorker(context, workerParams) {

	override suspend fun doWork(): Result = withContext(dispatcher){
        try {
            remoteDataSource.sendChat(
	                workerParams.inputData.getString("loungeId") ?: return@withContxt Result.failure(),
                workerParams.inputData.getString("content")
            ).first()

            Timber.e("서버로 메시지 전송 성공")
            // 서버로 메시지 전송 성공
            Result.success()
        } catch (e: Exception) {
            Timber.e("서버로 메시지 전송 실패 : ${e.message}")
            if (runAttemptCount > 3) {
                // 시도 횟수 초과 -> 로컬 데이터베이스에서 삭제
                localDataSource.deleteChat(
                    workerParams.inputData.getString("loungeId") ?: ""
                ).first()

                Result.failure()
            } else {
                Result.retry()
            }
        }
    }
}

contextworkerParams 파라미터에 @Assisted어노테이션을 붙여준다. 기존과 마찬가지로 doWork 내부에 처리할 작업을 명시하고 ListenableWorker.Result객체를 반환하도록 한다.

CoroutineWorker는 기본적으로 Dispatcher.Default가 기본 디스패처로 지정된다. 위의 예시에서는 Dispatchers.IO로 설정하여 작업을 실행하였다.

적용

Hilt를 사용하기 전과 마찬가지로 WorkRequest를 생성하여 WorkManager에 작업 요청을 추가해주면 된다.

class SendWorkerManager @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val workManager = WorkManager.getInstance(context)

    fun startSendWork(chatId: Long, loungeId: String, content: String){
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val inputData = Data.Builder()
            .putLong("chatId", chatId)
            .putString("loungeId", loungeId)
            .putString("content", content)
            .build()

        val workRequest = OneTimeWorkRequestBuilder<SendChatWorker>()
            .setConstraints(constraints)
            .setInputData(inputData)
            .build()

        workManager.enqueueUniqueWork("sendChat", ExistingWorkPolicy.APPEND, workRequest)
    }
}

작업 요청을 전담으로 처리하는 클래스를 새로 생성하였다. WorkRequest를 만들때 기존과 마찬가지로 제너릭 타입 안에 넣어주면 된다.

sendWorkerManager.startSendWork(loungeId, content)

다음과 같이 sendWorkerManager의 메서드를 호출하면 작업이 시작된다.


참고

https://velog.io/@beokbeok/WorkManager-hilt-not-working

https://medium.com/wantedjobs/android-workmanager-를-이용하여-매일-알림-표시하기-d29b3a1a3c7b

https://developer.android.com/topic/libraries/architecture/workmanager?hl=ko

profile
성장하는 활동적인 개발자

0개의 댓글