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
            }
        }
    }