iOS 백그라운드 작업: BGAppRefreshTask와 BGProcessingTask

모바일 개발

iOS백그라운드 작업BGTaskSchedulerURLSession백그라운드 다운로드

이 글은 누구를 위한 것인가

  • 앱이 백그라운드에서 데이터를 미리 가져오게 하고 싶은 팀
  • 대용량 파일 다운로드를 백그라운드에서 안정적으로 처리하려는 개발자
  • iOS 백그라운드 실행 정책과 제약을 이해하려는 iOS 개발자

들어가며

iOS는 배터리와 성능을 위해 백그라운드 실행을 엄격히 제한한다. BackgroundTasks 프레임워크의 두 가지 작업 유형을 올바르게 사용하면 앱이 포그라운드에 없어도 콘텐츠를 최신 상태로 유지할 수 있다.

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


1. iOS 백그라운드 실행 유형

[BackgroundTasks 프레임워크]

BGAppRefreshTask:
  목적: 짧은 콘텐츠 업데이트
  실행 시간: ~30초
  실행 조건: 시스템 재량 (배터리, 사용 패턴 기반)
  사용 예: 뉴스 피드 미리 가져오기, 메시지 확인
  Info.plist: BGTaskSchedulerPermittedIdentifiers

BGProcessingTask:
  목적: 장시간 처리 작업
  실행 시간: 수분 ~ 수십분
  실행 조건: 충전 중, Wi-Fi 연결 시 (옵션)
  사용 예: DB 마이그레이션, 머신러닝 모델 업데이트, 대용량 동기화
  requiresNetworkConnectivity: true/false
  requiresExternalPower: true/false

URLSession 백그라운드 다운로드:
  실행 시간: 무제한 (시스템 관리)
  목적: 대용량 파일 다운로드/업로드
  앱 종료 후에도 계속 실행
  완료 시 앱 깨워서 알림

사일런트 푸시 (content-available: 1):
  실행 시간: ~30초
  목적: 서버 이벤트에 반응해 콘텐츠 업데이트
  제약: 과도한 사용 시 시스템이 무시

2. BackgroundTasks 구현

import BackgroundTasks
import UIKit

// AppDelegate에서 등록
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        registerBackgroundTasks()
        return true
    }
    
    private func registerBackgroundTasks() {
        // BGAppRefreshTask 등록
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.refresh",
            using: nil
        ) { task in
            self.handleAppRefresh(task: task as! BGAppRefreshTask)
        }
        
        // BGProcessingTask 등록
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.app.processing",
            using: nil
        ) { task in
            self.handleProcessingTask(task: task as! BGProcessingTask)
        }
    }
    
    // 앱이 백그라운드로 갈 때 스케줄 등록
    func applicationDidEnterBackground(_ application: UIApplication) {
        scheduleAppRefresh()
        scheduleProcessingTask()
    }
}

// BGAppRefreshTask 처리
extension AppDelegate {
    func handleAppRefresh(task: BGAppRefreshTask) {
        // 다음 refresh 즉시 스케줄 (연쇄 실행)
        scheduleAppRefresh()
        
        let operation = ContentRefreshOperation()
        
        task.expirationHandler = {
            // 시간 초과 시 작업 중지
            operation.cancel()
        }
        
        operation.completionBlock = {
            task.setTaskCompleted(success: !operation.isCancelled)
        }
        
        OperationQueue.main.addOperation(operation)
    }
    
    func scheduleAppRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // 15분 후 최소
        
        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            print("BGAppRefreshTask 스케줄 실패: \(error)")
        }
    }
}

// BGProcessingTask 처리
extension AppDelegate {
    func handleProcessingTask(task: BGProcessingTask) {
        scheduleProcessingTask()  // 다음 실행 예약
        
        let syncTask = Task {
            do {
                try await performHeavySync()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }
        
        task.expirationHandler = {
            syncTask.cancel()
        }
    }
    
    func scheduleProcessingTask() {
        let request = BGProcessingTaskRequest(identifier: "com.app.processing")
        request.requiresNetworkConnectivity = true
        request.requiresExternalPower = true  // 충전 중에만
        request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)  // 1시간 후
        
        try? BGTaskScheduler.shared.submit(request)
    }
    
