Developing Myself Everyday
article thumbnail
Published 2023. 8. 25. 17:33
[AAC] WorkManager Android/AAC

 

이 게시글은 아래의 문서를 보고 작성했습니다.

 

앱 아키텍처: 데이터 영역 - WorkManager로 작업 예약 - Android 개발자  |  Android Developers

데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 지속적인 작업 유형과 기능 등을 알아보세요.

developer.android.com


 

 

 

WorkManager


WorkManager는 Android Jetpack 라이브러리의 일부로, 그라운드에서 주기적인 또는 일회성 작업을 처리하고 관리하는 데 도움이 되는 강력한 도구입니다.

 

 

게임을 다운로드하고 실행했을 때 추가적인 다운로드가 필요한 경우가 있습니다. 추가적인 다운로드를 진행하던 중 사용자가 앱을 종료하거나 백그라운드로 보내면 추가적인 다운로드는 종료되게 됩니다.

 

이런 상황에서 바로 WorkManager를 활용할 수 있습니다. WorkManager는 추가적인 다운로드를 계속 진행되도록 관리합니다.  

 

WorkManager작업이 완료되도록 보장해 줍니다. 심지어 기기를 다시 시작하는 경우에도 해당 작업을 꼭 이루어지게 합니다. 만약 작업이 꼭 지금 실행되지 않고, 나중에 실행해도 되는 작업이라면  WorkManager작업을 뒤로 미룰 수 있습니다

 

 

 

WorkManager 구현


Worker 정의

작업은 `Work` 클래스를 사용하여 정의합니다. doWork() 메서드는 WorkManager에서 제공하는 백그라운드 스레드에서 비동기적으로 실행됩니다.

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()에서 반환된 Result는 작업의 성공 여부를 알려주며 실패한 경우 WorkManager 서비스에 작업을 재시도해야 하는지 알려줍니다.

  • Result.success(): 작업이 성공적으로 완료되었습니다.
  • Result.failure(): 작업에 실패했습니다.
  • Result.retry(): 작업에 실패했으며 재시도 정책에 따라 다른 시점에 시도되어야 합니다.

 

 

Work 요청

작업을 요청하기 위해서는 WorkRequest를 만들어야 합니다. WorkRequest는 일정한 간격으로 주기적으로 실행되도록 예약하거나 한 번만 실행되도록 예약할 수 있습니다.

 

어떤 작업 예약 방식을 선택하든 항상 WorkRequest를 사용합니다. Worker는 작업 단위를 정의하는 반면 WorkRequest(및 서브클래스)는 언제, 어떻게 작업이 실행되어야 하는지 정의합니다.

 

가장 간단한 경우 다음 예와 같이 OneTimeWorkRequest를 사용하면 됩니다.

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

 

 

WorkRequest 제출

이제 equeue() 메서드를 사용해 WorkRequestWorkManager에 제출합니다.

WorkManager
    .getInstance(myContext)
    .enqueue(uploadWorkRequest)

 

이렇게 제출된 WorkRequest작업 큐에 들어갑니다. 작업 큐는 FIFO(First-In-First-Out) 방식으로 작업을 처리합니다.

 

 

 

작업 유형에 따른 WorkRequest 


작업의 유형

작업은 한 번(One Time)만 실행되거나 주기적(Periodic)으로 반복될 수 있습니다.

 

WorkManager가 작업을 처리하는 방법은 세 가지입니다.

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

다음 그림은 다른 유형의 지속적인 작업이 서로 어떻게 연관이 있는지 보여줍니다.

 

 

이제부터 위의 그림을 이해 하면서, 각 사례에 맞게 처리하는 코드를 보겠습니다.

 

 

 

One Time Work

간단한 일회성 작업에는 `from`을 사용해 WorkRequest를 만들 수 있습니다.

val myWorkRequest = OneTimeWorkRequest.from(MyWork::class.java)

 

더 복잡한 작업에는 Builder를 사용합니다.

val uploadWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<MyWork>()
       // Additional configuration
       .build()

 

 

 

신속하게 작업 처리

