본문 바로가기
Android/Open Source

[Open Source] WorkManager 기초

by 준그래머 2023. 7. 19.
반응형

본 게시물은 Medium에 2019년 1월 23일에 게시된 내용을 번역한 글입니다. 오역이 있을 수 있으니 원문을 읽고 싶으신 분들은 아래 링크를 이용해 주세요.

WorkManager Basics

 

WorkManager Basics

Getting started with WorkManager

medium.com

 

시작

WorkManager 시리즈의 두 번째 게시물에 온 것을 환영합니다. WorkManager는 작업에 대한 제약이 충족된다면 백그라운드 작업을 지연하거나 보장 해주는 Android Jetpack 라이브러리입니다. WorkManager는 현재 많은 백그라운드 작업 유형 중 가장 좋은 방법입니다. 첫 게시물에서 WorkManager가 무엇이고 언제 사용되어야 하는지에 대해 알아 보았습니다.

이번 게시물에서는

  • 백그라운드 작업을 Work로 정의하는 법
  • 구체적인 작업을 실행하는 방법 정의
  • 작업을 실행하는 법
  • 의존적인 작업을 위한 체인 사용법
  • 작업 상태를 관찰하는 법

또한 WorkManager가 뒤에서 어떻게 처리하고 있는지 대해 설명해서 WorkManager를 어떻게 사용해야 할지 도움을 주려 합니다.

 

예제부터 시작해 봅시다

이미지 필터와 전 세계 웹 사이트에 업로드를 하는 이미지 편집 앱이 존재한다고 가정해 봅시다. 여러분은 필터를 적용하고 이미지를 압축한 다음 업로드하는 일련의 백그라운드 작업들을 생성하려고 합니다.

필터를 적용하고 이미지를 압축한 다음 업로드하는 일련의 백그라운드 태스크를 생성하려고 합니다. 각 단계에서 확인하는 제약 조건이 필요합니다.

  • 이미지를 필터링하기 위해 충분한 배터리가 존재하는지
  • 이미지를 압축하기 위해 충분한 저장 공간이 존재하는지
  • 이미지를 업로드하기 위해 네트워크 연결이 되어 있는지

The example, visualized

이 작업에 대한 예시:

  • 연기할 수 있는지, 여러분은 즉시 이것을 처리할 필요가 없고 사실 제약 조건들이 충족될 때까지 기다리길 원할 것입니다. (네트워크 연결을 기다리는 것처럼)
  • 앱이 존재하는지 여부와 관계없이 실행되는 것이 보장되는지, 만약 필터링된 이미지들이 전 세계에 공유되지 않는 다면 여러분의 유저는 매우 실망할 것입니다.

이러한 특성들은 이미지 필터링 및 업로드 작업이 WorkManager의 완벽한 사용 사례가 될 수 있습니다.

 

WorkManager 종속성 추가

이 게시물의 코드들은 코틀린을 기준으로 되어 있습니다. (KTX 라이브러리 사용) 이 KTX 라이브러리 버전은 보다 간결하고 코틀린의 문법에 맞는 확장 기능을 제공합니다. 다음 종속성을 사용하여 KTX 버전의 WorkManager를 사용할 수 있습니다.

dependencies {
 def work_version = "1.0.0-beta02"
 implementation "android.arch.work:work-runtime-ktx:$work_version"
}

여기서 최신 라이브러리 버전을 확인할 수 있습니다. 만약 Java 종속성을 사용하기 원한다면 “-ktx” 구문을 삭제하세요.

 

작업에 대해 정의

다수의 작업을 함께 연결하기 전에 work의 한 부분은 어떻게 정의하는지에 대해 설명하겠습니다. 일단 업로드 작업에 대해 알아보면 첫 번째로 Worker 클래스를 구현하는 부분을 직접 만들어야 합니다. UploadWorker라는 이름의 클래스를 생성하고 doWork() 함수를 오버라이드 하고 있습니다.

Workers:

  • 실제로 작업할 것을 정의
  • 입력을 받고 출력을 생성, 입/출력 모두 키와 값의 쌍으로 이루어져 있음
  • 모든 리턴은 항상 sucess, failure 또는 retry로 나타내는 값을 반환

여기에 이미지를 업로드하는 Worker에 대한 예시가 있습니다.

