← Swift & Foundation 로드맵 25 / 33

25 · FileManager와 파일 I/O

Documents·Application Support·Caches·tmp의 백업·퍼지 규칙부터 원자적 쓰기가 보장하는 것, Data Protection과 재부팅 후 백그라운드 읽기 실패, FileHandle·InputStream·DispatchIO 선택, 백업 제외 표시까지.
진행률
0 / 0 완료

FileManager는 파일 하나 여닫는 API처럼 보이지만, 실제로는 어디에 무엇을 저장하느냐가 곧 백업 정책·저장공간 정책·잠금 화면 보안 정책과 그대로 맞물린다. 이 챕터는 앱 컨테이너의 네 디렉터리가 정확히 무엇을 보장하고 무엇을 보장하지 않는지, 원자적 쓰기가 실제로 지켜주는 범위가 어디까지인지, 재부팅 직후 백그라운드에서 파일을 못 여는 사고가 왜 나는지, 대용량 파일을 어떤 도구로 점진적으로 읽어야 하는지를 다룬다. 마지막엔 isExcludedFromBackupKey를 어디에 붙이고 어디에 붙이면 안 되는지로 마무리한다.

Q1. 앱 컨테이너의 Documents / Application Support / Caches / tmp는 백업·퍼지 규칙이 각각 어떻게 다른가?

🔑 30초 답변

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/안 됨 (자동 제외)될 수 있음 — 가장 적극적으로노출 안 됨수명이 매우 짧은 임시 파일. 다 쓰면 앱이 즉시 지워야 함

tmpCaches가 백업에서 자동 제외되는 이유는 인과관계가 뒤집혀 있지 않다 — Optimizing Your App's Data for iCloud Backup은 시스템이 이 디렉터리들을 주기적으로 퍼지하기 때문에 iCloud 백업이 기본적으로 이들을 제외한다고 못박는다. 즉 퍼지 가능성이 원인이고 백업 제외가 결과다. 같은 문서는 퍼지 대상이 아닌 데이터(사용자가 다시 만들기 어려운 것)를 Caches/tmp에 절대 넣지 말라고도 명시한다.

앱 컨테이너 — 디렉터리별 백업 · 퍼지 · 노출 Documents/ 백업 O 퍼지 X 노출 가능* 사용자 생성 장기 보존 콘텐츠 App Support/ 백업 O 퍼지 X 노출 X 앱 재생성 불가한 설정·구성 파일 Caches/ 백업 X 퍼지 O 노출 X 재생성 가능한 캐시 데이터 tmp/ 백업 X 퍼지 O(적극적) 노출 X 수명 매우 짧은 임시 파일 * UIFileSharingEnabled를 켰을 때만 Files 앱에 노출된다 퍼지 시점은 시스템이 결정 — 앱이 직접 지우지 않으면 신뢰할 수 없다
네 디렉터리는 그냥 저장 위치가 아니라 각각 다른 백업·퍼지·노출 계약이다.

실험 · 도구

이 디렉터리들은 macOS(비샌드박스 앱)에서도 그대로 조회할 수 있어서 직접 확인했다. 아래는 swift 파일.swift로 실행한 실제 출력이다.

FileManager.urls(for:in:)와 URL 정적 프로퍼티로 컨테이너 경로 조회 — 실제 출력
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에서 다룬다).

