← Swift & Foundation 로드맵 30 / 33

30 · Span·InlineArray·~Escapable

29장이 남긴 구멍 — 클로저 밖으로 새어나간 포인터 — 을 컴파일 타임에 막기 위해 Swift 6.2가 들여온 타입들. Span이 정확히 16바이트인 이유를 표준 라이브러리 선언으로 확인하고, InlineArray<64,Int>가 512바이트인 것을 실측하며, Span은 iOS 12.2까지 되는데 InlineArray는 왜 iOS 26이 필요한지, 그리고 내 타입에 ~Escapable을 붙이는 일이 아직 실험 기능인 이유까지 직접 컴파일해 가른다.
진행률
0 / 0 완료

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의 어떤 구멍을 막는가?

🔑 30초 답변

SE-0447Span<Element>"연속된 메모리를 안전하게 빌려 보는" 타입이다. UnsafeBufferPointer와 담고 있는 정보는 똑같다 — 시작 주소와 개수, 실측 16바이트. 다른 것은 단 하나, Span~Escapable이라는 점이다. 이 성질 덕에 "빌려온 대상보다 오래 살 수 없다"가 타입 시스템의 규칙이 되고, 29장에서 조용히 통과했던 포인터 탈출이 컴파일 에러로 바뀐다. 검사는 전부 컴파일 타임에 끝나므로 런타임 비용은 0이다.

원리

SDK에 탑재된 실제 선언을 보면 설계 의도가 그대로 읽힌다.

Swift.swiftinterface — Span의 실제 선언 (인용)
@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이 만들어질 때 컴파일러는 "이 Spanarr에 의존한다"는 관계를 기록하고, 그 Spanarr의 접근 범위를 벗어나려 하면 거부한다. 29장에서 본 배타적 접근이 "겹치는가"를 검사했다면, 수명 의존성은 "오래 사는가"를 검사한다.

담는 정보는 완전히 같다 — 16바이트 UnsafeBufferPointer<Int> baseAddress + count 타입에 수명 정보가 없다 Span<Int> _pointer + _count ~Escapable — 수명이 타입에 새겨진다 클로저·스코프 밖으로 빼내면? 컴파일 통과 · 실행 · exit 0 그럴듯한 값이 나온다 (29장 Q3 실측) 보장은 없다 — 신호가 남지 않는다 컴파일 에러 lifetime-dependent value escapes its scope
두 타입이 담는 비트는 같다. 차이는 타입 시스템이 수명을 아는지 여부이며, 그 차이가 exit 0과 컴파일 에러를 가른다.

실험 · 도구

가장 먼저 확인할 것은 크기다. 선언에서 예측한 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을 파일 스코프 변수에 담으려 하면 거부된다.

Span을 변수에 담으려는 시도 — 컴파일 실패
let arr = [10, 20, 30]
let s: Span<Int> = arr.span      // ❌
swiftc 실제 진단
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))   // 15
실행 결과
Array.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이 노리는 것은 후자다.
쉽게 말하면 주소 한 줄만 적힌 쪽지(8바이트)가 더 작지만, 그 주소로 매번 찾아가야 한다. 반면 "여기서부터 몇 칸"을 다 적은 쪽지(16바이트)는 조금 크지만 그 자리에서 바로 다 알 수 있다.
Span@available이 iOS 12.2인 것은 무슨 뜻인가? Swift 6.2 이전 OS에서도 되는 것인가?
런타임 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. SpanCopyable인데 MutableSpan은 왜 ~Copyable인가?

🔑 30초 답변

읽기는 여럿이 동시에 해도 되지만 쓰기는 하나만 해야 한다는, 29장 Q1의 배타적 접근 규칙을 타입 성질로 옮긴 것이다. Span은 읽기 전용 창이므로 복사해서 여러 곳에 넘겨도 안전하다 — 그래서 Copyable이다. MutableSpan은 쓰기 창이므로 복사되면 같은 메모리에 두 개의 쓰기 권한이 생겨 배타성이 깨진다 — 그래서 ~Copyable로 복사 자체를 금지한다. 실측 선언으로 확인했고, 이 차이 때문에 MutableSpan은 사용법이 눈에 띄게 까다롭다.

원리

Swift.swiftinterface — 두 타입의 선언을 나란히 (인용)
@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로 뒤집히고, 내부 포인터가 UnsafeRawPointerUnsafeMutableRawPointer로 바뀐다. 가변성이 곧 복사 금지라는 설계가 선언 한 줄에 담겨 있다.

