
안드로이드 앱을 개발할 때, UI의 원활한 작동을 방해하지 않으면서 백그라운드에서 작업을 처리하는 것은 매우 흔하고 또 중요합니다.
네트워크 요청, DB 접근, 파일 처리 등 시간이 걸리는 작업은 모두 백그라운드에서 이루어져야 합니다.
이번 글에서는 안드로이드에서 이러한 백그라운드 작업을 처리하는 여러 방법을 알아보고, 각 상황에 맞는 적절한 방법을 선택하는 기준을 제시하고자 합니다.
Activity가 사용자에게 표시되지 않고 앱에서
ForegroundService를 실행하지 않는 경우 앱은 백그라운드에서 실행된다.
공식 문서에서는 앱이 기본 워크플로 외부에서 수행하는 일을 지칭할 때 작업이라는 용어를 사용하고 있습니다.
위에서 언급한 네트워크 통신 및 DB 접근과 같은 IO 작업, GPS를 통한 위치 추적, 미디어 재생 등을 통틀어서 백그라운드 작업이라고 할 수 있습니다.
과거 안드로이드 버전에서는 이러한 백그라운드 작업을 비교적 자유롭게 실행할 수 있었지만,
이는 무분별한 리소스 사용으로 이어져 기기의 배터리를 빠르게 소모시키는 주된 원인이 되었습니다.
이를 방지하기 위해 구글은 잠자기 모드(Doze)와 앱 대기 모드를 시작으로,
최신 버전으로 올 수록 백그라운드 작업에 대한 제약을 강화하고 있습니다.
이러한 시스템의 제약에 걸리지 않으면서 의도한 대로 작업을 수행하려면
안드로이드에서 제공하는 API를 적절히 활용해야 합니다.
Coroutine안드로이드의 컴포넌트는 메인 스레드(UI 스레드)에서 실행되기 때문에,
해당 스레드에서 시간이 오래 걸리는 작업을 하게 되면 ANR이 발생할 수 있습니다.
따라서 별도의 워커 스레드를 생성하고 그 내부에서 작업을 처리해야 합니다.
또한 이러한 작업은 앱이 사용자와 상호작용하고 있을 때,
즉 앱의 생명주기 스코프 내부에서 처리하게 됩니다.
이 경우에는 흔히 사용하는 Coroutine을 통해 처리할 수 있습니다.
다만, 앱이 백그라운드로 가게 되면 OS에 의해 작업이 중단될 수 있으므로
짧은 시간 이내에 즉시 실행해야 하는 작업만을 처리해야 합니다.
앱이 포그라운드에 있을 때 처리해야 하는 백그라운드 작업
ForegroundService작업이 즉시 실행되어야 하며, 사용자에게 알려야 하는 경우가 있습니다.
이러한 서비스들은 퀵패널 등에서 앱의 작업을 확인할 수 있습니다.
즉 눈에 띄는 백그라운드 작업이라고 생각하면 됩니다.
이 경우에는 ForegroundService를 통해 처리할 수 있습니다.
먼저 AndroidManifest.xml에서 권한을 설정하고 서비스를 선언해야 합니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application ...>
<service
android:name=".MyMediaPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
</application>
</manifest>
SecurityException이 발생하여 앱이 즉시 종료됩니다.위 권한 외에 서비스 유형에 맞는 권한을 추가로 설정해줘야 합니다.
다음으로 Service를 상속받는 클래스를 구현합니다.
class MyMeidaPlaybackService : Service() {
override fun onCreate() {
super.onCreate()
// 각종 초기화 작업 (Notification 설정 등)
// 포그라운드 서비스로 실행
startForeground(..)
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// 서비스 시작이 요청될 때마다 호출
}
override fun onDestroy() {
super.onDestroy()
// 할당 해제
}
override fun onBind(intent: Intent?): IBinder? {
// Bound Service인 경우 구현
}
}
이러한 흐름으로 클래스를 구현하게 됩니다.
추가로, 다른 컴포넌트(액티비티, 프래그먼트 등)와 서비스 사이의 통신이 필요한 경우 onBind 내부에서 처리할 수 있습니다.
이러한 서비스를 Bound Service라고 부릅니다.
val intent = Intent(...) // Build the intent for the service
context.startForegroundService(intent)
이제 외부에서 startService나 startForegroundService로 서비스를 실행하면,
내부에서 startForeground를 통해 포그라운드로 승격되어 실행됩니다.
다만 ForegroundService를 구현할 때 주의해야 할 점이 몇 가지 있으므로,
이 부분은 공식 문서를 참고해주시길 바랍니다.
또한 ForegroundService는 사용자에게 보여지는 백그라운드 작업이기 때문에,
Notification을 생성해서 전달해야 한다는 점도 기억해야 합니다.
즉시 실행되어야 하며 사용자에게 알려야 하는 백그라운드 작업
JobScheduler종종 사용자가 앱을 실행하고 있지 않아도 처리해야 하는 작업이 있습니다.
이러한 작업은 즉시 실행될 필요 없으므로, 지연될 수 있습니다.
또한 동기화와 같은 작업은 많은 리소스를 요구하기 때문에,
배터리가 부족하거나 셀룰러 데이터를 사용하는 경우를 피해야 합니다.
이 경우 JobScheduler API를 통해 작업을 처리할 수 있습니다.
또한 JobScheduler는 다양한 조건을 설정하여 리소스를 절약할 수 있습니다.
아래는 간단한 구현 예시입니다.
class MyJobService : JobService()
override fun onStartJob(params: JobParameters?): Boolean {
// 백그라운드 작업 시작
// 예시: 서버 통신 코드
// 작업이 완료되었음을 명시해야 함
jobFinished(params, false)
// 작업이 비동기로 진행됨을 알림
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
// 작업이 중단될 때 호출됨
return false
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun scheduleJob(context: Context) {
val jobInfo = JobInfo.Builder(1, ComponentName(context, MyJobService::class.java))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED) // 와이파이 연결 시
.setRequiresCharging(true) // 충전 중일 때
.setPersisted(true) // 재부팅 후에도 유지
.setPeriodic(15 * 60 * 1000) // 15분 마다
.build()
jobScheduler.schedule(jobInfo)
}
위와 같이 작업에 특정 조건을 설정하면,
시스템 레벨에서 조건이 만족했을 때 작업을 실행하게 됩니다.
다만 JobScheduler는 API 21 이상에서 사용할 수 있기 때문에, 하위 버전에서는 작동하지 않는다는 점을 유의해야 합니다.
추가로 안드로이드 14(API 34) 이상부터 UIDT가 도입되었습니다.
사용자 시작 데이터 전송 작업은 사용자가 시작합니다. 이러한 작업은 알림이 필요하고, 즉시 시작되며, 시스템 조건에서 허용하는 한 장시간 실행될 수 있습니다. 사용자가 시작한 데이터 전송 작업 여러 개를 동시에 실행할 수 있습니다.
공식 문서에서는 JobScheduler를 통해 해당 작업을 처리하게끔 안내하고 있습니다.
JobScheduler는 시스템 레벨에 가까운 API이기 때문에,
다른 백그라운드 작업 API보다 안전하게 대용량 데이터를 처리할 수 있습니다.
하지만 작업을 등록할 때 설정한 조건에 맞지 않거나,
메모리 부족, 발열 등의 문제로 인해 작업이 종료될 수 있다고 합니다.
또한 UIDT는 API 34 이상부터 도입된 개념이므로,
하위 버전은 WorkManager를 통해 해당 작업을 처리해야 합니다.
지연 가능하며, 특정 조건이 충족됐을 때 처리해야 하는 백그라운드 작업
AlarmManagerJobScheduler가 디바이스의 상태(인터넷, 배터리 등)에 맞춰 작업을 실행한다면,
AlarmManager는 특정 날짜 및 시간에 맞춰 작업을 실행합니다.
AlarmManager는 설정한 날짜 및 시간에 인텐트를 실행하거나,
BroadcastReceiver와 조합하여 다양한 작업을 처리할 수 있습니다.
AlarmManager에 대한 설명은 이전 글에 자세히 나와있으므로,
해당 글을 참고하시면 될 것 같습니다.
특정 날짜 및 시간에 처리해야 하는 백그라운드 작업
WorkManager앱이 실행되고 있지 않을 때 사용할 수 있는 API는 위에서 여럿 살펴봤지만,
사실 공식적으로 권장되고 있는 API는 WorkManager입니다.
WorkManager는 다음과 같은 유형의 작업들을 처리할 수 있습니다.
ForegroundService)JobScheduler)JobScheduler, AlarmManager)처리 가능한 작업 유형을 보면 위의 API를 모두 대체할 수 있다는 것을 알 수 있습니다.
실제로 WorkManager는 내부적으로 위 API를 대부분 사용하고 있으며,
특히 하위 호환성을 잘 보장하고 있습니다.
예를 들어 API 21 이상에서는 JobScheduler를 메인 스케줄러로 사용하고,
이전 버전에서는 AlarmManager와 BroadcastReceiver를 적절히 조합하여 작업을 처리합니다.
이렇듯 다양한 이유때문에 백그라운드 작업은 WorkManager가 권장됩니다.
간단한 구현을 살펴보겠습니다.
class MyWorker(
context: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
// 처리해야 하는 작업
}
CoroutineWorker를 상속받아서 클래스를 생성하고,
doWork 메소드 내부에 처리할 작업을 작성하면 됩니다.
doWork는 Result를 반환하는데, 이는 코틀린의 Result가 아닌 androidx.work의 Result입니다.
Result에는 success, retry, failure가 있으며 retry를 반환할 경우 작업을 재시도할 수 있습니다.
class NotificationWorker(
context: Context,
workerParams: WorkerParameters, // 작업을 생성할 때 전달한 데이터
private val fetchNotificationsUseCase: FetchNotificationsUseCase,
) : CoroutineWorker(context, workerParams)
각종 작업을 처리하려면 당연하게도 Worker가 특정 객체에 의존하게 되는데,
필드에서 직접 의존성을 생성하거나 위의 방식처럼 생성자로 주입해야 합니다.
하지만 별도의 처리 없이 생성자로 주입하면 NoSuchMethodException이 발생합니다.
WorkManager에 의해 등록된 작업은 OS에 의해 실행되는데,
이 때 WorkManager가 Worker 인스턴스를 생성합니다.
인스턴스를 생성할 때는 Worker의 기본 생성자를 실행하기 때문에
생성자에 다른 파라미터가 포함되어 있을 경우 오류가 발생합니다.
이러한 경우를 위해 Worker의 생성 방식을 커스텀할 수 있습니다.
바로 WorkerFactory를 상속받은 클래스를 구현하는 것입니다.
class NotificationWorkerFactory(
private val fetchNotificationsUseCase: FetchNotificationsUseCase,
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? =
when (workerClassName) {
NotificationWorker::class.qualifiedName ->
NotificationWorker(
context = appContext,
workerParams = workerParameters,
fetchNotificationsUseCase = fetchNotificationsUseCase,
)
else -> null
}
}
이제 WorkerFactory의 생성자에 필요한 의존성을 주입하고,
createWorker를 오버라이딩하여 Worker를 반환하면 됩니다.
class BottariApplication :
Application(),
Configuration.Provider {
override val workManagerConfiguration: Configuration
get() {
val workerFactory =
DelegatingWorkerFactory().apply {
addFactory(
NotificationWorkerFactory(
fetchNotificationsUseCase = CommonUseCaseProvider.fetchNotificationsUseCase,
),
)
}
return Configuration
.Builder()
.setWorkerFactory(workerFactory)
.build()
}
}
Hilt를 사용하지 않았기 때문에 수동으로 DI를 구현했습니다.이렇게 선언한 WorkerFactory를 Application에서 등록하면 됩니다.
이제 실제 작업을 등록하는 코드를 살펴보겠습니다.
val workRequest =
OneTimeWorkRequestBuilder<NotificationWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkerManager에 작업을 등록할 때는 우선 작업의 유형을 설정할 수 있습니다.
OneTimeWorkRequestBuilder : 일회성 작업 예약PeriodicWorkRequestBuilder : 주기적 작업 예약 (주기 설정 가능)또한 JobScheduler처럼 특정 조건을 설정할 수도 있습니다.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi
.setRequiresCharging(true) // 충전 중
.build()
val myWorkRequest: WorkRequest =
OneTimeWorkRequestBuilder<MyWork>()
.setConstraints(constraints)
.build()
이 외에도 작업 체이닝, 재시도 및 백오프 전략, 그룹화 등을 설정할 수 있습니다.
자세한 내용은 공식 문서를 읽어 보시는 것을 추천드립니다.
이렇게 정의한 작업을 WorkManager에 등록하면 됩니다.
WorkManager.getInstance(context).enqueue(workRequest)
또한 WorkManager에 등록된 작업은 Room DB에 저장되는데,
각 작업에는 여러 상태가 존재합니다.
등록된 모든 작업들은 DB에 있는 상태와 doWork의 반환값에 기반하여 상태가 지속적으로 변경됩니다.

class DownloadWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as
NotificationManager
override suspend fun doWork(): Result {
val inputUrl = inputData.getString(KEY_INPUT_URL)
?: return Result.failure()
val outputFile = inputData.getString(KEY_OUTPUT_FILE_NAME)
?: return Result.failure()
// Mark the Worker as important
val progress = "Starting Download"
setForeground(createForegroundInfo(progress))
download(inputUrl, outputFile)
return Result.success()
}
setForeground를 통해 Foreground Service를 구현할 수도 있습니다.
이렇듯 WorkManager는 조건에 따른 실행 보장, 하위 호환성 등
기존의 API들이 가지고 있던 단점을 충분히 보완한 API입니다.
실제로 대부분의 상황에서는 WorkManager가 권장되는데, 공식 문서에서도 언급하고 있습니다.
대부분의 경우 백그라운드 작업을 실행하는 가장 좋은 방법은 WorkManager를 사용하는 것입니다. 하지만 다른 옵션이 더 나은 경우도 있습니다.
물론 UIDT처럼 다른 API를 사용해야 하는 경우도 있으므로,
본인의 상황에 맞는 적절한 API를 선택해야 합니다.
공식 문서에서는 백그라운드 작업 시나리오를 2가지로 나누고,
각 상황 별로 가이드라인을 제시하고 있습니다.

