[ Android Essential ] 라이브러리로 보는 비동기 시리즈 : OS 법정에서 살아남기

malcongmalcom·2025년 8월 26일

Android Essential

목록 보기
5/5
post-thumbnail

결국 OS가 모든 실행의 주인

내가 코드를 실행한다는 착각

안드로이드에서 코드를 “실행한다”는 감각은 착각에 가깝다. 진짜 주인은 항상 OS다. 우리는 함수 호출을 하고, 스레드를 만들고, 코루틴을 띄우지만, 결국 그 코드가 언제·얼마나·어디서 실행될지는 커널의 스케줄러와 안드로이드 프레임워크가 정한다. 이유는 단순하다. 스마트폰은 배터리, CPU, 메모리, 네트워크라는 공동 자원을 여러 앱이 함께 쓰는 환경이고, 그 질서를 유지하는 보안/자원 관리자 역할을 OS가 맡기 때문이다. 그래서 앱이 화면에서 사라지는 순간 우선순위가 떨어지고, 메모리가 부족하면 프로세스가 정리되고, 네트워크가 끊기면 대기하도록 강제된다. 개발자 입장에선 “내가 의도한 타이밍”이 언제든 무효화될 수 있다.

이 감각을 잡으려면 일단 역으로 생각해보면 쉽다. 만약 OS가 개입하지 않는다면? 어떤 앱이 백그라운드에서 무한 루프를 돌며 CPU 80%를 계속 잡아먹고, 셀룰러로 4K 동영상을 무한 업로드하며, 다른 앱이 쓰는 파일까지 들여다볼 수 있다면, 그 폰은 반나절도 못 간다. 그러니 OS는 강력한 제약을 건다. 화면 바깥으로 나가면 즉시 자원 회수 모드로 진입시키고(백그라운드 실행 제한), 일정 시간 지나면 더 깊게 잠재워서 네트워크/CPU를 최소화한다(Doze/App Standby). 네트워크는 푸시(FCM) 등 공용 채널을 쓰게 해 깨우기 비용을 공유한다. 파일/권한은 샌드박스와 퍼미션으로 막는다. 이 모든 제약이 “우리가 마음먹은 대로는 못 한다”는 현실을 만든다.

현실적인 장면을 몇 개 보자. 유튜브를 보다가 홈 버튼을 누르면 디코딩 파이프라인은 곧 정지된다. 프리미엄의 “백그라운드 재생”이 가능한 건 화면이 없어도 계속 달릴 수 있게 foreground service + 알림을 통해 “나 지금 사용자에게 보이는 중요한 작업이야”라고 OS에 공식 선언하기 때문이지, 유튜브가 특별해서가 아니다.

class MusicService : Service() {
    override fun onCreate() {
        val channelId = "player"
        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_music)
            .setContentTitle("재생 중")
            .setContentText("유튜브 백그라운드 재생")
            .setOngoing(true)
            .build()

        if (Build.VERSION.SDK_INT >= 26) {
            startForeground(1, notification) // 화면 밖에서도 계속 실행할 근거
        }
    }
    // ...
}

메신저도 마찬가지다. “항상 소켓 열어두기”는 배터리 자살 행위다. 그래서 대부분 FCM을 통해 깨우기 이벤트만 공유하고, 정말 필요한 순간에만 앱이 일어나 짧게 동작한다. 우리가 직접 while(true) { readLine() }로 소켓을 붙들고 있어도, OS가 배터리/전력 정책에 따라 네트워크를 잠깐 끊거나, 프로세스를 정리하면 끝이다. 즉, “항상 살아있다”는 전제가 안 된다.

초보 때 한 번쯤 이렇게 작성해본 적 있을 거다.

class UploadActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 사진 여러 장 업로드 (안티 패턴)
        Thread {
            photos.forEach { upload(it) }   // 네트워크/디스크 I/O 길게 수행
            runOnUiThread { toast("완료") }
        }.start()
    }
}

문제는 간단하다. 사용자가 홈으로 나가거나, 최근 앱 화면에서 밀어버리거나, 시스템이 메모리가 부족해지면(다른 게임을 띄우거나 카메라를 여는 순간 자주 발생), 이 스레드는 그냥 사라진다. 업로드가 97%에서 끊겨도, 어디까지 했는지 기록도 없다. GlobalScope.launch(Dispatchers.IO)로 바꿔도 본질은 같다. 앱 프로세스가 종료되면 함께 사라진다.

// 겉으론 "안전"해 보이지만 프로세스가 죽으면 같이 죽는다
GlobalScope.launch(Dispatchers.IO) {
    try {
        uploadAll(photos)
    } catch (e: IOException) {
        // 재시도 정책도 없고 진행률 영속화도 없다
    }
}

쇼핑 앱도 비슷하다. 결제 전송을 날려놓고 백그라운드로 가면, OS는 네트워크를 지연시키거나 작업을 묶어서 깨우려 한다. 이때 단순 스레드/코루틴은 아무런 보장을 받지 못한다. 반대로 “꼭 지금 보내야 하는 치명적 작업”이라면 아예 포그라운드 서비스로 올리고 사용자에게 노출해야 한다. 숨기면? 보안/경험 양쪽에서 모두 나빠진다.

OS의 결정권은 스케줄러 레벨에서도 드러난다. 코어별로 타임슬라이스를 분배하고, vruntime(가상 실행 시간)을 누적해 공평하게 CPU를 돌린다. 우리는 while(...)을 돌려도, 커널은 언제든 그 스레드를 프리엠프(preempt)해서 대기열 뒤로 보낸다. “지금 당장 2초만 더 쓰게 해줘”라고 읍소할 통로가 없다. 심지어 백그라운드에선 우선순위가 낮아져 더 자주 밀린다. 메모리는 더 엄격하다. LRU(least-recently-used) 규칙에 따라 최근에 보지 않은 앱부터 정리된다. 그 순간 힙/스택/파일 디스크립터는 전부 정리되고, “이어서…”라는 말은 가상에 불과해진다.

이 지점에서 보안 얘기를 빼면 반쪽이다. 배터리 때문에만 막는 게 아니다. 만약 우리가 임의의 파일을 언제든 백그라운드에서 쓸 수 있고, 네트워크를 마음껏 끌 수 있고, 시스템이 몰래 살아있게 허용한다면, 악성 코드에게도 같은 자유가 열린다. 그래서 안드로이드는 권한, 저장소 스코프, 백그라운드 실행 제한, 알림 노출이 필요한 포그라운드 서비스 같은 장치를 강제한다. “보이는 것만 오래 달릴 수 있다”는 원칙은 사용자 통제권을 위한 안전장치이기도 하다.

여기까지가 “OS가 주인”이라는 선언의 실체다. 우리가 의도한 흐름은 언제든 깨진다. 네트워크가 끊기고(터널), 배터리가 급감하고(3%), 비행기 모드가 켜지고(기내), 사용자가 앱을 밀어버리고(리센트), 시스템이 메모리를 회수한다(대형 게임 실행). 이 이벤트들의 공통점은, 앱이 감히 통제할 수 없다는 것. 그래서 “지금 당장”이 아니라 “조건이 갖춰지면 확실히”라는 관점으로 사고를 전환해야 한다. 그리고 그 관점에 맞춰 OS의 공식 스케줄러(알람/잡) 위에, 작업 상태를 영속화하고, 네트워크·전원 같은 제약을 선언하고, 실패 시 백오프까지 표준화한 실행 엔진이 필요하다.

살짝만 예고하자면, 아래처럼 “조건을 OS에 선언”해두고 작업 자체를 OS가 깨워줄 때 실행하게 만드는 방식이 그 해법이다. 여기선 구현을 자세히 파지 않겠다. 핵심은 발상 전환이다. “내가 돌린다”에서 “OS가 돌리게 한다”로.

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi일 때만
    .setRequiresCharging(true)                     // 충전 중일 때만
    .build()

val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
    .build()

WorkManager.getInstance(context).enqueue(request) // OS의 스케줄러/정책 아래로 편입

반대로 “지금 화면에 보여주는 플레이백을 끊기지 않게 유지해야 한다”면, OS와의 계약을 맺고(알림 노출) 달려야 한다.

class PlayerWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        setForeground(createForegroundInfo()) // 사용자에게 노출 = 계속 달릴 권한
        return streamAndCache()               // 끊기면 재개할 수 있도록 청크/체크포인트 설계
    }
}

결론적으로, “OS가 모든 실행의 주인”이라는 말은 추상적 원칙이 아니라, 우리가 매일 마주치는 실패 케이스들의 공통 원인이다. 스레드/코루틴을 직접 붙잡고 있는 한, 화면 밖의 세계에서 살아남을 수 없다. 다음 파트부터는 이 제약이 실제로 어떻게 모습을 드러나는지—유튜브에서 홈으로 나갈 때, Wi-Fi가 꺼질 때, 배터리가 2%일 때, 비행기 모드일 때, 앱을 강제 종료할 때—하나씩 시나리오로 깔고, OS와 협력하는 방식이 왜 유일한 해법인지 구체적으로 이어가겠다.

WorkManager?

유튜브를 켜고 좋아하는 영상을 재생한다고 해보자. 와이파이가 안정적으로 연결되어 있을 땐 영상 스트리밍이 매끄럽게 이어진다. 그런데 갑자기 엘리베이터를 타고 이동하거나 지하철 터널로 들어가면? 네트워크는 순간적으로 끊기고, 플레이어는 버퍼를 다 소비한 뒤 멈춰버린다. 이때 앱이 할 수 있는 건 제한적이다. try { stream() } catch { reconnect() } 같은 로직으로 재시도를 하더라도, OS가 네트워크 인터페이스를 꺼버린 상태라면 무용지물이다. 게다가 홈 버튼을 눌러 다른 앱으로 전환하면, OS는 유튜브의 네트워크·디코딩 스레드 우선순위를 바로 낮춰버린다. 사용자가 화면을 보고 있지 않다면 리소스를 아껴야 한다는 정책 때문이다.

try {
    streamVideo(url)
} catch (e: IOException) {
    // 단순 재시도 로직, 하지만 네트워크 자체가 꺼져 있으면 소용 없음
    delay(5000)
    streamVideo(url)
}

쇼핑 앱도 마찬가지다. 결제 직전, 서버에 최종 결제 요청을 보내는 순간 홈 버튼을 눌러 메시지를 확인하러 간다고 하자. 이 순간 네트워크 요청이 OS 레벨에서 지연되거나 끊길 수 있다. 백그라운드에서 장시간 돌아가는 네트워크 작업은 배터리·데이터 절약 정책에 의해 중단되기도 한다. 앱 내부에서 OkHttp나 Retrofit으로 비동기 요청을 보내더라도, 연결 자체가 OS에 의해 닫히면 개발자가 제어할 방법이 없다.

