Data는 Foundation에서 바이트 시퀀스를 다루는 기본 타입이지만, 이 타입을 어떻게 쓰느냐가 곧바로 iOS 앱의 생사를 가르기도 한다. 이 챕터는 Data가 [UInt8]·NSData와 근본적으로 무엇이 다른지에서 시작해서, 파일을 통째로 읽는 한 줄짜리 코드가 왜 500MB짜리 시한폭탄이 되는지, 메모리 매핑된 페이지가 iOS의 "메모리 사용량" 계산에서 어떻게 예외 취급을 받는지, 그리고 Data가 겉보기와 달리 여러 조각으로 흩어져 있을 수 있다는 사실과 바이트↔문자열 변환의 실무 함정까지 다룬다.
메모리 압박·clean/dirty 페이지·Jetsam의 CS 배경 자체는 iOS CS 로드맵 01장에서 이미 다뤘다. 여기서는 그 개념을 전제로 두고, Data라는 구체적인 타입이 그 규칙과 실제로 어떻게 맞물리는지에 집중한다. 이 챕터의 수치는 전부 Swift 6.2.1(arm64, macOS 26)에서 직접 실험해 얻은 값이다.
Q1. `Data`는 무엇이고 `[UInt8]` / `NSData`와 어떻게 다른가?
Data는 @frozen struct로 선언된 값 타입이다. 내부 버퍼를 여러 값이 공유하다가 실제로 바이트를 바꾸는 순간에만 복사하는 CoW(Copy-on-Write)로 동작하고, NSData로 문 없이 오갈 수 있게(toll-free) 브리징되어 ObjC 프레임워크와 Swift 코드 사이의 공용 화폐 노릇을 한다. Collection, RandomAccessCollection, ContiguousBytes, DataProtocol, Codable, Sendable까지 준수해 파일 I/O·네트워킹·직렬화 어디서나 같은 타입으로 통한다. [UInt8]은 순수 Swift Array라 항상 연속 메모리이고 CoW도 있지만 ObjC로 가는 다리가 없고, NSData는 참조 타입 클래스라서 값 하나를 여러 곳에서 공유하면 그중 하나가 바꾼 내용이 나머지에도 그대로 보인다 — 오늘날 새 코드에서 NSData를 직접 골라야 할 이유는 거의 없다.
원리
세 타입이 공존하는 이유는 Swift가 ObjC 위에서 자라난 언어이기 때문이다. ObjC 시절에는 바이트 버퍼를 표현할 방법이 참조 타입 NSData 하나뿐이었다. Swift가 값 의미론(value semantics)과 프로토콜 지향 설계를 들여오면서, 기존 ObjC 자산과 호환하면서도 값 타입으로 동작하는 다리가 필요해졌고 그 결과가 Data다. [UInt8]은 그런 다리 자체가 필요 없는, ObjC를 전혀 몰라도 되는 순수 Swift 제네릭 세계의 선택지다.
내부 동작
Data는 iOS 8부터 존재하며 Collection, BidirectionalCollection, RandomAccessCollection, RangeReplaceableCollection, ContiguousBytes, DataProtocol/MutableDataProtocol, Codable, Sendable을 한꺼번에 준수한다. 이 챕터에서 다루는 withUnsafeBytes나 regions(Q4)도 전부 이 프로토콜 준수에서 나온다.
직접 확인해보면 Data 값 자체의 크기는 MemoryLayout<Data>.size == 16, [UInt8]은 8, 클래스인 NSData도 참조 하나이므로 8이다(측정: Swift 6.2.1/arm64). Data가 [UInt8]보다 2배 큰 이유는 내부적으로 '비어 있음 / inline 소량 바이트 / 힙 버퍼 참조 / 매핑된 파일'처럼 여러 표현을 하나의 값 안에서 구분해 담아야 하기 때문이다 — 단순 포인터 하나로는 부족하다.
CoW는 실측으로도 바로 보인다. var b = a 직후에는 a와 b가 같은 버퍼를 가리키지만, b.append(4)를 호출한 순간부터 둘은 서로 다른 버퍼를 갖는다 — 슬라이싱이나 대입 자체는 바이트를 복사하지 않고, 쓰기가 일어나는 그 순간에만 복사가 발생한다는 뜻이다. 이 규칙은 메모리 매핑된 Data(Q2)에서 더 중요해진다 — 매핑된 백킹은 읽기 전용이므로, 그 Data를 수정하려는 순간 CoW가 강제로 새 힙 버퍼를 만들어 복사해준다(그렇지 않으면 파일에 그대로 쓰기가 되어버릴 것이다).
| 기준 | Data | [UInt8] | NSData |
|---|---|---|---|
| 타입 종류 | 값 타입(struct) | 값 타입(Array) | 참조 타입(class) |
| 값 크기(MemoryLayout.size) | 16바이트 | 8바이트 | 8바이트(참조) |
| CoW | 지원 | 지원 | 없음(참조 공유) |
| ObjC 브리징 | NSData로 toll-free | 없음 | 원본이 ObjC |
| DataProtocol / regions | 준수 | 미준수 | 미준수 |
| 새 코드에서 | Foundation API 경계, 파일·네트워크 I/O | 순수 Swift 제네릭, 브리징 불필요 | 사실상 거의 안 씀(레거시 ObjC 상호운용만) |
실험 · 도구
swift -e로 MemoryLayout 크기와 CoW 분리 시점을 바로 확인할 수 있다.
import Foundation
print("MemoryLayout<Data>.size =", MemoryLayout<Data>.size) // 16 (실측)
print("MemoryLayout<[UInt8]>.size =", MemoryLayout<[UInt8]>.size) // 8 (실측, Array는 포인터 하나)
print("MemoryLayout<NSData>.size =", MemoryLayout<NSData>.size) // 8 (실측, 참조 하나 = 포인터)
let a = Data([1, 2, 3])
var b = a // 아직은 같은 버퍼를 공유 (복사 없음)
b.append(4) // 이 순간 CoW가 발동해 b만 새 버퍼로 분리된다
print(Array(a), Array(b)) // [1, 2, 3] [1, 2, 3, 4] — a는 그대로프로젝트 적용
① Foundation API 경계(파일 읽기, URLSession 응답, Codable)에서는 그냥 Data를 써라 — 굳이 [UInt8]로 바꿔서 들고 다니면 매번 왕복 변환 비용만 늘어난다.
import Foundation
// ✅ Foundation/네트워킹 경계에서는 Data를 그대로 들고 다닌다.
// URLSession, Codable, 파일 I/O 전부 Data를 공용 화폐로 쓰므로
// 왕복마다 [UInt8]로 바꿨다 되돌리는 변환 비용이 사라진다.
func fetchPayload(from url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data // 호출자도 Data 그대로 받아 Codable에 바로 넘길 수 있다
}② 성능이 중요한 순수 계산 경로에서 Foundation을 전혀 끌어들이고 싶지 않다면 [UInt8]이 더 자연스럽다. Data가 들어오는 경계에서만 한 번 변환하고, 그 안쪽은 순수 Swift로 남긴다.
import Foundation
// ✅ Foundation을 전혀 몰라도 되는 순수 계산 루틴이라면 [UInt8]이 더 자연스럽다.
// 제네릭 알고리즘(체크섬, 파서 콤비네이터 등)에 Foundation 의존을 끌고 들어가지 않아도 된다.
func xorChecksum(_ bytes: [UInt8]) -> UInt8 {
bytes.reduce(0) { $0 ^ $1 }
}
// Data가 들어오는 경계에서만 한 번 변환하고, 그 안쪽은 순수 Swift로 남긴다.
func xorChecksum(of data: Data) -> UInt8 {
xorChecksum(Array(data))
}"Data는 항상 순수 힙 바이트 배열이다"는 부정확하다 — 매핑된 파일 백킹일 수도 있어서, 그 경우 인덱스 접근이 실제로는 페이지 폴트(디스크 I/O)를 유발할 수 있다(Q2). 또한 "NSData만 참조 의미고 Data는 값이니 100% 안전하다"도 과신이다 — 이 챕터가 다루는 것처럼 브리징 계층 자체에 다년간 보고된 버그(Q2)가 있었으니, "값 타입이라 안전하다"는 규칙이 브리징 경계까지 완벽히 보장하지는 않는다.
책 한 권이 있다고 하자. Data는 그 책을 그대로 복사하지 않고 "나도 이거 보고 있어"라고 표시만 해두다가, 누군가 낙서를 하려는 순간에만 몰래 복사본을 만들어주는 똑똑한 사서 같은 존재다. NSData는 책 한 권을 관리하는 도서관 직원인데, 그 직원이 가진 게 원본 그 자체라 누가 손대면 다들 그 손댄 버전을 보게 된다. [UInt8]은 그냥 개인 공책이다 — 도서관(ObjC) 시스템과는 상관없이 나 혼자 쓰는 공책. 비유가 깨지는 곳: 실제로 '낙서'(mutation)가 언제 진짜 복사를 유발하는지는 책이 매핑된 파일인지 아닌지에 따라 미묘하게 달라지는데, 이건 사서(Data)가 알아서 처리해줘서 겉으로는 안 보인다.
꼬리 질문
Data를 함수 인자로 넘길 때 값 복사 비용이 걱정되는데, 실제로 무슨 일이 일어나는가?
NSData가 Data로 브리징될 때 항상 공짜(toll-free)인가?
NSData↔Data 브리징 계층 버그 때문에 매핑 옵션을 줬는데도 그 매핑이 건너뛰어지고 전체가 읽혀버리는 문제가 있었다. "toll-free 브리징"은 대체로 성립하는 구현 디테일이지, 어떤 상황에서도 깨지지 않는 언어 차원의 계약은 아니다.그럼 [UInt8]과 Data 중 성능만 보면 뭐가 더 빠른가?
Data도 withUnsafeBytes로 포인터를 얻으면 [UInt8]과 큰 차이가 없다). 차이는 순수 성능보다 "어디서 오고 어디로 가는가"다 — Foundation/ObjC 경계를 넘나든다면 Data, 순수 Swift 안에서만 산다면 [UInt8]이 더 마찰이 적다.Q2. `Data(contentsOf:)`로 500MB를 읽으면 왜 앱이 죽는가? `.mappedIfSafe`는 이를 어떻게 피하는가?
Data(contentsOf: url)을 옵션 없이 부르면 파일 전체를 새 힙 버퍼로 복사해 넣는 '완전 로드'가 일어난다. 디스크에서 온 바이트로 채워진 그 버퍼는 힙에 새로 만들어진 익명(anonymous) 메모리이자 이미 손을 댄 dirty 페이지이고, iOS는 앱의 dirty 페이지를 디스크로 스왑하지 않는다. 그래서 500MB 파일 하나를 읽는 순간 footprint가 그대로 500MB 넘게 튀어 오르고, 앱별 메모리 상한을 넘기면 Jetsam이 예고 없이 프로세스를 종료한다. .mappedIfSafe 옵션은 파일을 읽어들이는 대신 프로세스의 가상 주소 공간에 그 파일을 매핑만 해두고, 실제로 접근한 페이지만 그 순간 디스크에서 읽어 물리 메모리에 올린다.
원리
이건 iOS만의 특수 규칙이 아니라 POSIX mmap(2)이 제공하는 일반적인 지연 로딩(lazy/demand loading) 원리다. read()로 파일을 읽으면 커널이 그 시점에 모든 바이트를 프로세스 메모리로 복사해야 하지만, mmap()은 파일의 페이지들을 프로세스 주소 공간에 매핑하는 페이지 테이블 엔트리만 만들어두고, 실제 데이터 이동은 그 주소에 처음 접근해 페이지 폴트(page fault)가 나는 순간으로 미룬다. Data의 읽기 옵션은 이 둘 중 어느 경로를 탈지 고르는 스위치일 뿐이다.
내부 동작
직접 재본 값이다(Swift 6.2.1, arm64 macOS 26, 페이지 크기 16KB, 400MB 무작위 바이트 파일). 프로세스 시작 직후 기준선은 resident ≈5.8MB, footprint ≈1.6MB였다.
- 옵션 없이
Data(contentsOf:)로 400MB 파일을 읽자마자: resident 405.9MB, footprint 401.9MB — 둘 다 파일 크기만큼 정확히 튀었다. .mappedIfSafe로 읽고 아무 바이트도 건드리지 않았을 때: resident 5.8MB, footprint 1.6MB — 기준선과 동일하다. 그런데도data.count는 즉시 419,430,400(파일 크기)을 정확히 돌려준다 — count는 파일의 stat 크기에서 오지, 실제로 읽은 바이트 수에서 오지 않기 때문이다.
/usr/bin/time -l의 'maximum resident set size'로 교차 검증해도 같은 그림이 나온다: 일반 읽기는 425,672,704바이트(≈406MB), 매핑만 하고 안 건드린 경우는 6,160,384바이트(≈5.9MB)다.
이 옵션은 계약이 아니라 힌트라는 점도 짚어야 한다. .mappedIfSafe는 '안전할 때만' 매핑하고, 네트워크 볼륨처럼 매핑이 안전하지 않다고 판단되는 경우에는 조용히 전체를 읽는 일반 읽기로 되돌아간다. 실제로 Apple DTS 엔지니어가 확인한 사례로는, 특정 Xcode/Swift 조합에서 NSData↔Data 브리징 버그 때문에 .alwaysMapped를 줬는데도 수 GB 파일이 매핑되지 않고 그대로 다 읽혀 앱이 몇 초간 멈춘 보고가 2019~2023년에 걸쳐 이어졌다 — "옵션을 줬으니 매핑됐다"고 단정하지 말고 실제로 매핑됐는지 직접 확인하는 습관이 필요하다.
실험 · 도구
직접 재현하려면: (1) dd if=/dev/urandom of=big.bin bs=1m count=400으로 큰 파일을 만들고, (2) 아래처럼 task_info(MACH_TASK_BASIC_INFO)의 resident_size를 읽는 작은 프로브를 만들어 로드 전후 값을 찍는다. swiftc -O probe.swift -o probe로 빌드해 ./probe plain big.bin / ./probe mapped big.bin으로 실행하고, 앞에 /usr/bin/time -l을 붙이면 'maximum resident set size' 줄에서 같은 결론을 교차 확인할 수 있다.
import Foundation
import Darwin
// 지금 이 프로세스의 resident(물리 메모리에 실제로 올라온 양)를 MB 단위로 반환
func residentMB() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size)
let kerr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
guard kerr == KERN_SUCCESS else { return -1 }
return Double(info.resident_size) / 1024 / 1024
}
let url = URL(fileURLWithPath: CommandLine.arguments[2])
print("시작: \(residentMB())MB")
if CommandLine.arguments[1] == "plain" {
let data = try! Data(contentsOf: url)
print("일반 읽기 후: \(residentMB())MB, count=\(data.count)")
} else {
let data = try! Data(contentsOf: url, options: .mappedIfSafe)
print("mappedIfSafe 후(안 만짐): \(residentMB())MB, count=\(data.count)")
}프로젝트 적용
① 큰 파일을 로드하는 진입점에서는 .mappedIfSafe를 기본값으로 박아두고, 정말 전체를 다 써야 하는 예외적인 경우에만 일반 읽기를 쓰는 쪽으로 뒤집어라.
import Foundation
// ❌ 옵션 없는 읽기: 파일 전체가 새 힙 버퍼로 복사된다.
// 실측(400MB 파일): resident/footprint 모두 즉시 +400MB — iOS엔 dirty 스왑이 없어 그대로 Jetsam 후보가 된다.
func loadPayload(_ url: URL) throws -> Data {
try Data(contentsOf: url)
}
// ✅ 큰 파일 로딩 진입점은 기본을 mappedIfSafe로 뒤집는다.
// 실측: 같은 파일을 이 옵션으로 열면 resident/footprint가 기준선(≈수 MB)에서 그대로 멈춘다.
func loadPayloadMapped(_ url: URL) throws -> Data {
try Data(contentsOf: url, options: .mappedIfSafe)
}② 매핑을 걸어놓고도 무심코 전체를 훑으면(체크섬 계산, 처음부터 끝까지 파싱) 결국 모든 페이지를 건드리게 되어 디스크 I/O 절감 효과가 사라진다. 필요한 부분만 슬라이스해서 읽는 습관이 매핑의 이득을 실제로 살린다.
import Foundation
// ⚠️ 매핑해놓고 이렇게 처음부터 끝까지 훑어버리면(체크섬, 전수 파싱 등)
// 결국 모든 페이지를 건드리게 되어 "안 쓰는 페이지는 안 올라온다"는 이점이 사라진다.
func fullScanChecksum(_ data: Data) -> UInt8 {
var sum: UInt8 = 0
data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in
for byte in buf { sum ^= byte }
}
return sum
}
// ✅ 정말 필요한 부분만 슬라이스해서 다룬다 — 예: 포맷 헤더만 읽고 나머지는 그대로 둔다.
func readHeader(_ data: Data, headerSize: Int = 4096) -> Data {
data.prefix(headerSize) // 이 4KB(=페이지 일부)만 실제로 물리 메모리에 올라온다
}".mappedIfSafe를 쓰면 파일 I/O가 아예 사라진다"는 틀렸다. 디스크 읽기 자체는 없어지지 않고 페이지 폴트 시점으로 미뤄질 뿐이며, 결국 전체를 다 읽으면(위 실측 세 번째 시나리오, Q3 참고) 총 I/O량은 비슷해진다. 진짜 이득은 "안 쓰는 부분은 아예 안 읽고 안 올린다"는 것과 "압박이 오면 커널이 되찾아갈 수 있다"는 두 가지지, 마법처럼 I/O가 없어지는 게 아니다.
500MB짜리 백과사전을 통째로 복사해서 내 책상 위에 쌓아두는 것과, 도서관 책장에 그대로 꽂아두고 필요한 페이지를 펼칠 때만 그 페이지를 슬쩍 베껴서 보는 것의 차이다. 복사해서 쌓아두면 책상(메모리)이 그 무게를 고스란히 견뎌야 하고, 책상이 좁으면(메모리 부족) 결국 누군가 책상 주인을 내쫓아야 한다(Jetsam). 도서관에 꽂아둔 채 필요한 페이지만 베끼면, 안 보는 페이지는 책상에 올라오지도 않고, 급하면 베낀 페이지도 버렸다가 다시 베끼면 그만이다. 비유가 깨지는 곳: 도서관 방식이라도 '이 사서가 지금 안전하게 빌려줄 수 있는 상황인지'(네트워크 드라이브처럼)를 판단해서, 어떤 때는 그냥 통째로 복사해주는 쪽으로 돌아간다 — 항상 도서관 방식이 보장되는 건 아니다.
꼬리 질문
왜 mappedIfSafe인데 항상 매핑되는 게 아닌가? 언제 매핑을 거부하나?
.mappedIfSafe로 읽은 Data를 수정하려 하면 무슨 일이 일어나나?
alwaysMapped와 mappedIfSafe는 무엇이 다른가?
Q3. 메모리 매핑된 페이지는 footprint에 어떻게 잡히는가? — clean/dirty 페이지와 Jetsam
커널이 세는 'resident(지금 물리 메모리에 올라와 있다)'와 iOS가 Jetsam 여부를 판단할 때 보는 'footprint(메모리 사용량)'는 서로 다른 숫자다. 매핑된 파일 페이지는 실제로 손을 대는 순간 물리 메모리에 올라와 resident가 되지만, 원본이 여전히 디스크에 그대로 있는 clean 페이지이기 때문에 footprint 계산에서는 빠진다 — 압박이 오면 커널이 그 페이지를 아무 대가 없이 버렸다가 필요할 때 다시 읽어오면 그만이기 때문이다. 반대로 앱이 직접 만들거나 고친 dirty 페이지는 원본이 따로 없어 되살릴 길이 없으므로 resident이자 곧 footprint다. 이 구분이 정확히 무엇이 Jetsam의 트리거가 되는지를 가른다.
원리
이 구분은 iOS 전용 발명이 아니라, 파일로 되돌릴 수 있는 페이지와 그렇지 않은 페이지를 나눠 회수 전략을 다르게 짜는 가상 메모리의 일반적인 아이디어다. 어느 OS든 clean 페이지는 그냥 버리고 다시 fault-in하면 되지만, dirty 페이지는 어딘가로 내보내거나(스왑) 압축하거나 계속 들고 있어야 한다. iOS가 특별한 지점은 '내보낼 곳(디스크 스왑)'이 앱 dirty 메모리에 대해 없다는 것뿐이다 — clean/dirty를 나누는 원리 자체는 보편적이다.
내부 동작
WWDC 2018 'iOS Memory Deep Dive'가 밝힌 정의로는 footprint = dirty + compressed고, mmap된 파일처럼 디스크에서 재적재 가능한 clean 메모리는 여기 포함되지 않는다. 이 챕터를 준비하며 직접 그 경계를 넘나드는 실험을 했다(Swift 6.2.1, 400MB 파일, 기준선 resident≈5.8MB/footprint≈1.6MB).
- 일반 읽기: resident 405.9MB / footprint 401.9MB — 함께 뛴다(둘 다 dirty).
.mappedIfSafe, 안 건드림: resident 5.8MB / footprint 1.6MB — 둘 다 그대로..mappedIfSafe로 읽은 뒤withUnsafeBytes로 전체 바이트를 한 번 훑어(체크섬 계산) 모든 페이지를 강제로 물리 메모리에 올림: resident는 405.8MB로 (1)과 거의 같이 뛰었지만, footprint는 1.9MB로 거의 그대로였다.
세 번째 줄이 이 챕터의 핵심이다. 전체를 다 스캔했으니 모든 페이지가 진짜로 resident가 됐는데도(≈(1)과 같은 resident) footprint는 전혀 안 뛰었다(≈(2)와 같은 footprint). 이 페이지들이 여전히 clean(파일 기반, 언제든 버렸다 재적재 가능)이기 때문이다. /usr/bin/time -l의 'maximum resident set size'는 (1)과 (3) 모두 ≈406MB로 거의 같게 보고한다 — 즉 /usr/bin/time -l의 RSS만 보고는 Jetsam 위험을 가늠할 수 없다. 실제로 Jetsam 임계값과 비교되는 숫자는 task_info(TASK_VM_INFO).phys_footprint이며, Xcode의 메모리 게이지와 MetricKit도 이 값 계열을 보여준다.
실험 · 도구
Q2의 프로브에 TASK_VM_INFO를 조회하는 footprintMB() 함수 하나만 더 붙이면 resident와 footprint를 나란히 찍을 수 있다. 여기에 withUnsafeBytes로 전체를 한 바퀴 훑는 모드를 하나 추가해서 비교해보면 위 그림의 세 시나리오를 스스로 재현할 수 있다.
import Darwin
// phys_footprint — iOS가 Jetsam 여부를 판단할 때 실제로 비교하는 값
func footprintMB() -> Double {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
let kerr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard kerr == KERN_SUCCESS else { return -1 }
return Double(info.phys_footprint) / 1024 / 1024
}
// Q2의 residentMB()와 나란히 찍으면 이 챕터 표의 세 시나리오를 그대로 재현할 수 있다.
print("resident=\(residentMB())MB footprint=\(footprintMB())MB")프로젝트 적용
① 메모리 관련 로그에 resident만 찍지 말고 footprint(및 남은 예산)를 함께 남겨라 — 매핑된 리소스를 많이 쓰는 앱일수록 두 숫자가 크게 벌어질 수 있다.
import Darwin
import os
// 메모리 관련 로그에 resident 하나만 남기지 말고 footprint(및 남은 예산)를 함께 남긴다.
func logMemory(_ tag: String) {
let remainingMB = os_proc_available_memory() / 1024 / 1024
print("[\(tag)] resident=\(residentMB())MB footprint=\(footprintMB())MB 남은예산=\(remainingMB)MB")
}② 대용량 매핑 리소스(지도 타일, 로컬 DB, 큰 애셋 번들)를 캐시할 때는 '만졌던 페이지가 다시 clean 상태로 남아 있을 수 있다'는 성질을 이용해 캐시 정책을 세워라 — 앱이 직접 언로드 로직을 짜지 않아도 압박 시 커널이 회수해준다. 단, mutable 접근으로 CoW가 발동해 dirty로 바뀌지 않도록 읽기 전용 경로를 유지해야 이 이점이 유지된다.
import Foundation
// ✅ 매핑된 리소스는 읽기 전용으로만 접근해야 clean 상태(=footprint 열외)가 유지된다.
final class TileCache {
private var tiles: [String: Data] = [:]
func tile(named name: String, at url: URL) -> Data {
if let cached = tiles[name] { return cached }
// mappedIfSafe로 열어두면, 압박 시 커널이 알아서 회수했다가
// 다시 접근할 때 파일에서 재적재해준다 — 앱이 직접 언로드 코드를 안 짜도 된다.
let mapped = (try? Data(contentsOf: url, options: .mappedIfSafe)) ?? Data()
tiles[name] = mapped
return mapped
}
// ⚠️ 여기서 tiles[name]을 직접 수정(mutating)하면 그 순간 CoW가 발동해
// dirty 버퍼로 복사되며 clean 상태의 이점을 잃는다. 항상 새 Data로 교체할 것.
}"/usr/bin/time -l이나 Instruments에서 resident가 크게 나오면 곧 Jetsam 위험이다"는 부정확하다 — 실측에서 보듯 매핑 후 전체를 스캔해도 resident는 치솟지만 footprint는 거의 그대로였다. resident와 footprint는 다른 지표이고, Jetsam은 footprint 기준으로 판단한다. "매핑된 메모리는 아예 footprint에 안 잡힌다"도 절반만 맞다 — 안 건드리면 안 잡히지만, 매핑된 페이지를 수정(mutation)하면 그 순간 dirty로 바뀌어 footprint에 그대로 반영된다.
옷장에 옷이 얼마나 걸려 있는지(resident)와, 그 옷이 '내가 꼭 갖고 있어야 하는 옷'인지 '언제든 세탁소에서 다시 찾아올 수 있는 옷'인지(footprint에 들어가는지)는 다른 질문이다. 세탁소에 맡겨둔 옷은 옷장 자리를 지금 당장 차지하고 있어도(resident) 급하면 세탁소가 알아서 치워버리고 나중에 다시 찾아오면 되니 '내 짐'으로 안 친다(footprint 제외). 반면 내가 직접 만든 옷은 세탁소가 없으니 옷장에서 없어지면 그냥 없어지는 거라 항상 '내 짐'으로 계산된다. 비유가 깨지는 곳: 세탁소에 맡긴 옷이라도 내가 거기에 수를 놓기(수정) 시작하는 순간, 그건 더 이상 세탁소가 대신 복원해줄 수 있는 옷이 아니게 되어 바로 '내 짐'으로 편입된다.
꼬리 질문
resident와 footprint 말고 실제로 앱이 볼 수 있는 세 번째 지표는 없나?
os_proc_available_memory()로 "지금 이 프로세스가 Jetsam 없이 더 쓸 수 있는 남은 예산"을 직접 물어볼 수 있다. footprint를 스스로 계산해서 임계값과 비교하는 대신, 시스템이 이미 계산해둔 여유분을 바로 받는 방식이라 더 실용적이다.compressed 메모리는 footprint에 잡히나?
매핑된 파일을 mutable하게 열면 이 clean 상태가 유지되나?
Q4. `Data`가 discontiguous할 수 있다는 게 무슨 뜻인가? `withUnsafeBytes`의 함정은?
Data는 DataProtocol을 준수하는데, 이 프로토콜은 '논리적으로 하나의 바이트 시퀀스처럼 보이지만 물리적으로는 여러 조각(region)에 나뉘어 저장될 수 있는' 타입까지 포괄하도록 설계돼 있다. 직접 Swift 6.2.1에서 실험해보면, Foundation의 구체 타입 Data 자신은 두 조각을 이어붙이거나(append) 매핑된 Data에 바이트를 더 붙여도 regions.count가 계속 1로 나온다 — 실무에서 흔히 마주치는 Data 값은 대체로 하나의 연속 버퍼라는 뜻이다. 반면 GCD의 DispatchData처럼 여러 버퍼를 복사 없이 이어붙이는 진짜 DataProtocol 타입은 실제로 discontiguous하다 — 두 조각을 이어붙이면 regions.count가 정확히 2로 나온다. 문제는 some DataProtocol/any DataProtocol을 받는 제네릭 코드가 "포인터 하나면 끝"이라고 가정하면, 그 함수에 DispatchData가 들어오는 순간 조용히 틀린 결과나 예상 밖의 복사 비용을 만들 수 있다는 것이다.
원리
DataProtocol은 RandomAccessCollection where Element == UInt8을 확장해 var regions: Regions { get }을 요구한다. Regions는 '연속된 조각들의 컬렉션'이라는 뜻의 BidirectionalCollection이다. 즉 이 프로토콜 자체가 "어떤 타입은 한 조각일 수도, 여러 조각일 수도 있다"는 사실을 계약에 넣어둔 것이다 — 구체 타입 Data 혼자만 보고 이 계약을 이해하면 안 된다.
내부 동작
연구 자료에는 "Data를 여러 번 append하면 discontiguous해질 수 있다"는 뉘앙스가 있었지만, 이 챕터를 준비하며 Swift 6.2.1에서 직접 검증한 결과는 달랐다. 작은 Data 세 조각을 이어붙여도, 매핑된 Data에 16바이트를 추가로 append해도 regions.count는 계속 1이었다 — 즉 오늘날 Foundation의 Data 구현은 append 시점에 결국 하나의 연속 버퍼로 정리해버리는 것으로 보인다. 반면 GCD의 DispatchData로 같은 실험을 하면 결과가 완전히 달라진다.
| 값 | regions.count | withContiguousStorageIfAvailable |
|---|---|---|
| Data(24바이트, 단일) | 1 | 성공 — 포인터 즉시 반환 |
| DispatchData(8B+8B를 복사 없이 연결) | 2 | nil — 진짜 discontiguous |
| Data(위 DispatchData에서 생성) | 1 | 성공 — 변환 시점에 한 버퍼로 복사됨 |
이 표가 말하는 것은 명확하다. "Data는 절대 여러 조각이 안 된다"고 단정하면 안 되지만(프로토콜 계약이 허용하므로), 실무에서 진짜로 조각난 값을 만나는 경로는 Data 자신의 append가 아니라 DispatchData처럼 원래부터 여러 버퍼를 복사 없이 참조하는 다른 DataProtocol 준수 타입을 통해서다. Data(dispatchData)처럼 구체 타입 Data로 변환하면 그 시점에 복사 비용을 치르고 다시 한 조각이 된다.
실험 · 도구
아래 코드를 swift regions.swift로 바로 돌려보면 위 표의 세 줄을 직접 확인할 수 있다.
import Foundation
import Dispatch
func describeRegions<D: DataProtocol>(_ label: String, _ data: D) {
let fast = data.withContiguousStorageIfAvailable { buf in buf.count }
print("\(label): regions=\(data.regions.count) 연속뷰=\(fast != nil ? "성공" : "nil")")
}
let plain = Data(repeating: 0x11, count: 24)
describeRegions("Data(단일)", plain) // regions=1 연속뷰=성공
var combined = DispatchData.empty
let a: [UInt8] = Array(repeating: 0xAA, count: 8)
let b: [UInt8] = Array(repeating: 0xBB, count: 8)
a.withUnsafeBytes { combined.append($0) }
b.withUnsafeBytes { combined.append($0) }
describeRegions("DispatchData(2조각)", combined) // regions=2 연속뷰=nil
describeRegions("Data(DispatchData에서 생성)", Data(combined)) // regions=1 연속뷰=성공(복사됨)프로젝트 적용
① some/any DataProtocol을 받는 제네릭 코드는 "포인터 하나면 끝"을 가정하지 말고, withContiguousStorageIfAvailable로 빠른 경로를 시도한 뒤 실패하면 복사해서 처리하는 폴백을 반드시 둔다.
import Foundation
// ✅ some/any DataProtocol을 받는 코드는 "포인터 하나"를 가정하지 않는다.
func hash<D: DataProtocol>(_ data: D) -> UInt64 {
if let fast = data.withContiguousStorageIfAvailable({ buf -> UInt64 in
computeHash(buf) // 빠른 경로: 진짜 연속이면 복사 없이 바로 처리
}) {
return fast
}
// 느린 경로: 진짜 discontiguous하면 한 번 복사해서 연속 버퍼로 만든 뒤 처리한다.
let contiguous = Array(data)
return contiguous.withUnsafeBufferPointer { computeHash($0) }
}
func computeHash(_ buffer: UnsafeBufferPointer<UInt8>) -> UInt64 {
var h: UInt64 = 0xcbf29ce484222325
for byte in buffer { h = (h ^ UInt64(byte)) &* 0x100000001b3 }
return h
}② withUnsafeBytes가 넘겨주는 포인터는 클로저 호출 수명 안에서만 유효하다 — Apple 문서도 명시적으로 경고하는 부분이다. 포인터 자체를 밖으로 들고 나오지 말고, 필요한 값을 클로저 안에서 복사해서 반환하라.
import Foundation
var stash: UnsafeRawBufferPointer?
// ❌ 클로저가 끝나면 포인터가 가리키던 버퍼의 생존을 보장할 수 없다.
// Apple 문서도 "클로저 호출 수명 밖에서 쓰지 말라"고 명시한다.
func captureDangling(_ data: Data) {
data.withUnsafeBytes { buf in
stash = buf // ⚠️ 여기서 저장해봤자 클로저가 끝나면 무효화될 수 있다
}
}
// ✅ 포인터가 아니라 내용을 복사해서 들고 나온다.
func captureSafely(_ data: Data) -> [UInt8] {
data.withUnsafeBytes { buf in
Array(buf) // 클로저 안에서 값으로 복사해 반환 — 포인터 자체는 탈출하지 않는다
}
}"Data는 절대 여러 조각으로 안 나뉜다, discontiguous 얘기는 이론일 뿐이다"는 절반만 맞다 — 실험에서 확인했듯 Data 자체는 오늘날 실무에서 거의 항상 한 조각으로 정리되지만, DataProtocol 계약 자체는 여러 조각을 허용하고 실제로 DispatchData가 그 사례를 보여준다. "Data는 항상 한 조각"이라고 단정하고 제네릭 DataProtocol 코드를 짜면, 나중에 다른 conformer가 들어왔을 때 깨진다. 또한 "withUnsafeBytes로 얻은 포인터는 어디에 저장해둬도 된다"도 틀렸다 — 그 포인터는 클로저 호출 수명 밖에서 쓰면 안 된다.
택배 상자 하나(Data)를 받았는데 열어보니 다행히 물건이 하나로 딱 붙어 있었다. 그런데 어떤 택배 서비스(DispatchData)는 원래 다른 창고 두 곳(region 0, region 1)에 있던 물건을 굳이 한 상자로 옮겨 담지 않고 "이 두 창고에 있는 걸 합치면 네 물건이야"라고만 알려준다. 상자 하나인 줄 알고 손을 쑥 넣었다가는(포인터 하나로 다 되는 줄 알았다가는) 실제로는 창고가 두 곳이라 헛손질을 하게 된다. 비유가 깨지는 곳: 실제 코드에서는 헛손질이 크래시가 아니라 조용히 nil을 돌려주거나 알아서 복사해주는 경우가 많아서, 문제가 겉으로 잘 안 드러난다는 점이 더 위험하다.
꼬리 질문
Data(dispatchData)처럼 DispatchData를 Data로 바꾸면 discontiguous 문제가 사라지나?
Data(combined)로 변환하면 regions.count가 다시 1이 된다 — 그 대가로 원본 조각들을 하나의 새 버퍼로 복사하는 비용을 그 시점에 미리 치른다. 자주 반복해서 접근할 값이라면 한 번 이렇게 정리해두는 게 유리할 수 있지만, 스트리밍처럼 한 번만 훑고 버릴 데이터라면 불필요한 복사가 된다.regions를 직접 순회하는 코드는 언제 필요한가?
withContiguousStorageIfAvailable이 nil을 돌려주는 진짜 discontiguous 데이터를, 복사 없이 조각 단위 그대로 처리하고 싶을 때다. 예를 들어 네트워크 스트림 파서가 청크 경계를 그대로 활용해 조각마다 순서대로 파싱하고 싶다면, 전체를 복사해서 한 버퍼로 만드는 대신 regions를 순회하는 편이 메모리도 아끼고 더 빠르다.Codable이 Data를 인코딩할 때도 이 discontiguous 이슈를 신경 써야 하나?
some DataProtocol 제네릭으로 짤 때만 이 챕터의 패턴이 필요하다.Q5. `String(data:encoding:)`은 언제 nil을 돌려주는가? Base64·hex 변환의 실무 주의점은?
String(data:encoding:)은 실패 가능한(failable) 이니셜라이저라서, 주어진 바이트가 지정한 인코딩의 규칙을 어기면 nil을 돌려준다. 실험으로 확인해보면, UTF-8 멀티바이트 문자("안"은 3바이트)를 중간에서 잘라 5바이트만 넘기면 String(data:encoding:.utf8)은 정확히 nil이 된다 — 실무에서 이런 상황은 인코딩을 잘못 지정해서라기보다 네트워크 응답이 중간에 끊기거나 서버가 선언한 문자셋과 실제 바이트가 어긋나는 경우가 더 흔하다. 실패를 감수하지 않고 "일단 문자열로 보고 싶다"면 String(decoding:as:)를 쓰면 되는데, 이건 실패하지 않는 대신 잘못된 바이트 시퀀스를 U+FFFD(REPLACEMENT CHARACTER, '�')로 바꿔치기한다 — nil이 아니라 조용히 깨진 글자가 섞인 문자열을 돌려준다는 뜻이라 오히려 놓치기 쉽다. Base64는 3바이트를 4글자로 묶어 표현하므로 항상 원본의 약 4/3(≈33%) 크기로 불어나고, hex 변환은 Foundation에 내장 API가 없어 직접 짜야 하는데 흔한 reduce(_:+) 구현은 실제로 크게 느려진다.
원리
UTF-8은 자기 동기화(self-synchronizing) 인코딩이라 어느 바이트를 봐도 그 바이트 하나만으로 "이건 시퀀스의 시작인지 중간인지"를 알 수 있다. 이 성질 덕분에 유효성 검증이 가능하지만, 반대로 시퀀스가 도중에 잘리면 그 유효성 검증에 실패해 디코딩 자체가 실패한다. 이건 UTF-8 자체의 설계이지 Swift나 Foundation의 특수한 제약이 아니다.
내부 동작
직접 실험한 값이다. "안녕".data(using: .utf8)!은 6바이트(완성형 한글 2글자, 글자당 3바이트)를 만든다. 마지막 1바이트를 잘라 5바이트로 만들면("녕"의 마지막 바이트가 없는 상태):
String(data: truncated, encoding: .utf8)→nilString(decoding: truncated, as: UTF8.self)→"안�"— 첫 글자는 온전히 복원되고, 잘려서 무효해진 마지막 시퀀스만 U+FFFD 하나로 치환된다.
Base64는 3바이트(24비트)를 6비트씩 4묶음으로 나눠 4개의 출력 문자로 표현하므로 이론상 정확히 4/3배로 불어난다. 실측으로 300바이트를 base64EncodedString()하면 정확히 400글자가 나와 비율이 1.3333...으로 딱 맞아떨어진다(길이가 3의 배수가 아니면 = 패딩이 붙어 이 비율이 살짝 더 커진다).
hex 변환은 Foundation에 표준 API가 없어 흔히 data.reduce("") { $0 + String(format: "%02x", $1) } 식으로 직접 짜는데, 이 구현은 매 스텝마다 지금까지 만든 문자열 전체를 새로 이어붙인 새 문자열을 만들어($0 + $1은 비-변형(non-mutating) 결합이라 버퍼를 재사용하지 못한다) 데이터가 커질수록 크게 느려진다. 직접 벤치마크한 결과, 5,000바이트에서는 3.8ms였던 것이 200,000바이트(40배)에서는 701.9ms로 늘어 약 185배 느려졌다 — 선형이라면 40배만 늘어야 할 실행 시간이 그보다 훨씬 가파르게 자란 것이다. 미리 크기를 아는 [UInt8] 버퍼에 lookup table로 직접 채워 넣는 구현은 같은 200,000바이트를 0.5ms에 끝낸다.
| 변환 | 실패 시 동작 | 언제 쓰나 |
|---|---|---|
| String(data:encoding:) | 유효하지 않으면 nil | 실패를 명시적으로 처리해야 하는 경계(파싱, 검증) |
| String(decoding:as:) | 유효하지 않은 부분만 U+FFFD로 치환, 절대 실패하지 않음 | 로그·디버깅처럼 "일단 뭐라도 보여주면 되는" 용도 |
| 수동 바이트 순회(hexFast류) | 해당 없음(바이트→텍스트 변환이지 디코딩이 아님) | 체크섬·해시 표시, 바이너리 디버깅 |
실험 · 도구
truncation 실험은 Data를 dropLast()로 한 바이트 잘라내는 것만으로 재현된다. hex 성능 차이는 아래 벤치마크로 직접 확인할 수 있다.
import Foundation
// ❌ 흔한 실수: reduce(_:+)는 매 스텝마다 지금까지 만든 문자열을 통째로 새로 이어붙인다.
func hexSlow(_ data: Data) -> String {
data.reduce("") { $0 + String(format: "%02x", $1) }
}
// ✅ 크기를 미리 계산해 버퍼를 한 번만 잡고 lookup table로 채운다 — O(n)
func hexFast(_ data: Data) -> String {
let digits: [UInt8] = Array("0123456789abcdef".utf8)
var out = [UInt8](repeating: 0, count: data.count * 2)
var i = 0
for byte in data {
out[i] = digits[Int(byte >> 4)]
out[i + 1] = digits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: out, as: UTF8.self)
}
// 실측(이 문서 작성 시): 5,000바이트 → hexSlow 3.8ms / hexFast 0.0ms
// 200,000바이트 → hexSlow 701.9ms / hexFast 0.5ms프로젝트 적용
① 네트워크에서 받은 바이트를 문자열로 만들 때는 실패를 무시하지 말고 명시적으로 분기하라. "일단 보여줘야 하는" 로그/디버깅 용도가 아니라면, 조용히 U+FFFD를 섞어 넣는 대신 실패를 사용자에게 알리거나 재요청하는 경로를 만들어라.
import Foundation
enum DecodeResult {
case text(String)
case invalidEncoding
}
// ✅ nil을 그냥 삼키지 않고 실패 경로를 명시적으로 분기한다.
func decodeStrict(_ data: Data) -> DecodeResult {
guard let text = String(data: data, encoding: .utf8) else {
return .invalidEncoding // 잘린 응답/잘못된 charset을 여기서 잡아낸다
}
return .text(text)
}
// ⚠️ "일단 보여주기" 용도(로그, 디버깅 화면)가 아니라면 이 함수를 기본값으로 쓰지 않는다.
// U+FFFD로 조용히 치환된 문자열이 실제 데이터인 것처럼 흘러들어갈 수 있다.
func decodeLossy(_ data: Data) -> String {
String(decoding: data, as: UTF8.self)
}② hex 변환이 필요하면 reduce(_:+)나 매 바이트 String(format:) 호출을 피하고, 위 hexFast처럼 크기를 미리 계산해 버퍼를 한 번에 잡는 lookup-table 구현을 재사용하라. Base64로 보낼 때는 용량 예산에 4/3배 inflation을 미리 반영한다.
import Foundation
// Base64는 3바이트를 4글자로 묶으므로 원본의 약 4/3배로 불어난다 — 용량 예산에 반영해야 한다.
func base64Size(of byteCount: Int) -> Int {
Int((Double(byteCount) * 4 / 3).rounded(.up))
}
// 디버깅용 hex 로그는 hexFast(위)를 그대로 재사용한다 — reduce(_:+)로 새로 짜지 않는다.
func debugDescription(_ data: Data) -> String {
"\(data.count)바이트, hex=\(hexFast(data.prefix(16)))..."
}"String(data:encoding:)이 nil이면 인코딩 이름을 잘못 지정한 것이다"는 틀렸다 — 지정한 인코딩 이름은 맞는데 실제 바이트가 그 규칙을 지키지 않는 경우(잘린 응답, 실제 인코딩과 선언이 다른 경우)가 더 흔하다. 또한 "hex 변환은 어차피 짧은 문자열이니 아무렇게나 짜도 상관없다"도 위험하다 — 로그 한 줄 찍는 용도면 맞지만, 파일 체크섬이나 큰 페이로드를 hex로 바꾸는 코드에 무심코 reduce(_:+)류를 쓰면 실측처럼 데이터 크기가 커질수록 가파르게 느려진다.
외국어로 온 편지를 우리말로 옮긴다고 생각해보자. 편지가 문장 중간에서 뚝 잘려서 왔다면(잘린 UTF-8), "이 문장, 도저히 무슨 말인지 모르겠어요"라며 번역을 거부하는 것이 String(data:encoding:)의 nil이다. String(decoding:as:)는 거부하지 않는 대신, 못 알아들은 부분만 물음표(U+FFFD) 하나로 때우고 나머지는 최선을 다해 번역해서 돌려주는 번역가다 — 편해 보이지만, 정작 중요한 부분이 물음표로 대충 넘어갔는지 확인하지 않으면 위험하다. 비유가 깨지는 곳: 실제로는 물음표가 하나만 나오는 게 아니라 잘린 바이트 개수·위치에 따라 여러 개 나올 수도 있고, 어떤 경우엔 멀쩡한 글자 하나가 통째로 손상되기도 한다.
꼬리 질문
서버가 charset을 잘못 선언해서 EUC-KR을 UTF-8이라고 우기면 어떻게 되나?
String(data:encoding:.utf8)이 십중팔구 nil이 된다 — EUC-KR 바이트가 우연히 유효한 UTF-8 시퀀스가 되는 경우는 드물다. 이럴 땐 응답 헤더의 실제 인코딩을 다시 확인하거나 서버와 합의된 인코딩으로 다시 시도하는 게 정답이지, String(decoding:as:)로 억지로 문자열을 뽑아내면 U+FFFD 치환이 아니라 완전히 다른 글자로 오역되어버린다.