2018년 6월 20일 수요일

[안드로이드] WorkManager에 관하여

Services. The life with/without. And WorkManager.

백그라운드를 다루는 안드로이드의 새 버전들의 출시로 인해, 백그라운드 다루기는 이전보다 더 복잡해졌습니다. 그래서 구글은 JetPack의 일부로, 이러한 백그라운드 다루는 작업을 도와주기 위해 WorkManager를 출시했습니다.
WorkManager가 무엇인지 배우기 전에,이것이 왜 만들어지게 되었고, 왜 필요한지를 알 필요가 있습니다. 어떤 컴포넌트 / 라이브러리든지 그 내부에서 어떤일이 일어나고 있고, 왜 그것을 사용해야 하는지 정확히 아는 개발자가 정말 좋은 개발자입니다.
이 글은 3개의 파트로 구성됩니다.
  1. 메모리 기본
  2. 현재 존재하는 백그라운드 방법
  3. WorkManager
먼저, 백그라운드에 관련된 것을 시작하기 전에, 우리는 안드로이드 프로세스의 메모리 관리의 기본에 대해 이해할 필요가 있습니다. 바로 이것이 첫번째 파트가 될 것입니다.

1. 메모리 기본

아주 오래전에, 안드로이드 커널은 리눅스 커널을 기반으로 해서 개발되었습니다. 안드로이드 커널과 모든 리눅스 커널들의 가장 핵심이 되는 차이점은 바로 스왑공간(Swap Space)가 없다는 것입니다.
리눅스의 스왑공간은 램이 꽉 찼을 때 사용됩니다. 시스템은 더 많은 메모리 리소스를 필요로 하지만 램이 꽉 찼을 때, 메모리상의 비활성 페이지를 스왑공간으로 이동시킵니다. 스왑공간은 램의 용량이 작은 디바이스에 도움이 되긴 하지만, 램의 용량을 늘리는것을 대체할 수는 없습니다.스왑공간은 램보다도 엑세스타임이 느린 하드 드라이브에 위치하기 때문입니다.


안드로이드에서는, 스왑공간 같은 것은 없습니다. 시스템의 메모리가 다 소진되었을 때, OOM 킬러를 이용해 프로세스를 강제종료 시켜버립니다. OOM 킬러는 Visible 상태와 소모된 메모리의 양에 기반하여 프로세시를 정리하여 여유 메모리를 확보합니다.
모든 프로세스는 엑티비티 매니저가 부여한 자신의 oom_adj 점수를 가지고 있습니다. 이 점수는 애플리케이션의 상태(Foreground, Background, Background Service 등등...)의 조합입니다. 다음은 모든 oom_adj값을 보여주고 있습니다.
# Define the oom_adj values for the classes of processes that can be
# killed by the kernel.  These are used in ActivityManagerService.
    setprop ro.FOREGROUND_APP_ADJ 0
    setprop ro.VISIBLE_APP_ADJ 1
    setprop ro.SECONDARY_SERVER_ADJ 2
    setprop ro.BACKUP_APP_ADJ 2
    setprop ro.HOME_APP_ADJ 4
    setprop ro.HIDDEN_APP_MIN_ADJ 7
    setprop ro.CONTENT_PROVIDER_ADJ 14
    setprop ro.EMPTY_APP_ADJ 15
프로세스의 oom_adj의 값이 클수록 커널의 OOM 킬러에게 정리당하기 쉽습니다. OOM킬러는 현재 사용가능한 여유메모리 크기와 oom_adj 임계값을 기반으로 구성한 규칙을 사용합니다. 즉, OOM 킬러의 조건은 다음과 같습니다.
여유 메모리의 공간이 X 보다 작을 때, oom_adj값이 Y보다 큰 프로세스를 정리하라!




즉, 가장 중요한 내용은, 앱이 메모리를 적게 소비할수록, 프로세스가 정리되지 않고 중요한 내용을 다룰 기회가 더 많아진다는 것입니다. 그리고, 두번째로 중요한 내용은 애플리케이션의 상태에 대해 이해하는 것입니다. 그래서, 앱이 백그라운드에 진입했을 때에도 뭔가를 지속적으로 하고싶으면, 서비스 컴포넌트를 사용해야 합니다.
  • 서비스는 UI를 제공하지 않고, 백그라운드에서 오래 걸리는 동작을 수행할 수 있도록 하는 앱 컴포넌트입니다.
