HOME > android > feature

안드로이드 - JobScheduler로 백그라운드 작업 실행하는 방법

JSFollow04 Mar 2019

안드로이드 롤리팝(5.0)의 프로젝트 볼타(Project Volta)는 디바이스는 전력 소비를 줄이기 위한 노력의 결과입니다. 볼타의 여러가지 아이디어 중에 하나가 JobScheduler입니다. JobScheduler(잡스케줄러)는 개발자가 백그라운드 작업(task)을 정의하고 언제 이 작업이 실행될지 타이밍을 정할 수 있게 도와줍니다. 이런 구조로 필요할 때만 디바이스가 깨어있게 되어 전력 소비를 줄일 수 있습니다.

예를들어, 개발자는 디바이스가 충전 중이고, IDLE 상태(유저가 폰을 만지지 않는 상태)가 되면 어떤 작업이 백그라운드에서 실행되도록 설정할 수 있습니다. 또는 24시간 주기로 이 작업이 반복적으로 실행되도록 만들 수도 있습니다. 개발자는 작업과 실행될 타이밍만 정해주면 되기 때문에 코드 양이 적어집니다. 또한 디바이스도 필요할 때만 깨어있으면(wake) 되기 때문에 전력소모를 줄일 수 있습니다.

만약 JobScheduler가 없었다면 AlarmManager로 비슷한 일을 하게 만들수도 있습니다. 하지만 JobScheduler처럼 디바이스 상태에 따라 동작(제약조건)하도록 처리할 수 없고 단순히 시간 주기만 설정할 수 있습니다.

JobScheduler는 안드로이드 프레임워크이며, 스케쥴링을 제어하는 JobScheduler와 작업을 정의하는 JobService가 있습니다. 이 객체들에 대해서 알아보고 어떻게 작업을 정의하고 실행시킬 수 있는지 알아보겠습니다.

JobScheduler

JobScheduler는 개발자가 정의한 작업을 스케쥴링해주는 서비스입니다. 이 객체에서 제공해주는 API를 사용하여 작업을 예약할 수 있습니다. 실행될 작업에 대한 구현은 JobService에 정의되어 있습니다. 그리고 작업이 언제, 어떤 상황에서 실행되어야 하는지는 JobInfo에 정의됩니다. JobInfo가 JobScheduler에 전달되면, JobScheduler는 적당한 때에 JobService를 실행시킵니다.

JobService

JobService(잡서비스)는 개발자가 작업을 정의해야 하는 클래스입니다. JobScheduler(잡스케줄러)는 예약된 작업을 실행할 때 JobService에 정의되어있는 onStartJob 함수를 호출해줍니다. 만약 어떤 이유로 작업을 중지해야 할 때 JobService에 정의되어 있는 onStopJob 함수를 호출해줍니다.

JobScheduler가 호출하는 onStartJob 등의 메소드는 모두 main thread에서 실행됩니다. 그렇기 때문에 무거운 작업을 여기서 모두 처리하면 안됩니다. 작업을 처리하는 다른 서비스나 쓰레드를 만들어 무거운 작업들을 위임해야 합니다.

JobService 구현하기

이제부터 간단한 구현을 통해 JobService와 JobScheduler에 대해서 알아보겠습니다.

완성된 예제는 GitHub에서 확인할 수 있습니다.

기본 프로젝트를 생성하고 MyJobService.kt파일을 생성합니다. 이 서비스는 JobService를 상속받는 서비스입니다.

class MyJobService : JobService() {
    companion object {
        private val TAG = "MyJobService"
    }

    override
    fun onStartJob(params: JobParameters): Boolean {
        Log.d(TAG, "onStartJob: ${params.jobId}")
        return false
    }

    override
    fun onStopJob(params: JobParameters): Boolean {
        Log.d(TAG, "onStopJob: ${params.jobId}")
        return false
    }
}

서비스를 정의했으면, AndroidManifest.xml에도 다음과 같이 정의를 해야 합니다. 중요한 것은 퍼미션에 android.permission.BIND_JOB_SERVICE를 주어야 합니다. 이 권한을 선언하지 않으면 시스템은 JobScheduler는 이 서비스를 무시합니다.

<service
    android:name=".MyJobService"
    android:permission="android.permission.BIND_JOB_SERVICE"/>

이제 JobService의 간단한 구현(스켈레톤)은 끝났습니다. 이 작업을 JobScheduler에 등록하면 설정한 시간에 실행이 됩니다.

JobInfo

JobInfo는 Job이 어떻게 실행되어야 하는지에 대한 정보가 담겨있습니다. JobInfo는 JobScheduler로 전달되고, 여기에 명시된 스펙대로 Job을 실행시킵니다.

JobInfo는 아래 코드처럼 JobInfo.Builder로 생성시킬 수 있습니다. 인자로 ID와 JobService의 ComponentName이 전달됩니다. 그리고 setMinimumLatency 처럼 실행 정보에 대한 내용을 저장합니다.

