Android O에서의 백그라운드 처리를 위한 JobIntentService

안드로이드의 가장 큰 장점 중 하나는 개발자가 앱 실행 여부와 관계없이 백그라운드에서 시스템 자원을 자유롭게 사용할 수 있다는 점입니다. 그러나 이는 때로 시스템 리소스를 과도하게 소모하는 지름길이 되기도 합니다.

이러한 문제를 해결하기 위해 지난 몇년 간 구글은 안드로이드에서의 시스템 리소스의 소모를 줄이기 위한 여러가지 작업들을 진행해왔습니다. 이러한 작업으로 DozeApp Standby 같이 사용자가 쉽게 인지하지 못하는 백그라운드에서의 동작을 제한하고 있습니다. 또 시스템 메뉴를 통해 사용자가 손쉽게 디바이스에서 앱이 사용한 리소스 수준을 알 수 있는 기능을 제공하고 있어 배터리나 네트워크 등의 효율적 사용이 앱의 중요한 지표가 되어가고 있습니다.

Android Nougat에 포함된 2단계의 Doze (Source: Android Developer Site)

안드로이드 O의 변경 사항은 여러가지를 들 수 있겠지만 그 중에서 가장 큰 부분은 이미 적용이 예고되었던 백그라운드 실행 제한위치 제한일 것입니다.

백그라운드 실행 제한을 알아보자

이번 글에서는 백그라운드 실행 제한에 해당하는 백그라운드 서비스 실행의 제한암시적(Implicit) 브로드캐스트 인텐트의 제한과 이를 해결하는 방안에 대해서 알아보도록 하겠습니다.

백그라운드 서비스 실행 제한

서두에서 말한 바와 같이 백그라운드 서비스는 원하는 시점에 작업을 실행할 수 있지만 사용자가 알아채기 어렵다는 문제가 있습니다. 사용자 입장에서 이야기하자면, 사용자가 앱을 사용하지 않는 시점에도 지속적으로 리소스를 사용하기 때문에 문제가 될 수 있습니다.

Note: 게임은 배터리나 네트워크, 메모리 등을 크게 소모하는 대표적인 장르지만, 사용자들은 이러한 소모가 게임 플레이로부터 일어난다는 사실을 학습하여 알고 있으므로, 이를 문제삼지 않습니다. 하지만 플레이하지 않은 게임이 그러하다면 어떨까요? 이것이 백그라운드에서의 시스템 리소스에 대한 UX라고 할 수 있습니다.
검색해보면 정말 많은 이들이 배터리 소모량에 대해 관심이 많다는 것을 알 수 있습니다.

안드로이드 O에서는 앱이 백그라운드에 진입하게 되면 몇분 뒤 동작 중인 백그라운드 서비스는 자동으로 중지되며 onDestroy()가 호출됩니다. 더하여 백그라운드 상태에서 서비스를 구동하기 위한 startService()의 호출은 IllegalStateException이 발생하며 허용되지 않습니다.

암시적 브로드캐스트 인텐트 제한

암시적 브로드캐스트 인텐트(Implicit Broadcast Intent)란 특정 앱을 대상으로 하지 않는 브로드캐스트 인텐트입니다. 이는 전달대상을 한정하지 않으므로 문제가 됩니다. 이를테면 차량을 빼달라고 하기 위해 아파트 전체에 방송을 하는 것과 마찬가지입니다.

“저기요. 차 빼달래요.” — Implicit Broadcast의 경우 절대로 이런 점잖은 상황은 아닙니다.

이러한 암시적 브로드캐스트 인텐트는 AndroidManifest.xml에 이를 기술하고 있는 모든 Receiver나 Service를 깨울 수 있습니다. 비활성되어 있는 앱 컴포넌트는 처리를 위해 로딩되어야 하고 실행되어야 하며, 이는 분명히 리소스를 소모하는 동작이므로 문제가 될 수 있습니다.