내부 동작

왜 복사를 막아야 하는지는 반대로 생각하면 분명하다. MutableSpan이 복사 가능하다면 이런 코드가 합법이 된다 — let a = m; let b = m. 그러면 a[0] = 1b[0] = 2가 같은 주소에 겹치는 쓰기를 하게 되고, 이는 29장에서 컴파일러가 overlapping accesses로 막던 바로 그 상황이다. ~Copyable은 그 상황을 애초에 표현할 수 없게 만든다.

성질SpanMutableSpan이유
복사가능 (Copyable)불가 (~Copyable)쓰기 권한이 둘이 되면 배타성 위반
탈출불가 (~Escapable)불가 (~Escapable)둘 다 빌린 메모리를 본다
내부 포인터UnsafeRawPointerUnsafeMutableRawPointer쓰기 여부
크기 (실측)1616둘 다 포인터 + 개수
가용성iOS 12.2iOS 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장)이므로, 복사 없이 정확히 하나의 쓰기 권한이 함수로 이동한다.

통하는 형태 — consuming 파라미터
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복사도 반환도 없이 원본 InlineArrayArray를 제자리에서 고쳤다. 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의 본질이다.

⚠️ 흔한 오해

"MutableSpaninout과 같은 것"이라는 말은 부정확하다. 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에서 다룬다.
쉽게 말하면 책의 글자를 고칠 권한은 있어도, 책에 새 페이지를 끼워 넣는 일은 책 주인만 할 수 있는 것과 같다.
SpanBitwiseCopyable인 것은 실무에서 무슨 차이를 만드는가?
BitwiseCopyable은 "이 값은 비트를 그대로 복사하는 것만으로 완전히 복사된다 — 참조 카운트 조작이나 특별한 복사 로직이 필요 없다"는 표시다. 실무적 함의는 전달 비용이 정수 두 개를 넘기는 것과 같다는 것이다. Array를 넘기면 상황에 따라 retain/release가 붙을 수 있는데, Span에는 그것이 원리적으로 없다. 그래서 루프 안에서 함수에 반복 전달하는 패턴에서 차이가 드러난다. MutableSpan~Copyable이라 애초에 복사가 없으므로 이 표시가 필요하지 않다.
쉽게 말하면 숫자를 적어 건네는 것과, 물건을 건네면서 "몇 명이 이걸 쓰고 있는지" 장부를 고쳐야 하는 것의 차이다. 앞쪽이 훨씬 싸다.

Q3. InlineArrayArray와 무엇이 다른가? — 값 제네릭과 인라인 저장

🔑 30초 답변

SE-0453InlineArray<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)이다.

Swift.swiftinterface — InlineArray의 실제 선언 (인용)
@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이다.

var a: [Int] = [1,2,3,4] — size 8 ptr 힙 참조 힙 버퍼 — refCount · count · capacity 헤더 1 2 3 4 ARC · CoW 대상 var v: InlineArray<4, Int> = [1,2,3,4] — size 32 Builtin.FixedArray — 그 자리에 원소 4개 1 2 3 4 힙 없음 · 헤더 없음 · ARC 없음 64개로 늘리면 — [Int]는 그대로 8, InlineArray<64,Int>는 512. 개수가 타입의 일부라 크기에 그대로 반영된다.
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]로 쓸 수 있고, 실측으로 완전히 같은 타입이다.

[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를 쓴 것에 주의한다. InlineArraySequence/Collection을 채택하지 않는다. 이유는 그 프로토콜들의 요구사항이 원소를 복사해 꺼내는 것을 전제하기 때문이다 — ~Copyable 원소를 담을 수 있게 만들려면 그 전제를 버려야 했다. 대신 count·indices·첨자·span이 제공된다.

그 포기가 무엇을 얻어낸 것인지는 대조 실험으로 바로 보인다. ~Copyable 원소를 두 컨테이너에 각각 담아 본다.

~Copyable 원소를 담아 본다 — InlineArray는 되고 Array는 안 된다
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)]
실행 결과 · 그리고 Array 쪽의 실제 진단
count: 3 | box[1].id = 2
size: 24

$ swiftc -c array_version.swift
error: type 'Token' does not conform to protocol 'Copyable'