class UploadWorker(appContext: Context, workerParams: WorkerParameters)
    : Worker(appContext, workerParams) {

    override fun doWork(): Result {
        try {
            // Get the input
            val imageUriInput = inputData.getString(Constants.KEY_IMAGE_URI)

            // Do the work
            val response = upload(imageUriInput)

            // Create the output of the work
            val imageResponse = response.body()
            val imgLink = imageResponse.data.link
            // workDataOf (part of KTX) converts a list of pairs to a [Data] object.
            val outputData = workDataOf(Constants.KEY_IMAGE_URI to imgLink)

            return Result.success(outputData)

        } catch (e: Exception) {
            return Result.failure()
        }
    }

    fun upload(imageUri: String): Response {
        TODO(“Webservice request code here”)
        // Webservice request code here; note this would need to be run
        // synchronously for reasons explained below.
    }

}

두 가지에 대해 주목하세요:

  • 입/출력은 Data (원시적인 타입과 배열의 map)로 전달되고 있습니다. (다시 말하면 배열을 value로 한 map으로 전달) 입/출력 간에 전체 크기에 대한 제약이 존재하도록 Data 객체는 매우 작게 설계되었습니다. (MAX_DATA_BYTES에 의해 제한됩니다.) 만약보다 많은 데이터를 Worker에서 주고받아야 한다면, Room database 같은 것을 사용해 데이터를 저장하도록 해야 합니다. 예를 들어 이미지 자체가 아닌 이미지 URI를 전달하는 방식이 있습니다.
  • 위의 코드에서 Result.sucess()Result.failure()를 리턴하고 있습니다. 나중에 작업을 다시 시도하는 Result.retry() 옵션도 존재합니다.

 

작업 실행 방법에 대해 정의

Worker가 작업을 정의하고 있는 동안 WorkRequest는 어떻게 그리고 언제 작업을 실행시켜야 할지 정의하고 있습니다.

여기 UploadWorker에는 OneTimeWorkRequest를 생성하는 예시가 있습니다. 물론 반복 적으로 실행하는 PeriodicWorkRequest도 사용할 수 있기는 합니다.

// workDataOf (part of KTX) converts a list of pairs to a [Data] object.
val imageData = workDataOf(Constants.KEY_IMAGE_URI to imageUriString)

val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
        .setInputData(imageData)
        .build()

이 WorkRequest는 ImageData:Data 객체를 입력받아 가능한 한 빨리 처리하도록 합니다.

업로드 작업이 항상 즉시 실행되는 것이 아닌 디바이스에 네트워크가 연결된 경우에 실행되어야 한다고 가정해 봅시다. 우리는 Constraints 객체를 추가함으로써 이 작업을 수행할 수 있습니다.

Constraints은 다음과 같이 생성이 가능합니다:

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

다른 제약 조건들:

val constraints = Constraints.Builder()
        .setRequiresBatteryNotLow(true)
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .setRequiresCharging(true)
        .setRequiresStorageNotLow(true)
        .setRequiresDeviceIdle(true)
        .build()

마지막으로 Result.retry()를 기억하십니까? 앞서 Worker가 Result.retry()를 반환하면 WorkManager는 작업 일정을 다시 잡을 것이라고 말한 적이 있습니다. 새 WorkRequest를 만들 때 사용자 정의로 백오프 기준 정할 수 있고 이를 통해 작업을 다시 시도할 시기를 정의할 수 있습니다.

backoff의 기준은 두 가지 속성에 의해 정의되어야 합니다:

  • BackoffPolicy: 기본적으로 지수 형태지만 선형으로 설정될 수도 있음 (무슨 소린지 모르겠음)
  • Duration: 기본은 30초

아래는 업로드 작업을 순차적으로 구현한 것과 제약 조건, 입력 및 커스텀한 back-off 정책을 조합한 코드입니다:

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

// Define the input
val imageData = workDataOf(Constants.KEY_IMAGE_URI to imageUriString)

// Bring it all together by creating the WorkRequest; this also sets the back off criteria
val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
        .setInputData(imageData)
        .setConstraints(constraints)        
        .setBackoffCriteria(
                BackoffPolicy.LINEAR, 
                OneTimeWorkRequest.MIN_BACKOFF_MILLIS, 
                TimeUnit.MILLISECONDS)
        .build()

 