안드로이드 N에서는 이를 줄이기 위해 특정 인텐트들에 대한 동작을 제한하는 기능을 소개했습니다. 올해 소개된 안드로이드 O는 앱 매니페스트에 있는 암시적 브로드캐스트 수신자를 등록할 수 없도록 하는 더 적극적인 제한을 통해 리소스의 사용을 제한합니다.

targetSDK ≥ 26

이러한 백그라운드 실행 제한은 target SDK가 Android O(API Level 26) 이상인 경우에 적용됩니다. 안드로이드 N 이하 버전을 타겟으로 할 경우 제한없이 서비스 실행이 가능합니다.

하지만, 앱 정보에서 사용자가 target SDK와 관계없이 백그라운드 실행 제한을 적용할 수 있는 옵션을 제공하고 있으므로 단순히 targetSDK 버전에 따른 코드 구현 형태로는 안심할 수는 없습니다.

Note: 즉, target SDK를 Android N 이하로 설정한 상태에서도 사용자 설정에 따라 startService는 IllegalStateException 예외를 발생할 수 있으며, 브로드캐스트 인텐트가 전달되지 않을 수도 있음을 주의해야 합니다. 이에 대한 더 자세한 내용은 여기를 참조합니다.

그럼에도 불구하고 가능한 것들

앞에서 앱이 백그라운드에 위치한 상태에서 백그라운드 서비스는 예외를 발생하며, 일부 암시적 브로드캐스트 인텐트는 AndroidManifest.xml에 인텐트 필터를 기술하는 형식으로는 수신되지 않음을 이야기 했습니다. 그렇다면 이쯤에서 나올 질문이 있습니다.

“그럼 되는 것은 뭔가요?”

되는 기능들 역시 여전히 많습니다.

포그라운드 서비스의 실행

포그라운드 서비스는 제한없이 사용 가능합니다. 그러나 애초에 백그라운드 상태에서 서비스를 시작할 수 없기 때문에 서비스 시작 후 이를 포그라운드 서비스로 설정하는 것은 불가능한 시나리오가 됩니다.

이는 API 26에 추가된 Context.startForegroundService()를 이용하여 해결 할 수 있습니다. 이 API는 모든 서비스를 제한없이 시작할 수 있게 해주지만, 5초 내에 Service.startForeground()를 통해 Notification과 연결되지 않으면 즉시 해당 서비스를 중지합니다.

미디어 플레이, 다운로드나 업로드는 대표적인 포그라운드 서비스의 사용 사례입니다.
Note: 가이드에는 NotificationManager.startServiceInForeground() API를 이용하여 포그라운드 서비스를 시작할 수 있다고 게시되어 있지만 해당 API는 Developer Preview 2에서 제거되었습니다.

사용자가 이미 기존의 백그라운드 동작을 이해할 수 있거나 적절하게 알림을 통해 전달하는 것이 무리하지 않다면, 안드로이드 O에서 포그라운드 서비스는 매우 좋은 선택이 될 수 있습니다.

암시적 브로드캐스트 예외

다행하게도 모든 암시적 브로드캐스트 인텐트가 제한되는 것은 아니며, 상당수의 필수적인 인텐트는 이전과 같은 방식으로 사용할 수 있습니다. 물론 AndroidManifest.xml에 명시적(Explicit) 브로드캐스트는 등록이 가능한 것은 당연합니다.

동적으로 등록된 리시버를 통한 암시적 브로드캐스트 수신

또한 여전히 Context.registerReceiver()를 통해 리시버를 동적으로 등록할 수 있고 이렇게 등록된 리시버는 암시적 브로드캐스트 인텐트의 수신 제한에 해당되지 않습니다.

JobScheduler를 통한 백그라운드 동작

앞에서 설명한 제한과 허용 기능 사이에서 백그라운드 동작을 생각해보면 아직 부족한 점이 있습니다. 주소록이나 사진과 같이 백그라운드에서 데이터를 동기화하는 작업이 필요한 시나리오를 완전히 배제할 수는 없기 때문입니다. 이러한 경우 JobScheduler를 통해 동작 시나리오를 보완할 수 있습니다.

JobScheduler는 무엇인가