채팅 앱에서 사용자가 메시지 또는 첨부된 이미지를 전송하려는 경우 Expedited로 작업 처리를 요청할 수 있습니다. 이런 작업을 Expedited Work라고 하겠습니다.

 

Expedited Work신속하게 실행되어야 하기 때문에 앱 실행 시간을 따로 배정해야 합니다. 앱이 실행되고 있을 경우에는 상관이 없지만 앱이 백그라운드에 있다면 실행 시간은 제한적이기 때문에 일정량에 도달한다면, 더 이상 Expedited Work을 처리할 수 없게 됩니다.

 

이를 Expedited Work을 처리하는 방법은 `setExpedited()` 메서드를 호출해 해당 WorkRequest가 최대한 빨리 실행되도록 할 수 있습니다.

val request = OneTimeWorkRequestBuilder()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

WorkManager.getInstance(context)
    .enqueue(request)

그림의 expedited ----[< 12] ----> foreground 는 Android 12보다 낮은 버전에서는 WorkManager로 Foreground에 알림을 표시할 수 있음을 나타냅니다.

 

 

 

Periodic Work

우리의 앱이 반복적으로 어떤 작업을 수행하길 원할 수가 있습니다. 예를 들면 데이터를 백업하거나, 새로운 Content를 다운로드하거나, 서버로 로드를 전송해야 할 수가 있습니다.

 

이럴때 우리는 작업 처리를 요청할 때 PeriodicWorkRequest를 사용합니다.

val saveRequest =
       PeriodicWorkRequestBuilder<SaveImageToFileWorker>(1, TimeUnit.HOURS)
    // Additional configuration
           .build()

위의 예시에서는 한 시간 단위로 작업이 실행됩니다.

 


최소 Interval(간격)은 15분입니다. 이는 JobScheduler API와 같습니다.

 

 

 

 

추가적인 작업 설정


Flexible run Intervals

각각의 간격을 유연하게 할 수도 있습니다. 작업이 실행될 수 있는 기간을 정의하고, 이 기간 내에서 작업이 언제든 실행될 수 있게 합니다.

 

 

 

이를 지정하는 방법은 큰 범주로 반복되는 간격(repeatInterval)을 정의하고, 그 간격보다 작은 시간의 범주(flexInterval)를 지정하면 됩니다.

val myUploadWork = PeriodicWorkRequestBuilder<SaveImageToFileWorker>(
       1, TimeUnit.HOURS, // repeatInterval (the period cycle)
       15, TimeUnit.MINUTES) // flexInterval
    .build()

 

 

 

Work Constraints(제약 조건)

제약 조건은 조건이 만족될 때까지 작업이 연기되도록 보장합니다.

 

NetworkType 작업을 실행하는 데 필요한 네트워크 유형을 제한합니다. 예: Wi-Fi(UNMETERED)
BatteryNotLow true로 설정하면 기기가 배터리 부족 모드인 경우 작업이 실행되지 않습니다.
RequiresCharging true로 설정하면 기기가 충전 중일 때만 작업이 실행됩니다.
DeviceIdle true로 설정하면 작업이 실행되기 전에 사용자 기기가 유휴 상태여야 합니다. 이는 사용자 기기에서 활발하게 실행되는 다른 앱의 성능에 부정적인 영향을 줄 수 있는 배치 작업을 실행하는 데 유용합니다.
StorageNotLow true로 설정하면 사용자의 기기 저장공간이 너무 부족한 경우 작업이 실행되지 않습니다.

 

일련의 제약 조건을 만들고 이를 작업 요청에 추가하면 됩니다.

val constraints = Constraints.Builder()
   .setRequiredNetworkType(NetworkType.UNMETERED)
   .setRequiresCharging(true)
   .build()

val myWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<MyWork>()
       .setConstraints(constraints)
       .build()

 

 

 

작업 연기

제약 조건이 없다면 시스템은 작업을 바로 실행할 것입니다. 하지만 바로 실행되기를 원하지 않는다면 어느 정도의 시간을 연기할 수 있습니다.