suspend fun confirmOrder(orderId: String): Boolean {
    val response = api.confirmOrder(orderId)
    return response.isSuccessful
}

위 코드가 아무리 깔끔해도, 앱이 백그라운드로 내려간 뒤 네트워크 연결이 OS 정책에 의해 해제되면 isSuccessful은 영영 돌아오지 않는다.

사진 업로드 앱의 경우는 더 복잡하다. 사용자가 50장의 사진을 선택해 업로드 버튼을 눌렀다고 해보자. 처음에는 빠르게 진행되다가, 도중에 배터리가 3%로 떨어져 충전 없이 종료된다면? 단순 스레드나 코루틴 기반 업로드라면 남은 사진은 어디까지 전송됐는지 기록조차 남기지 못한 채 사라진다. 앱을 다시 켰을 때 이어받기를 하려면, 개발자가 직접 업로드 상태를 디스크에 저장하고, 재시작 시 이를 읽어 복구하는 로직을 만들어야 한다. 하지만 이런 복구 로직을 구현하는 건 번거롭고, OS가 작업을 언제 종료시킬지 모르는 상황에선 완벽히 보장하기 어렵다.

photos.forEachIndexed { index, photo ->
    upload(photo)
    saveProgress(index) // 이걸 안 하면 재시작 시 어디까지 했는지 모름
}

이런 상황들을 한 번에 묶어서 보면, 공통점이 명확하다.

  • 네트워크 연결이 안정적이라는 전제는 깨지기 쉽다.
  • 사용자가 화면을 떠나는 순간, OS는 해당 앱의 자원을 회수한다.
  • 배터리와 메모리는 OS 관점에서 시스템 전체의 자원이지, 한 앱의 전유물이 아니다.
  • 예외 상황은 예고 없이 찾아오며, 앱이 이를 끝까지 제어하는 건 불가능하다.

결국 우리는 이런 현실적 제약 속에서 작업을 어떻게든 끝까지 보장할 수 있는 수단을 고민해야 한다. 단순한 Thread나 CoroutineScope.launch만으로는 부족하다. 작업을 OS의 스케줄링과 조건 시스템에 위임하고, 실패했을 때 안전하게 재시도할 수 있는 구조가 필요하다. 바로 이 지점에서 WorkManager 같은 OS 협력형 백그라운드 실행 엔진이 빛을 발한다.

포그라운드 vs 백그라운드

안드로이드에서 앱은 크게 포그라운드(Foreground) 와 백그라운드(Background) 두 가지 상태 중 하나에 있다. 겉으로 보기엔 그냥 화면이 보이느냐 아니냐의 차이처럼 느껴질 수 있지만, OS의 관점에서 이 둘은 전혀 다른 생태계다.

포그라운드 앱은 사용자가 직접 보고, 터치하고, 반응하는 중인 앱이다. OS는 이 상태의 앱을 가장 높은 우선순위로 취급한다. CPU, 네트워크, 메모리 모두 즉시 할당된다. 네트워크 요청도 가능한 한 빨리 처리되고, GPS·센서 데이터도 제한 없이 제공된다. 예를 들어 유튜브에서 영상을 재생하거나, 카메라로 사진을 찍는 순간이 여기에 해당한다.

반면 백그라운드 앱은 화면에서 사라진 직후부터 "기대 수명" 이 확 줄어든다. OS는 다음과 같은 생각을 한다. "이 앱은 당장 사용자와 상호작용하지 않네? 그럼 우선순위를 낮춰서 리소스를 다른 데 주자."

이때부터 벌어지는 변화는 꽤 극적이다. 네트워크 연결은 유지하던 TCP 소켓이 끊기거나 비활성 네트워크 정책이 적용되면서 지연이 생긴다. CPU 우선순위는 스레드 스케줄러에서 후순위로 밀려나 실행 빈도가 줄어든다. 메모리는 다른 앱이 필요로 하면 백그라운드 앱의 메모리부터 먼저 회수된다. 그리고 장시간 실행되는 비동기 작업은 JobScheduler나 Doze 모드에 의해 일시적으로 중단될 수 있다.

코드로 보면 똑같이 suspend fun upload() 를 호출하더라도, 포그라운드와 백그라운드에서의 동작 환경은 완전히 다르다.

// 포그라운드: 거의 즉시 실행
CoroutineScope(Dispatchers.IO).launch {
    uploadLargeFile(file)
}

// 백그라운드: Doze 모드 진입 시 네트워크 차단, 작업 지연
CoroutineScope(Dispatchers.IO).launch {
    uploadLargeFile(file) // 이 시점에서 OS가 언제 재개할지 모름
}

문제는, 사용자는 이 차이를 잘 모른다는 거다. 사진 업로드가 70% 진행된 시점에서 다른 앱으로 전환하면, 개발자가 보기엔 "작업이 중단될 수 있는 위험 구간"이지만, 사용자는 그냥 "앱이 버그로 업로드를 실패했다"고 느낀다. 즉, OS의 정책에 의한 자연스러운 동작이 사용자 눈에는 결함으로 보이는 것이다.

여기서 중요한 건 백그라운드 상태의 본질적인 한계를 인정하는 것이다. "백그라운드에서도 끊김 없이 작업이 돌아가야 한다"는 요구사항은 OS 설계 철학과 정면으로 충돌한다. OS는 배터리·발열·네트워크 사용량을 통제하기 위해 백그라운드 앱을 언제든 희생시킬 수 있는 권한을 갖고 있다.

그래서 WorkManager 같은 라이브러리는 이런 환경 차이를 흡수하는 중간 관리자 역할을 한다. 앱이 백그라운드로 가더라도 OS 정책에 맞춰 작업을 예약하고 조건이 충족되면 다시 실행하며 중단된 지점부터 재시도할 수 있도록 상태를 저장한다.

결국 포그라운드/백그라운드 차이를 이해한다는 건, "왜 WorkManager가 필요한가?"를 이해하는 첫 단계다. OS는 친구지만, 같은 공간에서 싸울 수 있는 상대이기도 하다. 포그라운드에서는 적극적으로 달리고, 백그라운드에서는 OS의 스케줄링에 몸을 맡겨야 한다.

WorkManager!

밤 11시, 침대에 누워서 쇼핑몰 앱에 상품 리뷰를 남기고 있었다. 사진 여러 장과 동영상을 함께 업로드해야 해서 전송 버튼을 누르자, 화면에는 로딩 스피너가 돌기 시작했다. 그런데 업로드가 30%쯤 진행되던 순간, 갑자기 카톡 알림이 와서 대화창으로 넘어갔다. 몇 분 뒤 쇼핑몰 앱으로 돌아와 보니 전송이 중단돼 있었다.

이 상황에서 개발자가 가장 먼저 떠올리는 건 “앱이 백그라운드로 가면 업로드가 끊기니까, 백그라운드에서도 돌아가게 하면 되잖아?”일 것이다. 하지만 이게 말처럼 쉽지 않다. OS는 모든 앱에 동일하게 “너 백그라운드네? 우선순위 낮출게”라는 규칙을 적용한다. 몰래 스레드를 돌리거나 무한 루프를 걸어도, OS가 CPU나 네트워크를 차단하면 거기서 끝이다.

여기서 WorkManager가 등장한다. WorkManager는 마치 OS에게 “이 작업은 꼭 해야 하는데, 어떻게 하면 너 방해 안 받고 할 수 있을까?” 하고 공식적으로 협상하는 메신저다. 개발자가 직접 OS 내부 정책을 우회하려고 하는 대신, OS가 제공하는 JobScheduler, AlarmManager, ForegroundService 같은 합법적인 경로를 사용해 작업을 예약하고 실행한다.

예를 들어, 대용량 파일 업로드를 WorkManager로 처리한다고 하자.

class UploadWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
    override suspend fun doWork(): Result {
        val fileUri = inputData.getString("fileUri") ?: return Result.failure()
        return try {
            uploadFile(fileUri)
            Result.success()
        } catch (e: IOException) {
            Result.retry()
        }
    }
}

그리고 예약은 이렇게 한다.

val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueue(uploadRequest)

이렇게 하면 앱이 포그라운드에 있든 백그라운드로 내려가든, 심지어 프로세스가 죽었다 다시 살아나도, OS가 조건이 맞을 때 작업을 이어서 실행한다. 중요한 건, WorkManager가 OS의 눈치를 보며 움직인다는 점이다. 네트워크가 끊겨 있으면 대기하고, 배터리가 부족하면 연기하며, 재부팅 후에도 작업 큐를 복원한다.

이 구조를 이해하면 WorkManager가 단순한 비동기 실행기가 아니라, OS와의 신뢰 기반 계약이라는 걸 알게 된다. 개발자는 “무조건 지금 당장”이 아니라 “언제든 반드시”를 보장받는 대신, 시점과 조건은 OS에게 맡긴다.

결국 WorkManager는 개발자와 OS 사이의 완충 장치다. OS의 정책을 거스르려 하지 않고, 그 정책을 활용해 앱의 작업을 끝까지 완수한다. 이걸 이해하지 못한 채 WorkManager를 쓰면, 그냥 “백그라운드에서도 돌아가는 실행기” 정도로만 생각하게 되지만, 실제로는 OS 협력 없이는 존재할 수 없는 구조다.

OS 법정에서 살아남기

사진 백업 앱을 하나 가정해보자. 사용자가 찍은 사진과 동영상을 매일 새벽 3시에 자동으로 클라우드에 업로드하는 기능이 있다. 사용자는 ‘와이파이일 때만’, ‘충전 중일 때만’ 업로드하겠다고 설정해두었고, 앱은 그 조건을 지키도록 구현돼 있다. 처음에는 단순하게 생각했다. 새벽 3시에 Handler로 딜레이를 걸어놓거나, 코루틴에서 delay로 시간을 맞춘 뒤 업로드를 시작하면 된다고.

fun scheduleBackup() {
    val delay = calculateDelayTo3AM()
    Handler(Looper.getMainLooper()).postDelayed({
        backupPhotos()
    }, delay)
}

fun backupPhotos() {
    Thread {
        val photos = loadPhotosFromDevice()
        for (photo in photos) {
            upload(photo)
        }
    }.start()
}

