파일 시스템과 I/O는 "저장은 그냥 되는 것"처럼 보이지만, 실제로는 어느 스레드에서·얼마나·어디에·어떻게 원자적으로 읽고 쓰느냐가 앱의 반응성과 데이터 무결성을 좌우한다. 이 챕터는 그 네 축을 iOS 관점에서 정리한다.
Q1. 동기 파일 I/O를 Main Thread에서 수행하면 왜 문제가 되는가?
메인 스레드는 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는 앱 스킴에서 기본으로 켜져 있어(스킴 > Run > Diagnostics에서 끌 수 있다) 메인 스레드의 동기 I/O와 우선순위 역전을 런타임 경고로 잡아 준다. 코드 레벨에서는 I/O 진입 직전에 스레드를 강제로 검증하는 게 가장 빠른 조기 발견 수단이다.
// 파일 I/O 앞에 두면 메인에서 호출될 때 즉시 크래시로 잡힌다
dispatchPrecondition(condition: .notOnQueue(.main))
// ❌ 나쁨: 스크롤 콜백에서 동기 읽기 → 런루프 정지
func scrollViewDidScroll(_ sv: UIScrollView) {
let data = try? Data(contentsOf: bigFileURL) // 메인에서 블록
}
// ✅ 좋음: 전용 큐에서 I/O — Task.detached는 협력 풀을 벗어나지 않는다, 결과만 메인으로 hop
Task.detached(priority: .utility) {
let data = try Data(contentsOf: bigFileURL)
await MainActor.run { self.apply(data) }
}프로젝트 적용
① 모든 파일 I/O는 백그라운드(Task.detached/전용 큐/전용 actor)에서 하고, UI 갱신 값만 MainActor로 넘긴다.
import Foundation
// 파일 I/O는 page cache에 없으면 커널이 스레드를 재운다.
// 지연이 µs~수십 ms로 비결정적이라 메인에서 하면 언제든 프레임을 놓친다.
// ✅ 전용 actor로 디스크 접근을 한곳에 모은다
actor DiskStore {
private let root: URL
init(root: URL) { self.root = root }
func load(_ name: String) throws -> Data {
try Data(contentsOf: root.appendingPathComponent(name))
}
func save(_ data: Data, as name: String) throws {
try data.write(to: root.appendingPathComponent(name), options: .atomic)
}
func delete(_ name: String) throws {
try FileManager.default.removeItem(at: root.appendingPathComponent(name))
}
}
// ✅ 호출부 — UI 갱신 값만 메인으로 넘어온다
@MainActor
final class NotesViewModel: ObservableObject {
@Published private(set) var notes: [Note] = []
private let store: DiskStore
init(store: DiskStore) { self.store = store }
func load() async {
do {
let data = try await store.load("notes.json")
notes = try JSONDecoder().decode([Note].self, from: data)
} catch {
notes = []
}
}
}
// ⚠️ dispatchPrecondition은 -Ounchecked를 제외하면 릴리스 빌드에서도 그대로 트랩한다.
// 출시 앱이 죽어도 되는 불변식에만 쓰고, 개발 중에만 잡고 싶으면 #if DEBUG로 감싼다
func readSync(_ url: URL) throws -> Data {
dispatchPrecondition(condition: .notOnQueue(.main))
return try Data(contentsOf: url)
}
struct Note: Codable {}② 큰 blob을 UserDefaults에 넣지 않는다 — 값이 앱과 cfprefsd 양쪽 메모리에 상주하고, 데몬이 플러시할 때마다 plist 전체를 다시 직렬화해 기록하므로 write amplification이 생긴다. 게다가 처음 값을 읽을 때는 cfprefsd와의 동기 XPC 왕복과 plist 전체 파싱이 호출 스레드(대개 메인)에서 일어나 시작이 느려진다. 디스크 쓰기 자체는 데몬이 비동기로 하므로 'set이 곧 동기 디스크 I/O'는 아니다.
import Foundation
// UserDefaults에 쓰면 내부적으로 plist 파일 전체를 다시 직렬화해 기록한다.
// 그 쓰기가 메인 스레드에서 동기로 일어날 수 있어 I/O가 숨어든다.
// ❌ 큰 데이터를 넣는 순간 메인에서 수백 KB~MB를 다시 쓴다
func badPersist(_ feed: [Item]) throws {
let data = try JSONEncoder().encode(feed)
UserDefaults.standard.set(data, forKey: "feedCache") // 💥 큰 blob은 넣지 않는다 (앱·데몬 양쪽 메모리 상주 + 플러시마다 전체 재직렬화)
}
// ✅ 파일로 저장하고, UserDefaults에는 경로·버전 같은 작은 메타만
actor FeedCache {
private let url: URL
init(url: URL) { self.url = url }
func save(_ feed: [Item]) throws {
let data = try JSONEncoder().encode(feed)
try data.write(to: url, options: .atomic) // 백그라운드에서
}
func load() throws -> [Item] {
try JSONDecoder().decode([Item].self, from: Data(contentsOf: url))
}
}
func saveMetadata(_ version: Int) {
UserDefaults.standard.set(version, forKey: "feedCacheVersion") // ✅ 작다
}
// ✅ 현재 UserDefaults 크기를 점검해 보는 방법
func auditUserDefaults() {
guard let id = Bundle.main.bundleIdentifier,
let domain = UserDefaults.standard.persistentDomain(forName: id) else { return }
let sized = domain.compactMap { key, value -> (String, Int)? in
guard let data = try? PropertyListSerialization.data(
fromPropertyList: [value], format: .binary, options: 0) else { return nil }
return (key, data.count)
}.sorted { $0.1 > $1.1 }
for (key, bytes) in sized.prefix(10) where bytes > 4096 {
print("⚠️ \(key): \(bytes / 1024)KB — 파일로 옮기는 것을 검토")
}
}
struct Item: Codable {}③ viewDidLoad·cellForRowAt·스크롤 콜백 안에서의 동기 읽기는 금지. 필요하면 미리 로드해 캐시한다.
import UIKit
// cellForRowAt과 scrollViewDidScroll은 프레임 예산 안에서 반복 호출된다.
// 여기에 동기 I/O가 하나라도 있으면 스크롤이 즉시 끊긴다.
@MainActor
final class FeedViewController: UITableViewController {
private var items: [Item] = []
private var thumbnails: [UUID: UIImage] = [:] // 미리 로드해 둔 캐시
// ❌ 셀 구성에서 디스크를 읽는다
func tableView(_ tv: UITableView, badCellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
let item = items[indexPath.row]
let data = try? Data(contentsOf: item.thumbURL) // 💥 동기 I/O
cell.imageView?.image = data.flatMap(UIImage.init)
return cell
}
// ✅ 캐시에서만 읽고, 없으면 비동기로 채운 뒤 갱신한다
override func tableView(_ tv: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let item = items[indexPath.row]
cell.imageView?.image = thumbnails[item.id] // ✅ 메모리에서만
if thumbnails[item.id] == nil {
Task { await loadThumbnail(for: item, at: indexPath) }
}
return cell
}
private func loadThumbnail(for item: Item, at indexPath: IndexPath) async {
guard let image = await DiskStore.shared.thumbnail(item.thumbURL) else { return }
thumbnails[item.id] = image
// 화면에 아직 보이는 셀만 갱신한다
if tableView.indexPathsForVisibleRows?.contains(indexPath) == true {
tableView.reloadRows(at: [indexPath], with: .none)
}
}
}
// ✅ 더 나은 방법: prefetch로 미리 채운다
// (UITableViewController의 메서드가 아니라 별도 프로토콜이다 — override 아님)
extension FeedViewController: UITableViewDataSourcePrefetching {
func tableView(_ tv: UITableView, prefetchRowsAt paths: [IndexPath]) {
for p in paths where thumbnails[items[p.row].id] == nil {
Task { await loadThumbnail(for: items[p.row], at: p) }
}
}
}
struct Item { let id: UUID; let thumbURL: URL }
actor DiskStore {
static let shared = DiskStore()
func thumbnail(_ url: URL) -> UIImage? { nil }
}"백그라운드 스레드로 옮기면 무조건 빠르고 안전하다"는 오해다. 스레드를 바꿔도 I/O 자체가 빨라지지 않으며, 동시 blocking 작업을 GCD 글로벌 큐에 무분별하게 던지면 thread explosion이 일어나 컨텍스트 스위칭 비용과 우선순위 역전으로 오히려 느려진다. "메인만 아니면 됨"이 아니라 "동시성 폭 제한 + 적절한 QoS"가 함께 있어야 한다.
메인 스레드를 손님 많은 식당의 홀 직원 한 명이라고 생각해 보세요. 이 한 명이 주문도 받고, 물도 채우고, 음식도 나른다. 화면이 부드럽게 움직인다는 건 이 직원이 1초에 60번씩 아주 짧은 간격으로 홀을 한 바퀴 도는 것이다. 그런데 이 직원한테 "창고 가서 물건 좀 찾아와" 하고 시키면, 창고 앞에 물건이 놓여 있으면 금방 오지만 없으면 한참을 뒤적인다. 그 사이 홀은 통째로 멈춰 있다 — 손님이 손을 흔들어도 아무도 안 온다.
진짜 문제는 "창고 일이 느리다"가 아니라 "얼마나 걸릴지 아무도 모른다"이다. 어떤 때는 눈 깜짝할 사이, 어떤 때는 몇십 밀리초. 게다가 가게 매니저 격인 watchdog(앱이 너무 오래 응답 없으면 강제로 꺼 버리는 시스템 감시자)이 홀이 오래 멈춘 걸 보면 아예 가게 문을 닫아 버린다. 그래서 해법은 "창고 일을 빨리 하기"가 아니라 "창고는 다른 직원한테 맡기고, 홀 직원은 받아 온 물건을 테이블에 놓기만" 하는 것이다. 다만 이 비유가 깨지는 데가 하나 있는데, 직원은 많이 뽑을수록 좋은 게 아니다 — 무작정 늘리면 서로 부딪혀 오히려 느려지는 게 thread explosion(스레드가 너무 많이 만들어져 서로 자리다툼하는 현상)이다.
꼬리 질문
백그라운드로 옮겼는데도 hitch가 남는다면 어디를 의심해야 하나?
I/O 자체는 백그라운드로 갔어도 그 결과를 메인에서 처리하는 부분이 무거운 경우가 가장 흔하다. 읽어 온 Data를 메인에서 디코딩·파싱·이미지 디컴프레션·오토레이아웃 재계산까지 하면 결국 메인이 블록된다. 다음으로 의심할 곳은 락 경합이다 — 백그라운드 I/O가 잡은 락을 메인이 기다리면(priority inversion) 옮긴 의미가 없다.
또한 Task.detached로 던졌어도 그 안에서 await MainActor.run이나 @MainActor 프로퍼티 접근이 잦으면 hop 비용과 메인 큐 적체가 생긴다. Instruments의 Time Profiler에서 메인 스레드 스택 최상단이 무엇인지부터 확인하세요.
크래시 로그에 watchdog의 0x8badf00d가 아니라 0xdead10cc가 찍히는 건 언제이며, 파일 락·열린 SQLite 핸들 같은 파일 I/O와 무슨 관계인가?
0x8badf00d("ate bad food")는 워치독이 메인 스레드의 무응답(hang)을 라이프사이클 마감 시한 안에 못 끝냈을 때 찍히는 반면, 0xdead10cc("deadlock")는 앱이 백그라운드로 서스펜드되는 순간 파일 락이나 SQLite 데이터베이스 락 — 공유 컨테이너(App Group)든 앱 개인 컨테이너든 위치를 가리지 않는다 — 을 아직 쥐고 있을 때 시스템이 그 자원을 풀려고 강제 종료하며 찍힌다. 원리는 서스펜드된 프로세스가 락을 계속 잡고 있으면 그 파일을 필요로 하는 다른 프로세스(익스텐션·데몬)가 무기한 막히므로, OS가 자원 회수를 위해 락 보유자를 죽이는 것이다.
iOS에서 가장 흔한 원인은 SQLite로, DB 본체·WAL·저널에 fcntl/flock 락을 걸어 둔 채 트랜잭션이 열려 있는 상태에서 백그라운드 진입이 겹치는 경우다. beginBackgroundTask로 종료 전에 쓰기를 마치고 커넥션을 닫거나 WAL 체크포인트를 돌려 락을 반납하고, 락이 걸리는 파일을 공유 컨테이너에 두지 않는 것으로 막는다.
0x8badf00d가 "너 왜 이렇게 오래 멍 때리냐"고 쫓겨나는 거라면, 0xdead10cc는 "공용 창고 열쇠를 손에 쥔 채로 잠들었다"고 쫓겨나는 거다. 앱이 백그라운드로 내려가는 순간 App Group 폴더의 파일 락(다른 프로세스가 못 건드리게 걸어 둔 잠금)을 아직 안 놨으면, 그 파일이 필요한 위젯이나 데몬이 영영 못 들어가니까 OS가 열쇠를 회수하려고 앱을 죽인다. 제일 흔한 "열쇠 쥔 채 잠든" 경우가 SQLite 트랜잭션을 열어 놓고 백그라운드로 간 상황이다.Swift Concurrency에는 진짜 async 파일 API가 있나? 없다면 blocking I/O를 어떻게 감싸야 하나?
순수한 로컬 파일 읽기/쓰기에 대해 커널까지 진짜 non-blocking으로 내려가는 async API는 사실상 없다. URL.AsyncBytes(for try await line in url.lines)나 URLSession의 async 메서드가 있지만, 로컬 파일 바이트 자체는 결국 blocking syscall을 감싼 것이다. POSIX read/write는 소켓과 달리 정규 파일에서 O_NONBLOCK이 거의 무의미하다는 커널 특성 때문이다.
따라서 blocking I/O는 전용 DispatchQueue(또는 DispatchSerialQueue를 커스텀 executor로 지정한 actor)로 감싸 격리하는 게 정석이다. 여기서 주의할 것은 Task.detached가 협력 스레드 풀을 벗어나지 않는다는 점이다 — detached는 우선순위·task-local 값·액터 컨텍스트 상속만 끊을 뿐 실행은 여전히 같은 풀에서 일어난다. 협력적 스레드 풀(cooperative pool)에서 오래 블록되면 풀이 고갈되므로, 무거운 blocking 작업은 detached나 별도 executor로 빼는 것이 안전하다.
async라고 다 같은 async가 아니다. 네트워크는 "요청 보내 놓고 답 올 때까지 딴 일 하기"가 커널 수준에서 진짜로 되지만, 로컬 파일은 결국 누군가 창고 앞에 서서 기다려야 한다 — Swift의 파일 async API도 속을 열어 보면 그 "서 있는 사람"을 다른 스레드로 옮겨 놓은 것에 가깝다. 그래서 중요한 건 그 기다리는 사람을 어디서 뽑느냐인데, 코어 수만큼만 있는 작은 일꾼 묶음인 cooperative pool에서 뽑아다 오래 세워 두면 앱 전체의 async가 같이 굳는다. 그걸 피하려고 전용 큐로 빼는 것이다(detached는 같은 풀이라 답이 아니다).Q2. Data(contentsOf:)가 대용량 파일에서 위험한 이유는 무엇인가?
옵션 없이 호출하면 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(강제 매핑)를 줄 수 있다. iPhone의 iOS는 앱 메모리를 디스크로 스왑하지 않고 메모리 압축만 사용하며(iPadOS는 16부터 일부 M 시리즈 iPad에서 virtual memory swap을 지원한다), 메모리 압박이 오면 jetsam이 우선순위(포그라운드/백그라운드)에 따라 프로세스를 강제 종료한다. 포그라운드 앱도 기기별 하드 리밋을 넘으면 EXC_RESOURCE/jetsam으로 죽는다. 매핑된 clean 페이지는 재확보 가능해 footprint 계산에 유리하지만, 쓰기로 dirty가 되거나 무작위로 전부 touch하면 결국 물리 메모리에 올라온다. 남은 메모리 예산은 os_proc_available_memory()로 확인할 수 있다.
실험 · 도구
100MB 이상 파일을 옵션 없이 vs .mappedIfSafe로 읽고, Instruments의 Allocations와 VM Tracker에서 resident/footprint를 비교하면 차이가 바로 드러난다. Xcode Memory Report의 그래프로도 급증이 관찰된다. 스트리밍 처리는 상수 메모리로 큰 파일을 소화하는 정석이다.
// ❌ 파일 크기만큼 힙에 상주
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()프로젝트 적용
① 비디오·대형 DB·로그처럼 큰 파일은 절대 전체 로드하지 말고 스트리밍하거나 매핑한다.
import Foundation
// Data(contentsOf:)는 파일 크기만큼 익명 dirty 메모리를 잡는다.
// iOS엔 스왑이 없으므로 상한을 넘는 순간 jetsam이다.
// ❌ 500MB 비디오를 통째로
func loadBad(_ url: URL) throws -> Data {
try Data(contentsOf: url)
}
// ✅ ① 순차 처리라면 청크 스트리밍 — 상수 메모리
func processStreaming(_ url: URL, chunkSize: Int = 1 << 20) throws {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty {
process(chunk) // 항상 1MB만 메모리에
}
}
// ✅ ② 임의 접근·일부만 읽는다면 매핑
func readHeader(_ url: URL) throws -> Data {
let mapped = try Data(contentsOf: url, options: .mappedIfSafe)
return Data(mapped.prefix(4096)) // 이 페이지들만 물리 메모리로
}
// ✅ ③ 해시·업로드처럼 전체를 훑되 담아 둘 필요가 없으면 증분 처리
import CryptoKit
func sha256(of url: URL) throws -> String {
var hasher = SHA256()
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return Data(hasher.finalize()).base64EncodedString()
}
// ✅ ④ 업로드는 파일 자체를 넘긴다 — 메모리에 올리지 않는다
func upload(_ url: URL, to endpoint: URL) async throws {
var request = URLRequest(url: endpoint)
request.httpMethod = "PUT"
_ = try await URLSession.shared.upload(for: request, fromFile: url)
}
func process(_ chunk: Data) {}② 대용량 JSON은 JSONDecoder(전체 Data 요구)가 위험 — 라인/스트리밍 파서를 쓰거나 애초에 포맷을 나눈다.
import Foundation
// JSONDecoder는 전체 Data를 요구한다. 200MB JSON이면 원본 200MB +
// 파싱 중 중간 표현까지 메모리에 올라온다.
// ❌ 통째로 디코딩
func decodeBad(_ url: URL) throws -> [Record] {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([Record].self, from: data)
}
// ✅ ① 포맷을 JSON Lines로 바꾼다 — 한 줄이 한 레코드
// {"id":1,"name":"a"}
// {"id":2,"name":"b"}
func decodeLines(_ url: URL) async throws -> Int {
var count = 0
let decoder = JSONDecoder()
for try await line in url.lines { // ✅ 한 줄씩 스트리밍
guard !line.isEmpty else { continue }
let record = try decoder.decode(Record.self, from: Data(line.utf8))
try insert(record)
count += 1
if count % 1000 == 0 { try Task.checkCancellation() }
}
return count
}
// ✅ ② 포맷을 바꿀 수 없으면 매핑 + 증분 파싱
func decodeMapped(_ url: URL) throws -> [Record] {
let mapped = try Data(contentsOf: url, options: .mappedIfSafe)
// 매핑이라 clean 페이지 → 압박 시 커널이 회수할 수 있다
return try JSONDecoder().decode([Record].self, from: mapped)
// ⚠️ 그래도 파싱 결과 객체는 dirty로 쌓인다 — 근본 해결은 아니다
}
// ✅ ③ 애초에 서버에 페이지네이션을 요청한다 (가장 확실한 해법)
func fetchPaged() async throws {
var cursor: String?
repeat {
let page = try await api.records(after: cursor, limit: 500)
for r in page.items { try insert(r) }
cursor = page.next
} while cursor != nil
}
struct Record: Decodable { let id: Int }
struct Page: Decodable { let items: [Record]; let next: String? }
enum api {
static func records(after: String?, limit: Int) async throws -> Page {
Page(items: [], next: nil)
}
}
func insert(_ r: Record) throws {}③ mmap한 파일을 도중에 다른 주체가 truncate하거나 지우면, 파일 끝을 넘어선 페이지를 건드리는 순간 SIGBUS로 죽는다(내용만 같은 크기로 덮어쓰는 경우는 해당되지 않는다). 네트워크 볼륨 등 신뢰할 수 없는 위치는 매핑하지 않는다.
import Foundation
// mmap한 영역을 읽는 도중 파일이 잘리거나 사라지면 SIGBUS로 즉사한다.
// try/catch로 못 잡는다 — 시그널이지 Swift 에러가 아니다.
// ❌ 위험한 위치
func riskyMapping() throws {
// 다른 프로세스가 언제든 바꿀 수 있는 App Group 공유 파일
let shared = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.app")!
.appendingPathComponent("shared.bin")
_ = try Data(contentsOf: shared, options: .mappedIfSafe) // 💥 확장이 truncate하면 SIGBUS
// 네트워크 볼륨·외부 저장소도 마찬가지
}
// ✅ 안전한 위치: 내 앱 컨테이너 안이고, 내가 수명을 통제하는 파일
func safeMapping() throws -> Data {
let docs = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let url = docs.appendingPathComponent("dictionary.bin") // 앱 번들에서 복사해 둔 것
return try Data(contentsOf: url, options: .mappedIfSafe)
}
// ✅ 앱 번들 리소스는 가장 안전하다 — 읽기 전용이고 바뀌지 않는다
func mapBundleResource() throws -> Data? {
guard let url = Bundle.main.url(forResource: "dictionary", withExtension: "bin")
else { return nil }
return try Data(contentsOf: url, options: .mappedIfSafe)
}
// ✅ 공유 파일을 꼭 매핑해야 하면 '스냅샷 사본'을 만들어 그걸 매핑한다
func mapSnapshot(of shared: URL) throws -> Data {
let temp = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.copyItem(at: shared, to: temp) // 내가 통제하는 사본
defer { /* 사용 후 정리 */ }
return try Data(contentsOf: temp, options: .mappedIfSafe)
}
// ⚠️ .mappedIfSafe는 "안전할 때만 매핑"이라 위험 판단이 되면 그냥 전체를 읽는다.
// 즉 매핑이 보장되지 않는다 — 메모리 절감을 전제로 설계하면 안 된다.".mappedIfSafe를 붙이면 메모리를 안 쓴다"는 오해다. 매핑은 lazy·재확보 가능할 뿐이라, 파일을 무작위로 전부 접근하면 결국 모든 페이지가 물리 메모리에 올라와 전체 로드와 다를 바 없어진다. 매핑은 "부분만 읽거나, 압박 시 버려도 되는 clean 데이터"일 때 이득이다.
도서관에서 500쪽짜리 백과사전이 필요하다고 해 봅시다. 한 방법은 책 전체를 복사기로 복사해서 내 책상에 쌓아 두는 것이고(Data(contentsOf:)), 다른 방법은 책은 서가에 그대로 두고 "몇 쪽에 뭐가 있다"는 지도만 받아 두는 것이다(mmap, 파일을 메모리 주소에 대응만 시켜 두는 방식). 지도만 받아 두면 내가 실제로 펼쳐 본 쪽만 책상에 올라온다. 500쪽 중 세 쪽만 볼 거라면 책상이 훨씬 한가하다. 여기서 "펼쳐 볼 때 그제서야 가져오는" 그 순간이 page fault(아직 메모리에 안 올라온 곳을 건드려서 커널이 채워 넣는 사건)이다.
다들 헷갈리는 지점은 여기다 — 지도를 받았다고 책상이 마법처럼 넓어지지는 않는다. 500쪽을 결국 다 펼쳐 보면 책상은 복사한 것과 똑같이 꽉 찬다. 매핑이 이득인 이유는 딱 두 가지다. 필요한 쪽만 본다는 것, 그리고 내가 고치지 않은 쪽은 원본이 서가에 그대로 있으니 책상이 좁아지면 도로 꽂았다가 나중에 다시 꺼내 올 수 있다는 것. 아이폰에는 "책상이 좁을 때 잠깐 옆방에 쌓아 두는" 스왑이 없어서, 책상이 넘치면 사서 격인 jetsam이 앱을 통째로 내보낸다.
꼬리 질문
매핑한 파일을 다른 프로세스가 truncate하면 무슨 일이 일어나나?
매핑은 파일의 크기 정보를 기준으로 가상 주소 영역을 잡아 둔다. 다른 주체가 파일을 잘라내(truncate) 이제는 존재하지 않는 오프셋의 페이지를 나중에 접근하면, 커널이 그 페이지를 채워 줄 수 없어 SIGBUS 시그널로 프로세스가 죽는다. 앞서 이미 물리 메모리에 올라온 페이지는 잠깐 살아 있을 수 있어 증상이 비결정적이다.
그래서 App Group 공유 컨테이너처럼 다른 프로세스(확장 등)가 동시에 건드릴 수 있거나, 네트워크·외부 볼륨처럼 신뢰할 수 없는 위치는 매핑하지 않는 게 원칙이다. 꼭 필요하면 NSFileCoordinator로 접근을 조율한다.
SIGBUS로 앱이 그냥 쓰러진다는 것 — try로 못 잡는다. 게다가 이미 손에 올라와 있던 쪽은 멀쩡히 읽히니까, 어떤 날은 되고 어떤 날은 죽는 재현 안 되는 버그가 된다.100MB JSON을 파싱해야 한다면 어떤 전략을 택하겠나?
JSONDecoder는 전체 Data와 완성된 객체 트리를 한꺼번에 메모리에 올리므로, 100MB 원본이 파싱 중엔 수백 MB 피크로 부풀어 jetsam 위험이 크다. 가능하면 애초에 포맷을 바꾸는 게 최선이다 — 최상위가 배열이면 한 줄에 한 객체인 JSON Lines(NDJSON)로 만들어 라인 단위 스트리밍 디코딩하면 상수 메모리로 처리된다.
포맷을 못 바꾸면 yajl류 SAX(이벤트) 파서로 토큰을 흘려보내며 필요한 필드만 뽑는다 — Foundation의 JSONSerialization·JSONDecoder에는 스트리밍/이벤트 파싱 모드가 없어(InputStream을 받는 오버로드도 결국 전체 객체 트리를 메모리에 만든다) 피크 메모리를 줄여 주지 못한다. 반복 조회가 목적이면 파싱 대신 SQLite로 한 번 적재해 두는 편이 낫다.
InputStream을 넘겨도 결국 방바닥에 다 펼친다.jetsam 리밋은 기기·포그라운드 여부에 따라 어떻게 달라지나?
물리 RAM이 큰 기기일수록 앱당 하드 리밋이 크다(구형 1GB 기기에서 수백 MB 수준, 최신 기기는 수 GB까지). 같은 기기라도 포그라운드 앱은 넉넉하고 백그라운드 앱은 훨씬 빨리 회수 대상이 된다 — jetsam은 메모리 압박 시 우선순위가 낮은(백그라운드·suspended) 프로세스부터 종료하기 때문이다.
구체적 수치는 비공개이고 OS 버전에 따라 바뀌므로 하드코딩하면 안 된다. 런타임에 os_proc_available_memory()로 남은 예산을 확인하고, com.apple.developer.kernel.increased-memory-limit 엔타이틀먼트로 특정 기기에서 상한을 높일 수 있다.
os_proc_available_memory()로 "지금 나한테 얼마 남았냐"를 그때그때 물어보는 게 맞다.Q3. Documents, Library, Caches, tmp 디렉터리는 어떻게 구분하는가?
모두 앱 샌드박스 컨테이너 안의 용도별 폴더이고, 세 축으로 갈린다 — 백업되는가, 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가 Finder/iTunes 파일 공유에 노출되고, Files 앱의 '나의 iPhone'에까지 보이게 하려면 LSSupportsOpeningDocumentsInPlace도 함께 YES로 둬야 한다. Application Support 폴더는 기본 생성되어 있지 않아 create: true로 만들어 써야 한다.
| 디렉터리 | 백업 | OS가 지움? | 노출 | 대표 용도 |
|---|---|---|---|---|
| Documents/ | 백업됨 | 유지 | File Sharing 시 노출 | 사용자 생성·재생성 불가 데이터 |
| Library/Application Support/ | 백업됨 | 유지 | 숨김 | DB·앱이 관리하는 영구 상태 |
| Library/Caches/ | 제외 | 삭제될 수 있음 | 숨김 | 재생성 가능한 캐시 |
| tmp/ | 제외 | 언제든 삭제 | 숨김 | 수명 짧은 임시 파일 |
실험 · 도구
시뮬레이터에서 각 URL을 찍어 실제 컨테이너 경로를 열어 보면 구조가 손에 잡힌다. 백업 제외 플래그는 URLResourceValues로 파일 단위로 설정한다.
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)프로젝트 적용
① SQLite/Core Data 스토어 같은 영구 앱 상태 → Application Support.
import Foundation
// 사용자가 만든 것도 아니고, 다시 받을 수도 없는 '앱의 살림살이'가 여기 간다.
// 백업되지만 파일 앱에는 보이지 않는다.
enum AppPaths {
private static let fm = FileManager.default
/// SQLite·Core Data 스토어, 앱 내부 상태
static var applicationSupport: URL {
get throws {
let base = try fm.url(for: .applicationSupportDirectory,
in: .userDomainMask, appropriateFor: nil, create: true)
// ⚠️ Application Support는 앱별 하위 폴더를 직접 만드는 게 관례다
let dir = base.appendingPathComponent(
Bundle.main.bundleIdentifier ?? "app", isDirectory: true)
if !fm.fileExists(atPath: dir.path) {
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
}
static var databaseURL: URL {
get throws { try applicationSupport.appendingPathComponent("store.sqlite") }
}
}
// Core Data 스토어 위치 지정
import CoreData
func makeContainer() throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "Model")
let description = NSPersistentStoreDescription(url: try AppPaths.databaseURL)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in precondition(error == nil) }
return container
}
// ⚠️ 기본값으로 두면 Core Data는 Application Support에 넣는다 —
// 이미 올바른 위치라 굳이 옮길 필요는 없다. 명시는 위치를 통제하고 싶을 때.② 네트워크에서 다시 받을 수 있는 이미지·응답 캐시 → Caches(또는 Documents + isExcludedFromBackup).
import Foundation
// Caches는 백업에서 자동 제외되고, 저장 공간이 부족하면 OS가 지운다.
// "없어지면 다시 만들면 되는 것"만 여기 둔다.
actor ImageDiskCache {
private let dir: URL
private let maxBytes: Int
init(maxBytes: Int = 200 * 1024 * 1024) throws {
let caches = try FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
dir = caches.appendingPathComponent("images", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.maxBytes = maxBytes
}
func store(_ data: Data, for key: String) throws {
try data.write(to: dir.appendingPathComponent(key), options: .atomic)
}
func load(for key: String) -> Data? {
try? Data(contentsOf: dir.appendingPathComponent(key))
}
// ✅ OS가 지우기 전에 스스로 상한을 지킨다
func trim() throws {
let keys: Set<URLResourceKey> = [.contentAccessDateKey, .fileSizeKey]
let files = try FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: Array(keys))
var entries = try files.map { url -> (URL, Date, Int) in
let v = try url.resourceValues(forKeys: keys)
return (url, v.contentAccessDate ?? .distantPast, v.fileSize ?? 0)
}
var total = entries.reduce(0) { $0 + $1.2 }
entries.sort { $0.1 < $1.1 } // LRU — 오래된 것부터
for (url, _, size) in entries where total > maxBytes {
try FileManager.default.removeItem(at: url)
total -= size
}
}
}
// ⚠️ "Caches니까 영구적"이라는 가정은 틀렸다. 항상 "없으면 다시 만든다" 경로가 있어야 한다.③ 유일본인 사용자 데이터를 tmp·Caches에 두지 않는다 — 어느 순간 사라져 복구 불가가 된다.
import Foundation
// tmp와 Caches는 OS가 언제든 지울 수 있다. 사용자가 만든 유일본을 여기 두면
// 어느 날 조용히 사라지고 복구할 방법이 없다.
// ❌ 작성 중인 글을 tmp에
func saveDraftBad(_ text: String) throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("draft.txt")
try text.write(to: url, atomically: true, encoding: .utf8) // 💥 사라질 수 있다
}
// ✅ 사용자 데이터는 Documents (백업 + 파일 앱 노출)
func saveDraft(_ text: String) throws {
let docs = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
try text.write(to: docs.appendingPathComponent("draft.txt"),
atomically: true, encoding: .utf8)
}
// ✅ 사용자에게 안 보여야 하지만 유일본이면 Application Support
func saveInternalState(_ data: Data) throws {
let dir = try AppPaths.applicationSupport
try data.write(to: dir.appendingPathComponent("state.bin"), options: .atomic)
}
// ✅ tmp의 올바른 용도: 작업 중간 산출물. 쓰고 나면 직접 지운다.
func convertVideo(_ input: URL) throws -> Data {
let temp = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".mp4")
defer { try? FileManager.default.removeItem(at: temp) } // ✅ 직접 정리
try transcode(input, to: temp)
return try Data(contentsOf: temp)
}
// 판단표
// 사용자가 만들었고 유일본 → Documents
// 앱 내부 영구 상태(DB 등) → Application Support
// 다시 받거나 만들 수 있음 → Caches
// 지금 이 작업에만 필요 → tmp (직접 삭제)
enum AppPaths {
static var applicationSupport: URL {
get throws {
try FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask, appropriateFor: nil, create: true)
}
}
}
func transcode(_ input: URL, to output: URL) throws {}"Caches는 앱 실행 중에도 아무 때나 지워진다" 또는 반대로 "한 번 넣으면 절대 안 지워진다" 둘 다 정확하지 않다. 실제로는 주로 앱이 실행 중이 아닐 때 저장공간이 부족하면 삭제될 수 있다. 정확한 태도는 "언제든 없어질 수 있다고 가정하고, 없으면 다시 만들 수 있게" 코딩하는 것이다.
이 네 폴더는 그냥 이름표가 아니라, 내가 iOS에게 미리 해 두는 약속이다. 집으로 옮겨 보면 Documents는 가족 앨범을 넣어 두는 서랍이다 — 이사 갈 때(백업) 반드시 챙기고, 손님한테 보여 줄 수도 있다. Library/Application Support는 벽장 안쪽 정리함이라 이사 때 챙기지만 손님 눈에는 안 띈다. Library/Caches는 냉장고에 남은 반찬 같아서, 없어져도 다시 만들 수 있으니 집이 좁아지면 버려진다. tmp는 책상에 붙여 둔 포스트잇이라 언제 떼어져도 이상하지 않고요.
중요한 건 방향이다. 내가 폴더를 고르는 게 아니라, 데이터의 성격을 폴더로 말해 주면 시스템이 그 말대로 취급한다. 그러니까 하나뿐인 사용자 데이터를 Caches에 넣으면 "이건 잃어버려도 되는 것"이라고 선언한 셈이고, 어느 날 사라져도 그건 버그가 아니라 약속대로 동작한 것이다. 반대로 다시 받으면 그만인 커다란 캐시를 Documents에 두면 "이사 짐"으로 분류돼 백업이 쓸데없이 무거워진다.
꼬리 질문
App Group 공유 컨테이너는 이 네 폴더와 무엇이 다른가?
Documents·Library·Caches·tmp는 한 앱만 접근하는 개인 샌드박스 안에 있다. App Group 컨테이너(group.com.example.foo)는 같은 그룹에 속한 앱과 App Extension(위젯, share/notification extension 등)이 함께 읽고 쓰는 별도 영역이라는 점이 근본 차이다. FileManager.containerURL(forSecurityApplicationGroupIdentifier:)로 얻는다.
여러 프로세스가 동시에 접근하므로 그냥 쓰면 경쟁 상태가 생긴다 — NSFileCoordinator/파일 락으로 조율해야 하고, 원자성이 필요한 쓰기라도 다른 프로세스가 열어 둔 fd가 있으면 관찰 시점이 어긋날 수 있다.
NSFileCoordinator나 파일 락)가 거실에서는 기본 예의가 되는 게 진짜 차이다.iCloud Documents(ubiquity container)에 두면 백업/동기화가 어떻게 달라지나?
ubiquity container에 넣은 파일은 로컬 백업 대상이라기보다 iCloud를 통해 사용자의 모든 기기로 동기화되는 대상이 된다. 파일 실체가 항상 로컬에 있지 않고, 필요할 때 내려받는(on-demand) 상태일 수 있어 접근 전에 startDownloadingUbiquitousItem이나 NSMetadataQuery로 다운로드를 촉발·대기해야 한다.
동기화이므로 여러 기기가 같은 파일을 고치면 충돌(version conflict)이 생기고, 이를 NSFileVersion으로 해소해야 한다. 단순 로컬 백업(Documents)과 달리 네트워크 지연·부분 다운로드·충돌을 항상 코드로 다뤄야 한다는 점이 크게 다르다.
앱을 삭제 후 재설치하면 이 폴더들 중 무엇이 남아 있나?
앱을 삭제하면 그 앱의 샌드박스 컨테이너가 통째로 지워지므로 Documents·Library·Caches·tmp 모두 사라진다 — 재설치 시 남아 있는 로컬 파일은 없다. 삭제와 재설치 사이에 iCloud/Finder 백업에서 복원한 경우에만 백업 대상이던 Documents와 Library가 되살아난다.
진짜로 삭제 후에도 유지하고 싶은 값(예: 재설치 감지, 라이선스 토큰)은 파일이 아니라 Keychain에 둔다. Keychain 항목은 기본적으로 앱 삭제와 무관하게 남을 수 있기 때문이다. iCloud Key-Value Store나 CloudKit도 기기 로컬이 아니라 서버에 있으므로 재설치 후 복구된다.
Q4. 파일 쓰기 중 앱이 종료되어도 데이터가 깨지지 않게 하려면 어떻게 하는가?
기존 파일을 제자리에서 덮어쓰면 도중에 죽었을 때 앞뒤가 섞인 반쪽 파일이 남는다. 대신 임시 파일에 완전히 쓴 뒤 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는 이 로그 선기록으로 원자성과 지속성을 함께 보장한다.
실험 · 도구
쓰기 도중 프로세스를 강제로 죽여(kill -9 또는 디버거에서 중단) 목적지 파일의 무결성을 확인하면 두 방식의 차이가 명확하다. fs_usage로 syscall을 추적하면 .atomic 경로가 실제로 aux 파일 생성 후 rename을 부르는 것을 볼 수 있다. 진짜 durability가 필요하면 아래처럼 F_FULLFSYNC를 직접 부른다.
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) 교체: replaceItemAt은 원본 메타데이터를 보존해 주지만 Apple이 '원자성'을 명시적으로
// 보장하지는 않는다(문서 표현은 '데이터 손실이 없도록'까지다).
// 문서화된 원자성이 꼭 필요하면 같은 볼륨에서 rename(tmp.path, dest.path)을 직접 부른다.
// (교체 자체의 지속성까지 필요하면 상위 디렉터리 fd도 fsync)
_ = try FileManager.default.replaceItemAt(dest, withItemAt: tmp)
}프로젝트 적용
① 단순 단일 파일 갱신은 Data.write(to:options:.atomic)로 충분하다. 금융·과금처럼 손실이 치명적이면 F_FULLFSYNC까지 건다.
import Foundation
// 두 성질을 구분해야 한다.
// 원자성(atomicity) : 반쪽 파일이 관찰되지 않는다 → rename으로 달성
// 지속성(durability): 전원이 나가도 남아 있다 → fsync로 달성
// ✅ ① 대부분의 경우 — 앱 종료(SIGKILL)까지는 이걸로 충분하다
func saveSettings(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .atomic)
// 내부적으로 aux 파일에 쓴 뒤 rename한다
}
// ✅ ② 금융·과금처럼 손실이 치명적이면 매체까지 강제로 내린다
func saveDurably(_ data: Data, to dest: URL) throws {
let dir = dest.deletingLastPathComponent()
let temp = dir.appendingPathComponent(".tmp-" + UUID().uuidString) // 같은 볼륨
FileManager.default.createFile(atPath: temp.path, contents: nil)
let handle = try FileHandle(forWritingTo: temp)
try handle.write(contentsOf: data)
// fsync는 '디스크에 보냈다'까지, F_FULLFSYNC는 '매체에 실제로 기록됐다'까지
guard fcntl(handle.fileDescriptor, F_FULLFSYNC) != -1 else {
try? handle.close()
throw POSIXError(.EIO)
}
try handle.close()
// replaceItemAt은 원본의 권한·확장속성·리소스 포크를 보존한다
_ = try FileManager.default.replaceItemAt(dest, withItemAt: temp)
}
// ⚠️ F_FULLFSYNC는 느리다(수십~수백 ms). 자주 부르면 체감 성능이 무너진다.
// "이 데이터를 잃으면 사용자가 돈을 잃는가?"가 기준이다.
// ✅ 성능과 안전의 절충: 평소엔 .atomic, 결정적 시점에만 fsync
func saveWithCheckpoint(_ data: Data, to url: URL, isCritical: Bool) throws {
if isCritical { try saveDurably(data, to: url) }
else { try data.write(to: url, options: .atomic) }
}② 여러 파일이 함께 일관돼야 하면 개별 원자적 쓰기로는 부족하다 — SQLite 트랜잭션(또는 Core Data)으로 묶는다.
import Foundation
import SQLite3
// 파일 단위 원자적 쓰기는 '한 파일'만 보장한다.
// A와 B가 함께 맞아야 하는데 A만 쓰고 죽으면 여전히 깨진 상태다.
// ❌ 세 파일을 각각 원자적으로 쓴다 — 파일 간 일관성은 없다
func saveBad(_ order: Order, _ payment: Payment, _ receipt: Receipt) throws {
try JSONEncoder().encode(order).write(to: orderURL, options: .atomic)
// 💥 여기서 죽으면 주문만 있고 결제·영수증은 없다
try JSONEncoder().encode(payment).write(to: paymentURL, options: .atomic)
try JSONEncoder().encode(receipt).write(to: receiptURL, options: .atomic)
}
// ✅ ① SQLite 트랜잭션으로 묶는다
func saveInTransaction(_ db: OpaquePointer,
_ order: Order, _ payment: Payment) throws {
sqlite3_exec(db, "BEGIN IMMEDIATE;", nil, nil, nil)
do {
try insertOrder(db, order)
try insertPayment(db, payment)
sqlite3_exec(db, "COMMIT;", nil, nil, nil) // ✅ 전부 반영되거나
} catch {
sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) // 전부 무효
throw error
}
}
// ✅ ② Core Data라면 하나의 save()
import CoreData
func saveWithCoreData(_ ctx: NSManagedObjectContext) throws {
// 여러 객체를 만들고
try ctx.save() // ✅ 한 번의 트랜잭션
}
// ✅ ③ DB를 못 쓰는 상황이면 '디렉터리 통째 교체'로 흉내낼 수 있다
func saveDirectoryAtomically(_ files: [String: Data], to dest: URL) throws {
let fm = FileManager.default
let staging = dest.deletingLastPathComponent()
.appendingPathComponent(".staging-" + UUID().uuidString)
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
for (name, data) in files {
try data.write(to: staging.appendingPathComponent(name), options: .atomic)
}
// 디렉터리 전체를 한 번에 교체 — 중간 상태가 관찰되지 않는다
_ = try fm.replaceItemAt(dest, withItemAt: staging)
}
struct Order: Codable {}; struct Payment: Codable {}; struct Receipt: Codable {}
let orderURL = URL(fileURLWithPath: "/o"); let paymentURL = URL(fileURLWithPath: "/p")
let receiptURL = URL(fileURLWithPath: "/r")
func insertOrder(_ db: OpaquePointer, _ o: Order) throws {}
func insertPayment(_ db: OpaquePointer, _ p: Payment) throws {}③ 매우 큰 파일에 무조건 .atomic을 쓰지 않는다 — 임시본 복사로 순간 디스크 사용량이 두 배가 되고, 원본의 확장속성(xattr)이 사라진다(퍼미션은 유지된다)(replaceItemAt은 이를 보존).
import Foundation
// .atomic은 임시 파일에 전부 쓴 뒤 교체한다.
// 1GB 파일이면 순간적으로 2GB의 디스크가 필요하고, 쓰기량도 2배다.
// ❌ 큰 파일을 매번 통째로 원자적으로
func appendLogBad(_ line: String, to url: URL) throws {
var existing = try Data(contentsOf: url) // 전체 읽기
existing.append(Data(line.utf8))
try existing.write(to: url, options: .atomic) // 💥 전체 다시 쓰기 + 사본
}
// ✅ ① append는 그냥 이어 쓴다 — 원자성이 필요 없는 연산이다
func appendLog(_ line: String, to url: URL) throws {
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: Data((line + "\n").utf8))
}
// ✅ ② 부분 갱신이 잦으면 DB로 옮긴다 — 애초에 이런 용도로 만들어진 것이다
// ✅ ③ 큰 파일을 꼭 교체해야 하면 공간을 먼저 확인한다
func hasSpace(for bytes: Int, at url: URL) throws -> Bool {
let values = try url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
let available = values.volumeAvailableCapacityForImportantUsage ?? 0
return available > Int64(bytes) * 2 // 사본 공간까지
}
// ⚠️ .atomic의 또 다른 부작용: 새 파일로 교체되므로 원본의 권한·확장속성·
// 파일 보호 클래스가 초기화될 수 있다.
// ✅ 그걸 보존하려면 replaceItemAt을 쓴다
func replacePreservingAttributes(_ data: Data, at dest: URL) throws {
let temp = dest.deletingLastPathComponent()
.appendingPathComponent(".tmp-" + UUID().uuidString)
try data.write(to: temp, options: .atomic)
_ = try FileManager.default.replaceItemAt(dest, withItemAt: temp)
}".atomic이면 전원이 꺼져도 100% 안전"이라는 오해다. 원자적 rename은 반쪽 파일이 생기지 않음(원자성)을 보장할 뿐, fsync/F_FULLFSYNC 없이는 이미 저장됐다고 본 데이터가 전원 손실 후에도 남아 있음(지속성)은 보장하지 못한다. 둘은 서로 다른 성질이며, iOS에서 흔한 SIGKILL(백그라운드 종료)은 rename만으로도 커버되지만 전원 손실은 그렇지 않는다.
공책에 적힌 명단을 고친다고 해 봅시다. 원래 공책에 지우개를 대고 한 줄씩 고치는 도중에 누가 공책을 뺏어 가면, 반쯤 고쳐진 엉망인 명단이 남는다. 대신 새 공책에 처음부터 끝까지 다 옮겨 적고, 마지막에 "이게 진짜 명단"이라는 딱지만 새 공책으로 옮겨 붙이면 어떨까. 딱지 옮기는 건 한순간이라 중간이 없다 — 언제 뺏겨도 온전한 옛 명단이거나 온전한 새 명단, 둘 중 하나다. 이게 rename()이 하는 일이고, 임시 파일에 다 써 놓고 이름만 갈아 끼우는 게 원자적 쓰기다.
그런데 "다 썼다"가 정말 "저장됐다"일까? 여기는 택배로 보면 꽤 정확하게 맞아떨어진다. write()는 물건을 문 앞에 내놓은 것(커널 page cache), fsync는 기사가 가져간 것까지 확인한 것. 문제는 기사가 트럭에 싣자마자 "완료"를 찍는다는 것이다(저장장치 자기 쓰기 캐시) — 트럭이 사고 나면, 그러니까 전원이 확 나가면 사라질 수 있다. F_FULLFSYNC는 "트럭 말고 받는 사람 손에 들어간 것까지 확인해 줘"에 해당한다. 대신 그만큼 느려서 아무 데나 쓰면 앱이 굼떠진다.
꼬리 질문
fsync와 F_FULLFSYNC는 무엇이 다른가? 왜 Apple은 후자를 권하나?
fsync는 데이터를 커널 페이지 캐시에서 스토리지 장치에 "넘겼다"까지만 보장한다. 그런데 많은 저장장치는 성능을 위해 자체 휘발성 쓰기 캐시(write-back cache)를 두고 "받았다"고 즉시 응답하므로, fsync 직후 전원이 나가면 그 캐시에 있던 데이터가 날아갈 수 있다.
F_FULLFSYNC(fcntl)는 장치에게 캐시를 실제 물리 매체까지 flush하라고 강제 명령하는 것이라, 전원 손실에도 데이터가 남는다. 그래서 Apple은 durability가 정말 중요하면 fsync가 아니라 F_FULLFSYNC를 쓰라고 안내한다. 대신 훨씬 느리므로 남발하면 성능이 크게 떨어진다.
fsync는 "접수했다" 문자까지 받은 것이고, F_FULLFSYNC는 "받는 분이 사인했다"까지 확인한 것이다. 저장장치는 빨라 보이려고 아직 자기 손수레에 실어 둔 단계에서 접수 문자를 보내 버리기 때문에, 딱 그 순간 전원이 나가면 짐이 사라진다. 대신 사인까지 기다리면 체감될 만큼 느려서, 결제 기록처럼 진짜 잃으면 큰일 나는 데만 골라서 건다.두 개의 파일을 동시에 일관되게 바꿔야 한다면 어떻게 설계하나?
파일 단위 원자적 쓰기(temp+rename)는 각 파일에만 원자적이라, 두 파일을 각각 rename하는 사이에 죽으면 A는 신버전·B는 구버전인 찢어진 조합이 남는다. 근본 해법은 두 데이터를 하나의 트랜잭션으로 묶을 수 있는 곳에 두는 것 — SQLite 트랜잭션이나 Core Data save()로 커밋하면 all-or-nothing이 보장된다.
파일 형태를 유지해야 한다면, 두 파일을 담은 디렉터리 하나를 통째로 새로 만든 뒤 renamex_np/renameatx_np의 RENAME_SWAP(FileManager.replaceItemAt이 내부적으로 활용)으로 교체하는 식으로 원자 단위를 디렉터리로 끌어올린다. 또는 매니페스트 파일 하나에 "현재 유효한 버전 번호"를 원자적으로 기록하는 간접 참조(indirection) 기법을 쓴다.
rename이 서로 다른 볼륨을 넘으면(cross-device) 왜 더 이상 원자적이지 않나?
rename(2)의 원자성은 "같은 파일시스템 안에서 디렉터리 엔트리가 가리키는 inode 포인터만 바꿔 끼우는 단일 메타데이터 연산"이라는 데서 나온다. 데이터는 그대로 있고 이름표만 순간적으로 옮기는 것이라 중간 상태가 없다.
그런데 출발지와 목적지가 다른 볼륨이면 그쪽 inode를 그대로 참조할 수 없어, 커널은 EXDEV 오류를 내거나(상위 라이브러리가) 실제로는 전체 복사 후 원본 삭제로 대체한다. 복사는 여러 블록·여러 syscall로 진행되므로 도중에 죽으면 반쪽 파일이 남는다. 그래서 .atomic 옵션은 aux 파일을 반드시 목적지와 같은 디렉터리(=같은 볼륨)에 만든다.
rename은 상자를 나르는 게 아니라 상자에 붙은 이름표를 떼서 다른 상자에 붙이는 일이라 한순간에 끝난다. 그런데 출발지와 목적지가 다른 창고면 이름표만으로는 안 되고 진짜로 짐을 옮겨야 하는데, 그건 여러 번 왔다 갔다 하는 일이라 중간에 멈추면 반만 옮겨진 상태가 남는다. .atomic이 임시 파일을 굳이 목적지 바로 옆에 만드는 이유가 이것 — "이름표만 바꿔 달기"가 성립하는 범위 밖으로 나가지 않으려고.Q5. Atomic write란 무엇인가?
관찰자 입장에서 쓰기가 "전부 반영" 아니면 "전혀 반영 안 됨" 둘 중 하나로만 보이는 쓰기다. 중간 상태(반쪽 파일)가 절대 노출되지 않는다. 파일 단위 원자적 쓰기의 표준 구현은 임시 파일에 다 쓰고 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 기반이라는 점은 동일하다.
// 내부적으로 aux 파일에 write 후 rename — 반쪽 파일이 관찰되지 않는다
try data.write(to: url, options: .atomic)
// 문자열/레거시 컬렉션도 동일한 원자적 옵션 제공
try text.write(to: url, atomically: true, encoding: .utf8)
// 주의: .atomic 은 '원자성'만 보장.
// 전원 손실 후 유지(지속성)는 별개 → Q4의 F_FULLFSYNC가 필요.프로젝트 적용
① 설정·문서·직렬화 결과 저장은 .atomic을 기본으로 둔다.
import Foundation
// 반쪽 파일이 남으면 다음 실행에서 파싱이 실패하고, 최악의 경우
// "설정이 전부 초기화됨"으로 사용자에게 보인다. .atomic이 이걸 막는다.
// ✅ Data
func save(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .atomic)
}
// ✅ String
func save(_ text: String, to url: URL) throws {
try text.write(to: url, atomically: true, encoding: .utf8)
}
// ✅ Codable 직렬화 결과
func save<T: Encodable>(_ value: T, to url: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys] // 재현 가능한 출력
try encoder.encode(value).write(to: url, options: .atomic)
}
// ✅ PropertyList
func savePlist<T: Encodable>(_ value: T, to url: URL) throws {
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
try encoder.encode(value).write(to: url, options: .atomic)
}
// ✅ 읽기 쪽도 손상에 대비한다 — 원자적 쓰기여도 디스크 오류는 있을 수 있다
func load<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
guard let data = try? Data(contentsOf: url) else { return nil }
do {
return try JSONDecoder().decode(type, from: data)
} catch {
// 손상된 파일은 치우고 기본값으로 시작한다
try? FileManager.default.moveItem(
at: url, to: url.appendingPathExtension("corrupt"))
return nil
}
}
// ⚠️ atomically: true는 목적지와 같은 디렉터리에 aux 파일을 만든다.
// 그래서 목적지 볼륨과 무관하게 원자성이 보장된다(cross-device 문제 없음).② 이미 mmap/열린 fd로 참조 중인 파일을 원자적으로 교체하면, 그 참조는 옛 inode를 계속 가리킨다는 점을 알아 둔다.
import Foundation
// 원자적 교체는 '새 파일을 만들어 이름을 바꾸는' 것이다.
// 이미 열어 둔 파일 디스크립터나 매핑은 옛 파일(inode)을 계속 본다.
func demonstrateStaleView() throws {
let url = URL(fileURLWithPath: NSTemporaryDirectory() + "data.bin")
try Data("버전1".utf8).write(to: url, options: .atomic)
// 매핑을 잡아 둔다
let mapped = try Data(contentsOf: url, options: .mappedIfSafe)
print(String(data: mapped, encoding: .utf8) ?? "") // "버전1"
// 다른 곳에서 원자적으로 교체
try Data("버전2".utf8).write(to: url, options: .atomic)
print(String(data: mapped, encoding: .utf8) ?? "") // ⚠️ 여전히 "버전1"
// 파일 시스템 상의 이름은 새 inode를 가리키지만,
// mapped는 옛 inode를 붙들고 있다.
// ✅ 새 내용을 보려면 다시 연다
let remapped = try Data(contentsOf: url, options: .mappedIfSafe)
print(String(data: remapped, encoding: .utf8) ?? "") // "버전2"
}
// ✅ 실무 대응 ① 변경 감시를 붙여 다시 연다
func watchAndRemap(_ url: URL,
onChange: @escaping () -> Void) -> DispatchSourceFileSystemObject? {
let fd = open(url.path, O_EVTONLY)
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd, eventMask: [.write, .rename, .delete], queue: .main)
source.setEventHandler { onChange() } // 여기서 다시 매핑한다
source.setCancelHandler { close(fd) }
source.resume()
return source
}
// ✅ 대응 ② 애초에 교체 대신 '같은 파일 안에서 갱신'하는 구조를 쓴다 (DB)
// ⚠️ 이 성질이 이득이 되기도 한다: 읽는 쪽은 교체 중에도 일관된 옛 버전을
// 끝까지 볼 수 있다. 그래서 rename 방식이 "반쪽을 보지 않는다"를 보장한다.③ 같은 큰 파일을 아주 자주 원자적으로 쓰면 매번 전체 복사가 일어나 write amplification과 플래시 수명에 불리하다.
import Foundation
// 원자적 쓰기는 매번 전체 복사다. 큰 파일을 초당 여러 번 쓰면
// write amplification으로 디스크 대역폭과 플래시 수명을 태운다.
// ❌ 100MB 상태 파일을 1초마다 통째로
final class BadStateStore {
private var state: [String: Data] = [:]
func update(_ key: String, _ value: Data) throws {
state[key] = value
try PropertyListSerialization
.data(fromPropertyList: state, format: .binary, options: 0)
.write(to: url, options: .atomic) // 💥 매번 100MB 쓰기
}
private let url = URL(fileURLWithPath: "/state.plist")
}
// ✅ ① 변경분만 append하고 주기적으로 압축한다 (로그 구조)
final class AppendLogStore {
private let handle: FileHandle
init(url: URL) throws {
if !FileManager.default.fileExists(atPath: url.path) {
FileManager.default.createFile(atPath: url.path, contents: nil)
}
handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd()
}
func append(_ record: Data) throws {
var length = UInt32(record.count).littleEndian
try handle.write(contentsOf: Data(bytes: &length, count: 4))
try handle.write(contentsOf: record) // ✅ 변경분만
}
/// 로그가 길어지면 현재 상태를 스냅샷으로 압축한다 (가끔만)
func compact(_ snapshot: Data, to url: URL) throws {
try snapshot.write(to: url, options: .atomic)
}
}
// ✅ ② 애초에 이런 요구면 SQLite가 정답이다 —
// 부분 갱신·트랜잭션·크래시 복구가 전부 해결된다
// ✅ ③ 쓰기를 디바운스해 빈도를 줄인다
@MainActor
final class DebouncedSaver {
private var task: Task<Void, Never>?
func scheduleSave(_ save: @escaping () throws -> Void) {
task?.cancel()
task = Task {
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
try? save()
}
}
}"atomic write = 데이터 손실 없음"이라는 오해다. 원자성은 찢어진 파일을 막는 것이지 손실 자체를 막는 게 아니다. 전원 손실 지속성은 fsync가 담당한다. 임시 파일을 목적지와 다른 볼륨(예: 별도 temp 디렉터리)에 두고 rename하면 cross-device라 복사+삭제로 전락해 원자성이 깨질 수 있는데, atomically: true는 aux를 목적지와 같은 디렉터리(=같은 볼륨)에 만들어 이 문제를 피하므로 cross-device 문제를 피한다. 다만 Apple 문서는 '원자적으로 접근할 수 없는 종류의 URL이면 이 파라미터가 무시된다'고 못 박고 있으므로, 로컬 파일 URL이 아닌 경우(파일 프로바이더·원격 위치 등)에는 원자성을 전제하면 안 된다.
이번엔 읽는 사람 입장에서 봅시다. 벽에 붙은 메뉴판을 손님이 보는 앞에서 한 줄씩 고치면, 누군가는 "짜장 7000원 / 짬뽕 (지워짐)" 같은 반쪽 메뉴판을 보게 된다. 대신 주방 뒤에서 새 메뉴판을 다 완성해 놓고 한 번에 갈아 끼우면, 손님은 옛 메뉴판 아니면 새 메뉴판만 본다. 중간을 볼 수 있는 순간 자체가 없다. atomic write가 보장하는 건 딱 이것 하나, "반쪽을 안 보여 준다"이다. 그래서 아래 셋은 이름만 비슷할 뿐 서로 다른 이야기다.
- 반쪽을 안 보여 준다 → 원자성. 갈아 끼우기(temp + rename)로 해결.
- 가게에 불이 나도 새 메뉴판이 남아 있다 → 지속성.
fsync/F_FULLFSYNC가 따로 필요. - 홀 메뉴판과 배달 메뉴판을 동시에 바꾼다 → 여러 파일의 일관성. 한 장씩 갈아 끼우는 사이에 가격이 어긋나므로 DB 트랜잭션이 필요.
비유가 깨지는 데도 하나 짚어 둔다. 메뉴판을 이미 사진으로 찍어 간 손님은 갈아 끼운 걸 모른다. 파일도 똑같아서, 이미 열어 둔 fd나 mmap은 경로가 아니라 옛 파일 실체를 붙잡고 있어 새 내용을 보려면 닫고 다시 열어야 한다.
꼬리 질문
원자적 쓰기가 진행되는 동안 다른 스레드/프로세스가 같은 경로를 새로 열어 읽으면 반쪽을 볼 수 있나? rename 기반 교체에서 리더가 항상 '옛 전체본' 아니면 '새 전체본'만 보는 이유를 디렉터리 엔트리 원자 교체로 설명하라.
아니요, 새로 open()하는 리더는 반쪽을 볼 수 없다. 임시 파일에 전체를 다 쓴 뒤 rename으로 교체하는데, 임시본은 임시 이름 아래에만 보여 목적지 경로를 여는 리더에겐 아예 존재하지 않고, rename(2)은 디렉터리 엔트리가 가리키는 inode 포인터를 바꿔 끼우는 단일 메타데이터 연산이라 리더의 open()이 그 이름을 해석하는 순간은 교체 전(옛 inode 전체본) 아니면 교체 후(새 inode 전체본) 둘 중 하나로만 떨어진다 — 그 사이의 부분 상태는 관찰할 지점 자체가 없다.
제자리 write는 같은 inode를 블록 단위로 갱신해 동시 리더가 반쪽을 읽을 수 있다는 점이 결정적 차이다. 다만 이 관찰 원자성은 "이름→inode" 해석에 대한 것이라, rename 전에 이미 파일을 열어 둔 핸들은 계속 옛 inode를 보며(형제 꼬리질문 참고), 지속성이나 여러 파일 묶음의 일관성은 이와 별개 문제다.
열린 fd나 mmap이 걸려 있는 파일을 원자적으로 교체하면 그 핸들은 무엇을 보나?
Unix 파일 시맨틱상 열린 fd와 mmap은 이름이 아니라 inode를 붙잡고 있다. rename으로 목적지 이름에 새 파일(새 inode)을 걸어도, 이미 열려 있던 핸들은 계속 옛 inode를 가리키며 예전 내용을 본다 — 이름과의 연결이 끊긴 옛 inode는 참조 카운트가 0이 될 때까지 디스크에서 살아 있다.
그래서 원자적 교체 후 최신 데이터를 보려면 핸들을 닫고 새 이름으로 다시 열어야 한다. mmap이라면 매핑을 munmap하고 재매핑해야 하며, 이 특성을 모르고 "교체했는데 왜 옛날 값이 보이지?" 하는 버그가 자주 나온다.
디렉터리(여러 파일)를 통째로 원자적으로 교체하려면 어떤 방법이 있나?
새 내용을 담은 디렉터리를 별도로 완성한 뒤, 그 디렉터리 자체를 하나의 원자 단위로 교체하는 것이 핵심이다. Foundation의 FileManager.replaceItemAt(_:withItemAt:)는 디렉터리에도 적용되고, 내부적으로 renamex_np/renameatx_np의 RENAME_SWAP(두 경로를 원자적으로 맞바꿈) 같은 APFS 지원을 활용해 폴더를 한 번에 스왑한다.
더 견고하게는 간접 참조를 쓴다 — data-v1/, data-v2/를 두고 "현재 유효 버전"을 가리키는 심볼릭 링크나 매니페스트 파일만 원자적으로 바꾸면, 여러 파일의 전환을 단일 원자 연산으로 환원할 수 있다. 진짜 다중 파일 트랜잭션이 필요하면 결국 SQLite가 가장 안전하다.
data-v1/과 data-v2/를 둘 다 놔두고 "지금 진짜는 v2야"라고 적힌 쪽지 한 장만 갈아 끼우는 것 — 원자적으로 바꿔야 할 대상이 쪽지 한 장으로 줄면 문제가 훨씬 쉬워진다.