iOS에서 File을 보여주는 방식에 대해

seunghee song·2025년 1월 14일

Sandbox 정책

ios의 파일시스템은 조금 특별합니다 높은 보안성을 자랑합니다.

기본적으로 앱마다 별도의 파일을 생성하고 Sandbox안에서만 접근이 가능합니다. 만약 특정 리소스에 접근하려고 하는데 내가 속한 sandbox 외부의 공간이라면 접근할 수 없습니다.

그렇기에 icloud로 동기화를 하지 않는 이상 외부 앱의 데이터를 열람할 수 없습니다. 왜냐면 다른 sandbox에 있기 때문이죠

우리가 오늘 주목해야할 부분은 DataContainer입니다.

  • Documents
    • 사용자가 생성한 콘텐츠를 저장시 이 디렉토리를 사용합니다.
    • 파일 공유를 통해 사용자가 사용할 수 있기에 사용자에게 노출하고자 하는 파일만 포함해야 합니다.
    • 이 디렉토리는 추가설정시 파일앱에 보여지게 할 수 있습니다.
  • Library
    • 사용자 데이터 파일 및 임시 파일을 제외한 모든 파일을 관리해주는 곳
    • 사용자가 접근할 수 없습니다.
  • tmp
    • 재사용이나 앱 실행시 필요하지 않은 데이터를 임시로 저장해주는 용도로서 iCloud에 동기화되어 백업되지 않습니다. 또한 시스템이 주기적으로 정리해줍니다.

만약 서로다른 sandbox로의 접근이 필요하다면 App Groups을 설정해주면 됩니다.

한가지 의문점이 생겼습니다.

iOS에서도 사용할 수 있는 다양한 File Viewer 앱이 있는데 이렇게 엄격한 Sandbox 정책 속에서 어떻게 앱의 외부에서 File을 열람하고 다운을 받는 것일까요?

File 탐색 관련 앱이나 뷰어 앱에서 크게 2가지로 File을 가져오고 열람할 수 있는 기능을 제공해 준다는 사실을 깨달았습니다.

FileProvider Extension에서 파일을 복사해오는 방식

이 방식은 뷰어 앱에서 언제나 외부 파일을 열람할 수 있게 하는 방식입니다.
a. ‘파일 앱’ 을 선택하면 UIDocumentPickerController를 통해 filepicker가 실행됩니다.
- 사용자는 이로부터 가져오고자하는 파일을 탐색하고 선택할 수 있습니다.

b. 파일을 선택하면 즉시 선택된 파일이 앱에서 열리게 됩니다.

  • 파일의 경로, 크기 등등의 메타 데이터를 받아옵니다.

c. FilePicker로부터 가져온 파일 데이터를 활용해 앱 내부에서 해당 파일에 접근하고 열람이 가능해집니다.

let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)

picker를 호출하고 정보를 가져오기 위해 필수적으로 작성해야하는 코드인데요 만약 ascopy를 true로 설정하게 되면 앱 sandbox내 tmp(일시적으로 저장하는 영역) 영역에 받아와지게 됩니다. 영구적으로 앱에서 파일을 열람할 수 있게 하기위해 해당 파일을 복사해 앱내 sandbox의 Library 혹은 Documents영역에 복사해올 수 있습니다.

  • 제약사항
    - 당연히 파일을 picker로부터 가져오는 시점에 복사를 하는 것이기에 실시간 동기화는 되지 않아 최신의 정보를 보기 어렵습니다.

File의 경로를 참조하여 접근하는 방식

사진과 같은 뷰어 앱은 외부 앱의 최신 데이터를 실시간으로 확인하거나 열람할 수 있습니다.

이 기능은 어떻게 동작할 수 있을까요?

let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: false)

앞서 filepicker를 호출할 때 써줘야하는 코드라고 소개하였습니다.
하지만 한가지가 변경되어있습니다.
바로 asCopy 매개변수의 값입니다.

false로 되어있다면 다음과 같은 기능이 가능해집니다.

  • 앱이 파일의 원본에 접근할 수 있는 위치를 받을 수 있습니다.
  • 사용자가 파일의 위치를 참조하고 직접 읽거나 수정할 수 있습니다.

다만 역시 깐깐한 Apple의 보안규칙으로 인해 받아오는 파일의 데이터 형태는 Security-Scoped URL로 받아와지게 됩니다.

  • security-scoped URL란?
    • 앱의 샌드박스 외부의 파일에 대해 읽기 및 쓰기 권한을 제공하기 위해 사용됩니다.
    • Scoped Access 내부에서만 일시적으로 권한을 가집니다. scope를 활성화 하는 코드와 종료하는 코드를 명시적으로 작성해줘야 합니다.
    • Security-Scoped URL은 활성화된 스코프 내에서만 유효하다는 한계가 있습니다.