겉으로 보면 문제 없어 보인다. 하지만 실제 기기에서 테스트해보면, 업로드는 종종 시작조차 하지 못한다. 화면을 꺼두면 안드로이드는 곧바로 앱을 백그라운드 상태로 전환하고, CPU와 네트워크 우선순위를 낮춘다. 배터리가 부족하거나 메모리가 필요하면 프로세스 자체를 종료해버린다. 3시에 예약해둔 콜백은 호출되지 않고 사라진다. 설령 시작했더라도, 네트워크가 끊기면 그대로 멈추고 이어서 진행할 방법이 없다. 업로드가 어디까지 진행됐는지 기록도 없기 때문에, 다음 실행에서 처음부터 다시 시작해야 한다.

문제의 본질은 OS가 모든 실행의 주인이라는 데 있다. 우리가 만든 스레드나 코루틴이 아무리 잘 돌아가더라도, OS가 CPU와 네트워크를 끊어버리는 순간 끝이다. 백그라운드에서의 지속적인 실행은 정책상 제한되고, 그 제한은 앱 코드 차원에서 우회할 수 없다. Doze 모드, App Standby, 백그라운드 실행 제한 같은 제약이 전부 이런 맥락에서 나온다.

이 상황에서 접근 방식을 바꿔야 한다. 내가 직접 돌리는 게 아니라, OS에게 “이런 조건에서 이 작업을 실행해달라”고 맡기는 쪽으로. WorkManager는 이걸 가능하게 해준다. 우리가 원하는 조건과 재시도 정책을 선언하면, WorkManager는 내부적으로 JobScheduler, AlarmManager, ForegroundService 같은 시스템 API를 조합해 실행을 보장한다. 앱이 죽어 있어도, 기기가 재부팅돼도, 조건이 충족되는 순간 작업이 다시 시작된다.

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .build()

val request = PeriodicWorkRequestBuilder<PhotoBackupWorker>(1, TimeUnit.DAYS)
    .setConstraints(constraints)
    .setInitialDelay(calculateDelayTo3AM(), TimeUnit.MILLISECONDS)
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "photo_backup",
    ExistingPeriodicWorkPolicy.KEEP,
    request
)

Worker 내부는 평범한 업로드 코드일 수 있다. 하지만 차이점은, 이 로직이 단순히 스레드에서 실행되는 것이 아니라, OS 스케줄러를 거쳐 실행된다는 점이다.

class PhotoBackupWorker(
    ctx: Context,
    params: WorkerParameters
) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val photos = loadPhotosFromDevice()
        for (photo in photos) {
            upload(photo)
        }
        return Result.success()
    }
}

CoroutineWorker는 suspend 기반이라 네트워크 I/O 중에도 스레드를 블로킹하지 않는다. OS는 네트워크와 전원 조건이 맞는 순간 Worker를 깨우고, 실행 중 프로세스가 종료되더라도 DB에 기록된 상태를 바탕으로 이어서 실행할 수 있다. 네트워크가 끊기면 Result.retry()로 재시도를 요청할 수 있고, 백오프 정책에 따라 간격을 늘려가며 다시 시도한다.

.setBackoffCriteria(
    BackoffPolicy.EXPONENTIAL,
    10, TimeUnit.MINUTES
)

이런 구조 덕분에 백그라운드에서도 안전하게 작업이 끝까지 실행된다. 중요한 건, 이 과정에서 모든 조건과 실행 타이밍의 결정권은 OS가 갖는다는 것이다. 우리는 “무조건 지금”이 아니라 “언제든 반드시”라는 관점으로 코드를 설계해야 한다. 스레드나 코루틴으로 직접 붙잡고 있는 한, 화면 밖의 세계에서는 살아남을 수 없다. OS의 정책 안에서 실행을 보장받으려면, 그 정책을 활용하는 수밖에 없다. WorkManager는 그 협력의 공식 창구다.

상태 기반 실행 모델

앱이 화면에 보일 때와 사라진 뒤의 환경은 완전히 다르다. 포그라운드 상태에서는 OS가 CPU·네트워크·메모리를 우선적으로 배정해준다. 네트워크 요청은 지연 없이 처리되고, 스레드는 필요한 만큼 타임슬라이스를 받아 연속적으로 실행된다. 하지만 홈 버튼을 누르거나 화면을 꺼서 앱이 백그라운드로 내려가는 순간부터는 이야기가 달라진다. OS는 해당 앱을 즉시 “사용자와 상호작용하지 않는 프로세스”로 분류하고, 다른 앱과 시스템 기능에 리소스를 배분하기 위해 이 앱의 우선순위를 낮춘다.

이때부터 네트워크 스택은 장시간 열려 있던 TCP 소켓을 끊거나, 패킷 송신을 모아서 일정 주기마다만 전송하는 정책을 적용한다. CPU 스케줄러는 스레드의 타임슬라이스를 줄이고, 대기열 뒤쪽으로 배치한다. 메모리가 부족해지면 가장 먼저 정리되는 건 최근 사용하지 않은 백그라운드 앱이다. 심지어 Doze 모드나 App Standby가 발동되면, 네트워크 접근 자체가 봉인되고, 지정된 메인터넌스 윈도우 외에는 깨어날 수 없게 된다.

이런 환경에서 새벽 3시에 딜레이를 걸어 업로드를 시작하는 코드는 거의 의미가 없다. OS가 해당 시점에 CPU를 배정하지 않으면 콜백 자체가 호출되지 않고, 네트워크가 닫혀 있으면 업로드 함수는 I/O 예외를 던지고 끝난다. 화면 밖에서도 계속 작업을 유지하려고 스레드나 코루틴을 붙잡아 봐야, 전력 관리 정책이 한 번 개입하면 그대로 끊긴다.

WorkManager가 설계된 이유가 바로 여기에 있다. 앱이 직접 타이밍과 실행을 관리하는 대신, OS의 공식 스케줄링 API(JobScheduler, AlarmManager, ForegroundService)를 통해 조건 기반 실행을 위임한다. 예를 들어 API 23 이상에서는 JobScheduler를 사용해 네트워크·전원·스토리지 등의 조건을 OS에 선언한다. 조건이 충족되지 않으면 Job은 보류되고, 충족되는 순간 OS가 앱 프로세스를 깨워서 실행시킨다.

작업 등록은 항상 내부 DB에 기록된다. WorkManager는 Room 기반의 SQLite 테이블에 각 작업의 ID, 상태(ENQUEUED, RUNNING, SUCCEEDED, FAILED, BLOCKED), 제약 조건, 백오프 정책, 입력 데이터 등을 저장한다. 이 덕분에 앱이 완전히 종료되어도, 재부팅 후에도 동일한 작업을 이어서 실행할 수 있다. DB는 WorkManager의 핵심이며, 이 상태를 기반으로 스케줄러가 OS와 통신한다.

실행 흐름을 간단히 그려보면 다음과 같다.

앱에서 WorkRequest 생성
      ↓
내부 DB에 저장
      ↓
Scheduler가 DB 읽고 OS 스케줄러(JobScheduler/AlarmManager)에 등록
      ↓
조건 충족 시 OS가 Job 실행
      ↓
WorkManager가 해당 Worker 클래스 인스턴스 생성
      ↓
doWork() 실행 (CoroutineWorker라면 suspend로 처리)

Worker는 OS가 “지금은 실행해도 된다”고 판단한 시점에 호출되기 때문에, 이때는 네트워크와 전원 조건이 이미 만족된 상태다. CoroutineWorker를 쓰면 내부적으로 ListenableFuture와 suspend가 연결되어, 네트워크 I/O 동안 스레드를 블로킹하지 않는다.

예를 들어 사진 백업 Worker는 이렇게 작성할 수 있다.

class PhotoBackupWorker(
    ctx: Context,
    params: WorkerParameters
) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val photos = loadPhotosFromDevice()
        for (photo in photos) {
            try {
                upload(photo) // HTTPS 업로드
            } catch (e: IOException) {
                return Result.retry()
            }
        }
        return Result.success()
    }
}

네트워크 오류가 발생하면 Result.retry()를 반환하고, WorkManager는 DB 상태를 갱신한 뒤 백오프 정책에 따라 다음 실행 시점을 계산한다.

.setBackoffCriteria(
    BackoffPolicy.EXPONENTIAL,
    10, TimeUnit.MINUTES
)

지수형 백오프를 사용하면 10분, 20분, 40분… 간격으로 재시도하고, 선형 백오프는 일정 간격으로 반복한다. 이때도 실제 재실행 시점은 OS가 스케줄링하며, 여러 앱의 네트워크 작업을 묶어서 처리해 배터리 소모를 줄인다.

여기서 중요한 건, WorkManager가 단순히 “백그라운드에서도 실행된다”는 수준의 라이브러리가 아니라는 점이다. 이 구조의 본질은 상태 기반 실행 모델에 있다. 앱이 직접 스레드와 타이머를 관리하는 대신, 작업의 조건과 실행 기록을 DB에 남기고, OS의 스케줄러를 통해 실행 기회를 받는다. 이 방식은 백그라운드 제약, Doze 모드, 앱 강제 종료, 재부팅 같은 변수 속에서도 작업을 이어갈 수 있는 유일한 현실적인 방법이다.

이 구조를 이해하면, 왜 WorkManager에서 “지금 당장”을 보장하지 않는지가 분명해진다. OS는 모든 앱을 공평하게 관리해야 하고, 특정 앱이 자원을 독점하지 않도록 설계돼 있다. WorkManager는 그 정책 안에서 “조건이 맞을 때 반드시” 실행되도록 하는 협상 도구다. 백그라운드에서 안정적으로 실행해야 하는 작업이라면, 스레드나 코루틴을 직접 붙잡는 대신, 이 구조를 받아들이는 것이 결국 살아남는 길이다.

WorkManager의 3중 구조

WorkManager가 단순히 “OS에 위임한다”라고 끝나는 건 아니다. 그 뒤에는 꽤 복잡한 브리징 로직이 있다. 안드로이드 버전별로 사용할 수 있는 API가 다르고, 각 API의 특성도 다르기 때문에, WorkManager는 실행 환경을 감지하고 적절한 스케줄러 조합을 선택한다.

API 23 이상에서는 JobScheduler가 메인 스케줄러가 된다. JobScheduler는 OS가 관리하는 백그라운드 실행 예약 시스템으로, 앱이 요청한 작업을 “조건이 충족될 때” 실행한다. 여기서 조건은 네트워크 상태, 충전 여부, 저장소 여유 공간, 유휴 상태 여부 등이 있다. OS는 같은 조건을 가진 여러 앱의 Job을 모아서 한 번에 처리하므로, 배터리와 네트워크 효율이 좋아진다. 예를 들어, “충전 중 + 와이파이” 조건을 만족하는 순간, OS는 여러 앱의 Job을 깨우고, 해당 시점에만 무선 LAN 칩을 활성화한다.

