← Study Hub

10 · 파일 시스템과 I/O

샌드박스 디렉터리 정책부터 논블로킹 I/O와 원자적 쓰기까지, 데이터를 깨뜨리지 않는 파일 다루기
진행률
0 / 0 완료

← 챕터 목록

파일 시스템과 I/O는 "저장은 그냥 되는 것"처럼 보이지만, 실제로는 어느 스레드에서·얼마나·어디에·어떻게 원자적으로 읽고 쓰느냐가 앱의 반응성과 데이터 무결성을 좌우합니다. 이 챕터는 그 네 축을 iOS 관점에서 정리합니다.

Q1. 동기 파일 I/O를 Main Thread에서 수행하면 왜 문제가 되는가?

🔑 30초 답변

메인 스레드는 UI 렌더링·터치·런루프를 한 프레임 예산(60Hz=16.7ms, ProMotion 120Hz=8.3ms) 안에서 처리합니다. 동기 파일 I/O는 read()/write() 같은 blocking syscall이라, 데이터가 page cache에 없으면 커널이 그 스레드를 잠재우고 스토리지 완료를 기다립니다. 이 지연은 마이크로초에서 수십 밀리초까지 비결정적이라, 그동안 런루프가 멈춰 프레임을 놓치고(hitch/jank), 시작·전환 같은 국면에서 길어지면 시스템 watchdog이 앱을 강제 종료합니다.

CS 원리

blocking I/O는 syscall로 커널에 진입한 뒤, 원하는 데이터가 커널의 페이지 캐시에 있으면 즉시(마이크로초) 복사해 돌아오지만, 없으면 프로세스를 sleep 상태로 두고 저장장치 I/O가 끝날 때까지 그 스레드를 다른 일에 쓸 수 없게 만듭니다. UI 프레임워크는 본질적으로 이벤트 루프 모델이라, 이 루프를 도는 스레드가 한 번 블록되면 그 시간만큼 입력·타이머·화면 갱신 콜백이 통째로 밀립니다. 문제의 핵심은 "느리다"가 아니라 지연이 예측 불가능하고, 하필 프레임을 그려야 하는 스레드를 붙잡는다는 점입니다.

iOS에서는

Foundation의 Data(contentsOf:), String(contentsOf:), FileHandle.readData 등은 결국 Darwin/XNU의 BSD VFS 계층에서 read(2)/pread(2) 같은 blocking syscall로 내려갑니다. 메인 스레드에서는 CoreAnimation의 커밋(CATransaction)이 CFRunLoop 위에서 돌기 때문에, 런루프가 블록되면 그 프레임의 레이어 변경이 화면에 올라가지 못해 정지처럼 보입니다. 게다가 Data Protection으로 암호화된 파일은 기기가 잠겨 있으면(파일의 보호 클래스에 따라) 접근 자체가 지연·실패할 수 있어 메인 스레드 블로킹이 더 위험합니다. 앱 시작·재개 등 특정 국면에서 이 지연이 임계치(국면마다 다르며 앱 시작은 통상 20초 안팎으로 알려져 있으나 Apple이 공식 수치를 명시하지는 않습니다)를 넘으면 SpringBoard의 watchdog이 SIGKILL을 보내고, 크래시 로그에 exception code 0x8badf00d("ate bad food")가 찍힙니다(이 코드는 TN2151에 정리돼 있습니다).

실험 · 도구

재현은 간단합니다. 수십 MB 파일을 스크롤 콜백 안에서 동기로 읽게 만들고 스크롤하면, Instruments의 Time Profiler에서 메인 스레드 스택에 I/O 함수가 잡히고 Animation Hitches(os_signpost)로 프레임 드랍이 보입니다. Xcode 스킴의 Thread Performance Checker를 켜면 메인 스레드에서의 디스크 접근을 런타임 경고로 잡아 줍니다. 코드 레벨에서는 I/O 진입 직전에 스레드를 강제로 검증하는 게 가장 빠른 조기 발견 수단입니다.

메인 스레드 I/O를 개발 중에 즉시 드러내기 (Swift)
// 파일 I/O 앞에 두면 메인에서 호출될 때 즉시 크래시로 잡힌다
dispatchPrecondition(condition: .notOnQueue(.main))

// ❌ 나쁨: 스크롤 콜백에서 동기 읽기 → 런루프 정지
func scrollViewDidScroll(_ sv: UIScrollView) {
    let data = try? Data(contentsOf: bigFileURL)   // 메인에서 블록
}