이 url은 정해진 범위 내에서만 유효하기 때문에 매번 파일을 참조하기 위해서는 BookMarkData 형태로 저장하여 접근할 수 있습니다.

  • bookmarkData 데이터란?
    • 북마크 데이터는 Security-Scoped URL의 접근 권한 (읽기, 쓰기)을 가지고 있으며 파일이나 디렉토리에 지속적으로 접근할 수 있는 형태로 저장된 데이터입니다.
    • Security-Scoped URL을 지속적으로 접근할 수 있는 형태로 저장하려면 bookmark data로 저장해야 시간이 지나도 알맞은 위치를 참조할 수 있습니다.
    • 북마크 데이터를 로컬에 저장하면, 해당 파일이나 디렉토리가 존재하는 한 안전하게 꺼내서 참조할 수 있습니다.
    • 만약 저장된 bookmarkData의 유효성을 판단하는 isStale 값이 true로 반환된다면 해당 데이터는 오래되었기에 갱신이 권장됩니다.
    • 북마크 데이터는 URL 자체가 아닌 파일 시스템에 대한 고유한 참조를 기반으로 하므로, 시간이 지나도 파일이나 디렉토리가 식별된다.(최근 삭제된 항목에서도 삭제된다면 식별 불가)

저는 두번째 기능을 직접 구현해보기 위해 샘플프로젝트를 생성해보았는데요 간단한 테스트를 위해 사용했던 코드를 첨부하겠습니다.

import UIKit
import UniformTypeIdentifiers
//secuity scoped url을 가져오며 위치 참조하는 경우
class FilePickerClass: NSObject, UIDocumentPickerDelegate, ObservableObject {
    //어노테이션이 swiftui뷰에 자동으로 업데이트를 알리게됨
    @Published var result = "디렉토리 리스트를 가져옵니다"
    func showFilePicker() {
        //asCopy => 선택한 파일을 복사하여 뷰어앱내에서 관리한다 만약 false라면 원본 파일을 그대로 참조한다
        let contentTypes: [UTType] = [.folder, .item]
        let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: false)
    
        documentPicker.delegate = self
        //다중선택 가능하도록 바꾸기 default는 false
        documentPicker.allowsMultipleSelection = true
        
        // 현재 활성화된 ViewController에서 Document Picker를 표시
        guard let topViewController = UIApplication.shared.windows.first?.rootViewController else {
            print("Root ViewController를 찾을 수 없습니다.")
            return
        }
        topViewController.present(documentPicker, animated: true, completion: nil)
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        guard let selectedFileUrl = urls.first else {
            print("선택된 파일이 없습니다.")
            return
        }
        do {
            guard selectedFileUrl.startAccessingSecurityScopedResource() else {
                print("보안 스코프를 활성화할 수 없습니다")
                return
            }
            defer {
                selectedFileUrl.stopAccessingSecurityScopedResource()
            }
            
            print("선택된 파일: \(selectedFileUrl)")
            //나중에 url에 접근하기 위해서
            let bookmarkData = try selectedFileUrl.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil)
            try bookmarkData.write(to: getMyUrlForBookMark())
        } catch let error {
            print("북마크 생성 도중 에러 발생 \(error)")
        }
        
        handleSelectedFile()
        
        
        
    }
    //북마크 url값을 다른 디렉토리에 저장한다
    func getMyUrlForBookMark() -> URL {
        let fileManager = FileManager.default
        let dir = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first
        let bookMarkUrl = dir?.appendingPathComponent("MyBookMark").appendingPathExtension("bookmark")
        return bookMarkUrl!
    }
    
    private func handleSelectedFile() {
        do {
            let fileData = try Data(contentsOf: getMyUrlForBookMark())
            var isStale = false
            //북마크 데이터를 url 복원
            let url = try URL(resolvingBookmarkData: fileData, bookmarkDataIsStale: &isStale)
            guard !isStale else {
                return
            }
            //보안 권한 활성화
            guard url.startAccessingSecurityScopedResource() else {
                print("handleSelectedFile 보안범위 리소스 접근 실패")
                return
            }
            defer {
                url.stopAccessingSecurityScopedResource()
            }

        } catch {
            print("파일 데이터를 가져올 수 없습니다: \(error.localizedDescription)")
        }
    }
    
    //접근하고자 하는 앱의 디렉토리 히스토리를 출력함
    func getDirectoryList() {
        do {
            let fileData = try Data(contentsOf: getMyUrlForBookMark())
            //북마크가 오래되었는지 확인하는 변수
            //만약 이 값이 true라면 북마크데이터 새로 생성하거나 다른방법으로 파일경로 복원해야함
            var isStale = false
            //북마크 데이터를 url 복원
            let url = try URL(resolvingBookmarkData: fileData, bookmarkDataIsStale: &isStale)
            guard !isStale else {
                return
            }
            guard url.startAccessingSecurityScopedResource() else {
                return
            }
            defer {
                url.stopAccessingSecurityScopedResource()
            }
            let fileManager = FileManager.default
            let directoryHistory = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
            result = ""
            for hi in directoryHistory {
                result.append(hi.path)
                result.append("\n")
                result.append("\n")
            }
            
        } catch {
            result = "파일 디렉토리 히스토리를 읽어올 수 없습니다"
            print("파일 디렉토리 히스토리를 읽어올 수 없습니다")
        }
    }

    
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        print("사용자가 파일 선택을 취소했습니다.")
    }

}

Apple의 보안 규칙과 개발 생태계를 배울 수 있는 좋은 기회였습니다!💻

profile
안드로이드 개발자

0개의 댓글