앱 전용 서브디렉터리를 Application Support 아래 만든다
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 파일도 이 네 디렉터리 규칙을 따르는가?
UserDefaults의 plist는 컨테이너 안의 Library/Preferences/에 있고, 이 디렉터리는 Application Support와 마찬가지로 백업 대상이며 시스템이 임의로 퍼지하지 않는다. 저장 방식과 검색 도메인 같은 UserDefaults 고유의 이야기는 26장에서 이어간다.
쉽게 말하면 설정 서랍도 옷장(Application Support)과 같은 취급을 받는 또 다른 서랍이라고 보면 된다.
Caches 안에 파일이 아주 많이 쌓이면 앱 시작 속도에 영향을 주는가?
"Caches 디렉터리 자체"가 앱 시작을 느리게 만든다는 공식 설명은 없지만, 파일 개수가 많은 디렉터리를 매 실행마다 contentsOfDirectory로 훑거나 정리하는 로직을 넣으면 그 로직 자체가 I/O 비용이 된다. 그래서 캐시 정리는 시작 시점보다 백그라운드 진입 시점에 하는 게 일반적이다.
쉽게 말하면 냉장고 안이 꽉 차 있어도 문을 여는 속도 자체는 안 느려지지만, 안을 뒤적거리며 정리하는 시간은 그만큼 걸린다.
재다운로드 가능한 대용량 콘텐츠를 Documents에 넣으면 왜 실무적으로 문제가 되는가?
Documents는 백업 대상이라, 다시 받을 수 있는 파일까지 거기 넣으면 사용자의 iCloud 백업 용량과 백업 소요 시간이 불필요하게 늘어난다. Apple의 iCloud 백업 최적화 문서가 정확히 이 케이스(HD 동영상 등 재다운로드 가능한 대용량 파일)를 들어 백업 제외를 권고하는 이유이기도 하다.
쉽게 말하면 이사할 때 온라인으로 다시 살 수 있는 물건까지 이삿짐 트럭에 다 실으면, 트럭도 더 커야 하고 이사 시간도 오래 걸린다.

Q2. `.atomic` 쓰기가 보장하는 것과 보장하지 않는 것은?

🔑 30초 답변

.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 코멘트 등 원본 메타데이터를 보존"하도록 설계됐다. 단, originalItemURLnewItemURL이 같은 볼륨에 있어야 하고, 다른 볼륨이면 에러가 난다 — 그럴 땐 .itemReplacementDirectory로 대상 볼륨의 임시 URL을 받으라고 문서가 권한다.

.atomic 쓰기 — 임시 파일에 쓰고 rename으로 교체 app.json 기존 값 v1 새 값 쓰기 시작 .tmp-a1b2c3 v2를 쓰는 중 여기서 크래시 쓰기 완료 → rename() app.json — 변화 없음 여전히 v1, 손상되지 않음 app.json → v2 .tmp-a1b2c3는 사라짐 동시에 다른 쓰기가 있으면 상호배제는 없다 — 마지막 rename이 그냥 이긴다
rename 한 번이 파일 전체를 통째로 교체한다 — 중간 상태는 존재할 수 없다.

실험 · 도구

메타데이터 리셋을 직접 재현해봤다. 파일을 .completeUnlessOpen과 백업 제외로 설정한 뒤 .atomic으로 덮어쓰고 다시 읽어봤다.

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을 쓴다.

메타데이터 보존이 필요하면 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은 같은 볼륨 안에서만 동작하는가?
rename이 원자적인 이유는 같은 파일시스템 안에서 디렉터리 엔트리 하나만 바꿔치기하면 되기 때문이다. 볼륨이 다르면 실제 데이터를 복사해야 하는데, 복사는 여러 블록에 걸친 여러 쓰기 연산이라 그 자체로는 원자적일 수 없다. 그래서 문서가 다른 볼륨이 필요하면 url(for:in:appropriateFor:create:).itemReplacementDirectory를 넘겨 대상 볼륨 위의 임시 URL을 먼저 받으라고 안내한다.
쉽게 말하면 같은 서랍 안에서 물건 위치를 바꾸는 건 순식간이지만, 다른 방으로 옮기려면 들고 가는 시간이 걸려서 그 중간 순간을 들키지 않을 수가 없다.
atomic 쓰기 도중 디스크가 꽉 차면 어떻게 되는가?
임시 파일에 쓰는 단계에서 공간 부족 에러가 나면서 실패하고, rename은 애초에 일어나지 않는다. 즉 원본 파일은 실패한 쓰기 시도의 영향을 전혀 받지 않고 그대로 남는다 — 이게 write-then-rename 패턴이 주는 가장 실질적인 안전망이다. 다만 이 말은 거꾸로 "쓰기 성공을 보장받으려면 원본 크기만큼의 여유 공간이 항상 필요하다"는 뜻이기도 하다.
쉽게 말하면 새 편지지를 살 돈이 없으면 편지를 아예 다시 못 쓸 뿐, 원래 갖고 있던 편지는 멀쩡히 그대로 있다.
앱과 위젯 익스텐션처럼 서로 다른 프로세스가 같은 파일에 atomic write를 하면?
각 프로세스의 쓰기 자체는 여전히 손상 없는 all-or-nothing이지만, 두 프로세스가 거의 동시에 rename하면 그냥 나중에 rename한 쪽이 이긴다 — 먼저 쓴 내용은 조용히 사라진다. 진짜로 여러 프로세스 간 조정이 필요하면 NSFileCoordinator나 App Group 수준의 별도 락이 필요하고, 이는 atomic 옵션이 해결해주는 문제가 아니다.
쉽게 말하면 두 사람이 동시에 같은 우편함의 이름표를 각자 다른 이름으로 바꿔치기하면, 마지막에 붙인 이름표만 남고 먼저 붙인 사람의 시도는 흔적도 없이 사라진다.