작업 실행

여기까지 모두 잘 진행했지만, 아직 실제로 작업을 실행하도록 스케줄 하지는 않았습니다. 다음은 WorkManager에 작업을 스케줄 하도록 하는 한 줄의 코드입니다.

WorkManager.getInstance().enqueue(uploadWorkRequest)

먼저 작업 실행을 담당할 WorkManager 인스턴스를 싱글톤으로 가져와야 합니다. enqueue 호출은 WorkManager가 작업을 추적하고 스케줄링하는 전체적인 프로세스를 시작하는 것입니다.

 

뒤에서 어떻게 실행되고 있는가?

그렇다면 WorkManager가 여러분들에게 어떤 작업을 해주길 바라시나요? 기본적으로는 다음과 같이 수행합니다:

  • 메인 스레드에서 작업이 실행됩니다. (위에 UploadWorker에서 보여준 것처럼 Worker클래스를 확장한다고 가정)
  • 작업이 실행될 것을 보장(장치나 앱이 다시 실행된다고 해도 작업을 잊지 않고 실행함)
  • 유저 API 레벨에 따라 가장 좋은 방법으로 수행될 것입니다. (이전 게시물에서 설명했음)

WorkManager가 메인 스레드에서 작업을 실행하고 실행을 보장하는 방법에 대해 알아보겠습니다. 뒤에서 WorkManager는 다음과 같이 수행됩니다.

  • Internal TaskExecutor: 작업을 순차적으로 처리하기 위한 요청을 처리하는 단일 스레드 실행자. Executor에 대해 잘 모르는 경우 여기에서 Executor에 대한 자세한 내용을 읽을 수 있습니다.
  • WorkManager database: 모든 작업의 모든 정보와 상태를 추적하는 로컬 데이터베이스입니다. 여기에는 작업의 현재 상태, 작업에 대한 입력 및 출력, 작업에 대한 제약 등이 포함되어 있습니다. 이 데이터베이스는 WorkManager가 작업을 완료할 수 있도록 보장합니다. 사용자의 장치가 다시 시작되고 작업이 중단되는 경우 작업의 모든 세부 정보를 데이터베이스에서 가져올 수 있으며 장치가 다시 부팅될 때 작업을 다시 시작할 수 있습니다.
  • WorkerFactory:** Worker들을 만들어 주는 기본 공장입니다. 이를 구성하는 이유와 방법에 대해서는 향후 게시물에서 설명하겠습니다. ****
  • Default Executor**: 달리 지정하지 않은 한 작업을 실행하는 기본 실행자입니다. 이렇게 하면 기본적으로 작업이 메인 스레드에서 동기적으로 실행됩니다.

이러한 부분은 서로 다른 동작을 수행하도록 재 정의할 수 있습니다.

Credit: Working with WorkManager Presentation Android Developer Summit 2018

WorkRequest를 순차 실행 시킬 때:

  1. Internal TaskExecutor는 바로 WorkRequest 정보를 WorkManager 데이터 베이스에 저장합니다.
  2. WorkRequest에 대한 Constraint가 충족되면(즉시 실행될 수 있는) Internal TaskExecutor는 WorkFactory에 Worker를 생성하라고 요청할 것입니다.
  3. 그러면 기본 실행자는 메인 스레드에서 Worker의 doWork() 함수를 호출할 것입니다.

이 방법을 따르면 기본적으로 여러분의 작업이 메인 스레드에서 수행하도록 보장해줄 수 있습니다.

만약에 Executor 외의 다른 메커니즘으로 작업을 실행하려면 그렇게도 할 수 있습니다!!

작업을 위한 수단으로 코루틴(CoroutineWorker)이나 RxJava(RxWorker)를 사용한 방법들이 있습니다. 또한 ListenableWorker를 사용함으로써 작업에 어떻게 수행되어야 할지 구체적으로 명시할 수도 있습니다. 실제로 Worker는 기본 Executor에서 작업을 실행해 동기화하는 ListenableWorker를 구현한 것입니다.

따라서 작업의 스레드 전략을 완전히 제어하거나 비동기로 작업을 실행하려면 ListenableWorker를 하위 클래스로 분류해서 작업하면 됩니다. (자세한 내용은 나중에 게시물에서 설명하겠습니다.)

