아티클 목록으로 가기

WorkManager 내부 구조: 보장된 백그라운드 작업이 실제로 동작하는 방식과 Service가 이를 대체할 수 없는 이유

skydovesJaewoong Eum (skydoves)||26분 소요

WorkManager 내부 구조: 보장된 백그라운드 작업이 실제로 동작하는 방식과 Service가 이를 대체할 수 없는 이유

안드로이드의 WorkManager는 영속적이면서도 지연 가능한(deferrable) 백그라운드 작업을 처리하기 위한 공식 권장 솔루션으로 자리잡았습니다. 앱 프로세스와 함께 생성되고 소멸되는 일시적인 백그라운드 작업과 달리, WorkManager는 사용자가 앱을 강제 종료하거나, 기기가 재부팅되거나, 아직 제약 조건이 충족되지 않은 상황에서도 등록된 작업이 최종적으로 반드시 실행되도록 보장합니다. API 자체는 표면적으로 단순해 보이지만, 내부적으로는 작업 영속성(work persistence), 이중 스케줄러 조율(dual-scheduler coordination), 제약 조건 추적(constraint tracking), 프로세스 복원력(process resilience), 상태 관리(state management)에 걸쳐 정교한 설계 결정이 담겨 있으며, Room 데이터베이스, 다수의 스케줄러 백엔드, 그리고 치밀하게 조율된 실행 파이프라인이 이를 뒷받침하고 있습니다.

이번 글에서는 Jetpack WorkManager의 내부 동작 원리를 깊이 있게 살펴봅니다. 구체적으로, 싱글톤이 AndroidX Startup을 통해 어떻게 초기화되고 부트스트랩되는지, WorkSpec 엔티티가 Room 데이터베이스에 작업 메타데이터를 어떻게 영속화하는지, GreedySchedulerSystemJobScheduler로 구성된 이중 스케줄러 시스템이 어떻게 조율되는지, ProcessorWorkerWrapper가 실제 작업 실행을 어떻게 관리하는지, ConstraintTracker가 시스템 상태를 어떻게 모니터링하여 제약 조건 충족 여부를 판단하는지, ForceStopRunnable이 앱 강제 종료를 감지하고 작업을 재스케줄링하는 방식, 그리고 작업 체이닝(work chaining)이 Dependency 테이블을 통해 의존성 그래프를 어떻게 구성하는지 알아보겠습니다.

근본적인 문제: 신뢰할 수 있는 백그라운드 실행

안드로이드에서의 백그라운드 실행은 근본적으로 불안정합니다. 시스템은 메모리를 회수하기 위해 프로세스를 적극적으로 종료하고, Doze 모드는 백그라운드 활동을 제한하며, 앱 대기 버킷(App Standby Buckets)은 자주 사용되지 않는 앱의 작업을 쓰로틀링합니다. 이러한 환경에서 백그라운드 작업을 단순하게 접근하면 다음과 같은 코드를 작성하게 됩니다.

class SyncActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Thread {
            // 서버와 데이터 동기화
            api.syncAllData()
        }.start()
    }
}

이 코드는 여러 측면에서 실패할 수밖에 없습니다. 프로세스가 종료되면 스레드도 함께 소멸됩니다. 네트워크 장애 시 재시도 메커니즘이 존재하지 않습니다. 기기 재부팅 이후에는 작업이 유지되지 않으며, "Wi-Fi 연결 시에만" 또는 "충전 중일 때만"과 같은 제약 조건을 지정할 방법도 없습니다.

이를 개선하기 위해 Service를 사용하는 방법을 생각해 볼 수 있습니다.

class SyncService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Thread { api.syncAllData() }.start()
        return START_REDELIVER_INTENT
    }
}

이 방식은 다소 나아진 면이 있습니다. START_REDELIVER_INTENT는 프로세스가 종료되었을 때 Intent가 재전달되도록 보장하기 때문입니다. 하지만 여전히 제약 조건 지원, 작업 체이닝, 재부팅 이후 영속성, 작업 상태에 대한 관찰 가능성(observability)이 없습니다. 이 모든 것을 직접 구축해야 하는 상황입니다.

