개발/Swift

SwiftUI TCA 파일 첨부 - 문서 파일 업로드, 다운로드, 저장 기능

덤벨로퍼 2025. 6. 20. 09:36
SwiftUI TCA 파일 첨부 - 업로드, 다운로드, 저장 기능
이 글에서는 SwiftUI TCA 환경에서 파일 첨부 및 관리 기능을 구현하는 포괄적인 방법을 설명합니다. 사용자 친화적인 파일 첨부부터 서버 통신, 그리고 기기 내 저장까지 모든 과정을 상세히 다룹니다.

1. 파일 불러오기 (Importing Files)

사용자가 기기에 저장된 파일을 앱으로 가져올 수 있도록 하려면 **UIDocumentPickerViewController**를 활용해야 합니다.
SwiftUI는 이 UIKit 컴포넌트를 직접 제공하지 않으므로, UIViewControllerRepresentable 프로토콜을 사용하여 래핑해야 합니다.
struct DocumentPicker: UIViewControllerRepresentable {
    let onAttachFile: (AttachedFile) -> Void

    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forOpeningContentTypes: getAllowedType(), asCopy: true)
        picker.delegate = context.coordinator
        picker.allowsMultipleSelection = false
        return picker
    }

    private func getAllowedType() -> [UTType] {
        return [.mp3, .mpeg4Audio,
                .doc, .docx, .dot, .dotx, .rtf,
                .xlsx, .xls, .xlt, .xlsm, .xlt, .xltx, .xlsm, .cell,
                .ppt, .pptx, .pps, .ppsx, .pot, .potx, .show,
                .hwdt, .hwpx, .hwp, .hwpx, .hml, .hwt, .pdf].compactMap { $0 }
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UIDocumentPickerDelegate {
        let parent: DocumentPicker

        init(_ parent: DocumentPicker) {
            self.parent = parent
        }

        public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) {
            self.documentImport(url)
            controller.dismiss(animated: true)
        }

        public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            controller.dismiss(animated: true)
        }

        private func documentImport(_ url: URL) {
            var fileSize: UInt64 = UInt64(0)
            if url.startAccessingSecurityScopedResource() {
                defer { url.stopAccessingSecurityScopedResource() }
                guard let size = url.fileSize else { return }
                fileSize = size
            }
            let item = AttachedFile(id: 0, status: "", originalFileName: url.lastPathComponent, original: url.path, accessKey: "", fileSize: fileSize, url: url)
            parent.onAttachFile(item)
        }
    }
}

 

위 코드에서 getAllowedType() 함수는 앱에서 허용할 파일 확장자 목록을 UTType 배열로 지정합니다. 이 목록에 원하는 파일 형식을 추가하여 지원 범위를 조절할 수 있습니다.
**allowsMultipleSelection**을 false로 설정하여 한 번에 하나의 파일만 선택하도록 제한합니다.

 

파일 선택 및 취소 이벤트를 처리하기 위해 UIDocumentPickerDelegate 프로토콜을 준수하는 Coordinator 클래스를 사용합니다.
사용자가 파일을 선택하면 documentPicker(_:didPickDocumentAt:) 메서드가 호출되며, 이 메서드 내에서 파일의 URL 정보를 가져올 수 있습니다.
이 URL을 통해 파일의 경로, 이름(lastPathComponent), 그리고 **사이즈(fileSize)**를 얻을 수 있습니다.
최종적으로 이 파일 정보는 onAttachFile 콜백을 통해 상위 뷰로 전달됩니다.
파일 선택이 완료되거나 취소되면 dismiss(animated: true)를 호출하여 피커를 닫습니다.
이렇게 구현된 DocumentPicker는 SwiftUI 뷰에서 .sheet 모디파이어를 사용하여 쉽게 표시할 수 있습니다.
.sheet(isPresented: $store.showDocumentPicker.sending(\.showDocumentPicker)) {
    DocumentPicker { file in
        store.send(.onAttachFile(file))
    }
}
onAttachFile 액션이 발행되면, TCA Reducer는 해당 파일 정보를 상태(state.attachment)에 추가하여 관리할 수 있습니다.
// Action - onAttachFile
state.attachment.append(.file(file))

2. 파일 업로드 (서버 전송)