val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
   .setInitialDelay(10, TimeUnit.MINUTES)
   .build()

 

위의 예시에서는 일회성 작업에 Delay를 주고 있습니다. 만약 반복되는 작업에 Delay를 주면 해당 작업이 처음 시작될 때 연기가 됩니다.

 

 

 

Retry와 Backoff 정책

작업을 요청하면 해당한 작업의 결과가 doWork() 메서드에 의해 반환된다고 말했습니다.

 

결과는 아래와 같이 3가지가 있습니다.

  • Result.success(): 작업이 성공적으로 완료되었습니다.
  • Result.failure(): 작업에 실패했습니다.
  • Result.retry(): 작업에 실패했으며 재시도 정책에 따라 다른 시점에 시도되어야 합니다.

 

Success와 Failure는 잘 알겠는데 만약 Retry를 반환했다면, 어떻게 어떤 기준으로 작업을 다시 실행할까요?

 

바로 이를 정의한 backoff 정책과 지연 시간이 있습니다.

 

기존 backoff 정책은 `EXPONENTIAL`로 10초 지연 후에 작업을 재시도합니다. 만약 계속 retry를 반환한다면 그 2배의 지연 시간 후에 작업을 재시도합니다. (ex - 10, 20, 40, 80 ··· )

 

backoff 정책을 커스터마이징할 수 도 있습니다. 아래의 예시를 보겠습니다.

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

위의 예시에서는 10초의 지연시간을 `LINEAR` backoff 정책으로 재시도합니다. (ex, 10, 20, 30, 40 ··· )

 

 

 

작업 Tag

나중에 하겠지만 작업 취소와 관찰 등의 작업을 하기 위해 고유 식별자인 Tag를 작업에 지정할 수 있습니다.

val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
   .addTag("cleanup")
   .build()

 

 

 

Unique name

Tag를 지정하는 것은 여러 개의 작업에 동일한 Tag를 지정할 수 있기 때문에, 여러 작업을 그룹화하고 식별하는데 사용될 수 있습니다.

 

Tag와 다르게 만약 작업의 하나의 인스턴스와 관련된 이름을 지정하고 싶다면 Unique name을 지정할 수 있습니다.

 

Tag는 작업을 요청할 때 지정했다면 Unique name은 작업을 할당할 때 지정합니다.

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
           "sendLogs",
           ExistingPeriodicWorkPolicy.KEEP,
           sendLogsWorkRequest

 

 

일회성 작업과 반복되는 작업을 메서드를 나눠서 지정해야합니다.

 

 - WorkManager.enqueueUniqueWork() for one time work

 - WorkManager.enqueueUniquePeriodicWork() for periodic work

 

두 메서드 모두 세 가지 인수를 허용합니다.

  • uniqueWorkName - 작업 요청을 고유하게 식별하는 데 사용되는 String입니다.
  • existingWorkPolicy - Unique name이 있는 작업 체인이 아직 완료되지 않은 경우 WorkManager에 해야 할 작업을 알려주는 enum입니다.
  • work - 예약할 WorkRequest입니다.

 

 

 

입력 데이터 할당

작업을 요청할 때 입력 데이터가 필요할 때가 있습니다. 이를 보내는 방법은 `setInputData`를 사용하면 됩니다.

// Create a WorkRequest for your Worker and sending it input
val myUploadWork = OneTimeWorkRequestBuilder<UploadWork>()
   .setInputData(workDataOf(
       "IMAGE_URI" to "http://..."
   ))
   .build()

 

입력 데이터는 키-값 쌍으로 workDataOf로 정의하고 할당합니다.

 

그리고 이렇게 할당한 데이터를 Worker에서 키를 통해 가져옵니다.

// Define the Worker requiring input
class UploadWork(appContext: Context, workerParams: WorkerParameters)
   : Worker(appContext, workerParams) {

   override fun doWork(): Result {
       val imageUriInput =
           inputData.getString("IMAGE_URI") ?: return Result.failure()

       uploadFile(imageUriInput)
       return Result.success()
   }
   ...
}

 

 

 

 

 

 

 

 

 