API 23 미만에서는 JobScheduler가 없기 때문에, WorkManager는 AlarmManager와 BroadcastReceiver를 조합해 예약을 구현한다. AlarmManager는 정해진 시각에 브로드캐스트를 날리고, 그걸 BroadcastReceiver가 받아서 Worker 실행을 트리거한다. 다만 AlarmManager는 Doze 모드에 들어가면 정확한 실행이 보장되지 않는다. 그래서 API 21~22 구간에서는 GcmNetworkManager나 Firebase JobDispatcher 같은 보조 툴을 사용하던 시절도 있었다. WorkManager는 이 과도기를 모두 흡수한 형태라고 보면 된다.

여기에 한 가지 더, 즉시 실행이 필요한 경우 ForegroundService도 활용한다. 예를 들어 파일 다운로드나 오디오 스트리밍처럼 사용자에게 명확히 진행 상태를 보여줘야 하는 작업은 Notification을 띄운 ForegroundService로 실행한다. WorkManager는 내부에서 이 모드를 “Expedited Work”라고 부르는데, API 31 이상에서는 무제한 사용이 불가능하고, 일정 쿼터 안에서만 허용된다.

실제 실행 시점에는 스케줄러가 Worker를 호출하기 전에, WorkManager 내부의 Processor라는 컴포넌트가 동작한다. Processor는 DB에 저장된 작업 목록을 읽고, 실행 가능한 상태의 WorkSpec을 선별한 뒤, WorkerWrapper 객체를 만들어 실행을 담당한다. 이때 WorkerWrapper는 TaskExecutor라는 내부 스레드 풀을 사용한다.

TaskExecutor는 크게 두 가지 풀로 나뉜다.

  1. Background Executor – Worker 로직을 실행하는 일반 스레드 풀. 기본적으로 고정 크기의 ThreadPoolExecutor이며, 병렬로 여러 Worker를 처리할 수 있다.
  2. Task Executor – DB 접근, 스케줄러 통신 등 WorkManager 내부의 관리 작업을 실행하는 별도의 풀. 이건 보통 싱글 스레드에 가깝다.

CoroutineWorker를 사용하면 이 Background Executor 위에 코루틴 디스패처가 얹혀진다. 내부적으로는 Dispatchers.Default나 Dispatchers.IO에 해당하는 Executor를 래핑한 형태다. 덕분에 Worker 안에서 suspend 함수를 호출해도 스레드 블로킹 없이 I/O 대기나 CPU 연산을 병행할 수 있다.

예를 들어, 다음과 같은 Worker가 있다고 하자.

class SyncWorker(
    ctx: Context,
    params: WorkerParameters
) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val users = api.fetchUsers() // 네트워크 I/O
        dao.insertAll(users)         // DB I/O
        processUsers(users)          // CPU 연산
        return Result.success()
    }
}

이 Worker가 실행되면 다음과 같은 흐름이 된다.

  1. Processor가 DB에서 WorkSpec을 읽어와 WorkerWrapper 생성
  2. WorkerWrapper가 Background Executor의 스레드에서 CoroutineWorker 실행
  3. fetchUsers() 호출 시 OkHttp나 Retrofit 내부에서 Dispatchers.IO 전환 → 네트워크 쓰레드 사용
  4. dao.insertAll() 호출 시 Room의 트랜잭션이 같은 IO 디스패처에서 처리
  5. processUsers() 호출 시 다시 Default 디스패처로 전환되어 CPU 바운드 작업 실행

여기서 중요한 점은, Worker 안에서 아무리 오래 걸리는 suspend 함수를 호출해도, OS가 보장한 실행 윈도우 안에서만 돌아간다는 것이다. OS가 윈도우를 닫아버리면, Worker는 중단되고, WorkManager는 상태를 DB에 저장한 뒤 재시도를 예약한다.

결국 WorkManager의 3중 구조는 이렇게 정리된다.

  • DB – 작업의 상태와 조건을 영속적으로 기록
  • OS 스케줄러 – 조건이 만족될 때 실행 기회를 부여
  • 내부 Executor/Dispatcher – Worker 로직을 병렬·비동기로 처리

이 세 가지가 동시에 맞물려야 비로소 “조건 기반의 안정적인 백그라운드 실행”이 가능해진다. 앱이 직접 스레드를 잡고 기다리는 방식으로는 절대 만들 수 없는 구조다.

작업 실행의 정합성

예를 들어, 매일 새벽 3시에 사진을 백업하는 Worker를 만들었는데, 실행 중 네트워크가 끊겨 실패했다고 해보자. 그냥 실패했다고 끝내면 다음날 새벽 3시까지 아무 일도 안 한다. 이건 사용자 입장에서 답답한 일이다. 그래서 WorkManager는 모든 Work에 대해 “재시도”라는 개념을 기본 내장하고 있다.

재시도는 단순히 다시 실행하는 게 아니라, 백오프(backoff) 정책을 따른다. 백오프는 실패 후 일정 시간 뒤에 다시 시도하는 방식인데, WorkManager는 EXPONENTIAL과 LINEAR 두 가지를 지원한다.

  • LINEAR: 10초 → 20초 → 30초 → …
  • EXPONENTIAL: 10초 → 20초 → 40초 → 80초 → …

디폴트는 EXPONENTIAL이고, 최댓값은 5시간이다. 즉, 네트워크가 계속 끊겨도 무한정 폭주하지 않는다. 설정은 이렇게 한다.

val request = OneTimeWorkRequestBuilder<SyncWorker>()
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        10, TimeUnit.SECONDS
    )
    .build()

Worker 안에서는 Result.retry()를 반환하면 이 정책이 발동한다. 예를 들어 네트워크 요청이 IOException으로 실패했을 때 이런 식으로 처리한다.

override suspend fun doWork(): Result {
    return try {
        api.uploadPhotos()
        Result.success()
    } catch (e: IOException) {
        Result.retry()
    }
}

재시도는 OS가 보장하는 실행 윈도우 안에서만 동작한다. 예를 들어 RequiresCharging 조건이 붙어있으면, 충전 중이 아닐 땐 백오프 시간이 끝나도 실행되지 않는다. 조건이 만족되는 시점에 맞춰서 다시 실행된다.

이 구조 덕분에 WorkManager는 실패 후에도 안정적으로 이어서 작업을 수행할 수 있다. 하지만 재시도만으로는 해결되지 않는 경우가 있다. 예를 들어 데이터 동기화 → 이미지 썸네일 생성 → 서버 업로드처럼, 순차적으로 실행해야 하는 작업이 있을 때다. 하나라도 실패하면 뒤에 있는 작업은 무의미하다.

이럴 때 쓰는 게 Work Chaining이다. WorkManager는 체이닝을 DB 레벨에서 관리한다. 각 Work는 Prerequisite 필드로 앞선 작업과 연결되고, 모든 선행 작업이 SUCCEEDED 상태가 되어야 다음 작업이 실행된다. 실패하면 체인 전체가 중단된다.

코드는 이렇게 생겼다.

val syncWork = OneTimeWorkRequestBuilder<SyncWorker>().build()
val thumbWork = OneTimeWorkRequestBuilder<ThumbnailWorker>().build()
val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>().build()

WorkManager.getInstance(context)
    .beginWith(syncWork)
    .then(thumbWork)
    .then(uploadWork)
    .enqueue()

이 체이닝은 내부적으로 DAG(Directed Acyclic Graph) 형태로 DB에 저장된다. Processor는 DAG를 순회하면서 실행 가능한 WorkSpec만 뽑아내고, 상태를 실시간으로 갱신한다. 만약 SyncWorker가 실패하면, thumbWork와 uploadWork는 CANCELLED 상태로 끝난다. 반대로 Retry가 반환되면, 그 Worker만 재시도되고, 성공해야만 다음 노드로 넘어간다.

여기서 흥미로운 점은, 체인 안의 일부 작업은 병렬 실행도 가능하다는 것이다. 예를 들어 썸네일 생성과 메타데이터 업로드를 동시에 돌리고 싶으면, .then() 대신 .then(listOf(work1, work2)) 형태로 묶으면 된다. 그러면 두 Worker가 동시에 실행되고, 둘 다 성공해야 다음 단계로 넘어간다.

실제 앱에서는 이런 구조를 결합하면 꽤 안정적인 파이프라인을 만들 수 있다. 예를 들어 “앱 실행 시 네트워크 동기화 → 캐시 DB 갱신 → UI 새로고침” 과정을 체인으로 구성해두면, 실행 중 어느 단계에서 실패하더라도 재시도 정책이 적용되고, 성공한 단계는 다시 반복하지 않는다.

결국 WorkManager의 재시도와 체이닝은 단순한 “백그라운드 실행”을 넘어, 실패 복구와 순차 실행이라는 두 가지 문제를 동시에 풀어준다. DataStore에서 atomic commit이 데이터 정합성을 지켜주듯, WorkManager의 DAG와 백오프 정책은 작업 실행의 정합성을 지켜준다. 이걸 모르고 Worker를 개별적으로 막 실행하는 건, DB 트랜잭션을 빼고 SQL을 난사하는 것과 크게 다르지 않다.

타이머보다 조건이 먼저다

사진을 업로드하는 백업 앱을 만든다고 해보자. 그런데 사용자가 LTE 요금제를 쓰고 있어서, 사진이 수백 장 쌓인 상태에서 갑자기 데이터로 업로드가 시작되면 요금 폭탄이 터질 수 있다. 배터리도 문제다. 고해상도 사진을 수백 장 전송하면 CPU와 모뎀이 풀가동되고, 배터리가 눈에 띄게 줄어든다. 사용자는 이런 상황을 원하지 않는다. 그래서 WorkManager는 Constraints라는 개념을 제공한다.

Constraints는 “이 조건이 만족될 때만 Work를 실행하라”는 규칙이다. 와이파이 환경일 때만, 기기가 충전 중일 때만, 배터리가 충분할 때만, 저장 공간이 넉넉할 때만, 심지어 기기가 유휴 상태일 때만 실행하도록 제한할 수 있다.

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED) // 와이파이만
    .setRequiresCharging(true) // 충전 중일 때만
    .setRequiresBatteryNotLow(true) // 배터리 부족 아닐 때
    .setRequiresStorageNotLow(true) // 저장공간 부족 아닐 때
    .build()