안드로이드 롤리팝에서는 Project Volta와 함께 백그라운드 동작을 최적화하기 위한 일환으로 JobScheduler를 소개하였습니다. 이는 작업에 필요한 조건 및 인자들(JobInfo)과 해당 조건의 동작(JobService)을 등록하고, 안드로이드 프레임워크에 의해 적정한 실행 시점이 제어되는 백그라운드 실행 기능입니다.

Using the Android Job Scheduler — Google Developers

예를 들어 대용량 데이터 동기화를 위해 ACTION_POWER_CONNECTED 브로드캐스트 인텐트를 수신하고 연결 중인 네트워크 상태나 스토리지를 지속적으로 체크하여야 합니다. JobScheduler를 활용하면 이러한 조건을 가지는 JobInfo 객체를 생성/등록하여 대체할 수 있습니다.

Job 구현은 아래와 같이 3가지 작업으로 구성됩니다.

  1. JobInfo를 통해 Job이 실행될 조건을 설정하고, JobScheduler를 통해 이를 시스템에 등록합니다.
  2. JobService를 상속받아 Job 실행 시 필요한 동작을 구현합니다.
  3. Job 실행을 위한 권한(android.permission.BIND_JOB_SERVICE)를 등록합니다.

실행 조건의 설정과 등록

JobInfo는 네트워크의 연결 상태나 충전 여부, 디바이스의 유휴 시점 등 JobService가 실행되어야 하는 조건을 관리합니다. 설정 가능한 동작 조건은 다음과 같습니다.

  • 연결된 네트워크 타입
  • 충전 여부
  • 디바이스 유휴(Idle) 여부
  • 콘텐트 프로바이더의 갱신
  • 클립 데이터
  • 실행 주기
  • 최소 지연 시간
  • 데드라인 설정
  • 재시도 정책
  • 리부팅 시의 현재 조건 유지 여부

이러한 조건은 단일 조건으로 설정할 수도 있고, 여러개의 조건을 동시에 만족했을 때 Job이 시작되도록 할 수도 있습니다.

동작의 처리

Job이 수행할 동작은 JobService를 상속하여 구현합니다. JobService는 시작/종료 시의 동작을 처리할 수 있고, 필요하다면 임의의 시점에 호출하여 Job의 종료를 요청할 수 있는 쉬운 구조를 가지고 있습니다. 이는 다음과 같이 2개의 콜백과 1개의 메소드를 제공합니다.

onStartJob()

Job이 시작될 시점에 시스템에 의해 호출되는 콜백입니다. 단,JobService는 메인스레드에서 실행되므로, 이 지점에서 필요에 따라 Thread나 AsyncTask를 고려해봄직 합니다.

  • onStartJob()의 종료 시 지속할 동작(예를 들면 AsyncTask나 Thread)가 있다면 true, 여기에서 완료된 동작이라면 false를 반환합니다.
  • true를 반환할 경우 이후에 finishJob()의 호출을 통해 명시적으로 작업 종료를 선언할 수 있습니다. 이러한 경우 시스템이 필요에 따라 onStopJob()를 호출하여 작업을 중지할 수 있습니다.

onStopJob()

Job이 종료되기 이전에 취소될 필요가 있을 경우 시스템에 의해 호출되는 콜백입니다.

  • 갑작스러운 중지로 현재 실행하던 Job을 다시 스케쥴러에 등록하여 다음 기회에 실행할 필요가 있다면 true를 반환하여 이를 수행할 수 있습니다.
  • 혹은 false를 반환하여 다시 이 작업이 스케쥴링되는 것을 방지할 수 있습니다.

jobFinished()

Job이 완료되었을 때 호출하여 JobManager로 하여금 해당 Job을 종료하도록 합니다.

JobService에 대한 실행 권한 부여

정상적으로 서비스가 동작하도록 android.permission.BIND_JOB_SERVICE 권한을 AndroidManifest.xml에 부여합니다.

<service
android:name=".SyncJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"/>
JobScheduler에 대해 궁금한 내용은 Joanna Smith의 “Scheduling jobs like a pro with JobScheduler”를 참조하세요.