// ✅ 좋음: 전용 백그라운드에서 I/O, 결과만 메인으로 hop
Task.detached(priority: .utility) {
    let data = try Data(contentsOf: bigFileURL)
    await MainActor.run { self.apply(data) }
}
← 각 칸 = 1 프레임 예산(16.7ms) → 정상 🖼 🖼 🖼 🖼 동기 I/O read() 블록 — 런루프 정지 놓친 프레임 ×3 (hitch)
같은 시간, 메인 스레드가 블록되면 그 구간의 프레임들이 통째로 사라진다.

프로젝트 적용

⚠️ 흔한 오해

"백그라운드 스레드로 옮기면 무조건 빠르고 안전하다"는 오해입니다. 스레드를 바꿔도 I/O 자체가 빨라지지 않으며, 동시 blocking 작업을 GCD 글로벌 큐에 무분별하게 던지면 thread explosion이 일어나 컨텍스트 스위칭 비용과 우선순위 역전으로 오히려 느려집니다. "메인만 아니면 됨"이 아니라 "동시성 폭 제한 + 적절한 QoS"가 함께 있어야 합니다.

꼬리 질문

Q2. Data(contentsOf:)가 대용량 파일에서 위험한 이유는 무엇인가?

🔑 30초 답변

옵션 없이 호출하면 Data(contentsOf:)는 파일 전체를 한 번에 힙 메모리로 복사합니다. 파일 크기만큼 물리 메모리를 쓰므로 대용량이면 footprint가 급증하고, iOS는 앱 메모리를 디스크로 스왑하지 않고(압축 메모리만 존재) 리밋을 넘으면 jetsam이 앱을 즉시 종료합니다. 대안은 .mappedIfSafe로 메모리 매핑하거나, FileHandle로 청크 단위 스트리밍하는 것입니다.

CS 원리

파일을 메모리로 가져오는 방식은 크게 둘입니다. (1) 복사 로드: read syscall로 사용자 버퍼에 내용을 복사 — 파일 크기만큼 상주 메모리를 차지하고 시간도 O(파일 크기). (2) 메모리 매핑(mmap): 파일을 프로세스의 가상 주소 공간에 매핑만 해 두고, 실제 물리 페이지는 해당 주소를 접근할 때(page fault) lazy하게 올립니다. 매핑된 페이지 중 수정하지 않은(clean) 것은 원본이 디스크에 그대로 있으므로, 메모리 압박 시 커널이 그냥 버렸다가 다시 필요할 때 fault로 재로딩할 수 있습니다. 즉 mmap은 "가상 공간은 크게, 물리 메모리는 접근한 만큼만"이 핵심입니다.

iOS에서는

Data(contentsOf:options:)ReadingOptions에서 .mappedIfSafe(안전할 때 매핑)·.alwaysMapped(강제 매핑)를 줄 수 있습니다. iOS 메모리 관리는 앱 메모리를 디스크로 스왑하지 않고 메모리 압축만 사용하며, 메모리 압박이 오면 jetsam이 우선순위(포그라운드/백그라운드)에 따라 프로세스를 강제 종료합니다. 포그라운드 앱도 기기별 하드 리밋을 넘으면 EXC_RESOURCE/jetsam으로 죽습니다. 매핑된 clean 페이지는 재확보 가능해 footprint 계산에 유리하지만, 쓰기로 dirty가 되거나 무작위로 전부 touch하면 결국 물리 메모리에 올라옵니다. 남은 메모리 예산은 os_proc_available_memory()로 확인할 수 있습니다.

Data(contentsOf:) options: .mappedIfSafe 파일 200MB (디스크) 파일 200MB (디스크) read 복사 mmap 힙(RAM) 200MB 전부 상주 → jetsam 위험 접근한 페이지만 상주(lazy)
같은 파일이라도 전체 로드는 크기만큼 RAM을 잡고, 매핑은 실제 만진 페이지만 올라온다.

실험 · 도구

100MB 이상 파일을 옵션 없이 vs .mappedIfSafe로 읽고, Instruments의 AllocationsVM Tracker에서 resident/footprint를 비교하면 차이가 바로 드러납니다. Xcode Memory Report의 그래프로도 급증이 관찰됩니다. 스트리밍 처리는 상수 메모리로 큰 파일을 소화하는 정석입니다.

전체 로드 · 매핑 · 스트리밍 (Swift)
// ❌ 파일 크기만큼 힙에 상주
let all = try Data(contentsOf: url)