val work = OneTimeWorkRequestBuilder<BackupWorker>()
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueue(work)

이 설정을 하면, Worker가 바로 실행되는 게 아니라 조건이 만족될 때까지 대기한다. 예를 들어 새벽 3시가 되어도 LTE 상태라면 업로드는 시작되지 않고, 사용자가 아침에 집에서 와이파이에 연결하는 순간 실행된다. 충전 조건이 붙어있으면 충전이 시작되는 시점까지 대기한다.

여기서 중요한 건 조건이 만족되기 전까지 WorkManager가 Work를 계속 추적한다는 점이다. 내부적으로 WorkSpec 테이블에 상태를 저장해두고, GreedyScheduler나 SystemJobScheduler가 조건을 모니터링한다. OS는 배터리 소모를 줄이기 위해 조건 체크를 실시간이 아니라 배치(batch)로 처리한다. 즉, 조건이 만족됐더라도 바로 실행되지 않고, 짧게는 수 초, 길게는 수 분 뒤에 실행될 수 있다.

이게 처음엔 답답하게 느껴질 수 있다. 하지만 이유가 있다. 안드로이드는 모든 백그라운드 작업을 OS 스케줄링 윈도우에 맞춰 모아서 실행한다. 네트워크를 쓰는 앱이 동시에 몰리면 전력 효율이 좋아지고, 무선 모뎀의 깨어있는 시간을 최소화할 수 있다. 마치 택배를 한 번에 묶어서 보내는 것처럼, 실행 타이밍을 맞춰주는 것이다.

문제는 이런 타이밍 최적화가 개발자 의도와 어긋날 수 있다는 점이다. 예를 들어 사용자가 “지금 당장 백업” 버튼을 눌렀는데, 네트워크 조건이 만족되지 않으면 몇 분, 심지어 몇 시간이 지나야 실행될 수 있다. 이럴 땐 보통 조건을 완화하거나, UI에서 “조건을 만족해야 실행됩니다” 같은 안내를 넣는다. 일부 앱은 즉시 실행을 위해 별도의 Foreground Service를 띄우지만, 그건 OS 정책상 예외적인 경우다.

Constraints는 체이닝과 결합하면 더 강력해진다. 예를 들어 “와이파이 연결 시 → 데이터 동기화 → 이미지 압축 → 서버 업로드” 같은 파이프라인을 만들고, 전체에 동일한 제약 조건을 걸면, 중간 단계에서 조건이 깨져도 이후 단계가 실행되지 않는다. WorkManager는 체인 전체를 하나의 DAG로 보기 때문에, 조건이 깨지면 해당 노드 이후로 진행이 중단된다.

그리고 이 모든 제약 조건은 재시도 정책과도 맞물린다. 예를 들어 EXPONENTIAL 백오프가 설정돼 있어도, 조건이 만족되지 않으면 재시도 타이머가 끝나도 실행되지 않는다. 조건이 먼저다. 조건이 만족된 뒤에서야 백오프 타이머가 적용된다.

이걸 이해하고 설계하면, 앱이 배터리와 네트워크를 낭비하지 않으면서도 안정적으로 작업을 이어갈 수 있다. DataStore에서 디스크 기록 시점이 OS 스케줄러와 맞물려 동작하는 것처럼, WorkManager의 Constraints도 OS 레벨의 리소스 스케줄링과 깊이 얽혀 있다.

결국 Constraints는 단순히 옵션 몇 개가 아니라, 앱이 OS와 협력하는 방식 그 자체다. 조건을 잘못 설계하면 사용자는 “왜 이 앱은 백업이 안 되지?”라고 불평할 수 있고, 잘 설계하면 “이 앱은 배터리를 많이 안 먹네”라는 긍정적인 피드백을 받을 수 있다. WorkManager는 이 두 결과 사이에서 균형을 맞출 수 있는 거의 유일한 공식 API다.

장기 실행의 정합성

앱을 실행하다가 화면을 끄고 5분 정도 지나면, 안드로이드가 슬그머니 모드 전환을 시작한다. 처음에는 큰 변화가 없다. 네트워크 연결도 살아있고, 백그라운드 스레드도 돌고 있다. 하지만 기기가 움직이지 않고 화면이 계속 꺼진 상태로 일정 시간이 지나면, 시스템은 배터리를 절약하기 위해 Doze 모드로 진입한다. Doze 모드에 들어가면 네트워크 소켓이 닫히거나, 주기적인 작업이 모아서 실행된다. 배터리를 오래 쓰기 위해 OS가 스케줄링 윈도우를 강제로 늘려버리는 것이다.

이 스케줄링 윈도우라는 개념은 단순히 “짧게 열리는 예외 시간”이 아니다. OS는 기기가 오랫동안 사용되지 않을수록 윈도우 간격을 점점 길게 만든다. 처음엔 수 분 간격으로 5~10초 정도 네트워크를 열어주지만, 계속 방치되면 그 간격이 배로 늘어난다. 예를 들어 처음에는 30분 뒤 10초, 그 다음은 1시간 뒤 20초, 또 그 다음은 2시간 뒤 30초 식으로 점점 희소해진다. 즉, Doze 모드에 들어간 순간부터 앱에게 주어지는 실행 기회는 점점 더 멀어진다. 이건 단순한 지연이 아니라 배터리 절약을 위해 OS가 작업 밀도를 의도적으로 줄이는 정책이다. 그리고 이 윈도우 안에서는 OS가 직접 관리하는 큐에 등록된 작업(AlarmManager, JobScheduler, WorkManager 등)만 실행된다. 반대로 Handler.postDelayed 같은 단순 타이머는 OS 큐에 등록되지 않으므로, 윈도우가 열려도 실행 기회를 얻지 못한다. 그래서 “정확히 3시 실행”이 아니라, “3시 이후 가장 가까운 윈도우에 실행”되는 것이 맞다.

App Standby는 조금 다르다. 이는 앱 단위로 “최근에 사용하지 않은 앱”을 판단해 네트워크 사용을 제한한다. 네가 만든 앱이 사용자의 홈 화면에서 자주 안 켜지면, OS가 “얘는 급한 일 없네” 하고 대기열 맨 뒤로 보내는 셈이다.

이 상황에서 단순한 Handler.postDelayed나 Timer를 쓰면 어떻게 될까? 3시에 백업하도록 해놨는데, Doze 모드라면 그 시각이 훌쩍 지나도 실행이 안 된다. 깨워줄 사람도 없으니, 타이머는 그냥 잊혀진다.

// ❌ 단순 타이머 (Doze 모드에서 실행 기회를 못 받음)
Handler(Looper.getMainLooper()).postDelayed({
    backupDatabase() // 화면 꺼지고 Doze 들어가면 이 콜백은 실행 안 됨
}, TimeUnit.HOURS.toMillis(3))

// ✅ WorkManager (OS 스케줄러에 등록되어 Maintenance Window에서 실행됨)
val work = OneTimeWorkRequestBuilder<BackupWorker>()
    .setInitialDelay(3, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.UNMETERED)
            .setRequiresCharging(true)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueue(work)

왜냐하면 내가 코드를 “실행한다”는 감각은 사실 착각에 가깝기 때문이다. 진짜 주인은 항상 OS다. 내가 함수 호출을 하고, 스레드를 만들고, 코루틴을 띄우더라도 그 코드가 언제·어디서·얼마나 실행될지는 커널 스케줄러와 안드로이드 프레임워크가 정한다. OS는 배터리·CPU·메모리·네트워크 같은 공동 자원을 관리하고, 앱은 거기에 종속된다. 화면에서 사라지면 우선순위가 내려가고, 메모리가 부족하면 프로세스가 정리되고, 네트워크가 끊기면 대기하도록 강제된다. 그래서 “내가 3시 정각에 실행시켰는데 왜 안 돼?” 하는 순간이 생기는 것이다.

여기서 중요한 구분이 나온다. AlarmManager, JobScheduler, ForegroundService 같은 건 뭐고, 시스템 콜이랑은 뭐가 다른 걸까? 시스템 콜(system call)은 커널이 직접 제공하는 저수준 호출이다. open(), read(), write(), mmap(), ioctl() 같은 함수들로, 유저 모드에서 커널 모드로 내려가 하드웨어 자원이나 커널 기능을 쓸 때 사용한다. 반면 우리가 코틀린/자바에서 부르는 AlarmManager·JobScheduler·ForegroundService는 프레임워크 API다. 이 함수들은 내부적으로 Binder IPC를 통해 system_server 프로세스에 있는 시스템 서비스(AlarmManagerService, JobSchedulerService, ActivityManagerService 등)에 요청을 보낸다. 그 서비스들이 필요하면 커널에 시스템 콜을 날려 타이머를 등록하고, wakelock을 관리하고, 네트워크 정책을 적용한다. 즉 개발자 → 프레임워크 API → Binder IPC → system_server(시스템 서비스) → 커널(시스템 콜) 순서로 일이 흘러간다. 프레임워크 API 자체는 시스템 콜이 아니지만, 결국 커널까지 일이 내려가 자원이 움직이는 것이다.

[앱 코드]      Handler.postDelayed    WorkManager.enqueue
     │                   │
     │                   ▼
     │        (앱 내부 타이머만 동작) 
     │                   │
     ▼                   ▼
[프레임워크 API]   AlarmManager / JobScheduler
     │                   │
     ▼                   ▼
 [Binder IPC] ──────────▶ system_server (JobSchedulerService, AlarmManagerService)
     │                                    │
     ▼                                    ▼
 [커널 드라이버 (/dev/binder)]          [커널 시스템 콜: timer, wakelock, net]
     │                                    │
     ▼                                    ▼
   (실행 X, Doze에서 잊힘)        (Doze Maintenance Window에서 실행 기회 제공)

여기서 Binder IPC를 잠깐 짚고 가자. IPC는 Inter-Process Communication, 즉 프로세스 간 통신이다. 앱 프로세스는 보안 때문에 다른 프로세스의 메모리를 직접 못 본다. 그런데 내 앱이 알람을 예약하려면 system_server에게 “3시에 깨워줘”라고 요청해야 한다. 이때 필요한 게 IPC고, 안드로이드는 Binder라는 자체 메커니즘을 쓴다. Binder는 커널 드라이버(/dev/binder)와 런타임으로 구성된다. 앱이 AlarmManager를 호출하면 프레임워크가 Binder 프록시를 통해 요청을 직렬화(파슬링)해서 커널 드라이버에 넘긴다. 실제 커널 진입은 ioctl("/dev/binder", …) 같은 시스템 콜로 이뤄진다. 커널 Binder 드라이버는 메시지를 system_server 쪽 큐에 전달하고, system_server의 Binder 스레드 풀이 그걸 꺼내 처리한다. 이 과정에서 커널이 송·수신자 UID/PID를 붙여주므로 권한 확인이 자동으로 들어간다. 그래서 Binder는 빠르고, 안전하고, Stub/Proxy 덕분에 개발자가 쓰기 쉽다. 정리하면 “Binder IPC 자체는 시스템 콜이 아니지만, 내부적으로 시스템 콜을 사용해 커널 드라이버와 통신한다”는 것이다.