Q3. Data Protection 클래스란 무엇이고, 재부팅 직후 백그라운드에서 파일이 안 열리는 이유는?

🔑 30초 답변

Data Protection은 파일을 기기 패스코드에서 파생된 키로 암호화해 4개 등급(.complete, .completeUnlessOpen, .completeUntilFirstUserAuthentication, .none)으로 나눈다. 앱 컨테이너 파일의 기본값은 .complete가 아니라 .completeUntilFirstUserAuthentication이다 — 부팅 후 사용자가 한 번이라도 잠금을 풀면 그 뒤로는 기기를 다시 잠가도 계속 읽을 수 있다. 문제는 재부팅 직후, 사용자가 아직 한 번도 잠금을 풀지 않은 상태에서 푸시 핸들러나 위치 업데이트, BGTask 같은 백그라운드 작업이 도는 경우다 — 이 시점엔 파일이 여전히 잠겨 있어서 여는 시도 자체가 실패한다.

원리

FileProtectionType은 4가지 값을 정의한다.

내부 동작

이 정의를 그대로 따라가면 재부팅 버그의 원인이 논리적으로 드러난다. .completeUntilFirstUserAuthentication은 "기기가 부팅된 뒤 접근할 수 없다"고 명시하고, 그 접근 가능 상태는 오직 "사용자가 처음 잠금을 해제한 뒤"부터 시작된다. 즉 재부팅과 첫 언락 사이에 실행되는 코드는 이 등급의 파일을 원천적으로 열 수 없다 — 이건 버그가 아니라 문서에 적힌 대로 동작하는 것이다. 다만 이 사실이 실무에서 잘 알려지지 않은 이유는, 개발 중에는 기기를 재부팅 직후 언락 전 상태로 오래 유지하며 테스트할 일이 거의 없어서 이 실패 경로를 우연히 밟기가 어렵기 때문이다.

직접 만든 파일의 기본 보호 클래스를 확인해보면, 별도로 지정하지 않은 새 파일은 .completeUntilFirstUserAuthentication을 받는다(Q2의 atomic 재작성 실험에서 .completeUnlessOpen으로 지정했던 값이 재작성 후 정확히 이 기본값으로 돌아간 것이 그 증거다). UserDefaults를 뒷받침하는 plist 파일도 이 기본 보호 등급 아래 있어서, 재부팅 직후 언락 전에 백그라운드 코드가 UserDefaults 값을 읽으려다 실패하는 사례가 Apple Developer Forums에도 여러 차례 보고돼 있다.

재부팅 → 첫 잠금 해제 타임라인 (completeUntilFirstUserAuthentication 기준) ① 재부팅 파일이 잠긴 상태 ② 백그라운드 작업 (푸시 · 위치 · BGTask) 파일 열기 실패 아직 첫 언락 전 ③ 첫 잠금 해제 파일 접근 가능 ④ 다시 잠가도 계속 접근 가능 .complete라면 ④에서 다시 잠기는 순간 다시 접근 불가로 돌아간다
completeUntilFirstUserAuthentication은 "부팅마다 다시 잠기지만, 한 번 풀리면 그 부팅 동안은 계속 풀려 있는" 등급이다.

실험 · 도구

실제 잠금·재부팅 동작은 iOS 전용이라 macOS에서 재현할 수 없지만, 보호 클래스를 읽고 쓰는 API 표면은 macOS에서도 그대로 동작해 확인했다.