// ✅ 안전하면 메모리 매핑 — 접근한 페이지만 올라옴
let mapped = try Data(contentsOf: url, options: .mappedIfSafe)

// ✅ 청크 스트리밍 — 상수 메모리로 처리
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
    process(chunk)                 // 1MB씩만 메모리에
}

// 남은 메모리 예산(바이트) 확인
let budget = os_proc_available_memory()

프로젝트 적용

⚠️ 흔한 오해

".mappedIfSafe를 붙이면 메모리를 안 쓴다"는 오해입니다. 매핑은 lazy·재확보 가능할 뿐이라, 파일을 무작위로 전부 접근하면 결국 모든 페이지가 물리 메모리에 올라와 전체 로드와 다를 바 없어집니다. 매핑은 "부분만 읽거나, 압박 시 버려도 되는 clean 데이터"일 때 이득입니다.

꼬리 질문

Q3. Documents, Library, Caches, tmp 디렉터리는 어떻게 구분하는가?

🔑 30초 답변

모두 앱 샌드박스 컨테이너 안의 용도별 폴더이고, 세 축으로 갈립니다 — 백업되는가, OS가 지울 수 있는가, 사용자에게 보이는가. Documents는 사용자가 만든/재생성 불가 데이터로 백업되고 File Sharing으로 노출될 수 있습니다. Library/Application Support는 앱이 관리하는 영구 데이터로 백업되지만 숨겨집니다. Library/Caches는 재생성 가능한 캐시로 백업 제외·저장공간 부족 시 삭제 대상입니다. tmp는 언제든 지워질 수 있는 임시 폴더입니다.

CS 원리

여러 OS가 공유하는 관례입니다. "사용자 문서 / 애플리케이션 상태 / 캐시 / 임시"를 폴더로 구분하고, 그 폴더가 곧 백업 정책과 purge 정책을 결정합니다(리눅스의 XDG Base Directory와 같은 발상). 개발자가 데이터의 성격을 올바른 폴더로 표현하면, 백업·정리·복원 같은 시스템 동작이 자동으로 맞게 흘러갑니다.

iOS에서는

FileManager로 각 위치를 얻습니다. iCloud/Finder 백업은 Documents와 Library(단, Caches 제외)를 포함하고, Caches와 tmp는 제외합니다. Documents 안이라도 재생성 가능한 큰 파일은 QA1719가 안내하듯 isExcludedFromBackup으로 개별 제외해야 합니다 — 안 하면 백업이 비대해져 리뷰 리젝 사유가 됩니다. UIFileSharingEnabled를 켜면 Documents가 Files 앱/Finder에 노출됩니다. Application Support 폴더는 기본 생성되어 있지 않아 create: true로 만들어 써야 합니다.

디렉터리백업OS가 지움?노출대표 용도
Documents/백업됨유지File Sharing 시 노출사용자 생성·재생성 불가 데이터
Library/Application Support/백업됨유지숨김DB·앱이 관리하는 영구 상태
Library/Caches/제외삭제될 수 있음숨김재생성 가능한 캐시
tmp/제외언제든 삭제숨김수명 짧은 임시 파일
앱 샌드박스 컨테이너 Documents/ 백업 ✓ · 노출 가능 Library/ tmp/ 백업 ✗ · 휘발 Application Support/ 백업 ✓ · 숨김 Caches/ 백업 ✗ · purge
Caches는 Library 아래에 있으며, 백업에서 빠지고 저장공간이 부족하면 OS가 정리한다.

실험 · 도구

시뮬레이터에서 각 URL을 찍어 실제 컨테이너 경로를 열어 보면 구조가 손에 잡힙니다. 백업 제외 플래그는 URLResourceValues로 파일 단위로 설정합니다.

디렉터리 얻기 + 백업 제외 (Swift)
let fm = FileManager.default

let docs   = try fm.url(for: .documentDirectory,          in: .userDomainMask, appropriateFor: nil, create: false)
let appSup = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try fm.url(for: .cachesDirectory,            in: .userDomainMask, appropriateFor: nil, create: false)
let tmp    = fm.temporaryDirectory

// Documents 안에 재생성 가능한 큰 파일을 둘 때: 백업에서 제외 (미설정 시 리젝 사유)
var url = docs.appendingPathComponent("cache.bin")
var values = URLResourceValues()
values.isExcludedFromBackup = true
try url.setResourceValues(values)