WorkManager는 영속적이면서 제약 조건을 인식하고, 관찰 가능하며, 체이닝이 가능한 백그라운드 작업을 보장된 실행과 함께 수행할 수 있는 완전한 인프라를 제공함으로써 이 문제를 해결합니다.

초기화: 부트스트랩 시퀀스

WorkManager는 Application.onCreate()가 실행되기 전에 자동으로 초기화됩니다. 진입점은 AndroidX Startup의 Initializer 인터페이스를 구현한 WorkManagerInitializer입니다.

public final class WorkManagerInitializer implements Initializer<WorkManager> {
    @Override
    public WorkManager create(Context context) {
        // 기본 설정으로 WorkManager 초기화
        Logger.get().debug(TAG, "Initializing WorkManager with default configuration.");
        WorkManager.initialize(context, new Configuration.Builder().build());
        return WorkManager.getInstance(context);
    }

    @Override
    public List<Class<? extends Initializer<?>>> dependencies() {
        return Collections.emptyList();
    }
}

AndroidX Startup은 ContentProvider를 사용하여 Application.onCreate() 이전에 초기화를 트리거합니다. 이는 애플리케이션 코드가 실행되기 전에 WorkManager가 준비 완료 상태가 되도록 보장하므로 매우 중요합니다. dependencies() 메서드는 빈 리스트를 반환하는데, 이는 WorkManager가 다른 Startup 초기화 모듈에 대한 의존성이 없다는 것을 의미합니다.

이중 잠금(dual-lock) 패턴의 싱글톤

WorkManager.initialize()WorkManagerImpl.initialize()에 위임하며, 동기화된 이중 인스턴스 패턴을 사용합니다.

public static void initialize(Context context, Configuration configuration) {
    synchronized (sLock) {
        if (sDelegatedInstance != null && sDefaultInstance != null) {
            throw new IllegalStateException("WorkManager is already initialized.");
        }
        if (sDelegatedInstance == null) {
            context = context.getApplicationContext();
            if (sDefaultInstance == null) {
                sDefaultInstance = createWorkManager(context, configuration);
            }
            sDelegatedInstance = sDefaultInstance;
        }
    }
}

두 개의 정적 필드가 각기 다른 역할을 수행합니다. sDefaultInstance는 실제 싱글톤 인스턴스를 보유합니다. sDelegatedInstance는 테스트 코드에서 setDelegate()를 통해 목(mock) 객체를 주입할 수 있도록 테스트 용도의 위임 기능을 제공합니다. sLock 객체는 스레드 안전한 접근을 보장하며, 이중 초기화에 대한 명시적 검사는 IllegalStateException을 발생시켜, 커스텀 초기화를 원하는 경우 매니페스트에서 WorkManagerInitializer를 비활성화하라는 가이드 메시지를 제공합니다.

Configuration.Provider를 통한 온디맨드 초기화

getInstance(Context)가 호출되었을 때 인스턴스가 존재하지 않으면, WorkManager는 온디맨드(on-demand) 초기화로 폴백(fallback)합니다.

public static WorkManagerImpl getInstance(Context context) {
    synchronized (sLock) {
        WorkManagerImpl instance = getInstance();
        if (instance == null) {
            Context appContext = context.getApplicationContext();
            if (appContext instanceof Configuration.Provider) {
                initialize(appContext,
                    ((Configuration.Provider) appContext).getWorkManagerConfiguration());
                instance = getInstance(appContext);
            } else {
                throw new IllegalStateException(
                    "WorkManager is not initialized properly.");
            }
        }
        return instance;
    }
}

Application 클래스가 Configuration.Provider를 구현하고 있으면, WorkManager는 해당 설정을 사용하여 지연 초기화(lazy initialization)를 수행합니다. 이 패턴을 활용하면 자동 초기화를 비활성화한 후 Application.onCreate()에서 initialize()를 명시적으로 호출하지 않고도 커스텀 설정을 제공할 수 있습니다.