protectionKey 설정과 URLResourceValues 왕복 확인 — 실제 출력
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차 문서를 신뢰해야 하는" 대목이다.

프로젝트 적용

백그라운드 작업이 재부팅 직후에도 반드시 읽어야 하는 파일은 보호 등급을 의도적으로 낮춘다.

백그라운드 접근이 필요한 파일은 completeUnlessOpen으로
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도 같은 문제를 겪는가?
그렇다. UserDefaults를 뒷받침하는 plist 파일도 기본 보호 등급 아래 있어서, 재부팅 직후 언락 전에 접근하면 값을 못 읽거나 심하면 기존 값을 덮어써버리는 사례가 보고돼 있다. 저장 방식과 실무 대응은 26장에서 이어간다.
쉽게 말하면 설정을 적어둔 메모장도 같은 금고 안에 있어서, 금고가 잠겨 있으면 메모장도 같이 못 꺼낸다.
`.completeUnlessOpen`은 어떤 상황에 특히 유용한가?
파일을 백그라운드에서 계속 열어둔 채로 써야 하는 경우, 예를 들어 백그라운드 다운로드가 진행 중인 파일이나 녹음이 계속되는 오디오 파일에 유용하다. 파일이 열려 있는 동안은 보호가 느슨해지지만, 앱이 그 파일을 닫는 순간 다시 암호화되어 잠긴다 — "쓰는 동안은 열어두고, 안 쓸 땐 잠근다"는 절충이다.
쉽게 말하면 물건을 지금 쓰고 있는 동안은 서랍을 열어두고, 다 쓰고 넣어두면 자동으로 잠기는 서랍이다.

Q4. 대용량 파일을 점진적으로 읽으려면 FileHandle / InputStream / DispatchIO 중 무엇을?

🔑 30초 답변

가장 단순한 순차 읽기엔 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 청크로 순회하며 메모리에 청크 하나만 유지한 결과다.

FileHandle로 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바이트씩 읽어 직접 확인했다.