프로젝트 적용

⚠️ 흔한 오해

"Caches는 앱 실행 중에도 아무 때나 지워진다" 또는 반대로 "한 번 넣으면 절대 안 지워진다" 둘 다 정확하지 않습니다. 실제로는 주로 앱이 실행 중이 아닐 때 저장공간이 부족하면 삭제될 수 있습니다. 정확한 태도는 "언제든 없어질 수 있다고 가정하고, 없으면 다시 만들 수 있게" 코딩하는 것입니다.

꼬리 질문

Q4. 파일 쓰기 중 앱이 종료되어도 데이터가 깨지지 않게 하려면 어떻게 하는가?

🔑 30초 답변

기존 파일을 제자리에서 덮어쓰면 도중에 죽었을 때 앞뒤가 섞인 반쪽 파일이 남습니다. 대신 임시 파일에 완전히 쓴 뒤 rename()으로 원자적으로 교체합니다 — 같은 볼륨에서 rename은 원자적이라, 어느 순간에 죽어도 목적지는 "옛 전체본"이거나 "새 전체본"입니다. 전원 손실까지 대비하려면 rename 전에 fsync/F_FULLFSYNC로 실제 매체까지 내려야 하고, 여러 파일이 서로 일관돼야 하면 SQLite/Core Data의 트랜잭션을 씁니다.

CS 원리

여기서 지켜야 할 성질은 두 가지로 나뉩니다. 원자성(atomicity)은 "부분 상태가 관찰되지 않음"이고, 지속성(durability)은 "커밋했다고 본 데이터가 전원 손실 후에도 남아 있음"입니다. temp에 쓰고 rename(tmp, dest)하면, 디렉터리 엔트리 교체가 원자적이라 원자성이 확보됩니다. 다만 rename만으로는 데이터가 아직 페이지 캐시에만 있을 수 있어, 전원이 꺼지면 사라질 수 있습니다. 그래서 순서가 중요합니다 — 데이터 fsync → rename → 디렉터리 fsync. 또한 원인을 구분해야 합니다. 앱 프로세스만 죽는 crash면 커널 페이지 캐시가 살아 있어 rename만으로 충분하지만, 커널 패닉·전원 손실이면 fsync가 필요합니다. 저널링/WAL을 쓰는 DB는 이 로그 선기록으로 원자성과 지속성을 함께 보장합니다.

제자리 덮어쓰기 dest (v1) 부분 write ⚡ ✕ 손상된 반쪽 파일 temp + rename tmp: v2 전체 write fsync → rename (원자) dest → v2 (온전) rename 전에 죽으면 dest = v1 전체 · 후면 v2 전체
제자리 쓰기는 찢어진 파일을 남기지만, temp+rename은 언제 죽어도 온전한 한 버전만 남긴다.

실험 · 도구

쓰기 도중 프로세스를 강제로 죽여(kill -9 또는 디버거에서 중단) 목적지 파일의 무결성을 확인하면 두 방식의 차이가 명확합니다. fs_usage로 syscall을 추적하면 .atomic 경로가 실제로 aux 파일 생성 후 rename을 부르는 것을 볼 수 있습니다. 진짜 durability가 필요하면 아래처럼 F_FULLFSYNC를 직접 부릅니다.

crash·전원 손실에 강한 원자적 쓰기 (Swift)
func atomicWriteDurable(_ data: Data, to dest: URL) throws {
    let dir = dest.deletingLastPathComponent()
    let tmp = dir.appendingPathComponent(".tmp-" + UUID().uuidString)  // 반드시 같은 볼륨

    // 1) tmp 파일에 전부 write
    FileManager.default.createFile(atPath: tmp.path, contents: nil)
    let fh = try FileHandle(forWritingTo: tmp)
    try fh.write(contentsOf: data)

    // 2) 디스크 매체까지 강제 flush — 전원 손실 대비 (fsync보다 강한 F_FULLFSYNC)
    if fcntl(fh.fileDescriptor, F_FULLFSYNC) == -1 { throw POSIXError(.EIO) }
    try fh.close()

    // 3) 원자적 교체: 이 지점 전에 죽으면 dest = 구버전 전체, 후면 신버전 전체
    //    (교체 자체의 지속성까지 필요하면 상위 디렉터리 fd도 fsync)
    _ = try FileManager.default.replaceItemAt(dest, withItemAt: tmp)
}

프로젝트 적용

⚠️ 흔한 오해