작업 상태


One Time State

아래의 그림은 지금까지 우리가 다뤘던 작업 중 일회성 작업의 상태를 나타냅니다.

 

 

우리가 작업을 요청하면 해당 작업은 `ENQUEUED` 상태가 됩니다. 제약 조건이나 Delay를 다 만족한 작업은 `RUNNING` 상태가 되고 작업의 Success, Failed, Retry 여부에 따라 상태가 바뀌게 됩니다.

 

`SUCCEEDED`, `FAILED`, `CANCELLED`는 작업이 마지막 상태이며, 작업이 이 상태들에 도달했을 때에는 `WorkInfo.State.isFinished()`를 반환해 작업이 마무리되었음을 알립니다.

 

 

 

Periodic Work State

아래는 반복 작업의 상태를 나타낸 그림입니다.

 

 

반복 작업은 마지막 상태로 `CANCELLED`만 가지고 있습니다. 반복 작업은 취소가 아니면 종료되지 않기 때문입니다.

 

 

 

Blocked State

아직 언급하지는 않았지만, 마지막으로 소개하는 최종 상태는 `BLOCKED`입니다. 이 상태는 일련의 작업 또는 작업 체인에서 조정된 작업에 적용됩니다. 작업 체인과 작업 체인의 상태 다이어그램은 작업 체이닝에서 다룹니다.

 

 

 

State 관찰

이런 상태를 관찰하는 방법은 작업의 식별자를 사용해서 이뤄집니다. Work의 식별자는 ID, Unique name, Tag가 있습니다.

 

이를 사용해서 작업의 상태를 관찰하는 예시입니다.

// by id
workManager.getWorkInfoById(syncWorker.id) // ListenableFuture<WorkInfo>

// by name
workManager.getWorkInfosForUniqueWork("sync") // ListenableFuture<List<WorkInfo>>

// by tag
workManager.getWorkInfosByTag("syncTag") // ListenableFuture<List<WorkInfo>>

 

 

 

Observe로 State 변경사항 추적

LiveData에서 사용되는 Observe를 여기서 가져와서 똑같이 사용할 수 있습니다.

 

아래의 예시는 작업의 상태를 관찰하고 상태가 `SUCCEEDED` 일 때 스낵바를 생성하는 코드입니다.

workManager.getWorkInfoByIdLiveData(syncWorker.id)
               .observe(viewLifecycleOwner) { workInfo ->
   if(workInfo?.state == WorkInfo.State.SUCCEEDED) {
       Snackbar.make(requireView(),
      R.string.work_completed, Snackbar.LENGTH_SHORT)
           .show()
   }
}

 

 

 

작업 취소

작업을 취소하는 방법도 작업을 관찰하는 방법과 유사합니다.

// by id
workManager.cancelWorkById(syncWorker.id)

// by name
workManager.cancelUniqueWork("sync")

// by tag
workManager.cancelAllWorkByTag("syncTag")

 

 

 

 

작업 Chaining 


순차적 실행

만약 영상을 업로드할 때 압축을 거친 다음 업로드하는 작업을 진행해야 한다고 생각해 보겠습니다.

 

 

이를 순차적으로 실행할 수 있는 방법이 작업을 Chaining하는 것입니다. 

 

작업을 완료하고 다른 작업으로 넘기려면 매개체가 필요합니다. 이를 지원하는 것이 WorkContinuation입니다. 작업을 완료하면 해당 작업은 WorkContinuation의 인스턴스를 반환하고 다음 작업으로 전달합니다.

 

작업의 순서는 beginWith()then()으로 지정합니다. 

WorkManager.getInstance(myContext)
   // Candidates to run in parallel
   .beginWith(compress)
   // Dependent work (only runs after all previous work in chain)
   .then(upload)
   // Call enqueue to kick things off
   .enqueue()

 

위의 코드에서 compress가 먼저 실행되도록 보장하고 성공한다면 WorkContinuation의 인스턴스를 반환합니다. 이를 unload에 전달하고 실행합니다.

 

 

 