다음과 같이 서비스를 사용해야 했던 몇가지 이유가 있습니다.
  1. 시스템에게 이 프로세스가 오래 걸리는 작업이 있음을 알려주고, 그에 맞는 oom_adj 점수를 얻도록 하기 위함입니다.
  2. 안드로이드 애플리케이션의 4대 진입점 중 하나입니다.
  3. 서비스를 별개의 프로세스에서 실행시킬 수 있습니다.
그러나!! 서비스를 사용하는 것의 단점이 있습니다. 프로세스가 계속 실행되고 있는 것이므로 배터리를 엄청나게 소비합니다.
아마 모든 개발자들이 아무런 제한없이 백그라운드에서, 원하는 모든 동작들을 수행했을 것입니다. 그래서, 구글은 마시멜로에서 처음 도즈(Doze)모드를 도입하고 누가에서 더 발전시켰습니다.
만약 도즈모드에 익숙하지 않다면, 반드시 익숙해지길 권합니다. 유저가 디바이스의 스크린을 끄고나면, 도즈모드가 시작되어 네트워크 통신, Sync, GPS, 알람, 와이파이 스캔 등을 비활성화 시켜버립니다. 이 도즈모드는 사용자가 스크린을 켜거나, 디바이스를 충전기에 연결할 때까지 유지됩니다. 중요하지 않은 일을 수행하는 앱의 개수를 줄임으로써 디바이스의 배터리를 절약하도록 합니다.
구글은 도즈모드를 통해 시작한 백그라운드 제한을 오레오에서 더더욱 강화했습니다. API 26으로 타게팅된 앱이 백그라운드 서비스 생성을 허가받지 않은 채로 startService() 메서드를 호출하려고 하면 IllegalStateException 예외를 던집니다.
혹자는 API 26으로 타게팅을 안하면 된다고 생각할 수도 있지만, 구글에서 다음과 같은 정책을 발표했습니다. (정책확인) 요약하자면 다음과 같습니다.
  • 2018년 8월 : 새로 출시되는 앱들은 반드시 API 26(Oreo 8.0) 이상
  • 2018년 11월 : 기존 앱들도 API 26(Oreo 8.0) 이상
  • 2019년 이후 : 매년 targetSdkVersion 요구사항이 향상될 것입니다. 안드로이드가 매년 새로운 버전을 낼 때마다, 모든 앱들은 해당 API 레벨 이상을 타겟팅해야 합니다.
이를 통해 알 수 있는 것은....
백그라운드에서 오랜시간 동작할 수 있다는 서비스의 기본목적을 수행하는 것이 허용되지 않게 되었다. 그러므로, 백그라운드 작업을 위해 더 이상 서비스를 사용하지 않게 될 것이다.
서비스를 대체하여 백그라운드에서 동작을 수행하려면, job API를 사용해야 합니다.

2. 현재 존재하는 백그라운드 방법

약간의 KB를 다운로드하는 간단한 네트워크 예제를 살펴봅시다.
가장 직접적인 방법(그리고 부정확한...)은 해당 동작을 수행하는 별도의 스레드를 가지는 것입니다.
int threads = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(threads);
executor.submit(myWork);
어떤 유저가 매우 느린 3G환경을 가지고 있고, 엘리베이터를 타면서 로그인 동작을 수행했다고 생각해 봅시다. 그리고, 네트워크 작업 중에 유저가 전화를 받게됩니다.
OkHttp의 기본 타임아웃은 꽤 큰 편입니다.
  • connectTimeout = 10_000;
  • readTimeout = 10_000;
  • writeTimeout = 10_000;
게다가, 기본적으로 네트워크의 재시도 회수는 3번입니다.
그러므로, 가장 최악의 경우 90초 (30 * 3)가 걸리게 됩니다.
자 그러면 다음의 문제를 생각해 봅시다.

로그인은 성공했을까요???