이 대비가 설계 의도를 그대로 보여준다. ArrayElement: Copyable을 전제하므로 Token담을 수조차 없다. InlineArrayCollection을 포기하고 얻은 것이 바로 이것이다 — Collection의 요구사항이 원소를 값으로 꺼내는 것(복사)을 전제하기 때문에, 둘을 동시에 만족시킬 수 없었다. 아래 꼬리 질문에서 그 요구사항을 구체적으로 짚는다.

대신 map/filter/reduce 같은 Sequence 메서드는 직접 쓸 수 없다. 필요하면 .span을 거치거나 인덱스 루프를 쓴다.

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으로 넘긴다.

큰 InlineArray는 값이 아니라 창으로 넘긴다
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는 프로파일링이 힙 할당을 지목한 좁은 자리를 위한 도구다.

기준ArrayInlineArray
개수를 아는 시점런타임 — 자유롭게 변경컴파일 타임 — 타입에 박힌다
저장 위치힙 버퍼그 자리(스택·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으로"라는 규칙이 힘을 얻는 또 하나의 이유다.
쉽게 말하면 "4칸 서랍장"과 "5칸 서랍장"이 아예 다른 가구인 것과 같다. 둘 다 받으려면 "서랍 몇 개든 상관없이 안을 들여다보는 창"(Span)을 기준으로 이야기해야 한다.
Collection을 채택하지 않는 것이 왜 ~Copyable 지원 때문인가?
Collection의 요구사항은 원소를 값으로 꺼내는 것을 전제한다. subscriptElement를 반환하고, Iterator.next()Element?를 반환한다 — 둘 다 "원소의 복사본을 만들어 돌려준다"는 의미다. 원소가 ~Copyable이면 복사본을 만들 수 없으므로 이 요구사항을 만족할 방법이 없다. ArrayElement: Copyable을 전제하므로 문제가 없지만, InlineArray는 처음부터 ~Copyable 원소를 담는 것을 목표로 설계됐기 때문에 Collection을 포기한 것이다. 대신 indices와 첨자로 빌려 보는 접근을 제공한다. 참고로 이 제약을 언젠가 풀기 위한 논의가 진행 중이며, Iterable 같은 후속 프로토콜 제안이 나와 있다.
쉽게 말하면 "하나씩 꺼내서 건네주는" 규칙을 지키려면 복사가 필요한데, 복사할 수 없는 물건도 담으려니 그 규칙 자체를 채택할 수 없었던 것이다. 대신 "몇 번째 칸을 들여다보라"는 방식만 제공한다.
InlineArray<4, UInt8>가 4바이트인데, 패딩이 없는 이유는?
Part A 01장의 정렬(alignment) 규칙 그대로다. 패딩은 각 원소가 자기 정렬 경계에 맞아야 할 때 생기는데, 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) = 48

MemoryLayoutsize/stride 구분(Part A 01장 Q3)이 여기서 그대로 쓰인다. stridesize보다 큰 타입을 담으면 그 차이가 원소 수만큼 곱해져 총 크기에 반영된다.

쉽게 말하면 작은 블록은 아무 자리에나 놓을 수 있어 빈틈 없이 붙지만, 큰 블록은 정해진 칸에만 놓을 수 있어서 사이에 빈 공간이 생길 수 있는 것과 같다.

Q4. Span은 iOS 12.2까지 되는데 InlineArray는 왜 iOS 26이 필요한가?

🔑 30초 답변

이 챕터에서 실무에 가장 직접적인 사실이며 대부분의 소개 글이 빠뜨리는 대목이다. 두 타입의 @available이 다르다 — Span·MutableSpanmacOS 10.14.4 / iOS 12.2, InlineArraymacOS 26.0 / iOS 26.0이다. 이유는 Span@frozen + @_alwaysEmitIntoClient앱 바이너리에 코드가 복사되어 back-deploy되는 반면, InlineArrayBuiltin.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)으로 매개화돼 있다는 것이다. 값으로 매개화된 타입의 메타데이터를 런타임이 다뤄야 한다면 앱 바이너리에 복사해 넣는 방식으로는 해결되지 않는다 — 이것이 필자의 추정이며, 근거는 선언의 모양뿐이다. 실무 판단에 필요한 것은 이유가 아니라 가용성 숫자이고, 그것은 아래에서 컴파일로 확정한다.

내부 동작