100바이트를 30바이트씩 읽기 — EOF에서 nil, 빈 Data가 아니다
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로 옮긴다.

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`를 백그라운드 스레드에서 쓰면 왜 문제가 되는가?
readabilityHandler와 readInBackgroundAndNotify() 같은 알림 기반 API는 런루프 소스로 파일 디스크립터를 모니터링하는 방식이라, 그 핸들러가 실제로 호출되려면 등록된 스레드에서 런루프가 계속 돌고 있어야 한다. 런루프를 굴리지 않는 백그라운드 스레드에 등록하면 핸들러가 영영 안 불릴 수 있다 — 이럴 땐 애초에 런루프 개념이 없는 DispatchIO가 더 안전한 선택이다.
쉽게 말하면 초인종(핸들러)이 울리려면 집에 사람이 있어야(런루프가 돌아야) 하는데, 아무도 없는 빈 집에 초인종만 달아둔 셈이다.
왜 `read(upToCount:)`는 nil을 반환하는데 문서엔 "빈 NSData"라고 적혀 있는가?
이 throwing/옵셔널 반환 오버로드는 예전 readData(ofLength:) 계열보다 나중에 추가된 API인데, 문서 설명 문구는 예전 비throwing 버전(항상 값을 반환하고 실패를 표현할 수 없어 빈 Data로 EOF를 표시하던 버전) 시절 그대로 남아 있는 것으로 보인다. 실제 실행 결과가 이 챕터 전체의 원칙과 정확히 맞아떨어진다 — 문서를 외우기보다 직접 돌려서 확인해야 하는 이유다.
쉽게 말하면 설명서가 예전 모델 사진 그대로인데 실제 제품은 업그레이드된 것과 비슷하다. 진짜 어떻게 동작하는지는 직접 눌러봐야 안다.
DispatchIO의 low/high water mark는 무엇을 조절하는가?
low water mark는 "이만큼 데이터가 모이기 전엔 핸들러를 부르지 않는다"는 최소 배치 크기, high water mark는 "이 이상 쌓이면 더 안 읽는다"는 상한이다. 이 둘로 콜백이 너무 자주(작은 조각마다) 불리거나 메모리에 너무 많이 쌓이는 것 모두를 막아, 읽는 속도와 처리하는 속도의 균형을 맞춘다 — 백프레셔의 실체다.
쉽게 말하면 물동이를 채울 때 "이만큼 안 차면 안 부르고, 이 이상 차면 그만 부어라"고 정해두는 규칙과 같다.

Q5. `isExcludedFromBackupKey`는 어디에 붙여야 하고 어디에 붙이면 안 되는가?

🔑 30초 답변

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 쓰기 한 번에 isExcludedFromBackuptrue에서 false로 조용히 리셋되는 걸 직접 측정했다. 여러 관련 파일을 묶어 관리하려면, 애플은 Library/ 아래 번들 ID로 이름 지은 전용 디렉터리를 만들고 그 디렉터리 자체에 값을 설정하는 패턴을 예제로 제시한다.

위치isExcludedFromBackup을 붙여야 하나이유
Library/Caches/, tmp/붙일 필요 없음이미 시스템이 자동으로 백업에서 제외한다
Documents/의 사용자 생성 콘텐츠붙이면 안 됨사용자는 복원 시 이 콘텐츠가 그대로 돌아오길 기대한다
Application Support(또는 Documents)의 재다운로드 가능한 대용량 파일붙여야 함디스크엔 남기되(퍼지 대상 아님) 백업 용량만 줄인다

실험 · 도구

설정과 왕복 자체는 macOS에서 그대로 검증된다.

isExcludedFromBackup 설정 후 즉시 읽어보기 — 실제 출력
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)로 돌아온다. "매번 다시 걸어야 한다"는 문서 경고가 실제로 관찰되는 순간이다.

프로젝트 적용

관련 파일이 여러 개면 애플이 권장하는 대로 전용 디렉터리 하나에 값을 걸어 묶어 관리한다.

번들 ID로 이름 지은 전용 디렉터리 자체를 백업에서 제외
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 쓰기 같은 일부 파일 연산)하면 포스트잇이 저절로 떨어져서 매번 다시 붙여야 한다.

꼬리 질문

디렉터리에 isExcludedFromBackup을 걸면 그 안의 모든 파일에 자동으로 적용되는가?
애플이 예제로 제시하는 권장 패턴은 디렉터리 자체에 값을 설정해 그 안의 파일들을 묶어 제외 의도를 표시하는 것이지만, 이 값이 하위 파일에 재귀적으로 얼마나 확실히 상속되는지는 파일 연산·파일시스템 구현에 따라 달라질 수 있다. 그러니 디렉터리에 걸어두더라도 개별 파일을 새로 만들 때 필요하면 확인하는 습관이 안전하다.
쉽게 말하면 상자 겉면에 "이 상자 통째로 제외"라고 써 붙여도, 상자 안 물건을 하나씩 다시 포장하면 그 물건엔 표시가 안 남아 있을 수 있다.
iCloud 백업을 꺼둔 사용자에게도 이 설정이 의미가 있는가?
그렇다. isExcludedFromBackup은 iCloud 백업뿐 아니라 Finder/iTunes로 컴퓨터에 하는 유선 백업 전체에 적용되는 리소스 값이라, iCloud를 꺼둔 사용자가 로컬 백업을 만들 때도 여전히 같은 이득(백업 용량·시간 절감)을 준다.
쉽게 말하면 포스트잇은 어떤 이삿짐센터를 부르든 똑같이 적용되는 표시다.
이 설정을 실수하면 앱 심사에서 리젝되는가?
이 값 자체의 유무가 직접적인 리젝 사유로 명시돼 있진 않지만, Apple의 iCloud 백업 최적화 문서가 재다운로드 가능한 대용량 파일은 반드시 제외하라고 권고하고 있고, 이를 어기면 불필요하게 부풀려진 백업이 사용자 경험(느린 백업, iCloud 저장공간 낭비)을 해친다는 게 공식적으로 제시되는 근거다.
쉽게 말하면 안 지킨다고 바로 불합격은 아니지만, 사용자를 불편하게 만드는 확실한 방법이라 애플이 하지 말라고 콕 집어 말해둔 것이다.

출처 · 참고자료