앱이 백그라운드에 있는 동안, 우리는 모릅니다. 우리가 위에서 살펴봤듯이, 프로세스가 계속 살아있어서 로그인 요청에 대한 응답을 받아서 유저정보를 잘 저장했을지를 보장할 수 없습니다. 혹, 사용자의 전화가 오프라인 상태가 되어 인터넷 연결이 끊길 수도 있습니다
유저 입장에서, "나는 정보를 입력하고 버튼을 눌렀으니 로그인이 잘 되었을 꺼야" 라고 생각할 수 있습니다. 그렇지만 만약에 로그인이 되어있지 않다면, 유저는 앱의 안좋은 UX를 탓하게 될 것입니다. 그러나, 이는 UX의 문제가 아니라 기술적인 문제입니다.
그래서 JobScheduler가 이 상황을 해결하기 위해 등장했습니다.
ComponentName service = new ComponentName(this, MyJobService.class);
JobScheduler mJobScheduler = (JobScheduler)getSystemService(Context.JOB_SCHEDULER_SERVICE);
JobInfo.Builder builder = new JobInfo.Builder(jobId, serviceComponent)
 .setRequiredNetworkType(jobInfoNetworkType)
 .setRequiresCharging(false)
 .setRequiresDeviceIdle(false)
 .setExtras(extras).build();
mJobScheduler.schedule(jobInfo);
JobScheduler는 시작된 Job을 스케쥴링합니다. 적땅한 시간이 되면, 시스템은 MyJobService를 시작하고, onStartJob() 메서드 안의 내용들을 수행할 것입니다. 꽤 좋은 이론이지만, JobScheduler는 최소 API21 이상에서만 사용할 수 있습니다. 게다가... API 21, 22에서의 JobScheduler는 버그덩어리 컴포넌트 였습니다. 즉, JobScheduler를 제대로 사용하려면 최소 API 레벨은 23 이여야 한다는 것입니다.
만약 API21, 22에서도 제대로 사용하려면 JobDispatcher를 사용할 수 있습니다.
Job myJob = firebaseJobDispatcher.newJobBuilder()
 .setService(SmartService.class)
 .setTag(SmartService.LOCATION_SMART_JOB)
 .setReplaceCurrent(false)
 .setConstraints(ON_ANY_NETWORK)
 .build();
firebaseJobDispatcher.mustSchedule(myJob);
그러나.... 이것은 구글플레이서비스에 의존성을 가지고 있습니다. 그러므로, 아마존기반의 디바이스, 중극 제조사에서는 사용할 수 없는 방법입니다. 그러므로 JobDispatcher도 좋은 선택은 아닙니다. 그러면, 오레오 이전 디바이스에서, 기존 서비스의 이점을을 취하려면 어떻게 해야 할까요??
바로 서포트 라이브러리의 JobIntentService를 이용하면 됩니다. 이 API는 기존의 인텐트서비스를 job으로 실행할 수 있습니다. 그러나, 이것도 오레오에서 가능한 빨리 호출을 수행하는데 도움이 되지 않습니다. 그러므로, 다시 원점으로 돌아갑니다. 현재 실행중인 기기의 안드로이드 버전에 따른 관리, 백그라운드에서 작업을 실행하고, 적절한 스케쥴러에게 작업 등록 등...

3. WorkManager

위의 내용을 종합했을 때, 디바이스의 안드로이드 버전 / 구글플레이서비스의 여부에 따라 다른 해결책들이 존재합니다. 그러므로, 여러분은 이걸 상황에 맞게 모두 구현해야 한다는 고민에 빠지게 될 것입니다. 안드로이드 팀은 이러한 불만사항을 수용하여 Google I/O에서 WorkManager라는 해결책을 제시했습니다.
WorkManager는 시스템 기반의 백그라운드 프로세싱 API를 제공하여, 구현을 단순화하도록 도와줍니다. 이 클래스는 앱이 포그라운드에 없어도, 백그라운드 Job에서 작업들이 수행되도록 합니다. JobScheduler나 Firebase의 JobDispatcher를 사용할 수 있다면 사용하고, 앱이 포그라운드라면 그 프로세스에서 가급적 처리할 수 있도록 합니다. 개발자가 백그라운드 처리과정을 깊게 신경쓰지 않아도 되도록 간단하게 래핑해놓았습니다.
WorkManager 라이브러리는 몇개의 컴포넌트를 가지고 있습니다.
  • WorkManager : 인수 / 제약조건을 가진 작업을 받아서, 큐에 추가합니다.
  • Worker : 백그라운드 스레드에서 동작하는 doWork() 메서드 하나만 가지고 있습니다. 모든 백그라운드 태스크들은 이 메서드 안에서 수행되어야 합니다. 가능한한 심플하도록 구성해야 합니다.
  • WorkRequest : Worker가 어떤 인수와 제약조건(ex : 인터넷, 충전 등)와 함께 큐에 추가되어야할지를 명시하는 역할입니다.
  • WorkResult : 성공, 실패, 재시도
  • Data : Worker로 주고받는 영구적인 키-벨류 쌍의 값 입니다.