말로 하는 대신 컴파일러에게 직접 물어보면 된다. 배포 타깃을 낮춰 두 타입을 함께 쓰는 코드를 컴파일한다.

avail.swift — 두 타입을 함께 쓴다
func useSpan(_ s: Span<Int>) -> Int { s.count }
func useInline() -> Int {
    let v: InlineArray<4, Int> = [1, 2, 3, 4]
    return v.count
}
macOS 15 타깃으로 컴파일 — InlineArray만 걸린다
$ 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
macOS 26 타깃으로 컴파일 — 둘 다 통과
$ 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.swift

iOS 26 미만을 지원해야 하는데 InlineArray가 필요하면 if #available로 갈라야 하지만, 타입 자체가 가용성에 걸리면 분기의 이득이 거의 없다 — 두 경로의 자료구조가 달라져 코드가 두 벌이 된다. 실무에서는 대개 Array + reserveCapacity로 통일하고, Span만 먼저 도입하는 편이 낫다.

프로젝트 적용

① 도입 순서가 명확해진다. Span은 지금 바로, InlineArray는 배포 타깃이 올라간 뒤.

타입필요 배포 타깃필요 툴체인지금 도입 가능?
Span · MutableSpaniOS 12.2 / macOS 10.14.4Swift 6.2+대부분의 프로젝트에서 가능
RawSpan · MutableRawSpaniOS 12.2 / macOS 10.14.4Swift 6.2+가능
InlineArrayiOS 26 / macOS 26Swift 6.2+배포 타깃이 26 이상일 때만
-strict-memory-safety (29장)무관 — 컴파일 타임Swift 6.2+가능

② SPM 라이브러리를 만든다면 이 차이가 API 결정으로 직결된다. 공개 API에 InlineArray를 쓰면 그 라이브러리를 쓰는 모든 앱이 iOS 26 이상이어야 한다. Span은 그런 제약을 만들지 않으므로, 공개 표면은 Span으로 두고 InlineArray는 내부 구현에 가용성 분기와 함께 쓰는 편이 안전하다.

공개 API는 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를 만드는 원리 ABI

Part 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인 프로젝트에서 InlineArrayif #available로 감싸면 쓸 수 있는가?
문법적으로는 가능하지만 실용성이 낮다. if #available(iOS 26, *) 블록 안에서만 그 타입을 쓸 수 있으므로, 저장 프로퍼티 타입이나 함수 시그니처에는 쓸 수 없고 지역 변수 수준에만 갇힌다. 결과적으로 같은 로직을 InlineArray 버전과 Array 버전으로 두 벌 유지해야 하는데, 얻는 이득(힙 할당 1회 절약)에 비해 유지보수 비용과 버그 위험이 훨씬 크다. 실무적으로는 Array로 통일하고, 힙 할당이 실제 병목으로 측정된 자리에만 29장의 withUnsafeTemporaryAllocation을 쓰는 편이 낫다.
쉽게 말하면 새 부엌 도구를 쓰려면 부엌을 개조해야 하는데, 개조 전에는 "새 도구용 조리법"과 "옛 도구용 조리법" 두 벌을 관리해야 한다. 요리 하나 빨라지는 것보다 조리법 두 벌 관리가 더 번거롭다.
Span@available이 iOS 12.2라는 구체적인 숫자는 어디서 왔는가?
iOS 12.2가 Swift 5의 ABI 안정화 이후 첫 OS 계열, 즉 표준 라이브러리가 OS에 포함되기 시작한 시점이기 때문이다. 그 이전 OS에서는 앱이 표준 라이브러리를 자기 번들에 통째로 넣어 배포했으므로 back-deploy라는 개념이 다르게 작동한다. 그래서 애플이 @_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 타입을 만들 수 있는가? — 수명 의존성의 현재 상태

🔑 30초 답변

Swift 6.2.1 정식 기능으로는 사실상 불가능하다. ~Escapable struct를 선언하는 것 자체는 되지만, 그 안에 Span 같은 ~Escapable 값을 저장하려 하면 the 'get' accessor cannot return a ~Escapable resultan initializer cannot return a ~Escapable result 에러가 난다. 해결에 필요한 수명 의존성 애노테이션이 아직 실험 기능이기 때문이다 — 정식 문법으로 보이는 @lifetime은 거부되고, @_lifetime(언더바)에 -enable-experimental-feature Lifetimes를 함께 줘야 컴파일된다. 즉 지금은 표준 라이브러리가 제공하는 Span 계열을 소비하는 쪽에 머무는 것이 맞고, 내 타입으로 새 창을 만드는 일은 아직 이르다. 이 대목은 블로그들이 가장 자주 틀리는 곳이라 실측을 그대로 싣는다.