createWorkManager 팩토리

실제 WorkManagerImpl의 생성 과정에서는 모든 내부 컴포넌트가 연결됩니다.

fun WorkManagerImpl(
    context: Context,
    configuration: Configuration,
    workTaskExecutor: TaskExecutor = WorkManagerTaskExecutor(configuration.taskExecutor),
    workDatabase: WorkDatabase = WorkDatabase.create(
        context.applicationContext,
        workTaskExecutor.serialTaskExecutor,
        configuration.clock,
        context.resources.getBoolean(R.bool.workmanager_test_configuration),
    ),
    trackers: Trackers = Trackers(context.applicationContext, workTaskExecutor),
    processor: Processor =
        Processor(context.applicationContext, configuration, workTaskExecutor, workDatabase),
    schedulersCreator: SchedulersCreator = ::createSchedulers,
): WorkManagerImpl {
    val schedulers = schedulersCreator(
        context, configuration, workTaskExecutor, workDatabase, trackers, processor,
    )
    return WorkManagerImpl(
        context.applicationContext, configuration, workTaskExecutor,
        workDatabase, schedulers, processor, trackers,
    )
}

이 코틀린 팩토리 함수는 기본 매개변수를 활용하여 의존성을 연결하고, 완전한 의존성 그래프(dependency graph)를 구성합니다. schedulersCreator 매개변수는 함수 타입 별칭(type alias)으로, 테스트 코드에서 커스텀 스케줄러를 주입할 수 있도록 설계되었습니다. 프로덕션 구현체는 SystemJobSchedulerGreedyScheduler를 함께 생성합니다.

private fun createSchedulers(
    context: Context,
    configuration: Configuration,
    workTaskExecutor: TaskExecutor,
    workDatabase: WorkDatabase,
    trackers: Trackers,
    processor: Processor,
): List<Scheduler> = listOf(
    Schedulers.createBestAvailableBackgroundScheduler(context, workDatabase, configuration),
    GreedyScheduler(
        context, configuration, trackers, processor,
        WorkLauncherImpl(processor, workTaskExecutor), workTaskExecutor,
    ),
)

이중 스케줄러 아키텍처(dual-scheduler architecture)는 WorkManager의 핵심 설계 결정입니다. SystemJobScheduler는 제약 조건이나 지연이 있는 작업을 안드로이드의 JobScheduler에 위임하여, 시스템이 프로세스를 깨울 수 있는 능력을 활용합니다. 반면 GreedyScheduler는 제약 조건이 없는 작업을 현재 프로세스 내에서 즉시 처리하여, 시스템 스케줄링의 오버헤드를 회피합니다. 이러한 이중 구조 덕분에 상황에 따라 가장 효율적인 실행 경로를 자동으로 선택할 수 있습니다.

Direct Boot 보호

생성자에는 중요한 안전 검사가 포함되어 있습니다.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
        && Api24Impl.isDeviceProtectedStorage(context)) {
    throw new IllegalStateException("Cannot initialize WorkManager in direct boot mode");
}

Direct Boot 모드는 사용자가 기기 잠금을 해제하기 전에 실행됩니다. WorkManager의 Room 데이터베이스는 자격 증명 암호화 저장소(credential-encrypted storage)를 사용하는데, Direct Boot 중에는 이 저장소에 접근할 수 없습니다. 이 검사는 알기 어려운 SQLite 오류를 방지하기 위해 명확한 메시지와 함께 빠르게 실패하도록(fail-fast) 설계되었습니다.

WorkSpec: 영속적 작업 엔티티

WorkManager 영속성의 핵심에는 WorkSpec이 있습니다. 이 Room 엔티티는 하나의 작업 단위에 대한 모든 정보를 저장합니다.