이런 구조 위에서 WorkManager가 돌아간다. WorkManager는 실행 조건과 시각을 바탕으로, 우선적으로 GreedyScheduler를 시도한다. 이건 앱 프로세스가 살아있을 때만 쓰는 방식으로, “바로 실행할 수 있으면 해라”라는 의미다. 하지만 앱이 백그라운드로 가면 OS 제약에 막혀 이 방식은 거의 무력해진다. 그래서 다음 단계로 SystemJobScheduler(Lollipop 이상)나 GcmScheduler(구버전 지원)를 통해 OS에게 “이 작업을 조건 맞으면 실행해달라”고 위임한다. 이때 WorkManager는 단순히 OS API를 매핑하는 게 아니라, 내부 DB(WorkDatabase)에 WorkSpec이라는 테이블을 만들어 작업의 상태, 제약 조건, 입력/출력 데이터를 기록해둔다. 이 DB 덕분에 앱이 죽어도, 재부팅해도 이어서 실행할 수 있다. 그리고 GreedyScheduler나 SystemJobScheduler 같은 스케줄러들이 이 WorkSpec을 읽고 OS API에 작업을 등록한다. JobScheduler에 등록된 순간부터는 OS가 관리한다. 이때 Doze 모드라면 OS는 Maintenance Window라는 짧은 예외 시간을 열어준다. 예를 들어 1시간에 한 번, 5~10초 정도 네트워크를 허용하는 식이다. WorkManager는 이 짧은 틈을 포착해 실행을 시도한다. 실행 타이밍이 약간 늦어지더라도, OS가 허락한 순간에 맞춰 동작하니 실패 확률이 줄어든다.

여기에도 함정이 있다. setInitialDelay를 10분으로 줬다고 해서 정확히 10분 뒤 실행되는 게 아니다. OS는 “지금은 배터리 아까우니까 5분 더 기다려”라고 할 수 있다. 그래서 WorkManager의 실행 시각은 알람 시계처럼 정확한 예약이 아니라 최소 대기 시간 개념에 가깝다. App Standby 상태에서도 마찬가지다. 네트워크가 차단된 앱은 Wi-Fi 연결 상태여도 업로드가 바로 되지 않는다. OS는 Standby 앱에 대해서도 Maintenance Window를 열어주는데, 이 주기는 Doze보다 훨씬 길다. 몇 시간 동안 실행 기회가 안 올 수도 있다. 이런 OS 레벨 제한을 우회하려고 ForegroundService를 쓰는 경우도 있지만, 안드로이드 12 이후에는 백그라운드에서 무분별하게 ForegroundService를 시작하면 크래시가 나거나 시스템이 강제 종료시킨다. 결국 장기적이고 안정적인 실행은 WorkManager처럼 OS와 협력하는 방식이 유일하다.

결국 WorkManager의 스케줄러 전환 구조는 단순한 편의 기능이 아니라, OS가 제공하는 실행 기회를 최대한 활용하기 위한 생존 전략이다. 앱이 살아있을 때는 즉시 실행하고, 죽어있거나 백그라운드에서는 OS의 Maintenance Window에 올라타는 것. 이 흐름을 이해하면 왜 “정확한 시각 예약”이 불가능한지, 왜 WorkManager가 장기 실행 작업의 표준이 되었는지를 알 수 있다. 더 넓게 보면, 내가 JobScheduler.schedule()을 호출하는 순간 Binder 트랜잭션이 만들어져 커널 드라이버로 흘러들어가고, system_server의 JobSchedulerService가 OS 큐에 등록한다. 조건이 만족되면 OS가 나를 깨운다. 앱은 system_server의 메모리를 직접 건드리지 않고도 강력한 시스템 기능을 함수 호출처럼 쓸 수 있다. 즉, 내 코드가 세상과 만나려면 반드시 IPC를 거쳐야 하고, 안드로이드에선 그게 Binder다. Binder는 시스템 콜을 품고 있고, WorkManager는 그 위에 상태와 규칙을 얹어 장기 실행의 정합성을 보장한다. 결국 OS가 모든 실행의 주인이라는 말은 추상적 구호가 아니라, 이 전 과정을 관통하는 사실이다. 화면 밖에서도 살아남으려면, OS의 규칙 위에 시스템을 세워야 한다.

BackoffPolicy

작업이 항상 한 번에 성공한다면 좋겠지만, 현실은 그렇지 않다. 네트워크 요청이 타임아웃될 수도 있고, 서버가 일시적으로 죽을 수도 있다. 심지어 로컬 디스크에 쓰는 과정에서도 I/O 예외가 터질 수 있다. 이럴 때 일반적인 스레드 코드에서는 예외를 잡아 다시 시도하거나, 사용자가 재시도 버튼을 누르게 만든다. 하지만 WorkManager는 이런 재시도를 자동으로 해줄 수 있는 구조를 가지고 있다.

WorkManager에서 재시도 정책을 정하는 핵심은 BackoffPolicy다. 두 가지 옵션이 있다.

  • LINEAR: 재시도 간격이 일정하게 늘어난다. 예를 들어 10초 → 20초 → 30초 식이다.
  • EXPONENTIAL: 재시도 간격이 기하급수적으로 늘어난다. 10초 → 20초 → 40초 → 80초 식으로, 금방 몇 분, 몇 시간이 된다.

이건 단순히 편의 기능이 아니라, OS 전력 정책과도 관련이 있다. 실패할 때마다 무작정 빠르게 재시도하면, 배터리를 쓸데없이 태우고 네트워크를 과도하게 점유하게 된다. 그래서 WorkManager는 최소 재시도 간격이 10초, 최대는 몇 시간 단위로 제한돼 있다.

val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        15, TimeUnit.SECONDS
    )
    .build()

setBackoffCriteria를 이렇게 주면, Result.retry()가 호출될 때마다 이 규칙에 따라 다음 실행 시각을 계산한다. 여기서 중요한 점은, doWork() 안에서 Result.retry()를 반환해야 WorkManager가 “이건 재시도 대상이구나” 하고 인식한다는 것이다. 그냥 예외를 던지면? WorkManager는 실패로 기록하고 재시도를 안 할 수도 있다.

override fun doWork(): Result {
    return try {
        uploadFile()
        Result.success()
    } catch (e: IOException) {
        Result.retry()
    } catch (e: Exception) {
        Result.failure()
    }
}

이 패턴에서 IOException 같은 네트워크/디스크 일시 오류는 재시도로, Exception은 영구 실패로 처리하는 식이다. DataStore에서 디스크 쓰기 실패를 suspend 재호출로 다시 시도했던 것과 비슷하게, WorkManager도 이런 분기 처리를 통해 “다시 해도 되는 실패”와 “포기해야 하는 실패”를 구분한다.

여기서 또 하나 고려할 게 있다. 재시도 간격 동안 앱이 완전히 종료되면 어떻게 될까? WorkManager는 내부 DB(WorkSpec 테이블)에 작업 상태와 다음 실행 시각을 저장해 두기 때문에, 앱이 다시 켜져도 이어서 재시도를 한다. 이게 그냥 코루틴이나 Handler 타이머와 결정적으로 다른 점이다.

하지만 재시도에도 한계가 있다. OS가 App Standby 상태로 오래 두면, 그 재시도 시각이 몇 시간씩 밀릴 수 있다. 예를 들어 15초 후 재시도를 설정했지만, 기기가 Doze 상태에 들어가면 실제로는 1시간 뒤 Maintenance Window에서야 실행될 수 있다. 이건 WorkManager가 잘못한 게 아니라, OS의 권한이 절대적이기 때문이다.

그래서 장기 작업을 설계할 때는 “재시도 정책”을 단순히 실패 처리의 도구로만 보지 않고, OS와의 협상 수단으로 생각해야 한다. 실패 간격을 너무 짧게 주면, OS가 “얘는 전력 낭비하는 놈”이라고 판단해 더 강하게 제한할 수 있다. 반대로 간격을 충분히 늘리면, 실행 기회가 왔을 때 성공 확률이 높아지고 배터리 소모도 줄어든다.

결국 WorkManager의 재시도 구조는 실패를 복구하는 도구이면서, 장기적인 작업 생존율을 높이는 전략적 장치다. DataStore에서 쓰기 실패 시에도 OS 스케줄러 타이밍을 고려해야 했듯이, WorkManager의 재시도 역시 단순 반복이 아니라, OS의 전력 정책과 유지보수 가능한 타이밍을 염두에 둔 설계가 필요하다.

상태 추적 API

작업을 등록했다고 해서 바로 끝나는 건 아니다. 실제로 서비스 운영을 해보면, 작업이 어디까지 진행됐는지, 실패했는지, 성공했는지, 중간에 무슨 데이터가 나왔는지를 확인해야 할 때가 많다. 단순히 로그만 찍는다면 개발 중에는 편하겠지만, 배포 이후 사용자 기기에서 무슨 일이 일어났는지 파악하기는 힘들다. WorkManager는 이런 상황을 대비해서 WorkInfo라는 상태 추적 API를 제공한다.

WorkInfo는 내부 DB(WorkSpec 테이블)에 기록된 작업의 현재 상태를 읽어오는 구조다. 상태는 크게 네 가지다.

  • ENQUEUED: 실행 대기 중
  • RUNNING: 현재 실행 중
  • SUCCEEDED: 성공적으로 끝남
  • FAILED: 실패 후 더 이상 재시도하지 않음
  • CANCELLED: 취소됨

이 값은 앱이 꺼졌다 켜져도 유지된다. 예를 들어, 서버 동기화 작업을 걸었는데 사용자가 앱을 종료해도, 다시 켰을 때 WorkInfo를 읽으면 진행 상태를 그대로 확인할 수 있다.

val workId = UUID.randomUUID()

val request = OneTimeWorkRequestBuilder<SyncWorker>()
    .setId(workId)
    .build()

WorkManager.getInstance(context).enqueue(request)

WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(workId)
    .observe(lifecycleOwner) { workInfo ->
        when (workInfo.state) {
            WorkInfo.State.ENQUEUED -> showStatus("대기 중")
            WorkInfo.State.RUNNING -> showStatus("실행 중")
            WorkInfo.State.SUCCEEDED -> showStatus("완료")
            WorkInfo.State.FAILED -> showStatus("실패")
            WorkInfo.State.CANCELLED -> showStatus("취소됨")
            else -> {}
        }
    }

여기서 중요한 점은, WorkInfo는 단순히 상태만 주는 게 아니라 OutputData도 같이 준다는 거다. OutputData는 작업이 끝나고 남긴 결과물이다. 예를 들어 이미지 압축 작업이 끝난 후 최종 파일 경로를 다른 작업에서 쓰고 싶다면 이렇게 한다.

// Worker 내부
override fun doWork(): Result {
    val output = workDataOf("compressed_path" to outputFilePath)
    return Result.success(output)
}

// 결과 읽기
val path = workInfo.outputData.getString("compressed_path")

이 구조 덕분에, 별도의 전역 변수나 파일 없이도 안전하게 작업 간 데이터를 주고받을 수 있다. DataStore 글에서 suspend 함수로 안전하게 읽고 쓰던 것처럼, 여기서는 OutputData가 그 역할을 한다고 보면 된다.

그다음은 체이닝(Chaining)이다. 여러 작업을 순서대로 실행하거나 병렬로 묶을 수 있는 기능이다. 예를 들어, “사진 압축 → 서버 업로드 → DB 기록” 이 세 단계를 순서대로 실행하고 싶다면 이렇게 한다.

val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val upload = OneTimeWorkRequestBuilder<UploadWorker>().build()
val record = OneTimeWorkRequestBuilder<RecordWorker>().build()

WorkManager.getInstance(context)
    .beginWith(compress)
    .then(upload)
    .then(record)
    .enqueue()

체이닝의 진짜 장점은, 앞 작업의 OutputData를 그대로 다음 작업에 InputData로 넘길 수 있다는 점이다. 마치 코루틴에서 suspend 함수들이 결과를 반환하며 이어지는 것처럼, 여기서는 Data 객체로 안전하게 이어진다.

// 다음 작업에서 InputData 읽기
override fun doWork(): Result {
    val path = inputData.getString("compressed_path") ?: return Result.failure()
    uploadFile(path)
    return Result.success()
}

OS 입장에서 보면, 이렇게 체이닝된 작업들은 내부적으로 동일한 WorkContinuation 객체로 묶인다. 이 덕분에 첫 작업이 실패하면 이후 작업은 자동으로 취소된다. 중간에 Doze 모드나 앱 종료가 있어도, WorkManager는 전체 체인 구조를 DB에 기록해 두었다가, 다시 실행 가능한 시점에 이어서 실행한다.

결국 WorkInfo와 OutputData, 그리고 체이닝은 WorkManager를 “그냥 예약 실행기”가 아니라 “상태 기반의 작업 파이프라인”으로 만들어준다. DataStore에서 Flow로 데이터 변화를 계속 추적할 수 있었던 것처럼, 여기서는 WorkInfo로 작업 흐름을 추적하고, OutputData로 안전하게 데이터를 넘기며, 체이닝으로 실행 순서를 보장하는 것이다. 이 세 가지를 제대로 쓰면, 장기 실행 작업도 훨씬 안정적이고 예측 가능하게 설계할 수 있다.

OS 법정에서 살아남는 코드의 법칙

장기 실행 작업을 설계할 때 제일 먼저 바꿔야 하는 건 사고방식이다. “지금 당장 보내기”가 아니라 “조건이 맞을 때 반드시 끝내기.” 화면이 꺼지고, 네트워크가 들쭉날쭉하고, 프로세스가 죽고 다시 살아나도, 같은 파이프라인이 계속 이어질 수 있어야 한다. 그래서 WorkManager를 사용할 때는 기능을 나열하기보다 운영 관점에서의 생존 전략을 먼저 박아두는 게 낫다. 결국 오랫동안 버티는 코드는 몇 가지 원칙을 지키는 코드다.

가장 먼저 멱등성이다. 동일한 작업이 여러 번 실행되어도 결과가 한 번 실행한 것과 같아야 한다. 재시도, 프로세스 재시작, 체인 재구동을 생각하면 멱등성 없이 버티는 건 거의 불가능에 가깝다. 파일 업로드라면 서버에 “이미 업로드된 청크인지”를 확인하는 엔드포인트를 두고, 클라이언트는 해시나 오프셋으로 재개하도록 만든다.

suspend fun uploadResumable(uri: Uri, token: String): Boolean {
    val digest = sha256(uri)
    val offset = api.queryOffset(digest, token) // 이미 올라간 바이트
    applicationContext.contentResolver.openInputStream(uri).use { input ->
        input?.skip(offset)
        streamChunks(input) { chunk ->
            api.appendChunk(digest, offset, chunk, token)
        }
    }
    return api.commit(digest, token).isSuccessful
}

다음은 Unique Work다. 장기 작업은 중복 enqueue가 매우 흔하다. 사용자가 연속으로 백업 버튼을 두 번 누르거나, 화면 회전으로 동일한 로직이 두 번 실행될 수 있다. 이름을 붙여 유일하게 만들어두면 중복을 피할 수 있다.

WorkManager.getInstance(context).enqueueUniqueWork(
    "nightly_photo_backup",
    ExistingWorkPolicy.KEEP, // 이미 있으면 기존 걸 유지
    OneTimeWorkRequestBuilder<BackupWorker>()
        .setConstraints(backupConstraints())
        .build()
)

Tag를 적극적으로 붙여두면 운영이 쉬워진다. 특정 사용자의 작업만 취소하거나, 업로드 계열만 모니터링하고 싶을 때 태그로 조회한다. 장기적으로 보면 태그는 관측성의 최소 단위다.

val work = OneTimeWorkRequestBuilder<UploadWorker>()
    .addTag("upload")
    .addTag("user:${userId}")
    .build()

WorkManager.getInstance(context).enqueue(work)

// 나중에 특정 사용자 업로드만 취소
WorkManager.getInstance(context).cancelAllWorkByTag("user:${userId}")

입출력 데이터는 작게 유지한다. InputData/OutputData는 키-값 바이너리지만 사이즈 제한이 있다(운영하다 보면 금방 부딪힌다). 덩치 큰 페이로드는 파일/콘텐츠 URI로 건네고, 파일 자체는 앱 샌드박스나 ContentProvider를 통해 접근한다. 보안이 필요한 경우엔 파일 자체를 암호화해서 저장하고, 키는 Keystore로 관리한다.

val data = workDataOf(
    "photo_uri" to persistedPhotoUri.toString(), // 실제 바이트는 파일에서
    "session_id" to sessionId
)

장시간 진행되는 사용자 체감형 작업은 ForegroundService와의 계약을 맺는다. OS에게 “지금은 사용자에게 보이는 중요한 일”이라고 알리려면 알림을 띄우고 포그라운드로 올려야 한다. WorkManager에서는 setForeground()를 호출해 이를 수행한다. 중요한 건, 이 경로는 남용하면 안 된다는 점이다. 사용자가 보지 않는 작업을 포그라운드로 올리면 경험도, 배터리도, 정책도 모두 역행한다.

class ExportWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        setForeground(createForegroundInfo(progress = 0))
        exportInChunks { p ->
            setForeground(createForegroundInfo(progress = p))
        }
        return Result.success()
    }

    private fun createForegroundInfo(progress: Int): ForegroundInfo {
        val notification = NotificationCompat.Builder(applicationContext, "export")
            .setSmallIcon(R.drawable.ic_export)
            .setContentTitle("내보내는 중")
            .setContentText("$progress%")
            .setOnlyAlertOnce(true)
            .setProgress(100, progress, false)
            .build()
        return ForegroundInfo(1001, notification)
    }
}

“지금 당장”이 꼭 필요한 경우가 있다. 예컨대 결제 확정 요청이나 보상형 광고 콜백 전송처럼 즉시성이 UX의 전부인 순간. 그럴 땐 Expedited Work를 고려할 수 있다. 단, 이건 쿼터가 있다. 무한정 난사하면 실패한다. 그래서 조건을 완화해 일반 Work로 대체되는 플랜 B를 항상 둔다. 즉시성을 요청하되, 실패하면 “조건 맞을 때 확실히”로 자연스럽게 폴백한다.

val request = OneTimeWorkRequestBuilder<ConfirmOrderWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK)
    .build()

취소와 중단 가능성은 기본 가정이다. 사용자가 로그아웃하면 해당 유저 태그의 작업을 즉시 취소하고, Worker 안에서는 주기적으로 isStopped를 확인하거나 coroutineContext.isActive로 협력적 취소를 구현해둔다. 중단된 작업은 Result.failure()보다 상황에 따라 Result.retry()가 현명할 수 있다. “사용자가 취소”라면 실패가 맞지만, “OS가 중단”이라면 재시도가 맞다.

override suspend fun doWork(): Result {
    for (chunk in chunks) {
        if (!isActive) return Result.retry()
        upload(chunk)
    }
    return Result.success()
}

체이닝은 길어질수록 한 덩어리로 취급해야 한다. 중간 단계가 실패하면 뒤는 자동으로 취소되지만, “이미 성공한 단계”를 불필요하게 반복하지 않도록 체인 쪼개기와 Unique Work 정책을 섞는다. 예를 들어 “미디어 스캔 → 압축”은 하루 한 번만 돌리고, “업로드”는 사용자가 수동으로 눌러도 기존 압축 산출물을 재사용한다. 굳이 같은 압축을 매번 반복할 필요가 없다.

// 일일 스캔·압축은 고유 작업으로 유지
WorkManager.getInstance(ctx).enqueueUniqueWork(
    "daily_prepare_media",
    ExistingWorkPolicy.KEEP,
    beginWith(scan).then(compress).work
)

// 업로드는 누락분만, 압축 산출물을 입력으로
WorkManager.getInstance(ctx).enqueue(
    OneTimeWorkRequestBuilder<UploadWorker>()
        .setInputData(workDataOf("prepared_dir" to preparedDir.toString()))
        .build()
)

UI와의 연결 고리는 WorkInfo 관찰이다. 앱이 죽었다 살아나도 상태는 DB에 남아 있으니, 화면에서는 Work ID나 태그를 기준으로 진행률과 결과를 다시 붙이면 된다. 진행률은 Worker에서 setProgress로 업데이트한다. 이건 진짜로 유용하다. “뒤에서 뭔가 하고 있다”는 신호가 사용자 경험을 살린다.

