Android 배터리 최적화: Doze 모드와 백그라운드 작업 전략

모바일 개발

Android배터리 최적화WorkManagerDoze 모드백그라운드 처리

이 글은 누구를 위한 것인가

  • 백그라운드 작업 때문에 배터리 소모가 크다는 리뷰를 받는 팀
  • WorkManager vs JobScheduler vs AlarmManager 중 무엇을 써야 할지 모르는 개발자
  • Doze 모드에서 작업이 실행되지 않는 문제를 겪는 엔지니어

들어가며

Android 6.0 이후 백그라운드 작업 제한이 계속 강화됐다. "알림이 안 온다", "동기화가 안 된다"는 리뷰는 대부분 Doze 모드와 백그라운드 제한 때문이다.

이 글은 bluefoxdev.kr의 Android 백그라운드 처리 가이드 를 참고하여 작성했습니다.


1. Android 백그라운드 제한 이해

[백그라운드 제한 타임라인]

Android 6.0: Doze 모드, App Standby
Android 7.0: Doze-on-the-Go (이동 중에도)
Android 8.0: 백그라운드 서비스 제한
Android 9.0: 백그라운드 앱 네트워크 제한
Android 12+: 정확한 알람 권한 제한

[Doze 모드 진입 조건]
  화면 꺼짐 + 충전 안 됨 + 정지 상태
  → Light Doze (즉시)
  → Full Doze (몇 시간 후)

[Doze 중 불가능한 것]
  네트워크 접근
  Wake lock
  AlarmManager (정확한 알람)
  JobScheduler 신규 시작

[Doze 중 가능한 것]
  FCM 고우선순위 푸시 (예외)
  유지보수 윈도우 (Doze 해제 시 잠깐)
  Foreground Service (알림 있어야)

[백그라운드 작업 선택 기준]
  즉시 실행 + 사용자에게 보임: Foreground Service
  즉시 실행 + 백그라운드: Thread/Coroutine (앱 실행 중만)
  지연 가능 + 보장 필요: WorkManager ✓
  정확한 시간: AlarmManager (권한 필요)

2. WorkManager 최적화 구현

import android.content.Context
import androidx.work.*
import java.util.concurrent.TimeUnit

// 백그라운드 동기화 Worker
class DataSyncWorker(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        return try {
            val data = inputData.getString("sync_type") ?: "full"
            
            // 네트워크 상태 확인 (Constraints로 보장되지만 이중 체크)
            if (!isNetworkAvailable()) {
                return Result.retry()
            }
            
            performSync(data)
            
            Result.success(
                workDataOf("sync_time" to System.currentTimeMillis())
            )
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry()
            else Result.failure()
        }
    }
    
    private suspend fun performSync(type: String) {
        // 실제 동기화 로직
    }
    
    private fun isNetworkAvailable(): Boolean {
        val cm = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
            as android.net.ConnectivityManager
        val network = cm.activeNetwork ?: return false
        val caps = cm.getNetworkCapabilities(network) ?: return false
        return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }
}

// WorkManager 설정
object WorkScheduler {
    
    fun schedulePeriodicSync(context: Context) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()
        
        val syncRequest = PeriodicWorkRequestBuilder<DataSyncWorker>(
            repeatInterval = 15,
            repeatIntervalTimeUnit = TimeUnit.MINUTES,
        )
            .setConstraints(constraints)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                WorkRequest.MIN_BACKOFF_MILLIS,
                TimeUnit.MILLISECONDS,
            )
            .setInputData(workDataOf("sync_type" to "incremental"))
            .build()
        
        WorkManager.getInstance(context).enqueueUniquePeriodicWork(
            "data_sync",
            ExistingPeriodicWorkPolicy.KEEP,  // 기존 작업 유지
            syncRequest,
        )
    }
    
    fun scheduleOneTimeUpload(context: Context, fileUri: String) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
        
        val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
            .setConstraints(constraints)
            .setInputData(workDataOf("file_uri" to fileUri))
            .addTag("upload")
            .build()
        
        WorkManager.getInstance(context).enqueue(uploadRequest)
    }
    
    // 체인 작업
    fun scheduleChainedWork(context: Context) {
        val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
        val upload = OneTimeWorkRequestBuilder<UploadWorker>().build()
        val notify = OneTimeWorkRequestBuilder<NotifyWorker>().build()
        
        WorkManager.getInstance(context)
            .beginWith(compress)
            .then(upload)
            .then(notify)
            .enqueue()
    }
}

// Foreground Service (사용자에게 보이는 장기 작업)
class DownloadForegroundService : android.app.Service() {
    
    private val NOTIFICATION_ID = 1001
    private val CHANNEL_ID = "download_channel"
    
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        createNotificationChannel()
        
        val notification = buildProgressNotification(0)
        startForeground(NOTIFICATION_ID, notification)
        
        // 실제 작업 (코루틴으로)
        kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
            performDownload()
            stopSelf()
        }
        
        return START_NOT_STICKY
    }
    
    private fun buildProgressNotification(progress: Int): android.app.Notification {
        return androidx.core.app.NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("다운로드 중...")
            .setProgress(100, progress, progress == 0)
            .setSmallIcon(android.R.drawable.stat_sys_download)
            .setOngoing(true)
            .build()
    }
    
    private fun createNotificationChannel() {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            val channel = android.app.NotificationChannel(
                CHANNEL_ID,
                "다운로드",
                android.app.NotificationManager.IMPORTANCE_LOW,
            )
            getSystemService(android.app.NotificationManager::class.java)
                .createNotificationChannel(channel)
        }
    }
    
    override fun onBind(intent: Intent?) = null
    private suspend fun performDownload() { /* 다운로드 로직 */ }
}

마무리

백그라운드 작업의 기본 원칙: 지연 가능한 작업은 WorkManager, 사용자가 알아야 하는 장기 작업은 Foreground Service. AlarmManager는 정확한 시간의 알람에만 쓰고 반드시 권한을 요청하라. 배터리 최적화 예외(REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)는 꼭 필요한 앱(헬스케어, 보안)만 써야 Google Play 정책을 통과한다.