@Entity(indices = [
    Index(value = ["schedule_requested_at"]),
    Index(value = ["last_enqueue_time"])
])
data class WorkSpec(
    @PrimaryKey val id: String,
    var state: WorkInfo.State = WorkInfo.State.ENQUEUED,
    var workerClassName: String,
    var inputMergerClassName: String = OverwritingInputMerger::class.java.name,
    var input: Data = Data.EMPTY,
    var output: Data = Data.EMPTY,
    var initialDelay: Long = 0,
    var intervalDuration: Long = 0,
    var flexDuration: Long = 0,
    @Embedded var constraints: Constraints = Constraints.NONE,
    var runAttemptCount: Int = 0,
    var backoffPolicy: BackoffPolicy = BackoffPolicy.EXPONENTIAL,
    var backoffDelayDuration: Long = WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS,
    var lastEnqueueTime: Long = NOT_ENQUEUED,
    var minimumRetentionDuration: Long = 0,
    var scheduleRequestedAt: Long = SCHEDULE_NOT_REQUESTED_YET,
    var expedited: Boolean = false,
    var outOfQuotaPolicy: OutOfQuotaPolicy = OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST,
    var periodCount: Int = 0,
    val generation: Int = 0,
    var nextScheduleTimeOverride: Long = Long.MAX_VALUE,
    var nextScheduleTimeOverrideGeneration: Int = 0,
    val stopReason: Int = WorkInfo.STOP_REASON_NOT_STOPPED,
    var traceTag: String? = null,
    // ...
)

WorkSpec의 모든 필드는 각각 특정 기능을 지원하도록 설계되어 있습니다. idWorkRequest로부터 생성된 UUID 문자열입니다. state enum은 ENQUEUED, RUNNING, BLOCKED, SUCCEEDED, FAILED, CANCELLED의 6가지 상태를 통해 작업의 생명주기를 추적합니다. workerClassName에는 인스턴스화할 ListenableWorker의 정규화된 클래스 이름(fully-qualified class name)이 저장되는데, 대기 중인 작업이 기존 이름을 참조하고 있기 때문에 Worker 클래스의 이름을 변경하면 위험할 수 있습니다. 이 점은 실무에서 반드시 유의해야 합니다.

Constraints 객체는 @Embedded 어노테이션을 통해 WorkSpec 테이블에 직접 포함(flatten)됩니다. requiredNetworkType, requiresCharging, requiresDeviceIdle과 같은 제약 조건 필드가 별도의 조인 테이블 없이 컬럼으로 저장되어, 일반적인 쿼리 성능을 높여 줍니다.

다음 실행 시간 계산

calculateNextRunTime() 메서드는 스케줄링 알고리즘을 구현합니다.

fun calculateNextRunTime(
    isBackedOff: Boolean, runAttemptCount: Int, backoffPolicy: BackoffPolicy,
    backoffDelayDuration: Long, lastEnqueueTime: Long, periodCount: Int,
    isPeriodic: Boolean, initialDelay: Long, flexDuration: Long,
    intervalDuration: Long, nextScheduleTimeOverride: Long,
): Long {
    return if (nextScheduleTimeOverride != Long.MAX_VALUE && isPeriodic) {
        return if (periodCount == 0) nextScheduleTimeOverride
        else nextScheduleTimeOverride.coerceAtLeast(
            lastEnqueueTime + MIN_PERIODIC_INTERVAL_MILLIS
        )
    } else if (isBackedOff) {
        val isLinearBackoff = backoffPolicy == BackoffPolicy.LINEAR
        val delay = if (isLinearBackoff) backoffDelayDuration * runAttemptCount
            else Math.scalb(backoffDelayDuration.toFloat(), runAttemptCount - 1).toLong()
        lastEnqueueTime + delay.coerceAtMost(WorkRequest.MAX_BACKOFF_MILLIS)
    } else if (isPeriodic) {
        var schedule = if (periodCount == 0) lastEnqueueTime + initialDelay
            else lastEnqueueTime + intervalDuration
        val isFlexApplicable = flexDuration != intervalDuration
        if (isFlexApplicable && periodCount == 0) {
            schedule += (intervalDuration - flexDuration)
        }
        schedule
    } else {
        lastEnqueueTime + initialDelay
    }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기