    private func performHeavySync() async throws {
        // 대용량 데이터 동기화
        let products = try await APIClient.fetchAllProducts()
        try await DatabaseManager.shared.bulkUpsert(products)
    }
}

// URLSession 백그라운드 다운로드
class BackgroundDownloadManager: NSObject {
    static let shared = BackgroundDownloadManager()
    
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: "com.app.download")
        config.isDiscretionary = false  // 즉시 시작 (true면 시스템 재량)
        config.sessionSendsLaunchEvents = true  // 완료 시 앱 깨움
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()
    
    var completionHandler: (() -> Void)?  // AppDelegate에서 받은 핸들러
    var progressHandlers: [URL: (Double) -> Void] = [:]
    var completionHandlers: [URL: (URL?, Error?) -> Void] = [:]
    
    func downloadFile(url: URL,
                      onProgress: @escaping (Double) -> Void,
                      onComplete: @escaping (URL?, Error?) -> Void) {
        progressHandlers[url] = onProgress
        completionHandlers[url] = onComplete
        
        let task = session.downloadTask(with: url)
        task.resume()
    }
}

extension BackgroundDownloadManager: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        guard let url = downloadTask.originalRequest?.url else { return }
        
        // 임시 파일을 앱 디렉토리로 이동
        let destURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent(url.lastPathComponent)
        
        try? FileManager.default.moveItem(at: location, to: destURL)
        
        DispatchQueue.main.async {
            self.completionHandlers[url]?(destURL, nil)
            self.completionHandlers.removeValue(forKey: url)
        }
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        guard let url = downloadTask.originalRequest?.url else { return }
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        
        DispatchQueue.main.async {
            self.progressHandlers[url]?(progress)
        }
    }
    
    // 백그라운드 세션 완료 시 호출
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            self.completionHandler?()
            self.completionHandler = nil
        }
    }
}

// AppDelegate에서 백그라운드 세션 완료 처리
extension AppDelegate {
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        BackgroundDownloadManager.shared.completionHandler = completionHandler
    }
}

// 사일런트 푸시 처리
extension AppDelegate {
    func application(_ application: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                     fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        // content-available: 1 인 사일런트 푸시
        guard userInfo["content-available"] as? Int == 1 else {
            completionHandler(.noData)
            return
        }
        
        Task {
            do {
                let hasNew = try await fetchNewContent()
                completionHandler(hasNew ? .newData : .noData)
            } catch {
                completionHandler(.failed)
            }
        }
    }
    
    private func fetchNewContent() async throws -> Bool {
        // 최신 데이터 확인 및 저장
        let latestVersion = try await APIClient.checkVersion()
        guard latestVersion > LocalCache.currentVersion else { return false }
        
        let data = try await APIClient.fetchUpdates()
        try await LocalCache.update(with: data)
        return true
    }
}

class ContentRefreshOperation: Operation {}
class DatabaseManager { static let shared = DatabaseManager()
    func bulkUpsert(_ items: [Any]) async throws {} }
class APIClient { static func fetchAllProducts() async throws -> [Any] { return [] }
    static func checkVersion() async throws -> Int { return 1 }
    static func fetchUpdates() async throws -> Data { return Data() } }
class LocalCache { static var currentVersion = 0
    static func update(with data: Data) async throws {} }

마무리

iOS 백그라운드 작업의 핵심은 "시스템이 언제 실행할지 모른다"는 전제다. BGAppRefreshTask는 30초 이내에 완료해야 하고, BGProcessingTask는 충전 중/Wi-Fi 환경에서 더 자주 실행된다. 대용량 다운로드는 반드시 URLSession 백그라운드 세션을 사용해야 앱 종료 후에도 완료된다. 사일런트 푸시는 과도하게 사용하면 시스템이 무시하므로 중요한 이벤트에만 사용하라.