29장은 하나의 미해결 문제로 끝났다. withUnsafeBufferPointer의 포인터를 클로저 밖으로 빼내면 정의되지 않은 동작인데, 컴파일러도 테스트도 그것을 잡지 못하고 exit 0으로 통과한다는 것. 이 챕터는 그 문제에 대한 Swift의 답이다. 답의 형태는 "기존 타입을 고친다"가 아니라 "수명이 타입에 새겨진 새 타입을 추가한다"였다.
그 새 타입이 Span이고, 함께 온 것이 InlineArray다. 둘은 자주 같이 소개되지만 성질이 상당히 다르고, 실무에서 쓸 수 있는 시점도 다르다. 이 챕터의 실측이 그 차이를 정확히 가른다 — 특히 배포 타깃 문제는 대부분의 소개 글이 빠뜨리는 대목인데, 실제 프로젝트에 넣을 수 있는지를 결정하는 가장 중요한 변수다.
본문의 크기·출력·진단문은 Swift 6.2.1 (swiftlang-6.2.1.4.8) / macOS 26.6.1 / arm64에서 직접 컴파일·실행한 실측값이다. 표준 라이브러리 선언은 Xcode 26.1.1 SDK에 동봉된 Swift.swiftmodule/arm64e-apple-macos.swiftinterface에서 그대로 인용했다 — 문서가 아니라 실제로 탑재된 선언이다.
Q1. Span은 무엇이고, UnsafeBufferPointer의 어떤 구멍을 막는가?
SE-0447의 Span<Element>는 "연속된 메모리를 안전하게 빌려 보는" 타입이다. UnsafeBufferPointer와 담고 있는 정보는 똑같다 — 시작 주소와 개수, 실측 16바이트. 다른 것은 단 하나, Span이 ~Escapable이라는 점이다. 이 성질 덕에 "빌려온 대상보다 오래 살 수 없다"가 타입 시스템의 규칙이 되고, 29장에서 조용히 통과했던 포인터 탈출이 컴파일 에러로 바뀐다. 검사는 전부 컴파일 타임에 끝나므로 런타임 비용은 0이다.
원리
SDK에 탑재된 실제 선언을 보면 설계 의도가 그대로 읽힌다.
@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *)
@frozen @safe public struct Span<Element>
: ~Swift.Escapable, Swift.Copyable, Swift.BitwiseCopyable
where Element : ~Copyable {
@usableFromInline internal let _pointer: Swift.UnsafeRawPointer?
@usableFromInline internal let _count: Swift.Int
@unsafe @_alwaysEmitIntoClient internal func _start() -> Swift.UnsafeRawPointer {
_pointer._unsafelyUnwrappedUnchecked
}
}읽을 것이 네 가지다. ① 저장 프로퍼티는 _pointer와 _count 단 둘 — 8 + 8 = 16바이트다. ② ~Escapable이 붙어 "이 값은 자기가 빌린 것보다 오래 살 수 없다"를 선언한다. ③ @safe가 붙어 있는데(29장 Q5) 내부는 UnsafeRawPointer다 — 내부는 포인터, 공개 API는 안전이라는 계약을 타입 선언으로 표현한 것이다. ④ Element : ~Copyable이므로 noncopyable 원소도 담을 수 있다.
내부 동작
핵심은 수명 의존성(lifetime dependency)이다. arr.span이 만들어질 때 컴파일러는 "이 Span은 arr에 의존한다"는 관계를 기록하고, 그 Span이 arr의 접근 범위를 벗어나려 하면 거부한다. 29장에서 본 배타적 접근이 "겹치는가"를 검사했다면, 수명 의존성은 "오래 사는가"를 검사한다.
실험 · 도구
가장 먼저 확인할 것은 크기다. 선언에서 예측한 16바이트가 실제로 나오는지 본다.
print("Span<Int> size =", MemoryLayout<Span<Int>>.size)
print("RawSpan size =", MemoryLayout<RawSpan>.size)
print("MutableSpan<Int> size =", MemoryLayout<MutableSpan<Int>>.size)
print("[Int] size =", MemoryLayout<[Int]>.size)Span<Int> size = 16
RawSpan size = 16
MutableSpan<Int> size = 16
[Int] size = 8[Int]이 8바이트인 것과 비교하면 구조가 보인다. Array는 힙 버퍼를 가리키는 포인터 하나만 들고 있고 개수는 그 버퍼의 헤더에 있다(Part A 01장). Span은 힙 객체를 소유하지 않으므로 헤더를 둘 곳이 없어 개수를 자기 안에 들고 다닌다. 그래서 8이 아니라 16이다.
이제 수명 규칙이 실제로 작동하는지 본다. Span을 파일 스코프 변수에 담으려 하면 거부된다.
let arr = [10, 20, 30]
let s: Span<Int> = arr.span // ❌error: lifetime-dependent value escapes its scope
note: it depends on the lifetime of variable 'arr'
note: this use causes the lifetime-dependent value to escape진단문이 정확히 무엇에 의존하는지(variable 'arr')까지 말해준다. 29장의 UnsafePointer 탈출에는 아무 진단도 없었다는 것과 대조하면, 이 타입이 무엇을 얻어냈는지가 분명해진다.
그럼 어떻게 쓰는가. 함수 인자로 넘기는 것이 기본형이다 — 호출이 끝나기 전에 사용이 종료되므로 탈출이 아니다.
func sum(_ sp: Span<Int>) -> Int {
var t = 0
for i in 0..<sp.count { t += sp[i] }
return t
}
let arr = [1, 2, 3, 4, 5]
print("Array.span 합 =", sum(arr.span)) // 15Array.span 합 = 15프로젝트 적용
① 가장 즉각적인 이득은 API 시그니처다. 지금까지 "연속 버퍼를 받는 함수"를 쓰려면 선택지가 셋뿐이었고 전부 대가가 있었다. Span은 그 셋의 단점을 동시에 피한다.
| 받는 방식 | 복사 | 안전 | 호출자 제약 |
|---|---|---|---|
func f(_ a: [Int]) | Array가 아니면 변환 복사 | 안전 | 호출자가 Array를 만들어야 함 |
func f<C: Collection>(_ c: C) | 없음 | 안전 | 제네릭 특수화 실패 시 느림 (Part A 08장) |
func f(_ p: UnsafeBufferPointer<Int>) | 없음 | unsafe — 탈출 못 막음 | 호출자가 계약을 지켜야 함 |
func f(_ s: Span<Int>) | 없음 | 안전 — 탈출이 컴파일 에러 | 없음 — Array·InlineArray 등 모두 .span 제공 |
② 그래서 실무 규칙은 이렇게 정리된다. 버퍼를 읽기만 하는 내부 함수의 파라미터 타입을 Span으로 바꾼다. 호출자는 .span 하나만 붙이면 되고, 여러 컨테이너를 같은 함수로 처리할 수 있다.
func checksum(_ s: Span<UInt8>) -> UInt8 {
var x: UInt8 = 0
for i in 0..<s.count { x ^= s[i] }
return x
}
let bytes: [UInt8] = [1, 2, 3]
_ = checksum(bytes.span) // Array
var fixed: InlineArray<3, UInt8> = [1, 2, 3]
_ = checksum(fixed.span) // InlineArray — 힙 할당 없이
// Data·UnsafeBufferPointer 등도 대응되는 span 프로퍼티가 있다③ 주의할 것은 반환 타입으로는 아직 쓰기 어렵다는 점이다. Span을 반환하려면 "반환값이 어느 인자의 수명에 의존하는지"를 명시해야 하고, 그 문법이 Q5에서 볼 실험 기능이다. 그래서 지금 당장 쓸 수 있는 자리는 파라미터이며, 이것만으로도 이득이 크다.
"Span은 런타임 검사를 추가하니 UnsafeBufferPointer보다 느리다"는 틀렸다 — 수명 검사는 전부 컴파일 타임이며, 생성되는 코드는 포인터 + 개수를 다루는 것과 같다. 첨자 접근의 범위 검사는 Array와 동일한 수준이고, 그것마저 피하려면 subscript(unchecked:)가 있다. 또 "Span은 데이터를 복사한다"도 틀렸다 — 이름 그대로 빌려 보는 창이며 소유하지 않는다. 마지막으로 "Span이 있으니 Array를 대체한다"도 아니다. Span은 소유하지 않으므로 저장할 수 없고, 소유가 필요한 자리는 여전히 Array다.
도서관 책을 보는 두 가지 방법이다. 29장의 UnsafePointer는 책 위치를 적은 쪽지였다 — 쪽지에는 "언제까지 유효한지"가 안 적혀 있어서, 책이 반납되거나 옮겨진 뒤에도 그 쪽지를 들고 찾아갈 수 있었다(그래서 사고가 났다). Span은 열람실에서만 볼 수 있는 대출증이다. 대출증에 "이 자리에서만"이라고 적혀 있고, 문 밖으로 나가려 하면 문에서 막힌다. 비유가 깨지는 곳: 진짜 도서관 문지기는 나갈 때 확인하지만, Swift의 문지기는 프로그램을 만들 때(컴파일) 미리 확인해서 아예 나갈 수 있는 프로그램을 못 만들게 한다 — 실행 중에는 문지기가 없어서 속도 손실도 없다.
꼬리 질문
Span이 16바이트인데 Array는 8바이트다. Span이 더 큰데 왜 더 효율적이라고 하는가?
Span이 두 배지만, Array의 8바이트는 힙에 있는 버퍼를 가리키는 포인터라서 실제 데이터에 접근하려면 그 힙 객체를 한 번 더 따라가야 하고, 그 힙 객체는 참조 카운트를 갖고 있어 복사·전달 때마다 ARC 연산이 붙을 수 있다. Span은 참조 카운트가 없는 BitwiseCopyable이라 전달이 레지스터 두 개를 넘기는 것으로 끝난다. 즉 "값이 작다"와 "접근·전달이 싸다"는 다른 축이고, Span이 노리는 것은 후자다.Span의 @available이 iOS 12.2인 것은 무슨 뜻인가? Swift 6.2 이전 OS에서도 되는 것인가?
Span은 @frozen이고 메서드 대부분이 @_alwaysEmitIntoClient라, 코드가 표준 라이브러리 dylib이 아니라 내 앱 바이너리에 복사되어 들어간다. 그래서 실행하는 OS의 표준 라이브러리에 Span이 없어도 동작한다 — 이것이 back-deployment다. 필요한 것은 컴파일할 때의 툴체인이 Swift 6.2 이상인 것뿐이며, 배포 타깃은 iOS 12.2까지 내려도 된다. Q4에서 InlineArray와의 차이를 실측으로 확인한다.왜 Array를 ~Escapable로 바꾸지 않고 새 타입을 만들었는가?
Array는 저장 프로퍼티에 넣고, 반환하고, 클로저가 캡처하고, Task로 넘기는 등 탈출하는 사용법이 정상적이고 필수적인 타입이다. 여기에 ~Escapable을 붙이면 세상의 거의 모든 Swift 코드가 컴파일되지 않는다. 더 근본적으로, 두 타입은 역할이 다르다 — Array는 메모리를 소유하고 Span은 빌린다. 소유하는 타입은 오래 살아야 하고, 빌리는 타입은 오래 살면 안 된다. 그래서 하나의 타입이 둘을 겸할 수 없고, Array가 .span 프로퍼티로 자기 버퍼에 대한 창을 내주는 구조가 됐다.Q2. Span은 Copyable인데 MutableSpan은 왜 ~Copyable인가?
읽기는 여럿이 동시에 해도 되지만 쓰기는 하나만 해야 한다는, 29장 Q1의 배타적 접근 규칙을 타입 성질로 옮긴 것이다. Span은 읽기 전용 창이므로 복사해서 여러 곳에 넘겨도 안전하다 — 그래서 Copyable이다. MutableSpan은 쓰기 창이므로 복사되면 같은 메모리에 두 개의 쓰기 권한이 생겨 배타성이 깨진다 — 그래서 ~Copyable로 복사 자체를 금지한다. 실측 선언으로 확인했고, 이 차이 때문에 MutableSpan은 사용법이 눈에 띄게 까다롭다.
원리
@available(macOS 10.14.4, iOS 12.2, ...)
@frozen @safe public struct Span<Element>
: ~Swift.Escapable, Swift.Copyable, Swift.BitwiseCopyable
where Element : ~Copyable {
@usableFromInline internal let _pointer: Swift.UnsafeRawPointer?
@usableFromInline internal let _count: Swift.Int
}
@available(macOS 10.14.4, iOS 12.2, ...)
@safe @frozen public struct MutableSpan<Element>
: ~Swift.Copyable, ~Swift.Escapable
where Element : ~Copyable {
@usableFromInline internal let _pointer: Swift.UnsafeMutableRawPointer?
...
}차이가 정확히 두 군데다. 준수 목록이 Copyable, BitwiseCopyable ↔ ~Copyable로 뒤집히고, 내부 포인터가 UnsafeRawPointer ↔ UnsafeMutableRawPointer로 바뀐다. 가변성이 곧 복사 금지라는 설계가 선언 한 줄에 담겨 있다.
내부 동작
왜 복사를 막아야 하는지는 반대로 생각하면 분명하다. MutableSpan이 복사 가능하다면 이런 코드가 합법이 된다 — let a = m; let b = m. 그러면 a[0] = 1과 b[0] = 2가 같은 주소에 겹치는 쓰기를 하게 되고, 이는 29장에서 컴파일러가 overlapping accesses로 막던 바로 그 상황이다. ~Copyable은 그 상황을 애초에 표현할 수 없게 만든다.
| 성질 | Span | MutableSpan | 이유 |
|---|---|---|---|
| 복사 | 가능 (Copyable) | 불가 (~Copyable) | 쓰기 권한이 둘이 되면 배타성 위반 |
| 탈출 | 불가 (~Escapable) | 불가 (~Escapable) | 둘 다 빌린 메모리를 본다 |
| 내부 포인터 | UnsafeRawPointer | UnsafeMutableRawPointer | 쓰기 여부 |
| 크기 (실측) | 16 | 16 | 둘 다 포인터 + 개수 |
| 가용성 | iOS 12.2 | iOS 12.2 | 둘 다 back-deploy된다 |
실험 · 도구
~Copyable + ~Escapable의 조합은 사용법을 제약한다. 실제로 세 가지 자연스러워 보이는 형태가 모두 거부된다.
var v: InlineArray<4, Int> = [10, 20, 30, 40]
// ① 변수에 담기 — 탈출
var m = v.mutableSpan
// error: lifetime-dependent value escapes its scope
// ② inout 인자로 넘기기 — mutableSpan은 get-only 프로퍼티
func double(_ m: inout MutableSpan<Int>) { /* ... */ }
double(&v.mutableSpan)
// error: cannot pass immutable value as inout argument:
// 'mutableSpan' is a get-only property
// ③ 첨자에 직접 대입
v.mutableSpan[0] = 99
// error: cannot assign through subscript:
// 'mutableSpan' is a get-only property통하는 형태는 consuming 파라미터로 받고, 호출 지점에서 .mutableSpan을 직접 넘기는 것이다. consuming은 "이 값의 소유권을 넘겨받는다"는 뜻(Part A 13장)이므로, 복사 없이 정확히 하나의 쓰기 권한이 함수로 이동한다.
func double(_ m: consuming MutableSpan<Int>) {
for i in 0..<m.count { m[i] *= 2 }
}
var v: InlineArray<4, Int> = [10, 20, 30, 40]
double(v.mutableSpan) // 직접 넘긴다
print("수정 후 v =", v[0], v[1], v[2], v[3])
var a2 = [1, 2, 3]
double(a2.mutableSpan) // Array에도 똑같이 통한다
print("수정 후 Array =", a2)수정 후 v = 20 40 60 80
수정 후 Array = [2, 4, 6]결과가 중요하다. double은 복사도 반환도 없이 원본 InlineArray와 Array를 제자리에서 고쳤다. inout [Int]로 받았다면 CoW 유일성 검사(Part A 01장)를 거쳐야 했을 자리다.
프로젝트 적용
① 쓸 자리는 버퍼를 제자리에서 변환하는 함수다. 이미지 픽셀 조작, 오디오 샘플 처리, 바이트 단위 인코딩 변환처럼 "같은 크기 버퍼를 훑으며 고치는" 작업이 정확히 여기에 해당한다.
func applyGain(_ samples: consuming MutableSpan<Float>, gain: Float) {
for i in 0..<samples.count { samples[i] *= gain }
}
var buffer = [Float](repeating: 0.5, count: 1024)
applyGain(buffer.mutableSpan, gain: 0.8) // 복사 없음, 반환 없음② 읽기만 하면 반드시 Span을 쓴다. MutableSpan은 제약이 훨씬 크므로, 필요하지 않은데 쓰면 호출부가 불편해진다. 이는 let/var 선택과 같은 종류의 규율이다.
// ✅ 읽기만 하므로 Span — 호출자가 편하다
func maxValue(_ s: Span<Float>) -> Float {
var m = -Float.infinity
for i in 0..<s.count { m = max(m, s[i]) }
return m
}
// ✅ 정말 고쳐야 할 때만 MutableSpan
func clampInPlace(_ s: consuming MutableSpan<Float>, to limit: Float) {
for i in 0..<s.count { s[i] = min(s[i], limit) }
}③ 마이그레이션 순서는 Span 먼저, MutableSpan 나중이다. 29장 Q5에서 unsafe 개수로 위험 표면을 셌다면, 그중 읽기 전용 지점부터 Span으로 바꾸는 것이 가장 비용이 낮고 효과가 크다.
~Copyable이 배타성을 대체하는 방식 설계 대조29장과 이 챕터를 겹쳐 보면 Swift가 같은 문제를 두 세대에 걸쳐 다르게 푼 것이 보인다. 예전 방식은 "복사는 자유롭게 허용하고, 접근이 겹치는지를 검사한다"였다 — 정적으로 못 잡으면 런타임 swift_beginAccess로 넘어가고, 실패는 exit 134다. 새 방식은 "복사 자체를 타입 수준에서 금지해, 겹칠 수 있는 상황을 표현 불가능하게 만든다"다 — 검사할 것이 없으므로 런타임 비용도 0이고 실패는 컴파일 에러다. 검사를 잘하는 대신, 검사가 필요 없는 형태로 문제를 옮긴 것이 ~Copyable·~Escapable의 본질이다.
"MutableSpan은 inout과 같은 것"이라는 말은 부정확하다. inout [Int]는 Array 값 전체를 빌려 크기 변경(append)까지 허용하지만, MutableSpan은 이미 있는 원소들의 내용만 고칠 수 있고 개수는 바꿀 수 없다. 또 "~Copyable이니 성능이 좋다"는 인과가 뒤집힌 설명이다 — ~Copyable은 안전성 장치이고, 성능 이득은 복사·ARC가 없다는 별개의 사실에서 온다. 마지막으로 "consuming으로 넘기면 원본이 사라진다"도 틀렸다 — 소비되는 것은 MutableSpan이라는 창이고, 창이 가리키던 Array는 그대로 남아 수정된 채 계속 쓸 수 있다. 위 실측이 그것을 보여준다.
도서관 열람실 비유를 이어가면, Span은 "읽기만 하는 열람증"이라 여러 장 복사해서 친구들과 나눠 가져도 아무 문제가 없다. 다들 같은 책을 보기만 하니까. MutableSpan은 "이 책에 연필로 고쳐 쓸 수 있는 편집 권한증"이다. 이건 복사하면 안 된다 — 두 사람이 동시에 같은 문장을 다르게 고치면 책이 엉망이 되니까. 그래서 도서관은 이 증서를 딱 한 장만 만들고, 넘길 때도 복사가 아니라 손에서 손으로 건넨다(consuming). 비유가 깨지는 곳: 사람은 증서를 몰래 복사할 수 있지만, Swift에서는 복사하는 코드를 아예 쓸 수 없다 — 컴파일이 거부한다.
꼬리 질문
consuming으로 창을 넘겼는데 원본 배열을 그 뒤에도 쓸 수 있는 이유는?
consuming MutableSpan이 소비하는 것은 "창"이라는 16바이트짜리 값이고, 그 창이 가리키던 힙 버퍼(또는 인라인 저장)는 여전히 원본 Array/InlineArray가 소유한다. 함수가 끝나면 창은 사라지지만 버퍼는 남고, 버퍼에 가한 변경은 원본에 그대로 보인다. 실측에서 double(a2.mutableSpan) 뒤에 a2가 [2, 4, 6]으로 출력된 것이 이 구조의 직접 증거다.MutableSpan으로 append는 왜 안 되는가?
MutableSpan은 "이미 초기화된 원소 _count개에 대한 쓰기 창"이라는 계약을 갖는다. 개수를 늘리려면 버퍼를 더 크게 재할당해야 할 수 있고(Part A 10장의 amortized 성장·reserveCapacity), 그것은 메모리 소유자만 할 수 있는 일이다. 창은 소유자가 아니므로 그 권한이 없다. 크기가 변하는 작업이 필요하면 소유 타입(Array)을 inout으로 받아야 한다. 참고로 "아직 초기화되지 않은 공간을 채우는" 별도 역할은 OutputSpan이 담당한다 — Q6에서 다룬다.Span이 BitwiseCopyable인 것은 실무에서 무슨 차이를 만드는가?
BitwiseCopyable은 "이 값은 비트를 그대로 복사하는 것만으로 완전히 복사된다 — 참조 카운트 조작이나 특별한 복사 로직이 필요 없다"는 표시다. 실무적 함의는 전달 비용이 정수 두 개를 넘기는 것과 같다는 것이다. Array를 넘기면 상황에 따라 retain/release가 붙을 수 있는데, Span에는 그것이 원리적으로 없다. 그래서 루프 안에서 함수에 반복 전달하는 패턴에서 차이가 드러난다. MutableSpan은 ~Copyable이라 애초에 복사가 없으므로 이 표시가 필요하지 않다.Q3. InlineArray는 Array와 무엇이 다른가? — 값 제네릭과 인라인 저장
SE-0453의 InlineArray<let count: Int, Element>는 개수가 타입의 일부인 고정 크기 배열이다. C의 T[N], C++의 std::array<T, N>에 해당한다. 원소를 힙이 아니라 자기 자신 안에 담으므로 MemoryLayout 크기가 원소 수에 비례한다 — 실측으로 InlineArray<4, Int>는 32바이트, InlineArray<64, Int>는 512바이트다([Int]은 언제나 8). 그래서 힙 할당·ARC·CoW가 전부 없다. 대가는 두 가지 — 개수를 컴파일 타임에 알아야 하고, ~Copyable 원소를 지원하느라 Collection을 채택하지 않는다.
원리
선언의 let count: Swift.Int가 이 타입의 새로움이다. 지금까지 Swift 제네릭 파라미터는 타입만 받을 수 있었는데, 여기서는 값을 받는다 — 값 제네릭(value generics)이다.
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
@frozen @safe @_addressableForDependencies
public struct InlineArray<let count : Swift.Int, Element>
: ~Swift.Copyable where Element : ~Copyable {
@usableFromInline internal let _storage: Builtin.FixedArray<count, Element>
}저장 프로퍼티가 Builtin.FixedArray<count, Element> 하나다. 컴파일러 내장 타입이며, "원소 count개를 연속으로 그 자리에 둔다"는 의미다. 포인터가 없다는 것이 곧 힙이 없다는 뜻이다.
: ~Swift.Copyable도 눈여겨볼 대목이다. 이것은 "InlineArray가 복사 불가"라는 뜻이 아니다. Element : ~Copyable과 짝을 이뤄 "원소가 복사 가능하면 이 타입도 복사 가능하고, 원소가 복사 불가면 이 타입도 복사 불가"라는 조건부 성질을 표현한다. 실제로 Int를 담은 InlineArray는 자유롭게 복사된다.
내부 동작
크기가 원소 수에 비례한다는 것을 실측으로 확인하면 구조가 곧바로 드러난다.
print("InlineArray<4,Int> size =", MemoryLayout<InlineArray<4, Int>>.size)
print("InlineArray<64,Int> size =", MemoryLayout<InlineArray<64, Int>>.size)
print("InlineArray<4,UInt8> size =", MemoryLayout<InlineArray<4, UInt8>>.size)
print("[Int] size =", MemoryLayout<[Int]>.size)InlineArray<4,Int> size = 32
InlineArray<64,Int> size = 512
InlineArray<4,UInt8> size = 4
[Int] size = 8세 줄이 각각 다른 것을 증명한다. 32 = 4 × 8이므로 원소가 그 자리에 있다. 512 = 64 × 8이므로 그 관계가 선형으로 유지된다 — 어딘가에 포인터로 빠지지 않는다. 4 = 4 × 1이므로 원소 사이에 패딩도 없다(Part A 01장의 alignment 규칙에 따라 UInt8의 정렬이 1이기 때문이다). 그리고 [Int]는 원소가 몇 개든 8이다.
Array는 버퍼를 가리키고, InlineArray는 버퍼가 된다. 힙 헤더가 없으므로 참조 카운트도 CoW도 존재하지 않는다.실험 · 도구
초기화와 순회 방식이 Array와 미묘하게 다르다. 배열 리터럴은 그대로 쓸 수 있지만, 개수가 타입과 맞아야 한다.
var v: InlineArray<4, Int> = [10, 20, 30, 40] // 리터럴 — 개수가 4와 일치해야 한다
let w = InlineArray<3, Int>(repeating: 7) // 반복 초기화
print("v =", v[0], v[1], v[2], v[3], "| w[1] =", w[1], "| w.count =", w.count)
var s = 0
for i in v.indices { s += v[i] } // indices로 순회한다
print("indices 순회 합 =", s)
for i in v.indices { v[i] *= 2 } // 제자리 수정
print("2배 후 =", v[0], v[1], v[2], v[3])v = 10 20 30 40 | w[1] = 7 | w.count = 3
indices 순회 합 = 100
2배 후 = 20 40 60 80타입 이름을 더 짧게 쓰는 슈가 문법도 함께 들어왔다(SE-0483). InlineArray<4, Int>를 [4 of Int]로 쓸 수 있고, 실측으로 완전히 같은 타입이다.
var a: [4 of Int] = [1, 2, 3, 4]
print("[4 of Int] size =", MemoryLayout<[4 of Int]>.size, "a[2] =", a[2])
print("같은 타입인가:", [4 of Int].self == InlineArray<4, Int>.self)[4 of Int] size = 32 a[2] = 3
같은 타입인가: true제안 심사 과정에서 [5 x Int] 같은 대안도 논의됐지만 최종 문법은 of다. 배열 리터럴과 시각적으로 가까워 읽기 편하지만, [Int](동적 배열)과 [4 of Int](고정 크기)가 한 글자 차이로 보이는 점은 코드 리뷰에서 주의할 대목이다 — 성능 특성이 완전히 다르다.
for x in v가 아니라 for i in v.indices를 쓴 것에 주의한다. InlineArray는 Sequence/Collection을 채택하지 않는다. 이유는 그 프로토콜들의 요구사항이 원소를 복사해 꺼내는 것을 전제하기 때문이다 — ~Copyable 원소를 담을 수 있게 만들려면 그 전제를 버려야 했다. 대신 count·indices·첨자·span이 제공된다.
그 포기가 무엇을 얻어낸 것인지는 대조 실험으로 바로 보인다. ~Copyable 원소를 두 컨테이너에 각각 담아 본다.
struct Token: ~Copyable {
let id: Int
deinit { }
}
// ✅ InlineArray — 컴파일·실행된다
var box: InlineArray<3, Token> = [Token(id: 1), Token(id: 2), Token(id: 3)]
print("count:", box.count, "| box[1].id =", box[1].id)
print("size:", MemoryLayout<InlineArray<3, Token>>.size)
// ❌ Array — 컴파일되지 않는다
// var a: [Token] = [Token(id: 1)]count: 3 | box[1].id = 2
size: 24
$ swiftc -c array_version.swift
error: type 'Token' does not conform to protocol 'Copyable'이 대비가 설계 의도를 그대로 보여준다. Array는 Element: Copyable을 전제하므로 Token을 담을 수조차 없다. InlineArray가 Collection을 포기하고 얻은 것이 바로 이것이다 — Collection의 요구사항이 원소를 값으로 꺼내는 것(복사)을 전제하기 때문에, 둘을 동시에 만족시킬 수 없었다. 아래 꼬리 질문에서 그 요구사항을 구체적으로 짚는다.
대신 map/filter/reduce 같은 Sequence 메서드는 직접 쓸 수 없다. 필요하면 .span을 거치거나 인덱스 루프를 쓴다.
func sum(_ sp: Span<Int>) -> Int {
var t = 0
for i in 0..<sp.count { t += sp[i] }
return t
}
var v: InlineArray<4, Int> = [10, 20, 30, 40]
print("InlineArray.span 합 =", sum(v.span)) // 100프로젝트 적용
① 쓸 자리는 개수가 진짜로 고정된 작은 데이터다. 3D 벡터·행렬, 색상 채널, 고정 길이 헤더, 링 버퍼의 슬롯처럼 "이 크기는 절대 변하지 않는다"가 도메인 사실인 경우다.
struct RGBA {
var channels: InlineArray<4, UInt8> // 딱 4개 — 힙 할당 0
init(r: UInt8, g: UInt8, b: UInt8, a: UInt8) { channels = [r, g, b, a] }
}
// MemoryLayout<RGBA>.size == 4 — struct 안에 그대로 들어간다② 쓰지 말아야 할 자리가 더 중요하다. 개수가 클 때 값으로 전달하면 그 크기만큼 복사가 일어난다. InlineArray<64, Int>를 함수 인자로 넘기면 512바이트가 복사될 수 있다 — Array라면 포인터 8바이트만 넘어갔을 자리다. 큰 것을 다룰 때는 Span/MutableSpan으로 넘긴다.
var big: InlineArray<64, Int> = ... // 512바이트
// ❌ 값으로 받으면 512바이트 복사 가능성
func processByValue(_ v: InlineArray<64, Int>) -> Int { ... }
// ✅ 창으로 받으면 16바이트만 넘어간다
func processBySpan(_ s: Span<Int>) -> Int { ... }
_ = processBySpan(big.span)③ 선택 기준을 정리하면 이렇다. 대부분의 코드는 여전히 Array가 맞다 — InlineArray는 프로파일링이 힙 할당을 지목한 좁은 자리를 위한 도구다.
| 기준 | Array | InlineArray |
|---|---|---|
| 개수를 아는 시점 | 런타임 — 자유롭게 변경 | 컴파일 타임 — 타입에 박힌다 |
| 저장 위치 | 힙 버퍼 | 그 자리(스택·struct 내부) |
| ARC · CoW | 있음 | 없음 |
Collection 준수 | 채택 | 미채택 — map/filter 직접 사용 불가 |
| 큰 크기 전달 | 포인터 8바이트 | 전체 복사 가능 — Span으로 넘길 것 |
| 배포 타깃 | 제약 없음 | iOS 26 / macOS 26 이상 (Q4) |
"InlineArray는 항상 스택에 있다"는 정확하지 않다 — 자기를 담은 것이 있는 곳에 있다. 지역 변수면 스택이고, 클래스 프로퍼티면 그 인스턴스가 있는 힙이고, Array<InlineArray<4,Int>>의 원소면 그 배열의 힙 버퍼 안이다. "힙 할당을 하지 않는다"가 정확한 표현이다. 또 "~Copyable이라 복사할 수 없다"도 오해다 — 원소가 복사 가능하면 InlineArray도 복사된다. 마지막으로 "Array보다 항상 빠르다"는 틀렸다. 크기가 커지면 값 복사 비용이 Array의 포인터 복사보다 훨씬 비싸진다.
Array는 사물함 열쇠다. 주머니에 열쇠 하나만 넣고 다니면 되니 가볍고(8바이트), 사물함 안에는 물건을 얼마든지 넣고 뺄 수 있다. 대신 물건을 볼 때마다 사물함까지 가야 한다. InlineArray는 물건을 그냥 주머니에 넣는 것이다. 사물함이 필요 없어 빠르지만, 주머니가 물건만큼 불룩해지고(4개면 32바이트, 64개면 512바이트) 주머니 크기를 옷 만들 때 미리 정해야 한다. 비유가 깨지는 곳: 주머니는 늘릴 수 있지만 InlineArray의 개수는 타입의 일부라서 나중에 절대 바꿀 수 없다 — 4개짜리와 5개짜리는 아예 다른 타입이다.
꼬리 질문
InlineArray<4, Int>와 InlineArray<5, Int>는 같은 타입인가?
Array<Int>와 Array<String>이 다른 만큼 다르다. 실무적 함의가 크다 — 하나의 함수가 여러 크기를 받으려면 개수를 제네릭으로 열어두거나(func f<let n: Int>(_ v: InlineArray<n, Int>)), 아니면 개수를 지운 표현인 Span으로 받아야 한다. 후자가 훨씬 간단하고, 이것이 "파라미터 타입은 Span으로"라는 규칙이 힘을 얻는 또 하나의 이유다.Collection을 채택하지 않는 것이 왜 ~Copyable 지원 때문인가?
Collection의 요구사항은 원소를 값으로 꺼내는 것을 전제한다. subscript가 Element를 반환하고, Iterator.next()가 Element?를 반환한다 — 둘 다 "원소의 복사본을 만들어 돌려준다"는 의미다. 원소가 ~Copyable이면 복사본을 만들 수 없으므로 이 요구사항을 만족할 방법이 없다. Array는 Element: Copyable을 전제하므로 문제가 없지만, InlineArray는 처음부터 ~Copyable 원소를 담는 것을 목표로 설계됐기 때문에 Collection을 포기한 것이다. 대신 indices와 첨자로 빌려 보는 접근을 제공한다. 참고로 이 제약을 언젠가 풀기 위한 논의가 진행 중이며, Iterable 같은 후속 프로토콜 제안이 나와 있다.InlineArray<4, UInt8>가 4바이트인데, 패딩이 없는 이유는?
UInt8의 정렬은 1이라 어떤 주소에 놓아도 정렬을 만족한다. 따라서 4개를 붙여 놓으면 정확히 4바이트다. 반면 정렬이 8인 Int는 각 원소가 8의 배수 주소에 있어야 하므로 4개면 32바이트가 된다(이 경우도 원소 크기가 정렬과 같아 패딩은 없다). 정확히 말하면 총 크기는 원소 수 × stride다 — size가 아니다. 실측으로 확인했다.
UInt8 align = 1 Int align = 8
InlineArray<4,UInt8> : size 4 align 1 // 4 x 1
InlineArray<4,Int> : size 32 align 8 // 4 x 8
struct Mixed { var a: UInt8; var b: Int } // size 16, stride 16, align 8
InlineArray<3,Mixed> : size 48 // 3 x stride(16) = 48MemoryLayout의 size/stride 구분(Part A 01장 Q3)이 여기서 그대로 쓰인다. stride가 size보다 큰 타입을 담으면 그 차이가 원소 수만큼 곱해져 총 크기에 반영된다.
Q4. Span은 iOS 12.2까지 되는데 InlineArray는 왜 iOS 26이 필요한가?
이 챕터에서 실무에 가장 직접적인 사실이며 대부분의 소개 글이 빠뜨리는 대목이다. 두 타입의 @available이 다르다 — Span·MutableSpan은 macOS 10.14.4 / iOS 12.2, InlineArray는 macOS 26.0 / iOS 26.0이다. 이유는 Span이 @frozen + @_alwaysEmitIntoClient로 앱 바이너리에 코드가 복사되어 back-deploy되는 반면, InlineArray는 Builtin.FixedArray라는 런타임의 메타데이터 지원을 필요로 하기 때문이다. 배포 타깃을 낮춰 실제로 컴파일하면 Span은 통과하고 InlineArray만 에러가 난다.
원리
Swift에서 새 표준 라이브러리 API가 낡은 OS에서 동작하는 방식은 두 가지다. ① 코드를 앱에 심는다(@_alwaysEmitIntoClient) — 함수 본문이 호출자 쪽으로 복사되므로 OS의 dylib에 그 심볼이 없어도 된다. ② OS의 표준 라이브러리에 의존한다 — 그러면 그 OS 버전 이상에서만 쓸 수 있다.
Span은 ①의 길을 택했다. Q1에서 인용한 선언을 보면 내부 함수에 @_alwaysEmitIntoClient가 붙어 있고, 구조체는 @frozen이라 레이아웃이 고정되어 앱이 직접 그 16바이트를 만들어 쓸 수 있다. InlineArray는 ①로 가지 않았다 — 그 @available이 macOS/iOS 26으로 잡혀 있다는 것이 실측이다.
왜 InlineArray가 back-deploy되지 않는지는 애플이 문서로 설명한 바가 없고, 이 강의도 확인하지 못했다. 선언에서 관찰되는 사실은 저장 프로퍼티가 Builtin.FixedArray<count, Element>라는 컴파일러 내장 타입이고 @_addressableForDependencies가 붙어 있으며, 값 제네릭(let count: Int)으로 매개화돼 있다는 것이다. 값으로 매개화된 타입의 메타데이터를 런타임이 다뤄야 한다면 앱 바이너리에 복사해 넣는 방식으로는 해결되지 않는다 — 이것이 필자의 추정이며, 근거는 선언의 모양뿐이다. 실무 판단에 필요한 것은 이유가 아니라 가용성 숫자이고, 그것은 아래에서 컴파일로 확정한다.
내부 동작
말로 하는 대신 컴파일러에게 직접 물어보면 된다. 배포 타깃을 낮춰 두 타입을 함께 쓰는 코드를 컴파일한다.
func useSpan(_ s: Span<Int>) -> Int { s.count }
func useInline() -> Int {
let v: InlineArray<4, Int> = [1, 2, 3, 4]
return v.count
}$ swiftc -target arm64-apple-macos15.0 -c avail.swift
avail.swift:3:12: error: 'InlineArray' is only available in macOS 26.0 or newer
note: add '@available' attribute to enclosing global function
note: add 'if #available' version check
avail.swift:4:14: error: 'count' is only available in macOS 26.0 or newer
note: add '@available' attribute to enclosing global function
note: add 'if #available' version check$ swiftc -target arm64-apple-macos26.0 -c avail.swift
(에러 없음)useSpan에 대한 에러가 한 건도 없다는 것이 결론이다. Span은 macOS 15 타깃에서 그대로 쓸 수 있다. 이 실험은 두 타입이 "Swift 6.2에 함께 들어온 형제"처럼 소개되지만 실무 도입 가능 시점이 완전히 다르다는 것을 보여준다.
실험 · 도구
같은 확인을 iOS 타깃으로도 할 수 있고, 프로젝트에서는 배포 타깃이 곧 답이다.
# 1) 배포 타깃 확인 (Xcode 프로젝트)
grep -r "IPHONEOS_DEPLOYMENT_TARGET" *.xcodeproj/project.pbxproj | sort -u
# 2) SPM 패키지라면
grep -A3 "platforms:" Package.swift
# 3) 직접 컴파일해 물어본다 — 가장 확실하다
swiftc -target arm64-apple-ios18.0-simulator -c probe.swiftiOS 26 미만을 지원해야 하는데 InlineArray가 필요하면 if #available로 갈라야 하지만, 타입 자체가 가용성에 걸리면 분기의 이득이 거의 없다 — 두 경로의 자료구조가 달라져 코드가 두 벌이 된다. 실무에서는 대개 Array + reserveCapacity로 통일하고, Span만 먼저 도입하는 편이 낫다.
프로젝트 적용
① 도입 순서가 명확해진다. Span은 지금 바로, InlineArray는 배포 타깃이 올라간 뒤.
| 타입 | 필요 배포 타깃 | 필요 툴체인 | 지금 도입 가능? |
|---|---|---|---|
Span · MutableSpan | iOS 12.2 / macOS 10.14.4 | Swift 6.2+ | 대부분의 프로젝트에서 가능 |
RawSpan · MutableRawSpan | iOS 12.2 / macOS 10.14.4 | Swift 6.2+ | 가능 |
InlineArray | iOS 26 / macOS 26 | Swift 6.2+ | 배포 타깃이 26 이상일 때만 |
-strict-memory-safety (29장) | 무관 — 컴파일 타임 | Swift 6.2+ | 가능 |
② SPM 라이브러리를 만든다면 이 차이가 API 결정으로 직결된다. 공개 API에 InlineArray를 쓰면 그 라이브러리를 쓰는 모든 앱이 iOS 26 이상이어야 한다. Span은 그런 제약을 만들지 않으므로, 공개 표면은 Span으로 두고 InlineArray는 내부 구현에 가용성 분기와 함께 쓰는 편이 안전하다.
// ✅ 공개 표면 — 배포 타깃 제약을 만들지 않는다
public func checksum(_ bytes: Span<UInt8>) -> UInt8 {
var x: UInt8 = 0
for i in 0..<bytes.count { x ^= bytes[i] }
return x
}
// 내부 최적화 경로만 조건부로
@available(iOS 26, macOS 26, *)
func fastPath() -> UInt8 {
var scratch: InlineArray<16, UInt8> = .init(repeating: 0)
// ... scratch 사용
return checksum(scratch.span)
}③ 툴체인과 배포 타깃을 혼동하지 않는 것이 이 절의 실무 핵심이다. "Swift 6.2를 쓴다"는 것은 컴파일러 버전이고, "iOS 26을 지원한다"는 것은 실행 환경이다. 새 API를 검토할 때 @available 줄을 먼저 보는 습관이 며칠짜리 삽질을 막아준다.
@_alwaysEmitIntoClient가 back-deploy를 만드는 원리 ABIPart A 13장에서 @inlinable과 라이브러리 진화(resilience)를 다뤘다. @_alwaysEmitIntoClient는 그 계열의 더 강한 형태로, "이 함수의 본문은 표준 라이브러리 dylib에 심볼로 남기지 않고 반드시 호출자 쪽에 방출한다"를 뜻한다. 결과적으로 실행 시점에 OS의 libswiftCore.dylib를 찾아갈 일이 없으므로 낡은 OS에서도 동작한다. 대가는 영구적인 ABI 고정이다 — 코드가 이미 수많은 앱 바이너리에 복사되어 배포됐으므로, 애플은 그 구현을 나중에 고쳐도 기존 앱의 동작을 바꿀 수 없다. Span이 @frozen인 것도 같은 맥락이다. back-deploy는 공짜가 아니라 "미래에 바꿀 권리"를 지불하고 사는 것이며, 그래서 애플이 모든 신규 API에 이 방식을 쓰지는 않는다.
"Swift 6.2로 컴파일하면 6.2의 모든 새 API를 쓸 수 있다"는 가장 흔하고 비용이 큰 오해다. 컴파일러 버전은 문법과 언어 기능을 결정하고, 표준 라이브러리 API의 사용 가능 여부는 배포 타깃이 결정한다. 또 "InlineArray가 iOS 26이니 Span도 그럴 것"이라는 유추도 틀렸다 — 실측이 정확히 그 반대를 보여준다. 반대로 "@_alwaysEmitIntoClient가 붙으면 무조건 back-deploy된다"도 과잉 일반화다. 그 함수가 런타임 지원이 필요한 다른 것에 의존하면 여전히 가용성 제약이 붙는다.
새 게임을 친구 집 오래된 게임기에서 돌리는 두 가지 방법이다. Span 방식은 게임 카트리지 안에 필요한 부품까지 다 넣어서 가져가는 것이다 — 게임기가 낡았어도 카트리지가 알아서 처리하니 잘 돌아간다. 대신 카트리지를 한번 찍어 배포하면 그 안의 부품을 나중에 바꿀 수 없다. InlineArray 방식은 게임기 본체에 새 칩이 꽂혀 있어야 하는 것이다 — 카트리지에 넣을 수 있는 종류의 것이 아니라서, 게임기를 새것(iOS 26)으로 바꿀 수밖에 없다. 비유가 깨지는 곳: 어느 방식일지는 게임 만든 사람이 고르는 게 아니라 그 기능이 무엇을 필요로 하는지에 따라 결정된다.
꼬리 질문
배포 타깃이 iOS 18인 프로젝트에서 InlineArray를 if #available로 감싸면 쓸 수 있는가?
if #available(iOS 26, *) 블록 안에서만 그 타입을 쓸 수 있으므로, 저장 프로퍼티 타입이나 함수 시그니처에는 쓸 수 없고 지역 변수 수준에만 갇힌다. 결과적으로 같은 로직을 InlineArray 버전과 Array 버전으로 두 벌 유지해야 하는데, 얻는 이득(힙 할당 1회 절약)에 비해 유지보수 비용과 버그 위험이 훨씬 크다. 실무적으로는 Array로 통일하고, 힙 할당이 실제 병목으로 측정된 자리에만 29장의 withUnsafeTemporaryAllocation을 쓰는 편이 낫다.Span의 @available이 iOS 12.2라는 구체적인 숫자는 어디서 왔는가?
@_alwaysEmitIntoClient 방식으로 back-deploy하는 신규 API에는 이 버전이 하한선으로 자주 등장한다 — MutableSpan·RawSpan도 실측에서 동일하게 macOS 10.14.4 / iOS 12.2였다. Part B 14장에서 본 Foundation의 계층 구조와 마찬가지로, 이런 숫자는 임의가 아니라 플랫폼 역사의 흔적이다.Span을 쓰면 Swift 6.2 이전 툴체인으로도 컴파일되는가?
Span은 Swift 6.2에서 표준 라이브러리에 추가됐으므로 6.1 이하 컴파일러는 그 타입 이름 자체를 모른다. 게다가 ~Escapable과 수명 의존성 검사는 컴파일러 기능이라 옛 컴파일러에는 존재하지 않는다. 정리하면 Span의 조건은 "Swift 6.2 이상 툴체인 + iOS 12.2 이상 배포 타깃"이고, InlineArray는 "Swift 6.2 이상 툴체인 + iOS 26 이상 배포 타깃"이다. CI에서 옛 Xcode를 함께 돌린다면 툴체인 쪽이 먼저 걸린다.Q5. 사용자 정의 ~Escapable 타입을 만들 수 있는가? — 수명 의존성의 현재 상태
Swift 6.2.1 정식 기능으로는 사실상 불가능하다. ~Escapable struct를 선언하는 것 자체는 되지만, 그 안에 Span 같은 ~Escapable 값을 저장하려 하면 the 'get' accessor cannot return a ~Escapable result 및 an initializer cannot return a ~Escapable result 에러가 난다. 해결에 필요한 수명 의존성 애노테이션이 아직 실험 기능이기 때문이다 — 정식 문법으로 보이는 @lifetime은 거부되고, @_lifetime(언더바)에 -enable-experimental-feature Lifetimes를 함께 줘야 컴파일된다. 즉 지금은 표준 라이브러리가 제공하는 Span 계열을 소비하는 쪽에 머무는 것이 맞고, 내 타입으로 새 창을 만드는 일은 아직 이르다. 이 대목은 블로그들이 가장 자주 틀리는 곳이라 실측을 그대로 싣는다.
원리
~Escapable 타입이 다른 ~Escapable 값을 품으면, 컴파일러는 "이 래퍼의 수명이 무엇에 묶여야 하는가"를 알아야 한다. Span이 arr에 의존했듯, 래퍼는 그 Span에 의존해야 한다. 그런데 그 의존 관계를 표현할 문법이 없으면 컴파일러는 초기화자와 게터의 반환값에 어떤 수명을 부여할지 결정할 수 없고, 그래서 거부한다. 에러 메시지가 정확히 그 지점을 가리킨다 — 문제는 "저장"이 아니라 "반환"이다.
내부 동작
세 단계로 실측했다. 먼저 애노테이션 없이 시도한다.
struct Wrapper: ~Escapable {
let s: Span<Int>
init(_ s: Span<Int>) { self.s = s }
}error: the 'get' accessor cannot return a ~Escapable result
| let s: Span<Int>
error: an initializer cannot return a ~Escapable result
| init(_ s: Span<Int>) { self.s = s }다음으로 정식 문법처럼 보이는 @lifetime을 붙인다. 여러 문서와 발표에 이 표기가 등장하므로 자연스러운 시도다.
struct Cursor: ~Escapable {
private let base: Span<Int>
@lifetime(copy base)
init(_ base: Span<Int>) { self.base = base }
var first: Int { base[0] }
}warning: Unsupported use of @lifetime, use @_lifetime to specify
lifetime dependencies
error: '@lifetime' attribute is only valid when experimental feature
LifetimeDependence is enabled
error: the 'get' accessor cannot return a ~Escapable result
error: an initializer cannot return a ~Escapable result진단이 @_lifetime을 쓰라고 안내한다. 그런데 그 안내를 따라 -enable-experimental-feature LifetimeDependence를 주면 또 다른 이름을 요구한다.
error: '@_lifetime' attribute is only valid when experimental feature
Lifetimes is enabled최종적으로 통하는 조합은 @_lifetime + -enable-experimental-feature Lifetimes다.
struct Cursor: ~Escapable {
private let base: Span<Int>
@_lifetime(copy base)
init(_ base: Span<Int>) { self.base = base }
var first: Int { base[0] }
}
func use(_ s: Span<Int>) -> Int { Cursor(s).first }
let arr = [10, 20, 30]
print("Cursor.first =", use(arr.span))Cursor.first = 10동작은 한다. 그러나 이것은 실험 기능이고, 위 실측이 보여준 것처럼 애트리뷰트 이름(@lifetime → @_lifetime)과 기능 플래그 이름(LifetimeDependence → Lifetimes)이 이미 한 번 이상 바뀌었다. 프로덕션 코드에 넣을 근거가 되지 못한다.
실험 · 도구
정리하면 네 조합의 결과가 이렇다.
| 시도 | 플래그 | 결과 |
|---|---|---|
| 애노테이션 없음 | 없음 | 에러 2건 — get/init이 ~Escapable 반환 불가 |
@lifetime(copy base) | 없음 | 에러 — @_lifetime을 쓰라는 경고 포함 |
@_lifetime(copy base) | LifetimeDependence | 에러 — Lifetimes를 켜라 |
@_lifetime(copy base) | Lifetimes | 컴파일·실행 성공 |
지금 이 기능의 상태를 확인하고 싶으면 컴파일러에게 직접 묻는 것이 가장 확실하다. 문서보다 빠르고 정확하다.
# 실험 플래그 없이 컴파일해 본다 — 통과하면 정식 기능이 된 것이다
swiftc probe.swift -o /dev/null
# 관련 실험 기능 목록을 훑는다
swiftc -Xfrontend -help-hidden 2>&1 | grep -i lifetime프로젝트 적용
① 지금 할 일은 "소비자로 머무는 것"이다. 표준 라이브러리가 주는 Span·MutableSpan을 파라미터로 받아 쓰는 것은 정식 기능이고 안정적이다. 반면 ~Escapable 타입을 직접 정의해 반환하는 설계는 미루는 것이 맞다.
| 패턴 | 지금 상태 | 권고 |
|---|---|---|
func f(_ s: Span<Int>) — 파라미터로 받기 | 정식 | 지금 도입 — 이득이 크다 |
x.span / x.mutableSpan 사용 | 정식 | 지금 도입 |
-> Span<Int> 반환 | 수명 애노테이션 필요 | 미룬다 — 파라미터로 우회 |
struct MyView: ~Escapable 정의 | 실험 기능 | 프로덕션 금지 |
② Span을 반환하고 싶은 욕구가 생기는 자리는 대개 파라미터로 뒤집으면 해결된다. "버퍼를 꺼내 주는 함수" 대신 "버퍼로 무엇을 할지 받는 함수"로 설계를 바꾸는 것이다 — 29장의 withUnsafe* 계열이 쓰는 것과 같은 형태다.
// ❌ 지금은 수명 애노테이션 없이 쓸 수 없다
// func slice(of a: [Int]) -> Span<Int> { a.span }
// ✅ 무엇을 할지 클로저로 받는다 — 정식 문법으로 충분하다
func withSlice<R>(of a: [Int], _ body: (Span<Int>) -> R) -> R {
body(a.span)
}
let total = withSlice(of: [1, 2, 3]) { s in
var t = 0
for i in 0..<s.count { t += s[i] }
return t
}
print(total) // 6③ 추적할 가치는 있다. 수명 의존성이 정식화되면 "빌려 보는 뷰"를 직접 만들 수 있게 되어, 지금 withUnsafe* + 클로저로 짜야 하는 API들이 훨씬 자연스러워진다. 이 방향의 후속 제안이 계속 나오고 있으므로(RawSpan의 안전한 로딩 API, 임시 할당의 Span 버전 등), 배포 타깃과 함께 한 번씩 확인해 볼 항목이다 — 33장에서 6.3 이후의 흐름을 정리한다.
이 기능은 진화 중이라 글이 쓰인 시점에 따라 문법이 다르다. 실측에서 확인한 것처럼 @lifetime, @_lifetime, 그리고 기능 플래그 LifetimeDependence/Lifetimes가 뒤섞여 돌아다닌다. 예제가 안 되면 내가 틀린 것이 아니라 그 글이 낡은 것일 수 있다. 확인 방법은 위의 "컴파일러에게 직접 묻는" 절차다.
"Swift 6.2에 ~Escapable이 들어왔으니 내 타입에도 쓸 수 있다"는 틀렸다 — 성질을 선언하는 것과 수명 관계를 표현하는 것은 다르고, 후자가 아직 실험 기능이다. 또 "@lifetime이 정식 문법"이라는 설명도 실측과 어긋난다. 반대로 "그래서 Span은 아직 쓸 수 없다"는 과도한 결론이다 — 소비하는 쪽은 완전히 정식이고, Q1~Q4에서 본 사용법 전부가 실험 플래그 없이 컴파일된다. 마지막으로 "실험 플래그를 켜면 되니 프로덕션에서 써도 된다"는 위험하다. 실험 기능은 마이너 릴리스에서 문법이 바뀌거나 제거될 수 있고, 실제로 이미 이름이 바뀌었다.
도서관이 새로 만든 "열람실 전용 대출증"(Span)은 완성돼서 누구나 받아 쓸 수 있다. 그런데 "내가 직접 새로운 종류의 대출증을 발급하는 일"은 아직 시범 운영 중이다 — 신청서 양식 이름이 지난달에 바뀌었고, 또 바뀔 수도 있다. 그래서 지금은 도서관이 주는 증서를 받아 쓰는 것까지가 안전하고, 내가 증서를 발급하는 쪽은 정식 규정이 나올 때까지 기다리는 게 맞다. 비유가 깨지는 곳: 시범 운영 양식으로 만든 증서도 지금은 실제로 작동한다는 점이다 — 그래서 "되는데 왜 쓰지 말라는 거지?" 하고 넘어가기 쉽고, 다음 업데이트에서 갑자기 안 되는 일이 생긴다.
꼬리 질문
에러가 "저장할 수 없다"가 아니라 "get 접근자가 ~Escapable을 반환할 수 없다"인 이유는?
let s: Span<Int>는 암묵적으로 게터를 만들고, 그 게터는 Span을 반환한다. 반환된 Span은 어딘가로 나가는데, 컴파일러는 그것이 무엇의 수명에 묶여야 하는지 — 래퍼 자신인지, 래퍼가 의존하는 원본인지 — 를 알아야 한다. 그 정보를 주는 문법이 @_lifetime(copy base)이고, 없으면 결정할 수 없어 거부한다. 초기화자 에러도 같은 구조다 — init은 Self를 "반환"하는데 Self가 ~Escapable이므로 그 수명을 어디에 묶을지 명시해야 한다.@_lifetime(copy base)의 copy는 무슨 뜻인가?
copy는 "base가 이미 갖고 있는 수명 의존성을 그대로 물려받는다"는 뜻이다 — base가 arr에 의존하고 있었다면 이 래퍼도 arr에 의존하게 된다. 즉 의존성을 새로 만드는 것이 아니라 전달(propagate)하는 것이다. 다른 종류로는 인자 자체를 빌리는 관계를 세우는 형태(borrow)가 있다. 이 구분이 필요한 이유는 Span의 Span처럼 창을 다시 감쌀 때, 최종 수명이 중간 창이 아니라 원본 저장소에 묶여야 하기 때문이다. 다만 이 문법의 세부는 실험 기능이라 바뀔 수 있으므로 이름과 의미를 외우기보다 개념만 잡아두는 것이 낫다.copy가 그 규칙이다.실험 기능을 켜지 않고도 Span을 필드에 담을 방법이 정말 없는가?
Escapable인 타입이 ~Escapable 값을 품으면 그 값을 탈출시킬 수 있게 되므로 원리적으로 금지된다. 실무적 대안은 셋이다. ① 담지 않고 그때그때 만든다 — 원본(Array 등)을 저장하고 필요할 때 .span을 호출한다. 이것이 표준적인 해법이며 .span은 계산 프로퍼티라 비용이 거의 없다. ② 클로저로 뒤집는다 — 위 withSlice 패턴. ③ 정말 필요하면 UnsafeBufferPointer로 내려가되 29장의 계약을 직접 지킨다(그리고 -strict-memory-safety로 그 지점을 표시한다). 대부분의 경우 ①로 충분하고, 그것이 Span이 계산 프로퍼티로 제공되는 이유이기도 하다.Q6. Span 계열 다섯 종류는 어떻게 골라 쓰는가?
표준 라이브러리에 실제로 존재하는 것은 Span · MutableSpan · RawSpan · MutableRawSpan · OutputSpan 다섯이다(SDK 인터페이스에서 확인). 두 축으로 갈린다 — 쓰기 가능한가(Mutable 접두사)와 원소 타입이 있는가(Raw는 타입 없는 바이트). 여기에 "아직 초기화되지 않은 공간을 채우는" 특수 역할로 OutputSpan이 하나 더 붙는다. 고르는 규칙은 단순하다 — 필요한 최소 권한을 고른다. 읽기만 하면 Span, 내용을 고치면 MutableSpan, 타입을 모르는 바이트열이면 RawSpan, 빈 버퍼를 채워 넘길 거면 OutputSpan.
원리
다섯 타입이 실제로 존재하는지부터 확인한다. 문서를 믿기보다 탑재된 인터페이스를 검색하는 것이 정확하다.
$ IF=".../Swift.swiftmodule/arm64e-apple-macos.swiftinterface"
$ grep -oE "public struct (MutableSpan|RawSpan|MutableRawSpan|OutputSpan)<?" "$IF" | sort -u
public struct MutableRawSpan
public struct MutableSpan<
public struct OutputSpan<
public struct RawSpanSpan을 포함해 다섯이다. RawSpan과 MutableRawSpan에 <가 없는 것도 정보다 — 제네릭 파라미터가 없다, 즉 원소 타입 개념이 없는 바이트 창이라는 뜻이다.
내부 동작
두 축으로 정리하면 관계가 한눈에 보인다.
OutputSpan이 붙는다.MutableSpan과 OutputSpan의 차이가 특히 중요하다. Q2 꼬리 질문에서 "MutableSpan으로 append는 왜 안 되는가"를 다뤘는데, 그 역할이 OutputSpan이다. MutableSpan은 이미 있는 원소 count개의 내용을 고치고, OutputSpan은 비어 있는 용량에 원소를 새로 심어 개수를 늘린다(29장 Q4의 initializeElement가 하던 일에 해당한다).
실험 · 도구
선택은 "권한 최소화"로 기계적으로 결정된다. 아래 결정 순서를 그대로 따르면 된다.
| 질문 | 예 | 아니오 |
|---|---|---|
| 버퍼를 새로 채우는가(개수 증가)? | OutputSpan | 아래로 |
| 원소 타입을 아는가? | 아래로 | RawSpan / MutableRawSpan |
| 내용을 고치는가? | MutableSpan | Span ← 기본값 |
실무에서 압도적으로 많이 쓰는 것은 첫 번째 후보인 Span이다. 나머지는 각각 좁고 분명한 자리가 있다.
// ① Span — 읽기 전용. 기본값이고 대부분 여기서 끝난다
func checksum(_ s: Span<UInt8>) -> UInt8 {
var x: UInt8 = 0
for i in 0..<s.count { x ^= s[i] }
return x
}
// ② MutableSpan — 개수는 그대로, 내용만 제자리 수정
func normalize(_ s: consuming MutableSpan<Float>, by d: Float) {
for i in 0..<s.count { s[i] /= d }
}
// ③ RawSpan — 타입 없는 바이트열. 파일 헤더 파싱, 네트워크 프레임 등
func firstByte(_ r: RawSpan) -> UInt8? {
r.byteCount > 0 ? r.unsafeLoad(fromByteOffset: 0, as: UInt8.self) : nil
}
let bytes: [UInt8] = [42, 7, 9]
print(firstByte(bytes.span.bytes) ?? 255) // 42 — .bytes 로 Span → RawSpan
// ④ OutputSpan — 빈 용량을 채워 개수를 늘리는 역할 (초기화 담당)프로젝트 적용
① API 설계 규칙 세 줄로 요약된다. Part C의 실무 결론이기도 하다.
① 연속 버퍼를 읽는 파라미터는 Span<T>로 받는다 — 복사 없고, 호출자가 Array·InlineArray 무엇이든 넘길 수 있고, 탈출은 컴파일 에러다. ② 제자리 수정이 필요하면 consuming MutableSpan<T>로 받는다. ③ Span을 반환하거나 필드에 저장하려는 순간 멈추고, 클로저로 뒤집거나 원본을 저장한 뒤 .span을 그때그때 만든다(Q5).
② 29장에서 센 unsafe 지점들을 이 표에 대응시키면 마이그레이션 계획이 그대로 나온다.
| 기존 코드 | 옮길 곳 | 난이도 |
|---|---|---|
func f(_ p: UnsafeBufferPointer<T>) | func f(_ s: Span<T>) | 낮음 — 시그니처만 교체 |
func f(_ p: UnsafeMutableBufferPointer<T>) | consuming MutableSpan<T> | 낮음 |
func f(_ p: UnsafeRawBufferPointer) | RawSpan | 중간 — 로딩 API 확인 필요 |
| 포인터를 반환하는 함수 | 클로저로 뒤집기 | 높음 — 설계 변경 |
| 포인터를 필드에 저장하는 타입 | 원본 소유 + .span | 높음 — 소유 구조 재검토 |
③ 위 두 줄(반환·저장)이 Q5가 말한 "아직 이른" 영역과 정확히 겹친다. 낮은 난이도 두 줄만 처리해도 위험 표면의 대부분이 정리되는 경우가 많으므로, 거기서 시작하는 것이 옳은 순서다.
29장은 Swift가 메모리 안전을 검사로 지켜온 역사였다 — 정적으로 잡히면 컴파일 에러, 못 잡으면 런타임 swift_beginAccess(exit 134), 그마저 안 되는 포인터 영역은 검사 없이 exit 0. 30장은 그 마지막 칸을 없애려는 시도다. 방법은 검사를 더 정교하게 만드는 게 아니라, ~Escapable·~Copyable로 위험한 상태를 애초에 표현할 수 없게 타입을 다시 설계하는 것이었다. 실측이 보여준 대가는 두 가지다 — 반환·저장이 아직 실험 기능이고(Q5), InlineArray는 배포 타깃 iOS 26을 요구한다(Q4). 그래서 2026년 현재의 현실적인 정답은 "파라미터부터 Span으로" 한 줄이다.
"RawSpan은 Span<UInt8>와 같다"는 틀렸다 — Span<UInt8>는 "원소가 UInt8인 타입이 있는 창"이고, RawSpan은 원소 개념 자체가 없는 바이트 영역이라 임의 타입으로 재해석해 로드하는 API를 갖는다(정렬 규칙을 지켜야 한다). 또 "OutputSpan은 MutableSpan의 상위 호환"도 아니다 — 초기화되지 않은 공간을 다루는 다른 역할이며, 이미 초기화된 원소를 고치는 데는 쓰지 않는다. 마지막으로 "다섯 개를 다 알아야 Span을 쓸 수 있다"는 부담을 가질 필요가 없다. 실무 코드의 대부분은 Span 하나로 해결된다.
도서관 열람증이 종류별로 나뉘어 있는 것과 같다. 읽기만 하는 증(Span)이 기본이고 제일 많이 쓴다. 연필로 고쳐 쓸 수 있는 증(MutableSpan)은 한 장만 발급되고 손에서 손으로 건네야 한다. 내용이 무슨 언어인지 모른 채 종이 자체만 보는 증(RawSpan)은 글자를 해석하지 않고 잉크 자국만 볼 때 쓴다. 그리고 빈 노트에 처음 글을 채워 넣는 증(OutputSpan)은 이미 쓰인 글을 고치는 것과 아예 다른 일이라 따로 있다. 비유가 깨지는 곳: 사람은 증서를 잘못 골라도 대충 쓸 수 있지만, Swift에서는 권한이 없는 일을 하려 하면 컴파일이 거부한다 — 그래서 고민할 여지 없이 맞는 것만 쓰게 된다.
꼬리 질문
RawSpan이 제네릭 파라미터가 없는 것은 실무에서 무슨 차이를 만드는가?
Span<Int>는 첨자가 곧바로 Int를 주지만, RawSpan은 바이트 오프셋과 대상 타입을 함께 지정해 읽는다. 그래서 두 가지 책임이 개발자에게 남는다 — 정렬(그 오프셋이 대상 타입의 정렬 요건을 만족하는지)과 유효성(그 바이트들이 실제로 그 타입의 유효한 표현인지). 쓸 자리는 분명하다. 파일 포맷 헤더 파싱, 네트워크 프레임 디코딩처럼 바이트열을 받아 구조를 해석해야 하는 코드다. Part B 18장의 Data + withUnsafeBytes로 하던 일이 여기로 옮겨온다.Span<Int>)과, 원서를 받아 내가 어느 페이지가 무슨 언어인지 판단해 읽는 것(RawSpan)의 차이다. 후자는 자유롭지만 판단 책임이 내게 있다.왜 Span을 기본값으로 삼으라고 하는가? MutableSpan이 더 많은 일을 할 수 있는데.
MutableSpan은 ~Copyable이라 변수에 담기·inout 전달·첨자 직접 대입이 모두 거부되고, consuming 파라미터라는 특정 형태로만 통한다. 읽기만 하는 함수를 MutableSpan으로 받으면 호출자가 그 불편을 대신 겪는다 — 게다가 let 상수에서는 mutableSpan을 얻을 수 없어 아예 호출이 불가능해진다. 이는 let/var, borrowing/consuming 선택과 완전히 같은 원리다. 필요한 최소 권한을 요구하는 API가 가장 쓰기 쉽다.Data(Part B 18장)에도 span이 있는가? discontiguous 문제는 어떻게 되는가?
span/mutableSpan을 제공하며(실측에서 Array·ContiguousArray·InlineArray·KeyValuePairs 등에서 확인됐다), Foundation의 Data에도 이미 있다 — 실측으로 Data([1,2,3]).span이 count == 3, [0] == 1을 돌려주는 것을 확인했다. 다만 Data는 Part B 18장 Q4에서 본 discontiguous 문제가 있다 — 내부가 여러 조각으로 나뉘어 있을 수 있어서 "하나의 연속 창"으로 볼 수 없는 경우가 존재한다. Span은 연속 저장을 전제하는 타입이므로, 이런 Data에서는 창을 그냥 내줄 수 없고 조각별로 다루거나 연속화가 필요하다. 그래서 Data를 다룰 때는 18장의 그 주의사항이 그대로 유효하며, "span이 있으니 항상 공짜"라고 가정하면 안 된다.