일반적인 고려 사항

JobScheduler는 배터리나 메모리 등의 시스템 리소스를 덜 영향을 주며 백그라운드 기능을 사용할 수 있게 하지만, 이것이 만능은 아닙니다. 이를 사용하기 전에 다음 사항들을 기억해두도록 합시다.

  • JobService는 기본적으로 메인스레드에서 동작합니다.
따라서, 비교적 실행시간이 길거나 복잡한 동작이 필요하다면, 새로운 스레드 내지는 비동기적인 동작을 구현할 수 있는 다른 방법을 취해야 합니다.
  • Job의 구분은 id를 통해 이루어집니다.
정적인 id를 사용할 것인지 동적인 id로 이를 관리할 것인지는 시나리오에 따라 다를 것입니다만, 대부분의 경우는 정적인 id로도 충분히 이를 구현할 수 있을 것입니다.
  • Job은 실행 조건을 만족하지 못하는 순간 종료됩니다.
이는 실행 중인 Job의 조건이 복잡할수록 실행과 더불어 유지도 어렵다는 뜻입니다. 배터리의 소모와 실행의 빈도 사이에서 적절한 시나리오를 결정하는 것이 중요합니다.
또한, Job이 중간에 취소되는 경우를 대비하기 위해 트랜잭션(transaction) 형태로 기능을 구현하거나 최종 진행 지점에서 재개가 가능하도록 명확한 중단/재개 시나리오를 가져야 합니다.
  • Job의 실행은 어쨌던 Wakelock을 전제로 합니다.
WakeLock 횟수와 시간은 해당 앱에 대한 배터리 소모 계산에 사용되는 항목입니다. 이는 결국 Job을 너무 자주 발생하게 한다면 사용자에게 모니터링되는 배터리 소모량에서 부정적인 경험을 초래할 수 있습니다. 네트워크나 위치에 대한 갱신 요청 역시 마찬가지입니다. (물론 위치 갱신에 대한 제한은 존재합니다만…)

가장 치명적인 문제는 하위 호환성

물론 JobService를 이용해서 백그라운드 서비스를 구현하는 것은 매우 긍정적인 접근입니다. 그러나, JobService는 기본적으로 API 레벨 21부터 지원되는 기능으로 하위 호환성에 대한 문제가 존재합니다.

특히 안드로이드 O에서 백그라운드 서비스가 원천적으로 차단될 수 있는 환경임을 감안할 때 JobScheduler는 선택이 아닌 필수 사항이 되므로 하나의 APK로 여러 플랫폼 버전들을 지원할 때는 코드 복잡도나 구현 상의 실수를 유발하게 되는 문제가 있습니다.

SupportLib: JobIntentService

Support Library에 추가된 JobIntentService는 이에 대해 보다 편리한 접근 방법을 제공합니다. 이는 안드로이드 O에서는 JobScheduler의 기능을 사용하지만, 그 미만의 플랫폼에서도 백그라운드 서비스로 동작하여 기능을 에뮬레이션하여 실행합니다. JobIntentService는 다음과 같은 특징을 가지고 있습니다.

  • 안드로이드 N 이하의 디바이스에서는 즉시 백그라운드 서비스가 시작하며 적재된 작업이 순차적으로 실행됩니다. 이를 통해 동일 코드로 하위호환성을 유지할 수 있습니다.
  • 안드로이드 O 이상의 디바이스에서는 JobScheduler를 통해 적재된 작업을 순차적으로 실행합니다. 내부적으로는 고정된 Job의 실행 조건(jobInfo.setOverrideDeadline(0).build() )에 의해 실행되므로, 이외의 조건은 직접 설정할 수 없습니다.
  • Wakelock을 알아서 관리해주기 때문에 하위 버전의 플랫폼에서 동작할 때 WakefulBroadcastReceiver 를 사용할 필요가 없습니다. 이는 실수로 인한 배터리 소모 등의 이슈를 원천적으로 해결해줍니다.