WorkManager가 모든 작업들의 정보를 데이터베이스에 저장하는 데 어려움을 겪는 사실은 실행을 보장해야 하는 작업에 완벽히 적합합니다. 이는 WorkManager가 실행이 보증되지 않고 단지 백그라운드 스레드에서 실행되는 작업에 대해 과도하게 제거하는 이유입니다. 예를 들어 이미지를 다운로드 했으며 이미지를 기반으로 UI의 일 부분 색상을 변경하려고 한다고 가정해봅시다. 이 작업은 메인 쓰레드에서 처리되어야만 하지만 UI와 직접적인 연관이 있기 때문에 앱이 종료된다면 더 이상 필요가 없어지게 됩니다. 그래서 이 경우에 WorkManager를 사용하지 않아도 됩니다. Kotlin coroutines를 사용하거나 Executor를 따로 생성해서 처리하는 게 보다 가볍습니다.

 

체인을 사용해 종속 작업 처리

두 개 이상의 작업을 처리하는 필터 예제는 다수의 이미지를 필터링 한 뒤에 압축하고 업로드하는 작업이 포함되어 있습니다. 일련의 WorkRequest들을 차례대로 실행하거나 병렬로 실행하려면 chain을 사용할 수 있습니다. 아래 다이어그램은 세 가지 필터가 병렬로 동작하고 그 뒤에 압축 작업, 업로드 작업의 순으로 연결된 것을 보여 줍니다.

위 작업은 WorkManager를 사용하면 굉장히 쉽게 할 수 있습니다. 적절한 제약 조건으로 모든 WorkReqeust들을 작성했다고 가정하면 코드는 다음과 같습니다:

WorkManager.getInstance()
    .beginWith(Arrays.asList(
                             filterImageOneWorkRequest, 
                             filterImageTwoWorkRequest, 
                             filterImageThreeWorkRequest))
    .then(compressWorkRequest)
    .then(uploadWorkRequest)
    .enqueue()

세 개의 이미지 필터 WorkRequest 들은 병렬로 처리될 것입니다. 필터 WorkRequest들이 모두 끝나고 나면(세 개의 필터 처리가 모두 끝난 경우) compressWorkRequest가 수행될 것이고 그 후에 uploadWorkRequest가 처리될 것 입니다.

체인의 또 다른 깔끔한 기능은 하나의 출력 WorkRequest가 다음 WorkRequest의 입력 값으로 전달된다는 것 입니다. 위의 UploadWorker 예제에서 했듯이 입출력 데이터가 정상적이라고 가정하면 이 값들은 자동으로 전달될 것입니다.

병렬로 실행되는 세 가지 필터 작업 요청의 출력을 처리하려면 InputMerger를 사용하면 되는데, ArrayCreatingInputMerger를 사용할 수도 있습니다. 다음과 같습니다:

val compressWorkRequest = OneTimeWorkRequestBuilder<CompressWorker>()
        .setInputMerger(ArrayCreatingInputMerger::class.java)
        .setConstraints(constraints)
        .build()

InputMerger는 병렬로 실행된 세 개의 필터링 요청들에 의해서가 아닌 compressWorkRequest에 의해 추가된다는 것을 알아야 합니다.

필터 작업 요청들의 각 각의 출력이 이미지 URI를 매핑하기 위한 “KEY_IMAGE_URI” 키라고 가정해 봅시다. ArrayCreatingInputMerger를 추가하면 병렬로 실행된 요청들에서 출력 값을 가져와 출력 값과 매칭되는 키들이 존재하면 단일 키에 모든 출력 값을 배열로 만들어 줍니다. 아래처럼 진행됩니다:

A visual of what an ArrayCreatingInputMerger does

그래서 compressWorkRequest에 입력은 “KEY_IMAGE_URI” 키 값으로 매핑된 필터 된 이미지 URI 배열의 쌍이 됩니다.

 

WorkRequest 상태를 관찰

작업을 관찰하기 가장 쉬운 방법은 LiveData를 사용하면 됩니다. 만약 여러분이 LiveData를 많이 써보지 않았다면 lifecycle-aware를 관찰 가능한 데이터 홀더라고 생각하시면 됩니다. 자세한 내용은 여기를 읽어보세요

