FileManager는 파일 하나 여닫는 API처럼 보이지만, 실제로는 어디에 무엇을 저장하느냐가 곧 백업 정책·저장공간 정책·잠금 화면 보안 정책과 그대로 맞물린다. 이 챕터는 앱 컨테이너의 네 디렉터리가 정확히 무엇을 보장하고 무엇을 보장하지 않는지, 원자적 쓰기가 실제로 지켜주는 범위가 어디까지인지, 재부팅 직후 백그라운드에서 파일을 못 여는 사고가 왜 나는지, 대용량 파일을 어떤 도구로 점진적으로 읽어야 하는지를 다룬다. 마지막엔 isExcludedFromBackupKey를 어디에 붙이고 어디에 붙이면 안 되는지로 마무리한다.
Q1. 앱 컨테이너의 Documents / Application Support / Caches / tmp는 백업·퍼지 규칙이 각각 어떻게 다른가?
Documents/와 Library/Application Support/는 iCloud·iTunes 백업 대상이고 시스템이 임의로 지우지 않는다. Library/Caches/와 tmp/는 백업에서 자동 제외되고, 앱이 실행 중이 아닐 때 시스템이 예고 없이 지울 수 있다. 사용자에게 실제로 노출되는 곳은 UIFileSharingEnabled를 켰을 때의 Documents/뿐이다. 재다운로드·재생성이 안 되는 데이터를 Caches/tmp에 두거나, 반대로 다시 받을 수 있는 대용량 데이터를 그냥 Documents에 방치하는 것 둘 다 실무에서 흔한 사고다.
원리
Apple은 파일을 어디에 두느냐 자체가 정책이 되도록 설계했다. Using the File System Effectively는 목적에 맞는 디렉터리를 쓰면 자동 백업이나 자동 퍼지 같은 혜택을 얻는다고 설명하고, 저장 공간을 아껴 쓰면 시스템 업데이트가 공간 부족으로 막히지 않고 백업이 작고 빨라진다고 근거를 든다. 경로를 직접 하드코딩하지 말라는 것도 원칙이다 — Finder 같은 도구가 디렉터리 이름을 로케일에 맞게 바꿔 보여줄 수 있어서, 반드시 URL.documentsDirectory 같은 정적 프로퍼티나 FileManager.urls(for:in:)로 얻어야 한다.
내부 동작
네 디렉터리의 실제 규칙은 다음과 같다.
| 디렉터리 | iCloud/iTunes 백업 | 시스템 퍼지 | 사용자 노출 | 용도 |
|---|---|---|---|---|
Documents/ | 됨 (기본) | 안 됨 | UIFileSharingEnabled 켰을 때 Files 앱 | 사용자가 만들고 관리하는 장기 보존 콘텐츠 |
Library/Application Support/ | 됨 | 안 됨 | 노출 안 됨 | 앱이 필요로 하지만 사용자가 재생성 못 하는 설정·템플릿·구성 파일 |
Library/Caches/ | 안 됨 (자동 제외) | 될 수 있음 — 앱이 실행 중이 아닐 때, 정확한 시점은 보장되지 않음 | 노출 안 됨 | 재생성·재다운로드 가능한 캐시 데이터 |
tmp/ | 안 됨 (자동 제외) | 될 수 있음 — 가장 적극적으로 | 노출 안 됨 | 수명이 매우 짧은 임시 파일. 다 쓰면 앱이 즉시 지워야 함 |
tmp와 Caches가 백업에서 자동 제외되는 이유는 인과관계가 뒤집혀 있지 않다 — Optimizing Your App's Data for iCloud Backup은 시스템이 이 디렉터리들을 주기적으로 퍼지하기 때문에 iCloud 백업이 기본적으로 이들을 제외한다고 못박는다. 즉 퍼지 가능성이 원인이고 백업 제외가 결과다. 같은 문서는 퍼지 대상이 아닌 데이터(사용자가 다시 만들기 어려운 것)를 Caches/tmp에 절대 넣지 말라고도 명시한다.
실험 · 도구
이 디렉터리들은 macOS(비샌드박스 앱)에서도 그대로 조회할 수 있어서 직접 확인했다. 아래는 swift 파일.swift로 실행한 실제 출력이다.
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
print("docs:", docs)
print("caches:", caches)
print("appSupport:", appSupport)
// 실행 결과 (비샌드박스 macOS — iOS 컨테이너와 매핑 루트만 다르다)
// docs: [file:///Users/xxx/Documents/]
// caches: [file:///Users/xxx/Library/Caches/]
// appSupport: [file:///Users/xxx/Library/Application%20Support/]비샌드박스 macOS 앱에서 applicationSupportDirectory가 ~/Library/Application Support로 풀리는 것도 문서에 명시된 그대로다. iOS 실기기·시뮬레이터에서는 각각 앱 컨테이너 하위의 Documents/, Library/Caches/, Library/Application Support/로 매핑된다 — 개념은 같고 루트만 다르다.
프로젝트 적용
① 재다운로드 가능하지만 자주 쓰이는 대용량 데이터(예: 오프라인 모델 파일)는 Caches에 두면 퍼지 타이밍을 예측할 수 없어 곤란하다. Application Support에 두고 백업만 별도로 제외하는 편이 낫다(Q5에서 다룬다).
func offlineAssetDirectory() throws -> URL {
let support = try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
// 번들 ID로 이름 지어 시스템이 나중에 만들 폴더와 충돌을 피한다
let dir = support.appendingPathComponent(
Bundle.main.bundleIdentifier ?? "app", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}② Caches는 "있으면 좋고 없어도 동작해야" 하는 데이터 전용이다. 읽기 전에 항상 존재 여부를 확인하고 없으면 재생성하는 경로를 반드시 같이 짠다.
func thumbnailURL(for id: String) throws -> URL {
let caches = try FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
return caches.appendingPathComponent("\(id).thumb.jpg")
}
func loadThumbnail(for id: String) throws -> Data {
let url = try thumbnailURL(for: id)
if let cached = try? Data(contentsOf: url) { return cached }
// 시스템이 퍼지했거나 처음 요청 — 원본에서 다시 만든다
let regenerated = try regenerateThumbnail(for: id)
try? regenerated.write(to: url)
return regenerated
}"디스크 공간이 부족할 때만 Caches가 지워진다"는 예전 통설이지만, 현재 애플 문서(Using the File System Effectively)는 트리거 조건을 "앱이 실행 중이 아닐 때"로만 설명하고 구체적인 디스크 임계값을 공개하지 않는다 — 정확히는 "예측 가능한 스케줄이 없다"는 게 핵심이지, 특정 조건에서만 안전하다고 가정하면 안 된다. "Caches 폴더 자체가 사라진다"도 정확하지 않다 — 시스템이 지우는 건 그 안의 내용물이지 디렉터리 자체가 아니다.
Documents와 Application Support는 이사 갈 때 반드시 챙기는 책상 서랍과 옷장 같은 것이다. Caches는 냉장고 속 재료다 — 관리인이 알아서 정리할 수 있으니 없어져도 다시 사 오면 된다. tmp는 다 쓰고 바로 버리는 포스트잇 메모다. 비유가 깨지는 곳: 냉장고는 유통기한이라는 뻔한 규칙으로 정리되지만, Caches는 애플이 정확히 언제 어떤 조건으로 치우는지 공개하지 않는다 — "알아서 치울 수 있다"는 계약만 있을 뿐, 예측 가능한 스케줄이 아니다.
꼬리 질문
UserDefaults를 뒷받침하는 plist 파일도 이 네 디렉터리 규칙을 따르는가?
Library/Preferences/에 있고, 이 디렉터리는 Application Support와 마찬가지로 백업 대상이며 시스템이 임의로 퍼지하지 않는다. 저장 방식과 검색 도메인 같은 UserDefaults 고유의 이야기는 26장에서 이어간다.Caches 안에 파일이 아주 많이 쌓이면 앱 시작 속도에 영향을 주는가?
contentsOfDirectory로 훑거나 정리하는 로직을 넣으면 그 로직 자체가 I/O 비용이 된다. 그래서 캐시 정리는 시작 시점보다 백그라운드 진입 시점에 하는 게 일반적이다.재다운로드 가능한 대용량 콘텐츠를 Documents에 넣으면 왜 실무적으로 문제가 되는가?
Q2. `.atomic` 쓰기가 보장하는 것과 보장하지 않는 것은?
.atomic은 새 내용을 임시 파일에 전부 쓴 뒤 rename으로 원본을 교체하는 것만 보장한다 — 그래서 쓰는 도중 앱이 크래시 나도 반쯤 쓰인 파일을 볼 일은 없다. 보장하지 않는 것은 두 스레드·프로세스가 동시에 같은 파일에 쓸 때의 상호배제(마지막 rename이 그냥 이긴다), fsync 없이 전원이 끊기는 상황에 대한 내구성, 그리고 새 파일이 만들어지는 구조라 그 파일에 붙어 있던 Data Protection 클래스나 백업 제외 표시 같은 메타데이터가 기본값으로 리셋될 수 있다는 점이다.
원리
NSData.WritingOptions.atomic은 "데이터를 먼저 보조(auxiliary) 파일에 쓰고, 쓰기가 완료되면 원본 파일을 그 보조 파일로 교체하는 옵션"이라고 정의된다 — atomically: true를 쓰는 것과 동일하다. 이 옵션이 실제로 약속하는 건 "쓰기가 중단되어도 파일이 손상되지 않도록, 새 데이터가 성공적으로 쓰인 뒤에만 원본이 교체된다"는 all-or-nothing 교체이지, 트랜잭션이나 락이 아니다.
내부 동작
메커니즘은 write-then-rename 패턴이다. 임시 파일에 전체 내용을 쓰고, 파일시스템 rename(POSIX에서 원자적 연산)으로 원본을 교체한다. 프로세스가 중간에 죽어도 "옛 파일 그대로" 또는 "새 파일 전체"만 존재하고 반쯤 쓰인 파일은 절대 보이지 않는다 — 이것이 "원자적"의 의미 전부다. 여러 스레드가 동시에 같은 경로에 atomic write를 하면 마지막에 rename한 쪽이 이길 뿐, 둘 사이의 상호 배제를 보장하지 않는다.
새 파일이 만들어진다는 사실은 눈에 잘 안 띄는 부작용을 낳는다. 직접 확인해보면, 처음에 .completeUnlessOpen 보호 클래스와 isExcludedFromBackup = true를 명시적으로 설정한 파일도 .atomic으로 덮어쓰고 나면 두 값 모두 기본값으로 되돌아간다. rename으로 자리를 차지한 건 완전히 새로운 inode이기 때문이다. 메타데이터까지 보존하고 싶다면 FileManager.replaceItemAt(_:withItemAt:backupItemName:options:)가 더 나은 선택이다 — "기본적으로 생성일, 권한, Finder 라벨/색상, Spotlight 코멘트 등 원본 메타데이터를 보존"하도록 설계됐다. 단, originalItemURL과 newItemURL이 같은 볼륨에 있어야 하고, 다른 볼륨이면 에러가 난다 — 그럴 땐 .itemReplacementDirectory로 대상 볼륨의 임시 URL을 받으라고 문서가 권한다.
실험 · 도구
메타데이터 리셋을 직접 재현해봤다. 파일을 .completeUnlessOpen과 백업 제외로 설정한 뒤 .atomic으로 덮어쓰고 다시 읽어봤다.
FileManager.default.createFile(atPath: f.path, contents: Data("v1".utf8),
attributes: [.protectionKey: FileProtectionType.completeUnlessOpen])
try (f as NSURL).setResourceValue(true, forKey: .isExcludedFromBackupKey)
try Data("v2-atomic-overwrite".utf8).write(to: f, options: .atomic)
let after = try FileManager.default.attributesOfItem(atPath: f.path)[.protectionKey] as! FileProtectionType
print("atomic 후 protectionKey:", after.rawValue)
// atomic 후 protectionKey: NSFileProtectionCompleteUntilFirstUserAuthentication (기본값으로 리셋됨)
let excludedAfter = try (f as NSURL).resourceValues(forKeys: [.isExcludedFromBackupKey])[.isExcludedFromBackupKey] as? Bool
print("atomic 후 isExcludedFromBackup:", excludedAfter as Any)
// atomic 후 isExcludedFromBackup: Optional(false) (역시 리셋됨)둘 다 명시적으로 켜뒀던 값인데 .atomic 한 번으로 조용히 기본값으로 돌아갔다 — API가 예외를 던지지도, 경고를 남기지도 않는다.
프로젝트 적용
① 원본의 메타데이터를 그대로 유지하고 싶다면 .atomic 대신 replaceItemAt을 쓴다.
func safeReplace(original: URL, withContentsOf tempURL: URL) throws {
// 생성일 · 권한 · Finder 라벨 · Spotlight 코멘트를 기본으로 보존한다
// 단, original과 temp는 반드시 같은 볼륨에 있어야 한다
_ = try FileManager.default.replaceItemAt(original, withItemAt: tempURL)
}② .atomic을 계속 쓰기로 했다면, 보호 클래스와 백업 제외 표시를 저장할 때마다 다시 걸어주는 헬퍼로 감싸는 편이 안전하다.
func writeProtectedAtomic(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .atomic)
// .atomic은 새 파일을 만드는 구조라 보호 클래스·백업 제외 표시가
// 기본값으로 되돌아간다 — 매번 다시 걸어야 한다
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: url.path)
var mutableURL = url
var values = URLResourceValues()
values.isExcludedFromBackup = true
try mutableURL.setResourceValues(values)
}".atomic을 쓰면 여러 스레드에서 동시에 써도 안전하다"는 틀렸다 — atomic은 "쓰기 도중 죽어도 반쯤 쓰인 파일이 안 남는다"만 보장하지, 동시 쓰기 사이의 순서나 상호 배제는 별개 문제다. "atomic 쓰기가 항상 더 빠르다"도 틀렸다 — 임시 파일 생성과 rename 오버헤드가 있고, 파일이 크면 그만큼의 여유 디스크 공간이 한 번 더 필요하다.
편지를 고쳐 쓸 때, 원본 편지지를 지우개로 지우고 다시 쓰는 대신 새 편지지에 처음부터 끝까지 다 쓴 다음 봉투 겉면 이름표만 바꿔 붙인다고 생각하면 된다. 이름표를 바꾸는 순간은 아주 짧아서, 그 사이엔 누가 봐도 "옛날 편지"거나 "새 편지" 둘 중 하나만 보인다. 비유가 깨지는 곳: 진짜 이름표 바꾸기와 달리 파일 시스템의 rename은 정전에도 일관성을 지켜주는 훨씬 강력한 보장이지만, 그 편지지에 붙어 있던 특별 스티커(암호화 등급, 백업 제외 표시)까지 같이 옮겨준다는 보장은 없다 — 새 편지지는 기본 스티커부터 다시 시작한다.
꼬리 질문
왜 replaceItemAt은 같은 볼륨 안에서만 동작하는가?
url(for:in:appropriateFor:create:)에 .itemReplacementDirectory를 넘겨 대상 볼륨 위의 임시 URL을 먼저 받으라고 안내한다.atomic 쓰기 도중 디스크가 꽉 차면 어떻게 되는가?
앱과 위젯 익스텐션처럼 서로 다른 프로세스가 같은 파일에 atomic write를 하면?
NSFileCoordinator나 App Group 수준의 별도 락이 필요하고, 이는 atomic 옵션이 해결해주는 문제가 아니다.Q3. Data Protection 클래스란 무엇이고, 재부팅 직후 백그라운드에서 파일이 안 열리는 이유는?
Data Protection은 파일을 기기 패스코드에서 파생된 키로 암호화해 4개 등급(.complete, .completeUnlessOpen, .completeUntilFirstUserAuthentication, .none)으로 나눈다. 앱 컨테이너 파일의 기본값은 .complete가 아니라 .completeUntilFirstUserAuthentication이다 — 부팅 후 사용자가 한 번이라도 잠금을 풀면 그 뒤로는 기기를 다시 잠가도 계속 읽을 수 있다. 문제는 재부팅 직후, 사용자가 아직 한 번도 잠금을 풀지 않은 상태에서 푸시 핸들러나 위치 업데이트, BGTask 같은 백그라운드 작업이 도는 경우다 — 이 시점엔 파일이 여전히 잠겨 있어서 여는 시도 자체가 실패한다.
원리
FileProtectionType은 4가지 값을 정의한다.
.complete— "디스크에 암호화된 형태로 저장되며, 기기가 잠겨 있거나 부팅 중일 때는 읽기·쓰기가 불가능하다.".completeUnlessOpen— 파일이 닫혀 있을 때만 암호화 보호가 걸린다. 열려 있는 동안은 계속 쓸 수 있다(예: 백그라운드 다운로드 중인 파일)..completeUntilFirstUserAuthentication— "디스크에 암호화된 형태로 저장되며, 기기가 부팅된 뒤 접근할 수 없다. 사용자가 기기 잠금을 처음 해제한 뒤부터는 앱이 파일에 접근할 수 있고, 그 뒤 사용자가 다시 기기를 잠가도 계속 접근할 수 있다.".none— 기기 패스코드에 묶인 특별한 보호가 없다(전체 디스크 암호화만 적용).
내부 동작
이 정의를 그대로 따라가면 재부팅 버그의 원인이 논리적으로 드러난다. .completeUntilFirstUserAuthentication은 "기기가 부팅된 뒤 접근할 수 없다"고 명시하고, 그 접근 가능 상태는 오직 "사용자가 처음 잠금을 해제한 뒤"부터 시작된다. 즉 재부팅과 첫 언락 사이에 실행되는 코드는 이 등급의 파일을 원천적으로 열 수 없다 — 이건 버그가 아니라 문서에 적힌 대로 동작하는 것이다. 다만 이 사실이 실무에서 잘 알려지지 않은 이유는, 개발 중에는 기기를 재부팅 직후 언락 전 상태로 오래 유지하며 테스트할 일이 거의 없어서 이 실패 경로를 우연히 밟기가 어렵기 때문이다.
직접 만든 파일의 기본 보호 클래스를 확인해보면, 별도로 지정하지 않은 새 파일은 .completeUntilFirstUserAuthentication을 받는다(Q2의 atomic 재작성 실험에서 .completeUnlessOpen으로 지정했던 값이 재작성 후 정확히 이 기본값으로 돌아간 것이 그 증거다). UserDefaults를 뒷받침하는 plist 파일도 이 기본 보호 등급 아래 있어서, 재부팅 직후 언락 전에 백그라운드 코드가 UserDefaults 값을 읽으려다 실패하는 사례가 Apple Developer Forums에도 여러 차례 보고돼 있다.
실험 · 도구
실제 잠금·재부팅 동작은 iOS 전용이라 macOS에서 재현할 수 없지만, 보호 클래스를 읽고 쓰는 API 표면은 macOS에서도 그대로 동작해 확인했다.
FileManager.default.createFile(atPath: f3.path, contents: Data("secret".utf8),
attributes: [.protectionKey: FileProtectionType.completeUnlessOpen])
let attrs = try FileManager.default.attributesOfItem(atPath: f3.path)
print("protectionKey attr:", attrs[.protectionKey] as Any)
// protectionKey attr: Optional(NSFileProtectionCompleteUnlessOpen)
var url3 = f3
let prot = try url3.resourceValues(forKeys: [.fileProtectionKey])
print("URLResourceValues.fileProtection readback:", prot.fileProtection as Any)
// URLResourceValues.fileProtection readback: Optional(__C.NSURLFileProtectionType(_rawValue: NSURLFileProtectionCompleteUnlessOpen))실제 잠금 화면 뒤에서 무슨 일이 벌어지는지는 이 API로 검증할 수 없으므로, 그 행동 자체는 위 Apple 공식 문서 정의에서 논리적으로 도출한 것이지 macOS 실측이 아니다 — 이 챕터에서 유일하게 "측정 대신 1차 문서를 신뢰해야 하는" 대목이다.
프로젝트 적용
① 백그라운드 작업이 재부팅 직후에도 반드시 읽어야 하는 파일은 보호 등급을 의도적으로 낮춘다.
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: backgroundCacheURL.path)
// 트레이드오프: 파일이 열려 있는 동안엔 보호가 약해진다.
// 그래도 완전 무보호(.none)보다는 낫다 — 닫혀 있을 때는 여전히 암호화된다.② 정말 민감한 데이터는 등급을 낮추는 대신, 백그라운드 경로 자체를 "아직 첫 언락 전이면 이 작업은 건너뛰고 다음 기회에 재시도"하도록 설계한다 — 보호 등급과 싸우지 않는다.
func readSensitiveFileIfUnlocked(at url: URL) -> Data? {
do {
return try Data(contentsOf: url)
} catch {
// 재부팅 직후 언락 전이면 여기로 떨어진다 — 크래시 대신 스킵하고
// 다음 백그라운드 실행 기회(또는 포그라운드 진입)에 다시 시도한다
return nil
}
}"Data Protection은 Keychain 얘기다"는 틀렸다 — 파일 시스템 레벨(FileProtectionType)과 Keychain 레벨(kSecAttrAccessible*)은 개념은 비슷해도 별도의 API이자 별도의 설정이다. "앱 컨테이너 파일은 기본적으로 완전 보호(.complete)다"도 틀렸다 — 기본값은 .completeUntilFirstUserAuthentication이고, .complete는 잠긴 동안 항상 접근 불가라 오히려 백그라운드 처리엔 거의 쓸 수 없다.
금고 문이 "오늘 처음 여는 사람에게만" 열리는 규칙이라고 생각해보자. 폰을 껐다 켜면(재부팅) 금고는 다시 잠긴다. 주인이 아침에 한 번 열면 그날은 계속 열려 있지만, 재부팅 직후 아직 아무도 안 열었는데 우체부(백그라운드 작업)가 몰래 찾아와 금고 속 서류를 꺼내려 하면 실패한다. 비유가 깨지는 곳: 진짜 금고는 한 번 열리면 다시 잠글 때까지 계속 열려 있어야 자연스럽지만, 이 등급은 "한 번 열리면 다시 잠가도 그 부팅 동안은 계속 열림"이라는 특이한 규칙이다 — 그래서 이름에 굳이 "until first user authentication"이 붙어 있다.
꼬리 질문
왜 `.complete`가 기본값이 아닌가?
.complete는 기기가 잠겨 있으면 예외 없이 접근 불가라, 대부분의 앱이 필요로 하는 백그라운드 실행(푸시 처리, 위치 업데이트, 오디오 재생)과 근본적으로 양립하지 않는다. 대부분의 앱 데이터는 "잠긴 화면에서도 백그라운드로 계속 동작해야" 하므로, 애플은 절충안인 .completeUntilFirstUserAuthentication을 기본값으로 골랐다.UserDefaults도 같은 문제를 겪는가?
`.completeUnlessOpen`은 어떤 상황에 특히 유용한가?
Q4. 대용량 파일을 점진적으로 읽으려면 FileHandle / InputStream / DispatchIO 중 무엇을?
가장 단순한 순차 읽기엔 FileHandle의 throwing read(upToCount:)로 충분하다. RunLoop 기반 델리게이트 패턴이 이미 있거나 파일·네트워크·메모리를 같은 인터페이스로 다뤄야 하면 InputStream. 진짜 성능이 중요한 대용량 비동기 I/O엔 DispatchIO — RunLoop 없이 지정한 큐에서 청크 콜백으로 처리하고 백프레셔까지 조절된다. 그리고 사실 대부분의 경우엔 이 셋 중 무엇도 아니라 Data(contentsOf:options:.mappedIfSafe)로 파일을 메모리에 매핑해 필요한 부분만 상주시키는 게 정답이다(18장 참고).
원리
파일 전체를 한 번에 메모리로 읽어들이는 것과, 필요한 만큼만 조금씩 읽는 것 사이엔 근본적인 트레이드오프가 있다. 전체 읽기는 코드가 단순하지만 파일 크기만큼 메모리를 즉시 잡아먹고, 점진적 읽기는 메모리 상한을 지킬 수 있는 대신 청크 경계·EOF 처리·에러 복구를 직접 관리해야 한다. Foundation은 이 스펙트럼에 세 가지 서로 다른 추상화 수준의 도구를 둔다.
내부 동작
| API | 동작 방식 | 실행 위치 | 언제 쓰나 |
|---|---|---|---|
FileHandle | 동기 블로킹 read, 또는 readabilityHandler로 알림 기반 | 호출 스레드(알림 기반은 런루프가 살아있는 스레드) | 단순 순차·청크 읽기, 코드가 짧아야 할 때 |
InputStream | 델리게이트 + RunLoop 스케줄링 | 스트림을 스케줄한 런루프 | 파일·네트워크·메모리를 같은 인터페이스로 다뤄야 할 때 |
DispatchIO | 완전 비동기, 청크 콜백 + 백프레셔(high/low water mark) | 지정한 GCD 큐 | 대용량 파일을 성능 critical하게, RunLoop 없이 |
Data(contentsOf:options:.mappedIfSafe) | 파일을 메모리에 매핑 — 실제로 건드린 페이지만 상주 | 호출 스레드(매핑 자체는 즉시 반환) | 대개 정답 — 파일 전체를 한 번에 다뤄도 되는 대용량 파일 |
FileHandle.read(upToCount:)의 공식 문서는 "요청한 길이만큼 못 채우면 현재 위치부터 파일 끝까지의 데이터를 반환하고, 파일 끝에 있으면 빈 NSData를 반환한다"고 적혀 있다. 그런데 실제로 실행해보면 다르게 동작한다.
실험 · 도구
10MB 파일을 64KB 청크로 순회하며 메모리에 청크 하나만 유지한 결과다.
let reader = try FileHandle(forReadingFrom: bigFile)
let chunkSize = 64 * 1024
var totalRead = 0, chunkCount = 0
while let chunk = try reader.read(upToCount: chunkSize), !chunk.isEmpty {
totalRead += chunk.count
chunkCount += 1
}
print("totalRead:", totalRead, "chunkCount:", chunkCount)
// file size: 10485760
// totalRead: 10485760 chunkCount: 160 (10,485,760 / 65,536 = 160, 정확히 맞아떨어짐)EOF에서 정확히 무엇이 돌아오는지는 100바이트짜리 파일을 30바이트씩 읽어 직접 확인했다.
while true {
let chunk = try reader.read(upToCount: 30)
print("chunk:", chunk.map { "\($0.count) bytes" } ?? "nil")
if chunk == nil { break }
}
// chunk: 30 bytes
// chunk: 30 bytes
// chunk: 30 bytes
// chunk: 10 bytes (마지막 자투리)
// chunk: nil ← 문서의 "빈 NSData"가 아니라 nil이 EOF 신호다문서 텍스트는 예전 readData(ofLength:) 시절 설명이 Data?를 반환하는 최신 throwing 오버로드로 그대로 넘어오면서 갱신되지 않은 것으로 보인다 — 측정한 동작을 믿어야 하는 실제 사례다: 루프 종료 조건은 !chunk.isEmpty가 아니라 chunk == nil 쪽이 실제 EOF 신호와 맞다.
프로젝트 적용
① 메모리 상한을 지키면서 큰 파일을 훑어야 할 때(체크섬 계산, 라인 스캔 등)는 청크 루프로 충분하다.
func streamingByteSum(of url: URL, chunkSize: Int = 64 * 1024) throws -> Int {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var sum = 0
// chunk == nil 이 진짜 EOF — 빈 Data와 혼동하지 않는다
while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty {
for byte in chunk { sum = (sum &+ Int(byte)) &+ 1 }
}
return sum
}② RunLoop 없이 진짜 비동기로, 그리고 읽는 속도가 처리 속도를 앞지르지 않게 조절하고 싶다면 DispatchIO로 옮긴다.
let queue = DispatchQueue(label: "io.chunked-reader")
let fd = open(url.path, O_RDONLY)
let channel = DispatchIO(type: .stream, fileDescriptor: fd, queue: queue) { _ in close(fd) }
channel.setLimit(lowWater: 64 * 1024) // 이만큼 모이기 전엔 핸들러를 안 부른다 — 백프레셔
channel.read(offset: 0, length: Int.max, queue: queue) { done, data, error in
guard error == 0 else { return }
// data가 채워질 때마다 이 큐에서 호출된다 — 런루프도, 델리게이트도 필요 없다
if done { /* 스트림 종료 처리 */ }
}"큰 파일은 무조건 청크 API로 손수 읽어야 한다"는 과장이다 — 파일 전체를 한 번에 다뤄도 되는 경우라면 .mappedIfSafe 매핑이 코드도 더 짧고, 커널이 페이지 단위로 알아서 상주·회수를 관리해준다(자세한 내용은 18장). "FileHandle 방식이 항상 제일 빠르다"도 틀렸다 — 진짜 성능이 critical한 대용량 비동기 I/O는 큐 기반 백프레셔가 있는 DispatchIO가 유리하다.
냉장고에 있는 큰 물통(파일)을 컵(청크)으로 퍼서 옮기는 상황을 떠올려보자. FileHandle은 한 사람이 한 컵씩 손수 퍼나르는 방식이고, InputStream은 옆에서 "이제 다 됐어요" 하고 신호를 주는 조수가 붙은 방식, DispatchIO는 여러 사람이 동시에 컵을 퍼나르면서 통이 넘치지 않게 속도까지 조절해주는 방식이다. 비유가 깨지는 곳: 사실 대부분의 부엌(대부분의 파일 작업)에는 통째로 들어도 무게가 실제로 손댄 만큼만 느껴지는 마법 같은 방법(메모리 매핑)이 있어서, 컵으로 퍼나르는 수고 자체가 필요 없을 때가 더 많다.
꼬리 질문
`FileHandle.readabilityHandler`를 백그라운드 스레드에서 쓰면 왜 문제가 되는가?
왜 `read(upToCount:)`는 nil을 반환하는데 문서엔 "빈 NSData"라고 적혀 있는가?
readData(ofLength:) 계열보다 나중에 추가된 API인데, 문서 설명 문구는 예전 비throwing 버전(항상 값을 반환하고 실패를 표현할 수 없어 빈 Data로 EOF를 표시하던 버전) 시절 그대로 남아 있는 것으로 보인다. 실제 실행 결과가 이 챕터 전체의 원칙과 정확히 맞아떨어진다 — 문서를 외우기보다 직접 돌려서 확인해야 하는 이유다.DispatchIO의 low/high water mark는 무엇을 조절하는가?
Q5. `isExcludedFromBackupKey`는 어디에 붙여야 하고 어디에 붙이면 안 되는가?
isExcludedFromBackup은 "디스크엔 남아 있어야 하지만 백업엔 넣을 필요 없는" 데이터에 붙이는 힌트다. 전형적인 대상은 Documents나 Application Support에 저장한, 다시 다운로드·재생성할 수 있는 대용량 파일이다. Caches/와 tmp/에는 이미 시스템이 자동으로 백업에서 제외해주므로 붙일 필요가 없고, Documents/의 사용자 생성 콘텐츠엔 절대 붙이면 안 된다 — 사용자는 복원했을 때 그게 그대로 돌아오길 기대한다. 그리고 이건 보장이 아니라 힌트일 뿐이라 일부 파일 연산이 이 값을 리셋시키므로 저장할 때마다 다시 걸어야 한다.
원리
URLResourceValues.isExcludedFromBackup의 공식 설명은 명확하다 — "캐시나 다른, 백업엔 필요 없는 애플리케이션 지원 파일을 제외하는 데만 유용하다. 사용자 문서에 흔히 이뤄지는 일부 조작이 이 값을 false로 리셋시킬 수 있으므로, 이 속성을 사용자 문서에는 쓰지 말아야 한다."
내부 동작
Optimizing Your App's Data for iCloud Backup은 이 값을 붙일 대상을 정확히 구분한다. Caches/tmp에 있는 퍼지 가능한(purgeable) 데이터는 이미 자동 제외되니 다시 표시할 필요가 없다. 이 값이 진짜 필요한 건 퍼지 대상이 아닌(nonpurgeable) 데이터 중에서 사용자가 다시 받거나 다시 만들 수 있는 것 — 예로 든 것이 정확히 "오프라인 재생을 위해 받아둔 고화질 동영상"이다. 반대로 사용자가 직접 가져온 PDF·전자책처럼 다시 만들기 어렵거나 불가능한 파일은 제외하면 안 된다고 명시한다.
중요한 단서는 이 값이 "보장이 아니라 시스템에 주는 가이던스일 뿐"이라는 점과, "특정 파일 연산이 리소스 값을 리셋시킬 수 있으므로 저장할 때마다 다시 적용해야 한다"는 점이다. 이건 추상적인 경고가 아니다 — Q2에서 .atomic 쓰기 한 번에 isExcludedFromBackup이 true에서 false로 조용히 리셋되는 걸 직접 측정했다. 여러 관련 파일을 묶어 관리하려면, 애플은 Library/ 아래 번들 ID로 이름 지은 전용 디렉터리를 만들고 그 디렉터리 자체에 값을 설정하는 패턴을 예제로 제시한다.
| 위치 | isExcludedFromBackup을 붙여야 하나 | 이유 |
|---|---|---|
Library/Caches/, tmp/ | 붙일 필요 없음 | 이미 시스템이 자동으로 백업에서 제외한다 |
Documents/의 사용자 생성 콘텐츠 | 붙이면 안 됨 | 사용자는 복원 시 이 콘텐츠가 그대로 돌아오길 기대한다 |
| Application Support(또는 Documents)의 재다운로드 가능한 대용량 파일 | 붙여야 함 | 디스크엔 남기되(퍼지 대상 아님) 백업 용량만 줄인다 |
실험 · 도구
설정과 왕복 자체는 macOS에서 그대로 검증된다.
var url1 = f1
var values = URLResourceValues()
values.isExcludedFromBackup = true
try url1.setResourceValues(values)
let readBack = try url1.resourceValues(forKeys: [.isExcludedFromBackupKey])
print("isExcludedFromBackup readback:", readBack.isExcludedFromBackup as Any)
// isExcludedFromBackup readback: Optional(true)그리고 Q2의 atomic 실험이 정확히 이 값의 리셋을 증명한다 — 같은 파일에 true를 설정한 뒤 .atomic으로 덮어쓰면 Optional(false)로 돌아온다. "매번 다시 걸어야 한다"는 문서 경고가 실제로 관찰되는 순간이다.
프로젝트 적용
① 관련 파일이 여러 개면 애플이 권장하는 대로 전용 디렉터리 하나에 값을 걸어 묶어 관리한다.
func makeExcludedSupportDirectory() throws -> URL {
let library = try FileManager.default.url(
for: .libraryDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
var dir = library.appendingPathComponent(
Bundle.main.bundleIdentifier ?? "app", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
var values = URLResourceValues()
values.isExcludedFromBackup = true
try dir.setResourceValues(values)
return dir
}② 개별 파일 단위로 관리한다면, 저장할 때마다 다시 거는 헬퍼로 감싸서 리셋을 놓치지 않게 한다.
func saveExcludingFromBackup(_ data: Data, to url: URL) throws {
try data.write(to: url)
var mutableURL = url
var values = URLResourceValues()
values.isExcludedFromBackup = true
// 파일 연산 후엔 리셋될 수 있으므로 저장 경로 안에서 항상 다시 건다 (Q2 참고)
try mutableURL.setResourceValues(values)
}"Caches에 있는 파일에도 isExcludedFromBackupKey를 걸어야 확실히 안전하다"는 불필요하다 — 이미 자동으로 제외된다. "한 번 설정하면 파일이 존재하는 한 계속 유지된다"도 틀렸다 — atomic 쓰기 같은 특정 파일 연산이 이 값을 리셋시킨다는 게 문서에 명시돼 있고, 실제로 측정된다.
이사할 때 짐 목록에 "이건 안 챙겨도 돼, 이사 가서 다시 사면 되니까"라고 포스트잇을 붙여두는 것과 같다. 포스트잇을 붙였다고 그 짐이 사라지는 건 아니다(디스크엔 그대로 남는다) — 그냥 이삿짐센터(백업 시스템)에게 "이건 트럭에 안 실어도 된다"는 힌트만 주는 것이다. 비유가 깨지는 곳: 진짜 포스트잇은 누가 떼지 않는 한 그대로 붙어 있지만, 이 표시는 짐을 다시 포장(atomic 쓰기 같은 일부 파일 연산)하면 포스트잇이 저절로 떨어져서 매번 다시 붙여야 한다.