378 lines
9.3 KiB
Markdown
378 lines
9.3 KiB
Markdown
---
|
|
id: mobile-background-sync
|
|
title: Background Sync — iOS / Android 비교
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [mobile, background, sync, vibe-coding]
|
|
tech_stack: { language: "Swift / Kotlin", applicable_to: ["iOS", "Android"] }
|
|
applied_in: []
|
|
aliases: [background fetch, BGTaskScheduler, WorkManager, periodic sync, Doze mode]
|
|
---
|
|
|
|
# Background Sync
|
|
|
|
> App 가 background 일 때 sync. **iOS = BGTaskScheduler (제약 강함). Android = WorkManager (더 유연)**. Battery + 데이터 절약 OS 가 throttle.
|
|
|
|
## 📖 핵심 개념
|
|
- iOS: OS 가 사용 패턴 학습 → 자유 결정.
|
|
- Android: WorkManager + 제약 (battery, network).
|
|
- Doze (Android) / Low Power (iOS): 추가 throttle.
|
|
- Push: 가장 reliable trigger.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### iOS — BGTaskScheduler (modern)
|
|
```swift
|
|
import BackgroundTasks
|
|
|
|
// Info.plist
|
|
// BGTaskSchedulerPermittedIdentifiers: ["com.acme.refresh"]
|
|
// UIBackgroundModes: [fetch, processing]
|
|
```
|
|
|
|
```swift
|
|
// Register (App init)
|
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.refresh", using: nil) { task in
|
|
handleRefresh(task as! BGAppRefreshTask)
|
|
}
|
|
|
|
func handleRefresh(_ task: BGAppRefreshTask) {
|
|
scheduleNextRefresh()
|
|
|
|
let op = SyncOperation()
|
|
|
|
task.expirationHandler = {
|
|
op.cancel()
|
|
}
|
|
|
|
op.completionBlock = {
|
|
task.setTaskCompleted(success: !op.isCancelled)
|
|
}
|
|
|
|
OperationQueue().addOperation(op)
|
|
}
|
|
|
|
func scheduleNextRefresh() {
|
|
let request = BGAppRefreshTaskRequest(identifier: "com.acme.refresh")
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15분 후+
|
|
try? BGTaskScheduler.shared.submit(request)
|
|
}
|
|
```
|
|
|
|
→ OS 가 사용자 패턴 학습 — "보통 9시 사용" 시간 가까이 트리거.
|
|
|
|
### iOS — Long task (BGProcessingTask)
|
|
```swift
|
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.cleanup", using: nil) { task in
|
|
handleCleanup(task as! BGProcessingTask)
|
|
}
|
|
|
|
let request = BGProcessingTaskRequest(identifier: "com.acme.cleanup")
|
|
request.requiresNetworkConnectivity = false
|
|
request.requiresExternalPower = true // 충전 중 만
|
|
try? BGTaskScheduler.shared.submit(request)
|
|
```
|
|
|
|
→ 1-30 min. 충전 중 / 야간.
|
|
|
|
### iOS — silent push (background)
|
|
```ts
|
|
// Server
|
|
{
|
|
aps: {
|
|
'content-available': 1
|
|
},
|
|
syncKey: '...'
|
|
}
|
|
```
|
|
|
|
```swift
|
|
// AppDelegate
|
|
func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
Task {
|
|
await syncData(key: userInfo["syncKey"] as? String ?? "")
|
|
completion(.newData)
|
|
}
|
|
}
|
|
```
|
|
|
|
→ Push 가 trigger. Server-driven.
|
|
|
|
⚠️ iOS 가 silent push throttle (1-2 / hour). Reliable 가정 X.
|
|
|
|
### Android — WorkManager
|
|
```kotlin
|
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
|
```
|
|
|
|
```kotlin
|
|
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
|
override suspend fun doWork(): Result {
|
|
return try {
|
|
val data = api.fetchUpdates()
|
|
db.update(data)
|
|
Result.success()
|
|
} catch (e: Exception) {
|
|
if (runAttemptCount < 3) Result.retry()
|
|
else Result.failure()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Schedule
|
|
val constraints = Constraints.Builder()
|
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
.setRequiresBatteryNotLow(true)
|
|
.build()
|
|
|
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
|
.setConstraints(constraints)
|
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
|
.build()
|
|
|
|
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
|
|
```
|
|
|
|
→ 15 min minimum. OS 가 정확 시점 결정.
|
|
|
|
### Android — Expedited (즉시 + 짧은)
|
|
```kotlin
|
|
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
|
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
|
.build()
|
|
|
|
WorkManager.getInstance(ctx).enqueue(request)
|
|
```
|
|
|
|
→ Foreground priority. 10 min limit.
|
|
|
|
### Android — Periodic vs One-time
|
|
```
|
|
Periodic: 15 min minimum. Repeats.
|
|
One-time: 한 번. (즉시 또는 delay)
|
|
```
|
|
|
|
### Android — FCM data message
|
|
```ts
|
|
// Server
|
|
{
|
|
data: { syncKey: '...' },
|
|
android: { priority: 'high' }
|
|
}
|
|
```
|
|
|
|
```kotlin
|
|
class MyFcmService : FirebaseMessagingService() {
|
|
override fun onMessageReceived(msg: RemoteMessage) {
|
|
val key = msg.data["syncKey"] ?: return
|
|
|
|
// Schedule WorkManager (즉시)
|
|
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
|
.setInputData(workDataOf("key" to key))
|
|
.build()
|
|
WorkManager.getInstance(this).enqueue(request)
|
|
}
|
|
}
|
|
```
|
|
|
|
→ Push trigger + WorkManager 처리.
|
|
|
|
### Common 패턴 — 동기화 strategy
|
|
```
|
|
1. Pull (period):
|
|
- WorkManager / BGTask
|
|
- 매 15-60 min
|
|
- Battery / data 비싸지만 simple
|
|
|
|
2. Push-driven:
|
|
- Server send notification
|
|
- App 가 fetch
|
|
- Reliable + efficient
|
|
|
|
3. WebSocket / SSE (foreground 만):
|
|
- Real-time
|
|
- Background = X (suspend)
|
|
|
|
4. CDC / sync:
|
|
- Cursor / version
|
|
- Delta only
|
|
```
|
|
|
|
→ Push + delta sync = best.
|
|
|
|
### Delta sync
|
|
```ts
|
|
// Server
|
|
GET /sync?since=<lastSyncTimestamp>
|
|
→ { items: [...changed], deleted: [...ids], cursor: 'new-cursor' }
|
|
|
|
// Client
|
|
const response = await api.sync({ since: lastSync });
|
|
db.applyDelta(response.items, response.deleted);
|
|
lastSync = response.cursor;
|
|
```
|
|
|
|
→ 적은 bandwidth + battery.
|
|
|
|
### iOS Doze / Low Power
|
|
```swift
|
|
import UIKit
|
|
|
|
if ProcessInfo.processInfo.isLowPowerModeEnabled {
|
|
// Reduce sync frequency
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { _ in
|
|
// Re-evaluate
|
|
}
|
|
```
|
|
|
|
### Android Doze
|
|
```kotlin
|
|
// Idle 상태 — WorkManager 가 throttle (15분 이하 안 됨).
|
|
// FCM high-priority 가 wake up 가능.
|
|
|
|
if (powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
|
// 사용자가 unrestricted 허용
|
|
}
|
|
```
|
|
|
|
### Background 권한 (사용자 friendly)
|
|
```
|
|
Android 12+:
|
|
- Background activity 차단 강
|
|
- Foreground service type 명시 (위 [[Android_Foreground_Service_Patterns]])
|
|
|
|
iOS:
|
|
- Background mode 일부만
|
|
- 권한 자동 X — Apple 가 사용 패턴 결정
|
|
```
|
|
|
|
### Sync conflict
|
|
```ts
|
|
// Server 와 client 가 둘 다 변경
|
|
- Last-write-wins (간단)
|
|
- Merge (CRDT)
|
|
- Conflict UI (사용자 해결)
|
|
- Three-way merge (base + client + server)
|
|
```
|
|
|
|
### Test (Android WorkManager)
|
|
```kotlin
|
|
@Test
|
|
fun testSyncWorker() = runTest {
|
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
val worker = TestListenableWorkerBuilder<SyncWorker>(context).build()
|
|
val result = worker.startWork().get()
|
|
assertEquals(Result.success(), result)
|
|
}
|
|
```
|
|
|
|
### Test (iOS BGTaskScheduler)
|
|
```
|
|
디버깅:
|
|
- Xcode → Debug → Simulate Background Fetch
|
|
- Physical device 권장 (정확한 throttle)
|
|
```
|
|
|
|
### Battery / data 모니터링
|
|
```kotlin
|
|
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
|
val isCharging = batteryManager.isCharging
|
|
val isMetered = ConnectivityManager.isActiveNetworkMetered
|
|
|
|
if (batteryLevel < 20 && !isCharging) skipSync()
|
|
if (isMetered) lightSyncOnly()
|
|
```
|
|
|
|
### iOS NetworkPathMonitor
|
|
```swift
|
|
import Network
|
|
let monitor = NWPathMonitor()
|
|
monitor.pathUpdateHandler = { path in
|
|
if path.usesInterfaceType(.cellular) {
|
|
// Cellular — 작게
|
|
} else if path.usesInterfaceType(.wifi) {
|
|
// Wifi OK
|
|
}
|
|
}
|
|
monitor.start(queue: .global())
|
|
```
|
|
|
|
### Cross-platform abstraction (RN / Flutter)
|
|
```ts
|
|
// react-native-background-fetch
|
|
import BackgroundFetch from 'react-native-background-fetch';
|
|
|
|
BackgroundFetch.configure({
|
|
minimumFetchInterval: 15,
|
|
enableHeadless: true,
|
|
}, async (taskId) => {
|
|
await syncData();
|
|
BackgroundFetch.finish(taskId);
|
|
});
|
|
```
|
|
|
|
### Sync UI feedback
|
|
```
|
|
사용자에 sync 결과 명시:
|
|
- "Last synced 5 min ago"
|
|
- "Sync failed — tap to retry"
|
|
- "Pending changes: 3"
|
|
|
|
→ Trust + control.
|
|
```
|
|
|
|
### Recurring vs one-time
|
|
```
|
|
Daily report: PeriodicWork / BGAppRefresh
|
|
Specific event: OneTimeWork
|
|
On data change: Push trigger
|
|
On wifi: Constraint required
|
|
```
|
|
|
|
### Best practices
|
|
```
|
|
1. Minimize battery / data.
|
|
2. Push 가 reliable, periodic 는 best-effort.
|
|
3. Sync UI 보임 (last synced time).
|
|
4. Conflict resolution 명시.
|
|
5. Failure 알람 (사용자에).
|
|
6. Cellular 시 작게.
|
|
7. Test on real device.
|
|
```
|
|
|
|
## 🤔 의사결정 기준
|
|
| 상황 | 추천 |
|
|
|---|---|
|
|
| 일반 sync (15 min+) | iOS BGTask / Android WorkManager |
|
|
| Real-time | Push + sync trigger |
|
|
| 큰 작업 | iOS BGProcessingTask / Android Foreground Service |
|
|
| 배터리 절약 | Constraint (charging, wifi) |
|
|
| Reliable | Push primary |
|
|
| Cross-platform | RN background-fetch / capacitor |
|
|
|
|
## ❌ 안티패턴
|
|
- **iOS background guarantee 가정**: Apple 가 결정. Best-effort.
|
|
- **Periodic 너무 자주 (1 min)**: throttle.
|
|
- **모든 사용자 push**: opt-out 무.
|
|
- **Sync 매번 모든 데이터**: delta 만.
|
|
- **Conflict 무시**: data loss.
|
|
- **Battery / data 무관심**: 사용자 uninstall.
|
|
- **Foreground service 없는 long task**: kill.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Push trigger + WorkManager / BGTask 처리.
|
|
- Delta sync + cursor.
|
|
- Constraint (battery, wifi).
|
|
- iOS = best-effort. Android = 더 reliable.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Android_WorkManager_Patterns]]
|
|
- [[iOS_Background_Tasks]]
|
|
- [[Mobile_Push_Deep]]
|