".atomic이면 전원이 꺼져도 100% 안전"이라는 오해입니다. 원자적 rename은 반쪽 파일이 생기지 않음(원자성)을 보장할 뿐, fsync/F_FULLFSYNC 없이는 이미 저장됐다고 본 데이터가 전원 손실 후에도 남아 있음(지속성)은 보장하지 못합니다. 둘은 서로 다른 성질이며, iOS에서 흔한 SIGKILL(백그라운드 종료)은 rename만으로도 커버되지만 전원 손실은 그렇지 않습니다.

꼬리 질문

Q5. Atomic write란 무엇인가?

🔑 30초 답변

관찰자 입장에서 쓰기가 "전부 반영" 아니면 "전혀 반영 안 됨" 둘 중 하나로만 보이는 쓰기입니다. 중간 상태(반쪽 파일)가 절대 노출되지 않습니다. 파일 단위 원자적 쓰기의 표준 구현은 임시 파일에 다 쓰고 rename()으로 교체하는 것이고, Foundation의 .atomic 옵션이 이걸 대신 해 줍니다. 핵심 주의: 원자성 ≠ 지속성이고, 여러 파일에 걸친 원자성은 이걸로 얻지 못합니다.

CS 원리

원자성은 "all-or-nothing, 부분 관찰 불가"라는 성질입니다. 제자리 write는 여러 블록·여러 syscall로 나뉘어 진행되므로, 중간에 죽거나 그 순간 다른 쪽이 읽으면 반쪽 상태가 관찰됩니다. 반면 rename(2)은 같은 파일시스템 안에서 원자적이라, 이걸 이용해 "완성된 새 파일"을 한 번에 목적지 이름에 붙입니다. 여기서 데이터베이스 ACID 용어를 빌려 구분하면 명확합니다 — 원자성(찢어짐 방지), 지속성(전원 손실 후 유지), 격리(동시 접근 일관성)는 서로 독립입니다. rename은 원자적이지만 fsync가 없으면 지속적이지 않고, 파일 하나에 대한 원자성일 뿐 여러 파일 묶음의 원자성은 트랜잭션이 있어야 합니다.

성질보장하는 것.atomic이 주나?필요한 도구
원자성 (Atomicity)반쪽 파일이 관찰되지 않음준다temp + rename
지속성 (Durability)전원 손실 후에도 유지안 준다fsync / F_FULLFSYNC
다중 파일 일관성여러 파일을 한꺼번에 교체안 준다DB 트랜잭션 (SQLite/Core Data)

iOS에서는

Data.write(to:options:.atomic), String.write(to:atomically:encoding:) 등이 내부적으로 원본과 같은 디렉터리에 보조(aux) 파일을 만들어 쓰고 rename으로 교체합니다. 같은 디렉터리를 쓰는 이유는 rename이 같은 볼륨에서만 원자적이기 때문입니다. 다른 프로세스·App Extension·Files 앱과 함께 접근한다면 NSFileCoordinator로 조율해 원자적 관찰을 보장해야 합니다. APFS는 copy-on-write 파일시스템으로 메타데이터 갱신을 crash-safe하게 처리하지만, 파일 교체의 원자성은 여전히 rename 기반이라는 점은 동일합니다.

원자적 쓰기 한 줄 (Swift)
// 내부적으로 aux 파일에 write 후 rename — 반쪽 파일이 관찰되지 않는다
try data.write(to: url, options: .atomic)

// 문자열/레거시 컬렉션도 동일한 원자적 옵션 제공
try text.write(to: url, atomically: true, encoding: .utf8)

// 주의: .atomic 은 '원자성'만 보장.
// 전원 손실 후 유지(지속성)는 별개 → Q4의 F_FULLFSYNC가 필요.

프로젝트 적용

⚠️ 흔한 오해

"atomic write = 데이터 손실 없음"이라는 오해입니다. 원자성은 찢어진 파일을 막는 것이지 손실 자체를 막는 게 아닙니다. 전원 손실 지속성은 fsync가 담당합니다. 임시 파일을 목적지와 다른 볼륨(예: 별도 temp 디렉터리)에 두고 rename하면 cross-device라 복사+삭제로 전락해 원자성이 깨질 수 있는데, atomically: true는 aux를 목적지와 같은 디렉터리(=같은 볼륨)에 만들어 이 문제를 피하므로 목적지 볼륨과 무관하게 원자적입니다.

꼬리 질문

출처 · 참고자료