getWorkInfoByIdLiveData를 호출하면 WorkInfo의 LiveData를 리턴해 줄 겁니다. WorkInfo에는 출력 데이터와 작업 상태를 나타내는 enum이 포함되어 있습니다. 작업이 성공적으로 끝났을 때, 이것의 State는 SUCCEEDED입니다. 그래서 아래 코드처럼 작업이 끝나면 자동적으로 이미지를 보여 줄 수 있습니다.

// In your UI (activity, fragment, etc)
WorkManager.getInstance().getWorkInfoByIdLiveData(uploadWorkRequest.id)
        .observe(lifecycleOwner, Observer { workInfo ->
            // Check if the current work's state is "successfully finished"
            if (workInfo != null && workInfo.state == WorkInfo.State.SUCCEEDED) {
                displayImage(workInfo.outputData.getString(KEY_IMAGE_URI))
            }
        })

몇 가지를 주목:

  • 각 각의 WorkRequest는 unique_id를 갖고 있고 이 아이디는 관련된 WorkInfo를 찾는 한 가지 방법일 겁니다.
  • WorkInfo가 변경된 것을 알아차리고 관찰하기 위한 능력은 LiveData에 의해 제공되는 특징입니다.

작업에는 여러 상태들로 대표되는 생명 주기를 갖고 있습니다. LiveData<WorkInfo>를 관찰하고 있는 경우 아래 예제와 같은 상태들을 볼 수 있습니다:

The “happy path” or work States

작업이 진행되는 happy path 상태:

  1. BLOCKED: 이 상태는 작업이 chain 안에 있고 그다음 작업은 chain에 있지 않은 경우에만 발생합니다.
  2. ENQUEUED: 작업이 다음 작업 체인에 포함되어 실행할 수 있는 자격을 갖추는 즉시 작업이 이 상태로 전환됩니다. 이 작업은 아마 제약이 충족되기를 기다리는 중일 수도 있습니다.
  3. RUNNING: 이 상태에서는 작업이 실제로 수행되는 중입니다. Worker들은 doWork() 함수가 호출된 상태로 보면 됩니다.
  4. SUCCEEDED: 작업이 마무리 단계에 진입해 doWork() 함수에서 Result.success()를 반환한 상태입니다.

지금 작업이 RUNNING일 때, Result.retry()를 호출할 수도 있습니다. 이것은 작업을 ENQUEUED로 되돌릴 겁니다. 이 작업은 언제라도 CANCELLED로 넘길 수 있습니다.

만약 작업 결과가 Result.success()가 아닌 Result.failure()를 반환한 경우 상태는 FAILED로 끝날 겁니다. 아래 전체 플로우 차트가 있습니다.

(Credit: Working with WorkManager Presentation Android Developer Summit 2018)

 

훌륭한 영상 설명이 있으니 WorkManager Android Developer Summit talk를 확인해 보세요.

 

결론

이 게시물에서 WorkManager API의 기초를 설명했습니다. 지금까지 다룬 부분을 사용하면 다음과 같은 것들을 처리할 수 있습니다.

  • 입출력을 처리하는 Worker들을 생성
  • 어떻게 Worker들이 실행될지 구성, WorkRequest, Constraint를 사용하고 Input, Back Off 정책들을 이용
  • WorkRequest들을 순서화
  • WorkManager 가 내부적으로 어떻게 동작하는지, 기본적으로 쓰레딩을 고려하고 수행을 보장해준다는 것을 이해
  • 상호 의존 적인 작업의 복잡한 연결들을 생성, 순차적이기도 하고 병렬로 처리되기도 하는 경우
  • WorkRequest들의 상태를 WorkInfo를 이용해 관찰

WorkManager를 이용한 프로젝트를 스스로 만들어보기 원하시나요? codelab을 확인해 보세요. 거기에 KotlinJava를 이용한 예제가 있을 겁니다.

이 시리즈를 계속 진행하면 WorkManager 주제에 대한 더 많은 블로그 게시물을 확인하실 수 있습니다. 저희가 다뤄줬으면 하는 질문이나 뭐가 있나요? 댓글로 알려 주세요!

Thanks to Pietro Maggi.

 

WorkManager의 출처

반응형