Background Check and Other Insights into the Android Operating System Framework — Google I/O ’17
Note: 안드로이드 O 이전의 디바이스에서도 사용하거나 보다 상세한 실행 조건이 요구되는 경우에는 JobIntentService 가 아니라 JobScheduler를 이용하여야 합니다. (그에 따른 코드 분기는 선물…이겠죠.)

JobIntentService의 프로젝트 적용

사용 설정

JobIntentService를 사용하기 위해서는 이전의 서포트 라이브러리와 동일하게 build.gradle 파일의 repositories 섹션에 다음과 같은 maven 설정이 포함되어야 합니다.

allprojects {
repositories {
jcenter()
maven {
url "https://maven.google.com"
}
}
}

또한, 다음과 같이 dependencies support-compat 모듈을 추가하여야 합니다. (2017/06/25 기준으로 최신 버전은 26.0.0-beta2입니다. 최신 버전에 대한 정보는 여기에서 참조하세요.)

dependencies {
...
compile 'com.android.support:support-compat:26.0.0-beta2'
}

JobIntentService는 onHandleWork()를 통해 설정된 Job이 enqueueWork()에 의해 적재된 작업들이 순차적으로 처리되는 부분에서 IntentService와 유사합니다. 단, Job의 수행 조건이 변경되는 경우 onStopCurrentWork()가 호출된다는 점은 유의하여야 합니다.

JobIntentService의 구조

JobIntentService도 다음과 같이 2개의 콜백과 1개의 메소드를 제공하는 간단한 구조를 취하고 있습니다.

enqueueWork (Context context, Class cls, int jobId, Intent work)

실행할 동작을 작업 큐에 추가합니다.

  • 안드로이드 O 이상의 플랫폼에서는 deadline이 0인 상태로 최대한 빠르게 동작할 것을 주문하지만, 단말이 doze 상태에 돌입하거나 메모리 부족 등의 이슈로 인해 실행은 임의의 시간에 시작될 수 있습니다.
  • 안드로이드 N 이하의 플랫폼에서는 startService()를 호출한 것과 동일하게 백그라운드 서비스가 시작되고 즉시 기능을 수행합니다. 이는 doze의 돌입 등과는 무관하게 동작하므로, 실행 시 Doze 등으로 인해 발생하는 네트워크 타임아웃 등은 별도의 이슈로 관리되어야 합니다.
  • 동일한 클래스(cls)를 대상으로 하는 jobId는 모두 같은 값이어야 합니다. 만약 다른 jobId를 부여하고 싶다면, 새로운 클래스로 정의하여 사용할 필요가 있습니다. 이를 지키지 않는다면 N 이하 버전에서는 아무 문제가 없어보이겠지만, 안드로이드 O 이상에서는 IllegalArgumentException이 발생할 것입니다.
  • 당연한 얘기지만 work 인자는 null일 수 없습니다. null일 경우 IllegalArgumentException가 발생합니다.
재차 말씀드리지만, JobIntentService가 안드로이드 O 이상에서 JobScheduler를 사용하지만 아래와 같이 Job의 실행 조건이 setOverrideDeadline(0) 로 고정되어 있으므로, 별도의 실행 조건이 필요할 경우에는 JobScheduler를 이용하여 처리하도록 하여야 합니다. 이는 서포트라이브러리 26.0.0-beta2에 포함된 JobIntentService의 디컴파일 코드를 보면 아래와 같이 구현되어 있음을 알 수 있습니다.
@RequiresApi(26)
static final class JobWorkEnqueuer extends JobIntentService.WorkEnqueuer {
private final JobInfo mJobInfo;
private final JobScheduler mJobScheduler;
JobWorkEnqueuer(Context context, Class cls, int jobId) {
...
JobInfo.Builder b = new JobInfo.Builder(jobId, mComponentName);
mJobInfo = b.setOverrideDeadline(0).build();
mJobScheduler = (JobScheduler) context.getApplicationContext().getSystemService(
Context.JOB_SCHEDULER_SERVICE);
}
...
}

onHandleWork(Intent intent)