Coroutine)JobScheduler, WorkManager 등)shortService (ForegroundService 유형 중 하나)ForegroundService여기서 shortService는 ForegroundService의 유형 중 하나로,
기존의 ForegroundService에 비해 쉽게 생성할 수 있고 많은 권한이 필요하지 않습니다.
하지만 작업이 3분 이내에 완료되어야 하므로 주의해야 합니다.
또한 ForegroundService는 많은 리소스를 소모하기 때문에
대체 가능한 API가 존재할 경우 해당 API를 사용하는 것을 권장하고 있습니다.
예를 들어 사용자가 특정 위치에 도착할 때 앱에서 작업을 실행해야 하는 경우 포그라운드 서비스로 사용자의 위치를 추적하는 대신 geofence API를 사용하는 것이 가장 좋습니다.

ForegroundService이벤트 발생에 의해 작업을 시작한 경우는 여럿 있겠지만,
대부분 AlarmManager가 전파한 브로드캐스트 수신이나 FCM 수신일 것 같습니다.
참고로 앱이 백그라운드에 있는데 어떻게 비동기 작업으로 처리하냐 할 수 있는데,
작업이 몇 초 이내에 완료될 것으로 확신할 수 있는 경우 시스템이 비동기 작업을 할 수 있도록 허용한다고 합니다.
이렇게 안드로이드에서의 백그라운드 작업 관리에 대해 알아보았습니다.
사실 이 부분에 대해 공부할 때마다 느끼는 점은,
대부분 WorkManager로 해결할 수 있다는 것인데요.
(실제로 공식 문서를 포함한 여러 아티클에서도 WorkManager를 권장하곤 합니다..)
그럼에도 불구하고 이런 복잡한 API들과 플로우를 인지하고 있어야
개발하는 서비스에서의 사용자 경험을 향상시킬 수 있을 것이라고 생각합니다.
제가 글에서 언급한 것 외에도 더 자세하고 복잡한 내용이 많이 있으니
백그라운드 작업을 처리해야 한다면 공식 문서를 꼭 정독하시길 바랍니다.
긴 글 읽어주셔서 감사합니다.