Jaebi의 Binary는 호남선

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

Swift

[Swift] 대용량 파일 다운로드 (2) - FileManager

jaebijae 2025. 3. 13. 05:11

목표

  • 파일 다운로드 성공시 원하는 Directory에 파일 저장
  • 해당 파일이 이미 존재할 경우 Replace 동작
  • 해당 파일은 App의 Files앱 내에서도 확인 가능

구현 내용

Info.plist의 키 설정

  • Info.plistSupports Document BrowserApplication supports iTunes file sharing값을 YES로 설정
    • Supports Document Browser (UISupportsDocumentBrowser)
      • iOS의 기본 문서 브라우저 기능 지원 설정, Files앱으로 문서 탐색 가능
    • Application supports iTunes file sharing (UIFileSharingEnabled)
      • 앱이 사용자와 파일 공유를 가능하게 함
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>UISupportsDocumentBrowser</key>
    <true/>
    <key>UIFileSharingEnabled</key>
    <true/>
</dict>
</plist>

FileManager를 통한 파일 이동 및 저장 구현

  • URLSessionDownloadTask를 통해 다운로드한 파일은 iOS에서 임시위치에 저장됨
    • 임시위치에 저장된 파일은 앱이 종료되거나 메모리 정리가 필요시 iOS가 삭제할 수 있음
  • urlSession(_:downloadTask:didFinishDownloadingTo:)의 다운받은 URL을 찍어보면 아래와 같이 출력
    • file:///private/var/mobile/Containers/Data/Application/{UUID}/Library/Caches/com.apple.nsurlsessiond/Downloads/{bundleID}/CFNetworkDownload_pamryr.tmp
  • 서버가 제공하는 파일명이 없거나 확장자가 빠진 경우가 있음
    • MIME 타입을 이용해 파일명을 보정
    • UniformTypeIdentifiersimport하여 타입 추론 작업 실행
  • 해당 파일을 지속적으로 접근할 수 있도록 파일을 저장소에 옮기는 작업 필요
  • 기존 파일 충돌 방지를 위해 같은 파일명이 동일한 위치에 존재하면 삭제
  • 모든 작업 완료 후 .finished(destinationURL) 을 반환하여 최종 위치 전달
private extension FileDownloadService {
    func moveDownloadedFile(downloadTask: URLSessionDownloadTask, downloadedLocation: URL) {
        let fileManager: FileManager = FileManager.default
        
        // 앱의 Documents 디렉토리를 가져옴, 실패시 에러 던지기
        guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            streamContinuation?.finish(throwing: NSError(domain: "Invalid File Document Directory", code: -1))
            return
        }
        
        // 파일명 결정: 서버 response의 suggestedFilename이 있으면 사용하고, 없으면 UUID로 생성
        var fileName = downloadTask.response?.suggestedFilename ?? UUID().uuidString
        
        // 만약 파일명에 확장자가 없다면, response의 MIME 타입을 통해 확장자를 추론해 추가
        if (fileName as NSString).pathExtension.isEmpty,
           let mimeType = downloadTask.response?.mimeType,
           let utType = UTType(mimeType: mimeType),
           let ext = utType.preferredFilenameExtension {
            fileName += ".\(ext)"
        }
        
        // 사용자가 지정한 Documents(또는 Library, Cache) 내의 폴더 (예제에서는 "MyDownloads")
        let folderURL = documentsDirectory.appendingPathComponent("MyDownloads", isDirectory: true)
        try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
        
        let destinationURL = folderURL.appendingPathComponent(fileName)
        
        do {
            // 기존에 같은 이름의 파일이 있다면 삭제
            if fileManager.fileExists(atPath: destinationURL.path) {
                try fileManager.removeItem(at: destinationURL)
            }
            // 임시 파일을 지정한 destination으로 이동
            try fileManager.moveItem(at: downloadedLocation, to: destinationURL)
            streamContinuation?.yield(.finished(destinationURL))
        } catch {
            // 파일 이동 및 실패시 에러 던지기
            streamContinuation?.finish(throwing: error)
            return
        }
        streamContinuation?.finish()
        streamContinuation = nil
    }
}

전체 코드

import Foundation
import UniformTypeIdentifiers

enum DownloadStatus {
    case progress(Double)  // 0.0 ~ 1.0 사이의 진행률 값
    case finished(URL)     // 다운로드 완료 후 파일이 위치한 URL
}
    
final class FileDownloadService: NSObject {
    static let shared = FileDownloadService()

    private lazy var session: URLSession = {
        let config: URLSessionConfiguration = URLSessionConfiguration.background(withIdentifier: "testDownload")
        config.isDiscretionary = false // 베터리, 네트워크 상태, 전원 연결 상태등을 무시하고 즉시 실행
        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()
        }
    }
}

// 가독성을 위해 Delegate 함수들은 extension으로 분리
extension FileDownloadService: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        print("Download finished at: \(location)")
        moveDownloadedFile(downloadTask: downloadTask, downloadedLocation: location)
    }
    
    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
        }
    }
}

// 파일 Management 관련 코드 분리
private extension FileDownloadService {
    func moveDownloadedFile(downloadTask: URLSessionDownloadTask, downloadedLocation: URL) {
        let fileManager: FileManager = FileManager.default
        
        // 앱의 Documents 디렉토리를 가져옴, 실패시 에러 던지기
        guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            streamContinuation?.finish(throwing: NSError(domain: "Invalid File Document Directory", code: -1))
            return
        }
        
        // 파일명 결정: 서버 response의 suggestedFilename이 있으면 사용하고, 없으면 UUID로 생성
        var fileName = downloadTask.response?.suggestedFilename ?? UUID().uuidString
        
        // 만약 파일명에 확장자가 없다면, response의 MIME 타입을 통해 확장자를 추론해 추가
        if (fileName as NSString).pathExtension.isEmpty,
           let mimeType = downloadTask.response?.mimeType,
           let utType = UTType(mimeType: mimeType),
           let ext = utType.preferredFilenameExtension {
            fileName += ".\(ext)"
        }
        
        // 사용자가 지정한 Documents(또는 Library, Cache) 내의 폴더 (예제에서는 "MyDownloads")
        let folderURL = documentsDirectory.appendingPathComponent("MyDownloads", isDirectory: true)
        try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
        
        let destinationURL = folderURL.appendingPathComponent(fileName)
        
        do {
            // 기존에 같은 이름의 파일이 있다면 삭제
            if fileManager.fileExists(atPath: destinationURL.path) {
                try fileManager.removeItem(at: destinationURL)
            }
            // 임시 파일을 지정한 destination으로 이동
            try fileManager.moveItem(at: downloadedLocation, to: destinationURL)
            streamContinuation?.yield(.finished(destinationURL))
        } catch {
            // 파일 이동 및 실패시 에러 던지기
            streamContinuation?.finish(throwing: error)
            return
        }
        streamContinuation?.finish()
        streamContinuation = nil
    }
}