작업 큐에서 dequeue한 처리 작업을 전달받는 메소드입니다.

  • enqueueWork()에 의해 적재된 인텐트는 서비스의 실행 시 이 메소드로 전달됩니다. IntentService와 마찬가지로 적재된 인텐트는 순차적으로 전달됩니다.
  • Wakelock은 첫번째 인텐트부터 최종 인텐트의 완료까지 자동으로 유지되므로, 실제 작업에 대한 관리만을 수행하면 됩니다.
  • onHandleWork()이 종료될 경우 작업 큐에 적재되어 있는 다음 작업이 이어서 전달됩니다. 이 메소드는 백그라운드 스레드에서 호출되므로, 많은 시간이 필요한 블로킹 동작들이 가능하지만, 안드로이드 O에서 JobScheduler에 의해 중지될 수 있음을 유의해야 합니다.
  • onHandleWork()의 실행 종료가 해당 작업의 종료를 의미하므로, 내부에서는 블로킹 형태로 동작하는 것을 권장합니다.
적재된 작업 큐를 처리하는 로직은 아래와 같이 AsyncTask 내에서 적재된 큐에서 작업을 dequeue하여 onHandleWork()로 전달하도록 구성되어 있습니다. 이 코드 역시 서포트라이브러리 26.0.0-beta2에 포함된 JobIntentService의 디컴파일 코드를 보면 아래와 같이 구현되어 있음을 알 수 있습니다.
final class CommandProcessor extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
GenericWorkItem work;

if (DEBUG) Log.d(TAG, "Starting to dequeue work...");

while ((work = dequeueWork()) != null) {
if (DEBUG) Log.d(TAG, "Processing next work: " + work);
onHandleWork(work.getIntent());
if (DEBUG) Log.d(TAG, "Completing work: " + work);
work.complete();
}


if (DEBUG) Log.d(TAG, "Done processing work!");

return null;
}

...
}

onStopCurrentWork()

현재 작업이 중지되어야 할 경우 호출됩니다.

  • JobService.onStopJob()과 마찬가지로 시스템에서 Job 종료 시 호출되며, 현재 처리 중인 동작들을 중지해야 합니다.
  • 갑작스러운 중지로 현재 실행하던 작업을 재실행해야 한다면 true를 반환하여 다음 기회에 해당 작업을 재시작할 수 있습니다. 만약 새로 스케쥴링을 할 필요가 없다면 false를 반환하여 작업을 종료할 수 있습니다.

JobIntentService를 통해 앨범 표지를 동기화하는 간단한 예시를 작성해보자면 아래와 같이 구성할 수 있습니다.

JobIntentService의 코드

암시적 브로드캐스트 수신과 JobIntentService

앞에서 말씀드린 바와 같이 Context.registerReceiver()를 통해 동적으로 등록된 리시버 암시적 브로드캐스트 인텐트의 수신 제한에 해당되지 않습니다.

그 외의 선택지들

Job 이외에도 적합한 선택지들은 존재합니다. 이에 대한 설명은 Google I/O 2017 “Background Check and Other Insights into the Android Operating System Framework” 세션을 살펴보시기 바랍니다.

Background Check and Other Insights into the Android Operating System Framework — Flowchart

최대한 미루고 모아서 처리하세요.

사실 JobScheduler를 이용하던 하지 않던 간에 중요한 것은 특정한 앱 기능을 사용 중이지 않을 때의 배터리나 메모리의 소모량을 사용자가 인식하는 것은 그렇게 좋은 사용자 경험이 아니라는 점입니다. 가능하면 미루고, 모아서 처리하세요. 이를 손쉽게 구현하는데에는 JobIntentService가 좋은 출발점이 되어 줄 수 있을 것입니다.

다음 편에는 Job의 Rescheduling 정책이나 메모리 등의 시스템 상태에 따른 내부 동작을 살펴보도록 하겠습니다.

이 글에 대한 이상한 점이나 오탈자, 추가 사항 등은 언제든지 댓글이나 메일로 보내주시기 바랍니다. :)