원리

~Escapable 타입이 다른 ~Escapable 값을 품으면, 컴파일러는 "이 래퍼의 수명이 무엇에 묶여야 하는가"를 알아야 한다. Spanarr에 의존했듯, 래퍼는 그 Span에 의존해야 한다. 그런데 그 의존 관계를 표현할 문법이 없으면 컴파일러는 초기화자와 게터의 반환값에 어떤 수명을 부여할지 결정할 수 없고, 그래서 거부한다. 에러 메시지가 정확히 그 지점을 가리킨다 — 문제는 "저장"이 아니라 "반환"이다.

내부 동작

세 단계로 실측했다. 먼저 애노테이션 없이 시도한다.

① 애노테이션 없이 — 거부된다
struct Wrapper: ~Escapable {
    let s: Span<Int>
    init(_ s: Span<Int>) { self.s = s }
}
swiftc 실제 진단
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을 붙인다. 여러 문서와 발표에 이 표기가 등장하므로 자연스러운 시도다.

② @lifetime (언더바 없음) — 여전히 거부되고, 게다가 이름이 틀렸다고 알려준다
struct Cursor: ~Escapable {
    private let base: Span<Int>
    @lifetime(copy base)
    init(_ base: Span<Int>) { self.base = base }
    var first: Int { base[0] }
}
swiftc 실제 진단 — 세 가지가 한꺼번에 나온다
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를 주면 또 다른 이름을 요구한다.

③ @_lifetime + 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))
swiftc -enable-experimental-feature Lifetimes — 컴파일 성공
Cursor.first = 10

동작은 한다. 그러나 이것은 실험 기능이고, 위 실측이 보여준 것처럼 애트리뷰트 이름(@lifetime@_lifetime)과 기능 플래그 이름(LifetimeDependenceLifetimes)이 이미 한 번 이상 바뀌었다. 프로덕션 코드에 넣을 근거가 되지 못한다.

실험 · 도구

정리하면 네 조합의 결과가 이렇다.

시도플래그결과
애노테이션 없음없음에러 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)이고, 없으면 결정할 수 없어 거부한다. 초기화자 에러도 같은 구조다 — initSelf를 "반환"하는데 Self~Escapable이므로 그 수명을 어디에 묶을지 명시해야 한다.
쉽게 말하면 물건을 상자에 넣는 건 문제가 없는데, 상자에서 꺼내 남에게 건넬 때 "이건 언제까지 유효한 겁니까?"에 답할 수 없어서 막히는 것이다.
@_lifetime(copy base)copy는 무슨 뜻인가?
수명 의존성의 종류를 고르는 키워드다. copy는 "base가 이미 갖고 있는 수명 의존성을 그대로 물려받는다"는 뜻이다 — basearr에 의존하고 있었다면 이 래퍼도 arr에 의존하게 된다. 즉 의존성을 새로 만드는 것이 아니라 전달(propagate)하는 것이다. 다른 종류로는 인자 자체를 빌리는 관계를 세우는 형태(borrow)가 있다. 이 구분이 필요한 이유는 SpanSpan처럼 창을 다시 감쌀 때, 최종 수명이 중간 창이 아니라 원본 저장소에 묶여야 하기 때문이다. 다만 이 문법의 세부는 실험 기능이라 바뀔 수 있으므로 이름과 의미를 외우기보다 개념만 잡아두는 것이 낫다.
쉽게 말하면 친구가 도서관에서 빌린 책을 내가 다시 빌릴 때, 내 반납일은 "친구에게 빌린 날"이 아니라 원래 도서관 반납일을 따라야 하는 것과 같다. copy가 그 규칙이다.
실험 기능을 켜지 않고도 Span을 필드에 담을 방법이 정말 없는가?
클래스나 일반 struct의 저장 프로퍼티로는 없다 — 애초에 Escapable인 타입이 ~Escapable 값을 품으면 그 값을 탈출시킬 수 있게 되므로 원리적으로 금지된다. 실무적 대안은 셋이다. ① 담지 않고 그때그때 만든다 — 원본(Array 등)을 저장하고 필요할 때 .span을 호출한다. 이것이 표준적인 해법이며 .span은 계산 프로퍼티라 비용이 거의 없다. ② 클로저로 뒤집는다 — 위 withSlice 패턴. ③ 정말 필요하면 UnsafeBufferPointer로 내려가되 29장의 계약을 직접 지킨다(그리고 -strict-memory-safety로 그 지점을 표시한다). 대부분의 경우 ①로 충분하고, 그것이 Span이 계산 프로퍼티로 제공되는 이유이기도 하다.
쉽게 말하면 열람증을 지갑에 넣어 보관하려 하지 말고, 필요할 때마다 창구에서 새로 받는 것이다. 발급이 워낙 빨라서 보관할 이유가 없다.