먼저, Worker 클래스를 상속받아 새로운 클래스를 만들고 doWork()메서드를 구현합니다.
public class LocationUploadWorker extends Worker {
    ...
     //Upload last passed location to the server
    public WorkerResult doWork() {
        ServerReport serverReport = new ServerReport(getInputData().getDouble(LOCATION_LONG, 0),
                getInputData().getDouble(LOCATION_LAT, 0), getInputData().getLong(LOCATION_TIME,
                0));
        FirebaseDatabase database = FirebaseDatabase.getInstance();
        DatabaseReference myRef =
                database.getReference("WorkerReport v" + android.os.Build.VERSION.SDK_INT);
        myRef.push().setValue(serverReport);
        return WorkerResult.SUCCESS;
    }
}
다음으로, 생성한 Worker를 실행하기 위해 WorkManager를 호출합니다.
Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType
            .CONNECTED).build();
Data inputData = new Data.Builder()
            .putDouble(LocationUploadWorker.LOCATION_LAT, location.getLatitude())
            .putDouble(LocationUploadWorker.LOCATION_LONG, location.getLongitude())
            .putLong(LocationUploadWorker.LOCATION_TIME, location.getTime())
            .build();
OneTimeWorkRequest uploadWork = new OneTimeWorkRequest.Builder(LocationUploadWorker.class)
            .setConstraints(constraints).setInputData(inputData).build();
WorkManager.getInstance().enqueue(uploadWork);
WorkManager가 남은 과정들을 모두 처리할 것입니다. WorkManager는 가장 최적의 스케쥴을 선택하여 작업을 큐에 추가할 것입니다. 또한, 작업의 인수, 세부사항, 변경되는 상태를 저장할 것입니다. 심지어, LiveData를 이용하여 작업상태를 구독할 수도 있습니다.
WorkManager.getInstance().getStatusById(locationWork.getId()).observe(this,
        workStatus -> {
    if(workStatus!=null && workStatus.getState().isFinished()){
         ...
    }
});
WorkManager 라이브러리의 구조는 다음의 그림을 참고할 수 있습니다.


  1. 백그라운드로 수행할 작업을 Worker의 doWork() 메서드를 구현하여 정의합니다.
    2.생성한 Worker 인스턴스를 가지고 WorkRequest를 생성합니다. 필요하다면WorkRequest를 생성할 때 DataConstraints를 추가합니다.
  2. WorkManager의 enqueue() 메서드를 이용해 생성한 WorkRequest를 큐에 추가합니다. 이 떄, 내부적으로 Room에 작업을 저장합니다.
  3. 내부적으로 적절한 스케쥴러(JobScheduler, JobDispatcher, Executor, AlarmManager)중 하나를 골라 doWork() 메서드를 호출합니다.
  4. 작업의 결과는 LiveData를 이용해 구독할 수 있고, 해당 작업의 산출물은 Data로 이용할 수 있습니다.
이것은 어디까지나 기본동작으로, 여러 추가기능들이 있습니다. 이 추가기능을 따로 써도 되고, 조합해서도 쓸 수 있습니다.
  • 주기적으로 실행하기
Constraints constraints = new Constraints.Builder().setRequiredNetworkType
        (NetworkType.CONNECTED).build();
PeriodicWorkRequest locationWork = new PeriodicWorkRequest.Builder(LocationWork
        .class, 15, TimeUnit.MINUTES).addTag(LocationWork.TAG)
        .setConstraints(constraints).build();
WorkManager.getInstance().enqueue(locationWork);
  • 두개 이상의 job을 순차적으로 실행하기
WorkManager.getInstance(this)
        .beginWith(Work.from(LocationWork.class))
        .then(Work.from(LocationUploadWorker.class))
        .enqueue();
  • 두개 이상의 job을 병렬로 실행하기
WorkManager.getInstance(this).enqueue(Work.from(LocationWork.class,  LocationUploadWorker.class));
  • Note : 주기적 job과 일회성 job을 함께 조합할 수 없습니다.

이 외에도 WorkManager를 이용해 할 수 있는 많은 상황들이 있습니다. Work 취소하기, 결합하기, 연결하기, 한 Work의 Data를 다른 Work로 합치기 등등... 이 다른 기능들을 더 확인하려면 문서를 찾아보길 권합니다.

원문

https://medium.com/google-developer-experts/services-the-life-with-without-and-worker-6933111d62a6

댓글 없음:

댓글 쓰기