선택된 파일을 서버로 전송하는 과정은 주로 파일의 이름과 URL 정보를 활용합니다. 이 모든 정보는 UIDocumentPickerViewController를 통해 얻은 URL 객체에 포함되어 있습니다.
네트워킹 라이브러리로는 Moya를 사용하여 POST 요청과 **MultipartFormData**를 통해 파일을 전송할 수 있습니다.
//Task
let fileName = url.lastPathComponent
let filePath = url.path
let mimeType = filePath.mimeType

return .uploadMultipart(
    [
        MultipartFormData(provider: .file(filePath), name: "file_1", fileName: fileName, mimeType: mimeType)
    ]
)
위 코드에서 MultipartFormData는 파일의 실제 데이터(filePath), 서버에서 사용할 필드 이름(name), 파일의 원래 이름(fileName), 그리고 파일의 **MIME 타입(mimeType)**을 포함하여 서버로 전송됩니다.
 

3. 파일 다운로드

서버에 업로드된 파일을 다시 디바이스로 다운로드할 때는 두 가지 주요 경로가 필요합니다: 서버의 **웹 경로(다운로드 URL)**와 디바이스에 파일을 저장할 로컬 경로(디바이스 경로).
웹 경로가 주어졌다면, 디바이스의 로컬 경로를 받아와야 합니다. iOS 앱에서 캐시 디렉터리는 임시 파일을 저장하기에 적합한 경로 중 하나입니다.
let cashDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first!
이 코드는 다음과 유사한 경로를 반환합니다: "/Users/keone/Library/Developer/CoreSimulator/Devices/74C61097-FE32-4AD9-A4C3-B2B5933B9ADF/data/Containers/Data/Application/6C77D04F-C0FA-4E9A-80C9-E3EA07EABC90/Library/Caches"
이제 웹 경로와 디바이스 경로를 모두 준비했다면, Moya 라이브러리를 사용하여 파일을 다운로드할 수 있습니다.
GET 요청에 다운로드할 웹 경로를 지정하고, **downloadDestination**을 사용하여 파일을 저장할 디바이스 경로를 설정합니다.
return .downloadDestination({ _, _ in
    return (path, [.removePreviousFile, .createIntermediateDirectories])
})

 

downloadDestination 클로저 내에서 path는 파일을 저장할 최종 디바이스 경로를 나타냅니다. .removePreviousFile 옵션은 동일한 이름의 파일이 이미 존재할 경우 이전 파일을 제거하고, .createIntermediateDirectories는 필요한 중간 디렉터리를 자동으로 생성합니다.

 

4. 파일 저장 (Saving Files)

파일 다운로드가 완료되면, 해당 파일이 저장된 디바이스 경로를 사용하여 **UIActivityViewController**를 통해 사용자에게 공유 및 저장 옵션을 제공할 수 있습니다.
UIActivityViewController 역시 UIKit 컴포넌트이므로, SwiftUI에서 사용하려면 **UIViewControllerRepresentable**로 래핑해야 합니다.
struct ActivityViewRepresentable: UIViewControllerRepresentable {
    let activityItems: [Any]
    let applicationActivities: [UIActivity]? = nil

    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
DocumentPicker와 마찬가지로, 이 ActivityViewRepresentable도 SwiftUI의 .sheet 모디파이어를 사용하여 표시합니다.
.sheet(item: $store.showActivityView.sending(\.showActivityView)) { item in
    ActivityViewRepresentable(activityItems: item.activityItems)
}
여기서 중요한 점은 sheet(item:)을 사용할 때 전달되는 값이 Identifiable 프로토콜을 준수해야 한다는 것입니다.
URL이나 String 타입은 기본적으로 Identifiable하지 않으므로, 다음과 같이 Identifiable을 준수하는 사용자 정의 타입을 생성하여 해결할 수 있습니다.
struct ActivityViewItem: Identifiable, Equatable {
    static func == (lhs: ActivityViewItem, rhs: ActivityViewItem) -> Bool {
        lhs.id == rhs.id
    }
    var id: UUID = UUID()
    var activityItems: [Any]

    init(activityItems: [Any]) {
        self.activityItems = activityItems
    }
}
ActivityViewItem 구조체는 **UUID**를 id로 사용하여 Identifiable 조건을 충족시키며, activityItems 배열에 URL 정보를 담아 UIActivityViewController에 전달합니다.
ActivityViewItem(activityItems: [url])
이렇게 ActivityViewItem 인스턴스를 TCA State로 관리하고 sheet(item:)에 전달함으로써, 파일 다운로드 후 사용자에게 다양한 저장 및 공유 옵션을 제공할 수 있습니다.