Jaebi의 Binary는 호남선

[Swift] 대용량 파일 다운로드 (1) - FileDownloadService 본문

Swift

[Swift] 대용량 파일 다운로드 (1) - FileDownloadService

jaebijae 2025. 3. 8. 22:18

목표

  • async / await 사용
  • 다운로드 진행상황 확인 기능
  • Background에서 다운로드 기능

구현 내용

Singleton FileDownloadService 클래스 구현

  • URLSessionDownloadDelegate를 준수하여 Delegate 함수들로 세션 진행상황(+ 종료, 정지, 재개, 취소) 처리

URLSession만들기 및 Configuration 설정 (Configuration은 주석으로 설명)

  • URLSession
    • URLSession.downloadTask(with:)를 사용
  •  URLSessionConfiguration.background (Configuration 설정들은 주석으로 설명)
    • 앱이 백그라운드에 있거나 종료된 상태에서도 다운로드 작업을 수행
final class FileDownloadService: NSObject, URLSessionDownloadDelegate {
    static let shared = FileDownloadService()
    
    private lazy var session: URLSession = {
        let config: URLSessionConfiguration = URLSessionConfiguration.background(withIdentifier: "testDownload")
        config.isDiscretionary = true // 시스템 최적화 허용 - 베터리, 네트워크 상태, 전원 연결 상태등을 고려한 상황 판단하여 실행
        config.sessionSendsLaunchEvents = true // 앱이 종료되었거나 백그라운드 상태일 경우에도 URLSession 이벤트 발생시 시스템이 앱 자동 실행
        return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
    }()
    
    private override init() {
        super.init()
    }
    
    ...
}

다운로드 진행상황 및 시작 / 종료를 구독하기 위한 스트림 및 다운로드 함수 구현

  • 하나의 스트림으로 다운로드 진행상황 + 완료를 알 수 있기위해 DownloadStatus enum 구현
  • AsyncThrowingStream.Continuation을 생성하여 해당 서비스의 다운로드 Status 구독 하게 함
  •  ViewModel / Reducer / ViewController가 호출할 downloadFile함수 구현
    • 다운로드할 파일 URL String을 받아 downloadTask 실행
      • 유효하지 않은 URL 에러 처리
    • URLSession.downloadTask(with:)로 downloadTask 실행
enum DownloadStatus {
    case progress(Double)  // 0.0 ~ 1.0 사이의 진행률 값
    case finished(URL)     // 다운로드 완료 후 파일이 위치한 URL
}

final class FileDownloadService: NSObject, URLSessionDownloadDelegate {
    
    ...
    
    private var streamContinuation: AsyncThrowingStream<DownloadStatus, Error>.Continuation?
    
    func downloadFile(urlString: String) -> AsyncThrowingStream<DownloadStatus, Error> {
        guard let url = URL(string: urlString) else {
            return AsyncThrowingStream { continuation in
                continuation.finish(throwing: NSError(domain: "Invalid URL", code: -1))
            }
        }
        
        return AsyncThrowingStream { continuation in
            self.streamContinuation = continuation
            let task = self.session.downloadTask(with: url)
            task.resume()
        }
    }
    
    ...
    
}

Delegate 함수 처리

    • func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL)
      • 다운로드 작업이 성공적으로 완료되어, 파일이 임시 위치에 저장되었을 때 호출
      • 다운로드가 완료된 파일은 임시 위치에 저장되어 있으므로, 이 파일을 앱 저장소에 이동 및 이동된 위치를 스트림에 전달
      • 완료시 스트림 종료 및 스트림 nil 처리
    • func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)
      • 다운로드가 진행되는 동안 데이터가 쓰여질 때마다 호출
      • 현재까지 다운로드된 Bytes (totalBytesWritten)와 전체 예상 다운로드 Bytes (totalBytesExpectedToWrite)를 이용해 진행률 계산 (두번 1024로 나눠서 MB 계산)
      • 계산된 진행률을 스트림에 전달
    • func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64)
      • 중단되었던 다운로드 작업이 재개될 때 호출
      • 현재 간단하게 로깅만 함, 추후 Resume 기능 구현시 변경 예정
    • func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
      • 다운로드 작업이 완료된 후 (성공 / 실패 무관) 마지막으로 호출
      • 실패시 스트림 종료 및 스트림 nil 처리
extension FileDownloadService {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        print("Download finished at: \(location)")
        
        // TODO: URLSession이 임시 위치에 저장한 파일을 앱 내의 저장소로 옮기는 작업 및 최종 위치를 yield
        
        streamContinuation?.finish()
        streamContinuation = nil
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        print("Downloading: \(totalBytesWritten/1024/1024)MB of \(totalBytesExpectedToWrite/1024/1024)MB, \(String(format: "%.0f", progress*100))%")
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
        print("Download Resume: \(fileOffset/1024/1024)MB of \(expectedTotalBytes/1024/1024)MB")
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            print("Download Error: \(error.localizedDescription)")
            streamContinuation?.finish(throwing: error)
            streamContinuation = nil
        }
    }
}

 

전체 코드

import Foundation

enum DownloadStatus {
    case progress(Double)  // 0.0 ~ 1.0 사이의 진행률 값
    case finished(URL)     // 다운로드 완료 후 파일이 위치한 URL
}

final class FileDownloadService: NSObject, URLSessionDownloadDelegate {
    static let shared = FileDownloadService()
    
    private lazy var session: URLSession = {
        let config: URLSessionConfiguration = URLSessionConfiguration.background(withIdentifier: "testDownload")
        config.isDiscretionary = true // 시스템 최적화 허용 - 베터리, 네트워크 상태, 전원 연결 상태등을 고려한 상황 판단하여 실행
        config.sessionSendsLaunchEvents = true // 앱이 종료되었거나 백그라운드 상태일 경우에도 URLSession 이벤트 발생시 시스템이 앱 자동 실행
        return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
    }()
    
    private override init() {
        super.init()
    }
    
    private var streamContinuation: AsyncThrowingStream<DownloadStatus, Error>.Continuation?
    
    func downloadFile(urlString: String) -> AsyncThrowingStream<DownloadStatus, Error> {
        guard let url = URL(string: urlString) else {
            return AsyncThrowingStream { continuation in
                continuation.finish(throwing: NSError(domain: "Invalid URL", code: -1))
            }
        }
        
        return AsyncThrowingStream { continuation in
            self.streamContinuation = continuation
            let task = self.session.downloadTask(with: url)
            task.resume()
        }
    }
}

extension FileDownloadService {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        print("Download finished at: \(location)")
        
        // TODO: URLSession이 임시 위치에 저장한 파일을 앱 내의 저장소로 옮기는 작업 및 최종 위치를 yield
        
        streamContinuation?.finish()
        streamContinuation = nil
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        print("Downloading: \(totalBytesWritten/1024/1024)MB of \(totalBytesExpectedToWrite/1024/1024)MB, \(String(format: "%.0f", progress*100))%")
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
        print("Download Resume: \(fileOffset/1024/1024)MB of \(expectedTotalBytes/1024/1024)MB")
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            print("Download Error: \(error.localizedDescription)")
            streamContinuation?.finish(throwing: error)
            streamContinuation = nil
        }
    }
}