// Worker
setProgress(workDataOf("progress" to percent))

// UI
WorkManager.getInstance(context)
    .getWorkInfosByTagLiveData("upload")
    .observe(owner) { infos ->
        val p = infos.mapNotNull { it.progress.getInt("progress", -1).takeIf { it >= 0 } }
            .maxOrNull() ?: 0
        renderProgress(p)
    }

운영 관점에서는 Prune와 Diagnostics가 필요하다. 오래된 성공/실패 기록을 적절히 정리(prune)하지 않으면 DB가 불어나서 오히려 스케줄링이 느려진다. 주기적으로 pruneWork()를 호출해 청소하는 루틴을 넣는다. 디버깅 시에는 WorkManager.initialize에서 Logger 레벨을 올리고, 문제 현상 재현은 테스트 러너에서 WorkManagerTestInitHelper로 고립된 환경을 만든다. 장기 작업은 반드시 자동화된 테스트로 재시도/취소/조건 전환을 흉내 내 봐야 한다.

// 앱 시작 시 한 번씩 (디스크 여유 있을 때)
WorkManager.getInstance(context).pruneWork()

보안을 빼먹으면 나중에 반드시 발목을 잡힌다. 백업·동기화·결제는 전부 네트워크와 저장소를 돈다. 전송은 TLS가 기본이고, 저장은 암호화된 임시 파일 + Keystore 키로 잠가둔다. OutputData/Log에는 개인 식별자나 토큰을 남기지 않는 걸 원칙으로 한다. 재시도·체이닝·진행률 같은 운영 정보는 풍부하게, 개인 정보는 최소화해서. 장기 실행이란 결국 “오래, 많이” 돌린다는 뜻이고, 그만큼 유출면적이 넓어진다.

마지막으로 현실적 타임라인을 받아들이는 태도다. WorkManager는 알람 시계가 아니다. 최소 대기 시간과 제약 조건을 선언하면, OS는 유지보수 가능한 시점에 실행을 준다. 그래서 UI에는 “대략적인 시간”을, 시스템에는 “명확한 조건”을 준다. 즉시성은 포그라운드 계약으로, 나머지는 조건 기반 계약으로. 이 둘을 섞어서 쓰는 게 실전이다.

정리하면, 장기 실행 시나리오는 멱등성, Unique Work, 태그·관측성, 작은 Data·큰 페이로드는 파일/URI, 포그라운드 계약의 절제된 사용, 협력적 취소, 체인 분해와 재사용, 주기적 프루닝, 테스트와 로깅, 그리고 보안. 이 원칙들을 코드에 실제로 녹여두면, 화면 밖의 세계에서도 작업은 살아남는다. 스레드나 코루틴만으로는 절대 만들 수 없는 종류의 안정성이다. 결국 OS와 협력하는 규약을 받아들이는 쪽이, 사용자와 개발자 둘 다에게 이득이다.

소회

앱을 오래 만들다 보면, 한 번쯤은 이런 경험을 한다. “내가 코드를 짰으니, 내가 의도한 타이밍에 실행될 거야”라고 믿었다가, 실제 사용자 환경에서 전혀 다른 결과를 마주하는 것. 개발 환경에서는 매끄럽게 동작하던 업로드가, 사용자가 화면을 꺼버린 순간 조용히 사라지고, 정시에 맞춰 두었던 예약 작업이 아무 이유 없이 건너뛰고, 결제 API 호출이 서버에 닿기도 전에 끊겨버린다. 원인을 파고들면 결국 똑같은 결론에 도달한다. OS는 절대권력이고, 우리는 그 위에서만 움직일 수 있다는 사실이다.

이 절대권력을 무시한 채, 스레드와 코루틴으로만 백그라운드 실행을 설계하는 건 마치 고속도로 한복판에 텐트를 치는 것과 같다. 잠깐은 서 있을 수 있지만, 언제 치워질지 전혀 예측할 수 없다. OS는 배터리와 메모리, 네트워크를 전체 기기 차원에서 관리하고, 필요하다면 우리가 붙잡고 있는 스레드쯤은 미련 없이 정리한다. 특히 화면 밖에서의 실행은 OS 입장에서 “최대한 줄여야 하는 대상”이기 때문에, 조건이 맞지 않으면 아예 실행 기회조차 주지 않는다.

WorkManager가 하는 일은 바로 이 ‘실행 기회’를 합법적으로 얻어내는 것이다. OS가 허락하는 방식, OS가 이미 만들어둔 스케줄링 API(JobScheduler, AlarmManager, ForegroundService) 위에, 작업 상태를 기록하고, 조건을 선언하고, 실패 시 재시도를 요청하는 로직을 얹는다. 단순히 while(true)로 계속 돌리면서 버티는 게 아니라, “이런 조건일 때 이 작업을 실행해줘”라고 OS와 계약을 맺는 방식이다.

이 계약 구조의 본질은 상태 기반 실행이다. WorkManager는 모든 작업을 내부 DB에 저장한다. 작업 ID, 상태(ENQUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED), 제약 조건, 입력·출력 데이터, 백오프 정책이 전부 기록된다. 앱이 강제 종료돼도, 기기가 재부팅돼도 이 DB가 남아 있는 한 WorkManager는 다시 실행을 시도할 수 있다. 즉, 한 번 등록한 작업은 프로세스 생명주기를 초월해 살아남는다.

여기에 더해, Constraints를 걸어두면 OS의 자원 정책을 그대로 활용할 수 있다. “와이파이 연결 + 충전 중 + 배터리 충분”이라는 조건이 만족될 때만 실행하도록 해두면, OS는 자연스럽게 같은 조건의 다른 앱 작업과 묶어서 실행한다. 이는 배터리 효율을 높이고, 모뎀의 깨어 있는 시간을 최소화하는 효과가 있다. 우리가 굳이 “언제”를 고민하지 않아도, OS는 최적의 타이밍에 맞춰준다. 단, 이 타이밍은 우리가 생각한 시각과 다를 수 있다는 점을 인정해야 한다. WorkManager의 예약 시각은 알람 시계가 아니라 최소 대기 시간에 가깝다.

실패 처리도 마찬가지다. 일반 코드에서는 예외가 나면 바로 다시 호출하거나, 사용자가 버튼을 눌러 재시도를 유도한다. 하지만 WorkManager에서는 Result.retry()와 백오프 정책을 통해 재시도를 OS 스케줄러와 맞춰서 진행한다. EXPONENTIAL 백오프를 쓰면, 10초 → 20초 → 40초 식으로 간격을 늘려가며, 전력 소모를 최소화하면서도 성공 확률을 높인다. 이때도 조건이 우선이기 때문에, 네트워크나 전원 조건이 맞지 않으면 백오프 타이머가 끝나도 실행되지 않는다.

그리고 체이닝(Chaining)은 WorkManager를 단순 실행기가 아니라 파이프라인 실행기로 만들어준다. “데이터 동기화 → 이미지 변환 → 서버 업로드” 같은 단계를 하나로 묶어두면, 앞 단계가 성공해야 다음 단계가 실행된다. 실패하면 뒤는 자동 취소되고, 재시도 시에도 실패한 단계부터 이어간다. OutputData를 이용하면 각 단계 간에 안전하게 데이터를 주고받을 수 있어서, 중간 결과를 파일로 저장하고 경로를 넘기는 식으로 설계하면 된다.

여기에 멱등성(Idempotency) 개념을 얹으면 안정성은 더 올라간다. 동일한 작업이 여러 번 실행돼도 결과가 변하지 않도록 만드는 것. 서버에 파일을 업로드할 때는 해시나 오프셋 기반으로 재개하고, 중복 요청을 방지한다. Unique Work로 작업 이름을 고정하면, 사용자가 여러 번 버튼을 눌러도 하나의 작업만 유지된다. Tag를 붙여두면 특정 사용자나 특정 범주의 작업만 조회·취소할 수 있어서, 운영 단계에서 굉장히 유용하다.

UI와의 연결은 WorkInfo 관찰로 가능하다. 앱이 죽었다 살아나도 DB에서 상태를 읽어와 진행률을 다시 표시할 수 있다. Worker 내부에서 setProgress()를 호출하면, LiveData나 Flow로 UI에 실시간 전달된다. 사용자 입장에서는 “뒤에서 무언가 진행 중”이라는 피드백이 생기고, 이는 장기 실행 작업에서 매우 중요하다.

운영 관점에서는 Prune와 로깅이 필요하다. 오래된 작업 기록을 주기적으로 정리하지 않으면 DB가 커져서 스케줄링 속도가 느려진다. pruneWork()를 앱 시작 시나 특정 시점에 호출해 청소 루틴을 유지하는 것이 좋다. 디버깅할 때는 WorkManager의 Logger 레벨을 올리고, 테스트 환경에서는 WorkManagerTestInitHelper로 스케줄링을 강제로 조작하며 시나리오를 재현한다.

마지막으로, 모든 백그라운드 설계는 취소 가능성을 전제로 해야 한다. 사용자가 로그아웃하면 해당 유저 태그의 작업을 즉시 취소하고, Worker 내부에서는 isStopped나 isActive를 주기적으로 확인해 협력적 취소를 구현한다. 중단이 OS의 개입이라면 재시도를, 사용자의 의도라면 실패로 처리한다.

이 모든 구조를 관통하는 한 줄은 변하지 않는다.

내가 돌리는 게 아니라, OS가 돌리게 한다.

우리가 제어할 수 없는 순간에도 작업을 이어가려면, 절대권력인 OS의 정책을 받아들이고 그 위에서 설계해야 한다. 그게 멱등성이고, Constraints고, 백오프고, 체이닝이다. WorkManager는 이 원칙을 코드로 옮겨놓은 도구다. 화면 밖의 세계에서도 작업이 살아남게 만드는 유일하게 합법적이고 지속 가능한 방법.

그리고 그 원칙을 받아들이는 순간, 장기 실행 작업은 더 이상 불확실성이 아니라 예측 가능한 시스템이 된다. 1장과 2장에서 본 제약들은 더 이상 걸림돌이 아니라, 우리가 활용할 수 있는 규칙이 된다. OS와 맞서 싸우는 대신, OS의 흐름 위에 올라타는 것. 그게 WorkManager의 철학이고, 이 글에서 반복해서 강조한 생존 전략의 핵심이다.

profile
안녕하세요. 날씨가 참 덥네요.

0개의 댓글