Q6. Span 계열 다섯 종류는 어떻게 골라 쓰는가?

🔑 30초 답변

표준 라이브러리에 실제로 존재하는 것은 Span · MutableSpan · RawSpan · MutableRawSpan · OutputSpan 다섯이다(SDK 인터페이스에서 확인). 두 축으로 갈린다 — 쓰기 가능한가(Mutable 접두사)와 원소 타입이 있는가(Raw는 타입 없는 바이트). 여기에 "아직 초기화되지 않은 공간을 채우는" 특수 역할로 OutputSpan이 하나 더 붙는다. 고르는 규칙은 단순하다 — 필요한 최소 권한을 고른다. 읽기만 하면 Span, 내용을 고치면 MutableSpan, 타입을 모르는 바이트열이면 RawSpan, 빈 버퍼를 채워 넘길 거면 OutputSpan.

원리

다섯 타입이 실제로 존재하는지부터 확인한다. 문서를 믿기보다 탑재된 인터페이스를 검색하는 것이 정확하다.

SDK 인터페이스에서 직접 확인
$ 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 RawSpan

Span을 포함해 다섯이다. RawSpanMutableRawSpan<가 없는 것도 정보다 — 제네릭 파라미터가 없다, 즉 원소 타입 개념이 없는 바이트 창이라는 뜻이다.

내부 동작

두 축으로 정리하면 관계가 한눈에 보인다.

읽기 전용 쓰기 가능 타입 있음 바이트(Raw) Span<Element> Copyable · ~Escapable · 16B MutableSpan<Element> ~Copyable · ~Escapable · 16B RawSpan 제네릭 없음 · 16B MutableRawSpan 제네릭 없음 OutputSpan<Element> 아직 초기화되지 않은 공간을 채워 개수를 늘리는 특수 역할
쓰기 가능성 × 원소 타입 유무로 넷이 갈리고, "빈 공간을 채운다"는 별개 역할로 OutputSpan이 붙는다.

MutableSpanOutputSpan의 차이가 특히 중요하다. Q2 꼬리 질문에서 "MutableSpan으로 append는 왜 안 되는가"를 다뤘는데, 그 역할이 OutputSpan이다. MutableSpan이미 있는 원소 count개의 내용을 고치고, OutputSpan비어 있는 용량에 원소를 새로 심어 개수를 늘린다(29장 Q4의 initializeElement가 하던 일에 해당한다).

실험 · 도구

선택은 "권한 최소화"로 기계적으로 결정된다. 아래 결정 순서를 그대로 따르면 된다.

질문아니오
버퍼를 새로 채우는가(개수 증가)?OutputSpan아래로
원소 타입을 아는가?아래로RawSpan / MutableRawSpan
내용을 고치는가?MutableSpanSpan ← 기본값

실무에서 압도적으로 많이 쓰는 것은 첫 번째 후보인 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가 말한 "아직 이른" 영역과 정확히 겹친다. 낮은 난이도 두 줄만 처리해도 위험 표면의 대부분이 정리되는 경우가 많으므로, 거기서 시작하는 것이 옳은 순서다.

🟣 한 걸음 더 — Part C 29·30장을 한 문장으로 종합

29장은 Swift가 메모리 안전을 검사로 지켜온 역사였다 — 정적으로 잡히면 컴파일 에러, 못 잡으면 런타임 swift_beginAccess(exit 134), 그마저 안 되는 포인터 영역은 검사 없이 exit 0. 30장은 그 마지막 칸을 없애려는 시도다. 방법은 검사를 더 정교하게 만드는 게 아니라, ~Escapable·~Copyable로 위험한 상태를 애초에 표현할 수 없게 타입을 다시 설계하는 것이었다. 실측이 보여준 대가는 두 가지다 — 반환·저장이 아직 실험 기능이고(Q5), InlineArray는 배포 타깃 iOS 26을 요구한다(Q4). 그래서 2026년 현재의 현실적인 정답은 "파라미터부터 Span으로" 한 줄이다.