val jobInfo = JobInfo.Builder(JOB_ID_A, serviceComponent)
    .setMinimumLatency(TimeUnit.MINUTES.toMillis(1))
    .setOverrideDeadline(TimeUnit.MINUTES.toMillis(3))
    .build()

비주기적으로 스케줄링하기(1회만 실행하기)

위에서 만든 작업을 주기적으로 실행하는 방법을 알아보겠습니다. 간단히 버튼을 누르면 Job이 1분뒤 실행되도록 만들 것입니다. 먼저 MainActivity의 layout에 버튼 두개를 추가해주세요.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    <Button
        android:id="@+id/btnJob1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Scheduling Job1"/>
    <Button
        android:id="@+id/btnJob2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Scheduling Job2"/>
</LinearLayout>

버튼 btnJob1을 눌렀을 때 작업이 1분뒤 실행되도록 JobScheduler에 예약을 하겠습니다. JobInfo를 생성하고 JobScheduler.schedule API로 스케쥴링을 예약합니다.

btnJob1.setOnClickListener {
    val js = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
    val serviceComponent = ComponentName(this, MyJobService::class.java)
    val jobInfo = JobInfo.Builder(JOB_ID_A, serviceComponent)
        .setMinimumLatency(TimeUnit.MINUTES.toMillis(1))
        .setOverrideDeadline(TimeUnit.MINUTES.toMillis(3))
        .build()
    js.schedule(jobInfo)
    Log.d(TAG, "Scheduled JobA")
}

위의 코드에서 setMinimumLatency는 최소 얼마 후에 작업이 실행되어야하는지 시간을 설정하는 옵션입니다. 인자는 millisecond 단위로 입력해야 합니다. 저는 1분(60*1000)을 인자로 넣었습니다. setOverrideDeadline는 최대 넘기지 말아야 하는 deadline을 설정하는 옵션입니다. 위의 코드에는 3분을 입력하였는데, 3분 내에 실행시키라는 의미입니다.

이제 앱을 빌드하고 실행해보세요. 버튼을 누르시면 작업이 추가되고 약 1분 후 쯤 MyJobService.onStartJob가 호출되는 것을 확인할 수 있습니다. 비주기적으로 실행했기 때문에 한번 Job이 실행되고 다시 실행되지 않습니다.

2019-03-04 21:51:31.562 8134-8134/com.codechacha.jobscheduler D/MainActivity: Scheduled JobA
2019-03-04 21:52:44.190 8134-8134/com.codechacha.jobscheduler D/MyJobService: onStartJob: 100

로그의 PID를 보시면 앱의 main thread로 함수가 호출된 것을 볼 수 있습니다. 여기서는 로그만 출력하기 때문에 쓰레드를 새로만들거나, 서비스를 따로 만들지 않았습니다. 만약 무거운 작업을 처리하실 것이라면 main thread에서 처리하도록 하시면 ANR(Android Not Responding)등의 문제가 발생할 수 있습니다.

주기적으로 스케줄링하기(반복적으로 실행하기)

만약 주기적으로 작업을 실행하고 싶다면 어떻게 해야 할까요? JobInfo에 setPeriodic API로 주기를 설정해주면 됩니다.

다음 코드는 주기를 15분으로 설정한 예제입니다.

val js = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val serviceComponent = ComponentName(this, MyJobService::class.java)
val jobInfo = JobInfo.Builder(JOB_ID_A, serviceComponent)
    .setPeriodic(TimeUnit.MINUTES.toMillis(15))
    .build()
js.schedule(jobInfo)

실행해보면 주기적으로 실행이 됩니다. (간혹 오차가 있을 때가 있네요)

참고로, 안드로이드 Nougat부터 전력 소비를 줄이기 위해 15분 미만의 주기는 설정되지 않습니다. 예를 들어 1분을 설정했다면 다음 로그가 출력되면서 실행이 되지 않습니다.

03-05 21:39:42.726  5752  5752 W JobInfo : Requested interval +1m0s0ms for job 100 is too small; raising to +15m0s0ms
03-05 21:39:42.726  5752  5752 W JobInfo : Requested flex +1m0s0ms for job 100 is too small; raising to +5m0s0ms

Constraints(제약조건 설정)

위에서는 단순히 실행될 시간에 대해서 설정했지만, 충전 또는 인터넷 연결이 되었을 때 작업이 실행되도록 조건을 추가할 수도 있습니다.

아래코드는 제약조건을 추가한 JobInfo입니다. setRequiresDeviceIdle(true)을 설정하면 디바이스가 IDLE상태일 때만 실행됩니다. setRequiresCharging(true)가 설정되었다면 디바이스가 충전 중일 때만 작업이 실행됩니다.

btnJob2.setOnClickListener {
    val js = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
    val serviceComponent = ComponentName(this, MyJobService::class.java)
    val jobInfo = JobInfo.Builder(JOB_ID_B, serviceComponent)
        .setRequiresDeviceIdle(true)
        .setRequiresCharging(true)
        .setPeriodic(TimeUnit.MINUTES.toMillis(15))
        .build()
    js.schedule(jobInfo)
    Log.d(TAG, "Scheduled JobB")
}