병렬적 실행

그럼 이제 업로드할 작업이 여러가지 라고 생각해 보겠습니다. 우린 해당 작업을 필러링한 후 압축하고 업로드해야 합니다.

 

 

우리는 이 작업을 병렬적으로 실행할 수 있습니다. 아래의 예시를 보겠습니다.

WorkManager.getInstance(myContext)
   .beginWith(filterWork1, filterWork2, filterWork3)
   .then(compress)
   .then(upload)
   .enqueue()

 

beginWith에 여러 작업을 넣는 걸로 각 작업이 병렬적으로 실행되도록 합니다. 각 작업은 완료될 시 압축, 업로드 과정을 거치고 큐에 할당됩니다.

 

 

 

병합

여러 부모의 작업 요청으로부터 입력을 관리하기 위해 WorkManager는 InputMerger를 사용합니다.

 

WorkManager에서 제공하는 두 가지 다른 유형의 InputMerger가 있습니다:

 

 1. OverwritingInputMerger는 모든 입력에서 모든 키를 출력에 추가하려고 시도합니다. 충돌이 발생하는 경우 이전에 설정된 키를 덮어씁니다.

 2. ArrayCreatingInputMerger는 입력을 병합하려고 시도하며 필요한 경우 배열을 생성합니다.

 

 

 

OverwritingInputMerger

OverwritingInputMerger는 병합 작업의 기본 방법입니다. 만약 병합에 충돌이 있다면 가장 최신의 값으로 이전의 값을 덮어쓰기 합니다.

 

아래의 예시를 보겠습니다.

 

 

`plantName` 이란 키를 가지는 값이 2개가 입력되었습니다. OverwritingInputMerger는 2번째로 들어온 값으로 덮어쓰는 것을 확인할 수 있습니다.

 

 

 

ArrayCreatingInputMerger

ArrayCreatingInputMerger는 기존의 값을 없애지 않고 보존하게 해 줍니다. 아래의 그림을 보겠습니다.

 

 

 

이 방법들을 사용하는 방법은 아래와 같습니다.

val cache: OneTimeWorkRequest = OneTimeWorkRequestBuilder<PlantWorker>()
   .setInputMerger(ArrayCreatingInputMerger::class)
   .setConstraints(constraints)
   .build()

 

 

 

Chaining과 작업 상태

이제 위에서 우리가 제대로 말하지 못했던 `BLOCKED`에 말할 차례입니다. 작업 Chaining은 각 과정이 성공적으로 완료될 때, 순차적으로 실행됩니다. 하지만 작업은 실행중에 취소되거나 실패할 수 있습니다.

 

 

 

첫번째 작업이 요청되면 나머지 Chaining으로 연결된 작업들은 `BLOCKED`됩니다. 

 

 

 

 

 

만약 첫번째 작업이 `SUCCEEDED` 된다면 첫번째 작업과 연결된 작업들은 `ENQUEUED` 됩니다.

 

 

 

 

 

 

그럼 작업이 retry를 반환한다면 어떻게 될까요? 다른 작업들은 영향을 받지 않고 retry를 하면 됩니다.

 

 

 

 

 

 

해당 작업이 `FAILED` 되거나 `CANCELLED` 된다면 해당 작업과 연결되어있는 자식 작업들은 전부 `FAILED` 되거나 `CANCELLED` 됩니다.


 

 

마무리하며

이렇게해서 WorkManager에 대해 알아보는 시간을 가졌습니다. WorkManager를 처음 접했을 때에는 너무 어렵다는 생각이 많이 들었습니다. 하지만 이렇게 한번 제대로 공부를 해보니 다음에 프로젝트에서 꼭 도입해봐야겠다는 생각이 듭니다.

 

 

 

 

 

Reference

 

'Android > AAC' 카테고리의 다른 글

[AAC] DataStore with SharedPreferences  (0) 2023.08.24
[AAC] Paging 라이브러리  (0) 2023.08.16
[AAC] LiveData  (0) 2023.08.12
[AAC] LifeCycle  (0) 2023.08.03
profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!