⚠️ 흔한 오해

"RawSpanSpan<UInt8>와 같다"는 틀렸다 — Span<UInt8>는 "원소가 UInt8타입이 있는 창"이고, RawSpan은 원소 개념 자체가 없는 바이트 영역이라 임의 타입으로 재해석해 로드하는 API를 갖는다(정렬 규칙을 지켜야 한다). 또 "OutputSpanMutableSpan의 상위 호환"도 아니다 — 초기화되지 않은 공간을 다루는 다른 역할이며, 이미 초기화된 원소를 고치는 데는 쓰지 않는다. 마지막으로 "다섯 개를 다 알아야 Span을 쓸 수 있다"는 부담을 가질 필요가 없다. 실무 코드의 대부분은 Span 하나로 해결된다.

🧒 쉽게 이해하기

도서관 열람증이 종류별로 나뉘어 있는 것과 같다. 읽기만 하는 증(Span)이 기본이고 제일 많이 쓴다. 연필로 고쳐 쓸 수 있는 증(MutableSpan)은 한 장만 발급되고 손에서 손으로 건네야 한다. 내용이 무슨 언어인지 모른 채 종이 자체만 보는 증(RawSpan)은 글자를 해석하지 않고 잉크 자국만 볼 때 쓴다. 그리고 빈 노트에 처음 글을 채워 넣는 증(OutputSpan)은 이미 쓰인 글을 고치는 것과 아예 다른 일이라 따로 있다. 비유가 깨지는 곳: 사람은 증서를 잘못 골라도 대충 쓸 수 있지만, Swift에서는 권한이 없는 일을 하려 하면 컴파일이 거부한다 — 그래서 고민할 여지 없이 맞는 것만 쓰게 된다.

꼬리 질문

RawSpan이 제네릭 파라미터가 없는 것은 실무에서 무슨 차이를 만드는가?
"이 바이트가 무슨 타입인지"를 내가 결정해서 로드해야 한다는 뜻이다. Span<Int>는 첨자가 곧바로 Int를 주지만, RawSpan은 바이트 오프셋과 대상 타입을 함께 지정해 읽는다. 그래서 두 가지 책임이 개발자에게 남는다 — 정렬(그 오프셋이 대상 타입의 정렬 요건을 만족하는지)과 유효성(그 바이트들이 실제로 그 타입의 유효한 표현인지). 쓸 자리는 분명하다. 파일 포맷 헤더 파싱, 네트워크 프레임 디코딩처럼 바이트열을 받아 구조를 해석해야 하는 코드다. Part B 18장의 Data + withUnsafeBytes로 하던 일이 여기로 옮겨온다.
쉽게 말하면 번역된 책을 받는 것(Span<Int>)과, 원서를 받아 내가 어느 페이지가 무슨 언어인지 판단해 읽는 것(RawSpan)의 차이다. 후자는 자유롭지만 판단 책임이 내게 있다.
Span을 기본값으로 삼으라고 하는가? MutableSpan이 더 많은 일을 할 수 있는데.
권한이 많은 타입은 제약도 많기 때문이다. Q2 실측에서 본 것처럼 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]).spancount == 3, [0] == 1을 돌려주는 것을 확인했다. 다만 Data는 Part B 18장 Q4에서 본 discontiguous 문제가 있다 — 내부가 여러 조각으로 나뉘어 있을 수 있어서 "하나의 연속 창"으로 볼 수 없는 경우가 존재한다. Span연속 저장을 전제하는 타입이므로, 이런 Data에서는 창을 그냥 내줄 수 없고 조각별로 다루거나 연속화가 필요하다. 그래서 Data를 다룰 때는 18장의 그 주의사항이 그대로 유효하며, "span이 있으니 항상 공짜"라고 가정하면 안 된다.
쉽게 말하면 책 한 권이 한 묶음으로 제본돼 있으면 "몇 쪽부터 몇 쪽까지"라는 창을 낼 수 있지만, 낱장이 여러 봉투에 흩어져 있으면 한 번에 훑는 창을 만들 수 없는 것과 같다.

출처 · 참고자료