JobInfo.Builder에서 제공하는 제약조건 API는 다음과 같습니다.

  • setRequiresDeviceIdle
  • setRequiresCharging
  • setRequiredNetworkType
  • setRequiredNetwork
  • setRequiresBatteryNotLow
  • setRequiresStorageNotLow

자세한 내용은 Android Developer: JobInfo를 참고해주세요.

onStartJob(작업 실행)

JobInfo에 명시된 조건이 충족되면 JobScheduler는 JobService에 정의된 onStartJob 메소드를 호출합니다.

class MyJobService : JobService() {
    override
    fun onStartJob(params: JobParameters): Boolean {
        Log.d(TAG, "onStartJob: ${params.jobId}")
        return false
    }
    ...
}

메소드의 인자로 params을 전달하여 어떤 Job이 실행되었는지 알 수 있습니다. 리턴 값은 서비스가 실행 중인지를 의미합니다.

리턴 값으로 만약 true를 리턴하면 서비스는 아직 실행 중이고 다른 쓰레드에서 동작하고 있다는 것을 의미합니다. 왜 실행 중인 상태를 JobScheduler에 알려주는 것일까요? 실행 중인 서비스가 있다면 JobScheduler는 디바이스가 슬립 상태로 들어가지 못하도록 WakeLock을 잡고 있기 때문입니다. 다른 쓰레드에서 동작 중인 작업이 모두 완료되었다면 어떻게 해야 할까요? JobScheduler에게 끝났다는 것을 알려주지 않으면 WakeLock을 풀지 않고 단말은 슬립상태로 들어가지 않게 될 수 있습니다. 그럼 소모전력도 커지게 되죠. JobService는 작업이 끝났다는 것을 알려주는 jobFinished 메소드를 제공합니다.

jobFinished를 호출하면 JobScheduler에게 작업이 완료되었다는 것을 알려줍니다. params는 완료된 Job에 대한 정보이고, wantsReschedule는 작업이 재실행되어야하는지를 의미합니다.

public final void jobFinished(JobParameters params, boolean wantsReschedule)

반면에 onStartJob의 리턴 값이 false라면, JobScheduler는 WakeLock을 잡지 않고, 디바이스는 슬립 상태로 들어갈 수 있습니다.

jobFinished

바로 위에서 jobFinished에 대해서 설명을 하였습니다. 어떻게, 언제 호출해야하는지 예제로 알아보겠습니다.

예를들어, 아래와 같이 구현할 수 있습니다. onStartJob는 true를 리턴합니다. 다른 쓰레드에서 작업이 진행 중이라는 의미죠. 코드를 보면 새로운 Thread를 생성하고 그 안에서 로그를 출력해주는 일을 하고 있습니다. 작업이 끝나면 jobFinished(params, false)를 호출하여 JobScheduler에게 작업이 완료되었음을 알려줍니다. 그러면 JobScheduler는 WakeLock을 해제할 것입니다.

fun onStartJob(params: JobParameters): Boolean {
    Log.d(TAG, "onStartJob: ${params.jobId}")

    Thread {
        Thread.sleep(1000)
        Log.d(TAG, "doing Job in other thread")
        jobFinished(params, false)
    }

    return true
}

실행 결과

03-06 21:47:30.861  5124  5124 D MainActivity: Scheduled JobA
03-06 21:48:37.764  5124  5124 D MyJobService: onStartJob: 100
03-06 21:48:38.765  5124  5183 D MyJobService: doing Job in other thread

onStopJob(작업 중지)

onStopJob은 작업이 중단될 때 호출되는 API입니다. 예를들어, 어떤 작업은 제약조건으로 setRequiresCharging(true)가 설정되어 충전 중일 때만 동작할 수 있습니다. 디바이스가 충전기를 꼽아 조건이 충족되었고 작업이 실행 중인데, 충전 케이블이 뽑혀 Not charging 상태가 되었습니다. 작업이 완료되지 않았다면 JobScheduler는 onStopJob을 호출합니다. 스케줄링의 조건과 맞지 않기 때문에 onStopJob으로 중지할 필요가 있다는 것을 알려주는 것입니다.

class MyJobService : JobService() {
    override
    fun onStopJob(params: JobParameters): Boolean {
        Log.d(TAG, "onStopJob: ${params.jobId}")
        return false
    }
}

리턴값은 작업이 재실행될 필요가 있는지를 의미합니다. 만약 false를 리턴한다면 작업이 다시 스케줄링되지 않습니다. true라면 작업이 다시 스케줄링됩니다.

정리

JobService와 JobScheduler에 대해서 알아보았습니다. 스케줄링 정보가 담긴 JobInfo를 통해서 JobScheduler는 적당한 때에 JobService를 실행시킵니다. 구조적인 특성으로, Job이 실행될 때 쓰레드를 만들거나 다른 서비스에 작업을 위임하도록 구현하는 것이 좋습니다. 다른 쓰레드에서 작업을 처리한다면 jobFinished을 호출해줘야 합니다.

참고