Swift 6.2 InlineArray 완벽 가이드: 고정 크기 배열로 성능 극대화하기

Swift 6.2의 InlineArray를 완벽하게 정리했습니다. 기존 Array와의 차이, 생성 방법, 메모리 레이아웃, Span 연동부터 RGB 색상, 게임 개발, 행렬 연산, C 인터롭, 신호 처리까지 실전 활용 사례를 코드 예제와 함께 다룹니다.

Swift 6.2에서 개인적으로 가장 기대했던 기능이 바로 InlineArray입니다. Swift Evolution 제안서 SE-0453을 통해 도입된 이 타입은, 솔직히 Swift 개발자들이 꽤 오랫동안 기다려 온 고정 크기 배열(fixed-size array)을 드디어 언어 수준에서 지원해 줍니다. C 언어의 T[N], C++의 std::array<T, N>, Rust의 [T; N]과 비슷한 개념인데요, 컴파일 타임에 크기가 결정되고 힙 할당 없이 인라인으로 저장되는 배열이라고 보면 됩니다.

기존의 Array는 동적 크기 조절, Copy-on-Write, 레퍼런스 카운팅 등 강력한 기능을 제공하지만, 이런 편의 기능에는 어쩔 수 없이 성능 오버헤드가 따라옵니다. 실시간 렌더링이나 신호 처리, 게임 개발, 수학 연산처럼 성능이 극도로 중요한 영역에서는 이 오버헤드가 진짜 병목이 될 수 있거든요. InlineArray는 바로 이 간극을 메워주는 타입입니다.

이 글에서는 InlineArray의 개념부터 생성, 초기화, 요소 접근, 반복 처리, 메모리 레이아웃, Span과의 연동, 실전 활용 사례, 그리고 제한 사항까지 하나하나 다뤄볼 예정입니다. 코드 예제를 풍부하게 준비했으니, 바로 실무에 적용할 수 있을 거예요.

InlineArray란 무엇인가?

InlineArray는 Swift 표준 라이브러리에 새롭게 추가된 고정 크기 배열 타입입니다. 튜플의 고정 크기 특성과 배열의 자연스러운 서브스크립트 접근 방식을 합쳐놓은 느낌인데, 성능 면에서도 상당한 이점을 제공합니다.

타입 정의를 한번 살펴볼까요?

@frozen
public struct InlineArray<let count: Int, Element: ~Copyable>: ~Copyable { }

extension InlineArray: Copyable where Element: Copyable { }
extension InlineArray: BitwiseCopyable where Element: BitwiseCopyable { }
extension InlineArray: Sendable where Element: Sendable { }

여기서 핵심은 let count: Int라는 정수 제네릭 파라미터(Integer Generic Parameter)입니다. Swift 6.2에서 SE-0452를 통해 도입된 값 제네릭(Value Generics) 기능을 활용한 건데요, 타입 수준에서 정수 값을 파라미터로 받을 수 있게 해줍니다. 쉽게 말해, 배열의 크기가 타입 시그니처의 일부가 되어 컴파일 타임에 완전히 결정된다는 뜻이죠.

InlineArray<3, Int>InlineArray<5, Int>는 서로 완전히 다른 타입입니다. (Int, Int, Int) 튜플과 (Int, Int, Int, Int, Int) 튜플이 다른 타입인 것과 같은 원리예요. 컴파일러가 크기를 정확히 알고 있으니 불필요한 런타임 검사를 제거하고 메모리 레이아웃을 최적화할 수 있습니다.

그리고 InlineArray는 기본적으로 비복사 타입(noncopyable type)으로 정의되어 있어서, 요소 타입이 Copyable을 준수할 때만 조건부로 복사 가능해집니다. 비복사 요소도 저장할 수 있다는 건 꽤 유연한 설계라고 할 수 있겠네요.

왜 "InlineArray"라는 이름인가?

재미있는 뒷이야기가 있는데요. 이 타입은 처음에 Vector라는 이름으로 제안되었습니다. 하지만 그래픽 프로그래밍 쪽에서 "벡터"라는 용어가 이미 수학적 벡터를 의미하는 데 널리 쓰이고 있어서 혼동의 여지가 있었죠.

최종적으로 InlineArray라는 이름이 채택되었는데, 요소들이 별도의 힙 저장소가 아닌 인라인으로(inline) 저장된다는 핵심 특성을 잘 반영합니다. 클래스의 프로퍼티로 사용될 때는 클래스 인스턴스와 함께 힙에 인라인으로 배치되고, 지역 변수로 사용될 때는 스택에 직접 할당됩니다. 핵심은 InlineArray 자체를 위한 별도의 힙 할당이 절대 발생하지 않는다는 것입니다.

기존 Array와의 차이점

Swift의 기존 Array는 매우 강력하고 유연한 컬렉션 타입이지만, 그 유연성에는 대가가 따릅니다. InlineArrayArray의 근본적인 차이점을 하나씩 뜯어보겠습니다.

힙 할당 vs 스택 할당

Array는 요소들을 힙(heap)에 저장합니다. 배열을 생성할 때마다 운영체제에 메모리 할당을 요청해야 하고, 배열이 소멸될 때는 해당 메모리를 반환해야 합니다. 이 과정은 시스템 콜을 수반하기 때문에 상대적으로 느립니다.

반면 InlineArray는 요소들을 인라인으로 저장합니다. 지역 변수로 사용될 때는 스택에, 구조체의 프로퍼티로 사용될 때는 해당 구조체 내부에, 클래스의 프로퍼티로 사용될 때는 클래스 인스턴스 내부에 직접 배치되죠. 별도의 힙 할당이 필요 없으니 생성과 소멸이 극도로 빠릅니다.

레퍼런스 카운팅

Array는 내부적으로 버퍼 객체에 대한 참조를 관리하기 위해 레퍼런스 카운팅(reference counting)을 수행합니다. 배열을 변수에 대입하거나 함수에 전달할 때마다 레퍼런스 카운트가 증가하고, 스코프를 벗어나면 감소하죠. 이 원자적 연산(atomic operation)은 멀티스레드 환경에서의 안전성을 보장하지만, 반복적으로 수행될 때 무시 못 할 오버헤드를 만들어냅니다.

InlineArray는 값 타입이라 레퍼런스 카운팅이 필요 없습니다. 복사될 때 요소들이 통째로 복사되므로, 참조 추적을 위한 추가 비용이 전혀 없습니다.

Copy-on-Write

ArrayCopy-on-Write(COW) 최적화를 사용합니다. 배열이 복사될 때 즉시 모든 요소를 복사하지 않고, 실제로 수정이 일어날 때까지 내부 버퍼를 공유하는 방식이죠. 대부분의 상황에서 효율적이지만, 매 수정 시마다 버퍼의 유일 소유자인지 확인하는 검사가 필요합니다.

InlineArray는 튜플과 마찬가지로 Copy-on-Copy 의미론을 따릅니다. 대입할 때 항상 전체 내용이 복사돼요. COW 검사 오버헤드가 없으며, 작은 크기의 고정 배열에서는 오히려 이 방식이 더 효율적입니다.

동적 크기 조절

Arrayappend, remove, insert 등의 메서드로 크기를 마음대로 바꿀 수 있습니다. 내부 버퍼 용량이 부족하면 더 큰 버퍼를 새로 할당하고 기존 요소를 복사하는 재할당(reallocation)이 발생하고요.

InlineArray는 크기가 컴파일 타임에 고정되므로, 요소의 추가나 삭제가 불가능합니다. 제한처럼 보일 수 있지만, 크기가 변하지 않는다는 보장 덕분에 컴파일러가 더 공격적인 최적화를 수행할 수 있다는 장점이 있습니다.

// Array: 힙 할당, 레퍼런스 카운팅, COW 오버헤드 발생
var dynamicArray: [Int] = [1, 2, 3]
dynamicArray.append(4) // 크기 동적 변경 가능

// InlineArray: 스택 할당, 오버헤드 없음
var fixedArray: InlineArray<3, Int> = [1, 2, 3]
// fixedArray.append(4) // 컴파일 에러 - 크기 변경 불가
fixedArray[2] = 10 // 요소 수정은 가능

InlineArray 생성 및 초기화

InlineArray는 여러 가지 방법으로 생성하고 초기화할 수 있습니다. 각 패턴별로 어떤 상황에 맞는지 살펴보죠.

배열 리터럴을 이용한 초기화

가장 직관적인 방법은 배열 리터럴 구문을 사용하는 것입니다. 타입을 명시적으로 지정하면 끝이에요.

// 타입과 크기를 명시적으로 지정
var scores: InlineArray<3, Int> = [85, 90, 95]

// 문자열 배열
var weekdays: InlineArray<5, String> = ["월", "화", "수", "목", "금"]

// 부동소수점 배열
var coordinates: InlineArray<2, Double> = [37.5665, 126.9780]

중요: 리터럴의 요소 수는 반드시 지정된 크기와 일치해야 합니다. 안 맞으면 컴파일 에러가 바로 뜹니다.

// 컴파일 에러! 3개를 선언했지만 4개의 요소를 제공
var invalid: InlineArray<3, Int> = [1, 2, 3, 4]

// 컴파일 에러! 3개를 선언했지만 2개의 요소만 제공
var tooFew: InlineArray<3, Int> = [1, 2]

타입 추론을 이용한 초기화

Swift 컴파일러는 리터럴에서 크기와 요소 타입을 알아서 추론해 줍니다. 꽤 똑똑하죠.

// 크기와 요소 타입 모두 추론 (InlineArray<3, String>으로 추론)
var colors: InlineArray = ["Red", "Green", "Blue"]

// 크기만 추론 (InlineArray<4, Int>로 추론)
var numbers: InlineArray<_, Int> = [10, 20, 30, 40]

// 요소 타입만 추론 (InlineArray<3, Double>로 추론)
var values: InlineArray<3, _> = [1.5, 2.5, 3.5]

repeating 이니셜라이저

모든 요소를 동일한 값으로 채우고 싶을 때 사용합니다. 초기 버퍼를 만들 때 특히 유용해요.

// 5개의 요소를 모두 42로 초기화
var filled: InlineArray<5, Int> = .init(repeating: 42)
// 결과: [42, 42, 42, 42, 42]

// 10개의 요소를 모두 0.0으로 초기화
var zeros: InlineArray<10, Double> = .init(repeating: 0.0)

// 빈 문자열로 초기화
var emptyStrings: InlineArray<4, String> = .init(repeating: "")

인덱스 기반 클로저 이니셜라이저

각 요소를 인덱스에 기반해서 계산하고 싶을 때 이 방법을 쓰면 됩니다.

// 인덱스의 2배 값으로 초기화
var doubled: InlineArray<4, Int> = .init { index in
    index * 2
}
// 결과: [0, 2, 4, 6]

// 인덱스를 활용한 제곱 수 배열
var squares: InlineArray<5, Int> = .init { i in
    i * i
}
// 결과: [0, 1, 4, 9, 16]

// 인덱스 기반 문자열 생성
var labels: InlineArray<3, String> = .init { i in
    "항목 \(i + 1)"
}
// 결과: ["항목 1", "항목 2", "항목 3"]

first + next 이니셜라이저

첫 번째 요소를 직접 지정하고, 나머지 요소는 이전 값을 기반으로 생성하는 패턴입니다. 연속적인 값의 시퀀스를 만들 때 정말 깔끔하게 쓸 수 있어요.

// 첫 번째 요소는 1, 이후 요소는 이전 값의 2배
var powers: InlineArray<6, Int> = .init(first: 1) { previous in
    previous * 2
}
// 결과: [1, 2, 4, 8, 16, 32]

// 피보나치 수열은 이 패턴으로 직접 구현하기 어렵지만,
// 연속된 값의 생성에는 매우 효과적입니다.
var increments: InlineArray<5, Double> = .init(first: 0.0) { prev in
    prev + 0.5
}
// 결과: [0.0, 0.5, 1.0, 1.5, 2.0]

요소 접근 및 수정

InlineArray의 요소에 접근하고 수정하는 방법은 기존 배열과 거의 똑같습니다. 여기서는 크게 어려울 건 없어요.

서브스크립트 접근

var temperatures: InlineArray<4, Double> = [22.5, 25.0, 23.8, 21.2]

// 읽기
let first = temperatures[0]   // 22.5
let last = temperatures[3]    // 21.2

// 쓰기
temperatures[1] = 26.5
print(temperatures[1])  // 26.5

경계 검사 (Bounds Checking)

InlineArray는 안전한 서브스크립트와 경계 검사 없는 서브스크립트를 모두 제공합니다. 성능이 극히 중요한 코드에서는 후자가 유용하지만, 솔직히 웬만하면 안전한 버전을 쓰는 게 좋습니다.

var data: InlineArray<3, Int> = [10, 20, 30]

// 안전한 접근 - 범위를 벗어나면 런타임 에러 발생
let value = data[1]  // 20

// 범위를 벗어난 접근 시 런타임 트랩 발생
// let crash = data[5]  // 런타임 에러!

// 경계 검사 없는 접근 (성능 극대화가 필요할 때)
// 주의: 범위를 벗어나면 정의되지 않은 동작이 발생할 수 있습니다
let unchecked = data[unchecked: 2]  // 30

요소 교환 (Swapping)

var items: InlineArray<4, String> = ["사과", "바나나", "체리", "대추"]

// 첫 번째와 세 번째 요소 교환
items.swapAt(0, 2)
// 결과: ["체리", "바나나", "사과", "대추"]

유용한 프로퍼티들

var matrix: InlineArray<9, Int> = .init(repeating: 0)

// 정적 프로퍼티로 크기 확인
print(InlineArray<9, Int>.count)  // 9

// 인스턴스 프로퍼티
print(matrix.count)       // 9
print(matrix.isEmpty)     // false
print(matrix.startIndex)  // 0
print(matrix.endIndex)    // 9
print(matrix.indices)     // 0..<9

// 빈 InlineArray 확인
var empty: InlineArray<0, Int> = []
print(empty.isEmpty)  // true
print(empty.count)    // 0

반복 처리

InlineArray를 사용할 때 꼭 알아둬야 할 중요한 점이 하나 있습니다. 현재 InlineArraySequenceCollection 프로토콜을 준수하지 않습니다. 의도적인 설계 결정이라고 하는데, 처음 접하면 좀 당황스러울 수 있어요. 향후 버전에서 변경될 가능성은 있습니다.

indices를 이용한 반복

가장 기본적이고 권장되는 반복 방법은 indices 프로퍼티를 사용하는 것입니다.

var scores: InlineArray<5, Int> = [95, 88, 72, 91, 85]

// indices를 이용한 반복
for index in scores.indices {
    print("학생 \(index + 1)의 점수: \(scores[index])")
}
// 출력:
// 학생 1의 점수: 95
// 학생 2의 점수: 88
// 학생 3의 점수: 72
// 학생 4의 점수: 91
// 학생 5의 점수: 85

수동 인덱스 탐색

InlineArrayindex(after:)index(before:) 메서드를 제공하므로, 수동으로 인덱스를 직접 관리할 수도 있습니다.

var values: InlineArray<4, Double> = [1.1, 2.2, 3.3, 4.4]

// 순방향 수동 탐색
var idx = values.startIndex
while idx < values.endIndex {
    print(values[idx])
    idx = values.index(after: idx)
}

// 역방향 수동 탐색
var reverseIdx = values.index(before: values.endIndex)
while reverseIdx >= values.startIndex {
    print(values[reverseIdx])
    if reverseIdx == values.startIndex { break }
    reverseIdx = values.index(before: reverseIdx)
}

총합, 최대값 등 집계 연산

map, filter, reduce를 직접 쓸 수 없으니, 이런 연산은 직접 구현해야 합니다. 약간 번거롭긴 하지만 어렵지는 않습니다.

var numbers: InlineArray<6, Int> = [12, 45, 23, 67, 34, 89]

// 합계 계산
var sum = 0
for i in numbers.indices {
    sum += numbers[i]
}
print("합계: \(sum)")  // 합계: 270

// 최대값 찾기
var maxVal = numbers[0]
for i in numbers.indices {
    if numbers[i] > maxVal {
        maxVal = numbers[i]
    }
}
print("최대값: \(maxVal)")  // 최대값: 89

// 조건에 맞는 요소 카운트
var aboveFifty = 0
for i in numbers.indices {
    if numbers[i] > 50 {
        aboveFifty += 1
    }
}
print("50 초과 요소 수: \(aboveFifty)")  // 50 초과 요소 수: 2

메모리 레이아웃과 성능

InlineArray의 진짜 매력은 메모리 레이아웃과 성능 특성에 있습니다. 여기부터가 본격적으로 재미있는 부분이에요.

메모리 레이아웃

InlineArray의 메모리 크기는 아주 예측 가능한 공식을 따릅니다.

  • 크기(size) = Element.stride x count
  • 정렬(alignment) = 요소 타입의 정렬과 동일
  • 보폭(stride) = Element.stride x count (빈 배열의 경우 stride는 1)
// UInt8 4개: 4바이트
print(MemoryLayout<InlineArray<4, UInt8>>.size)       // 4
print(MemoryLayout<InlineArray<4, UInt8>>.alignment)  // 1
print(MemoryLayout<InlineArray<4, UInt8>>.stride)     // 4

// Int32 3개: 12바이트 (4바이트 × 3)
print(MemoryLayout<InlineArray<3, Int32>>.size)       // 12
print(MemoryLayout<InlineArray<3, Int32>>.alignment)  // 4
print(MemoryLayout<InlineArray<3, Int32>>.stride)     // 12

// Double 2개: 16바이트 (8바이트 × 2)
print(MemoryLayout<InlineArray<2, Double>>.size)      // 16
print(MemoryLayout<InlineArray<2, Double>>.alignment) // 8
print(MemoryLayout<InlineArray<2, Double>>.stride)    // 16

// UInt16 3개: 6바이트 (2바이트 × 3)
print(MemoryLayout<InlineArray<3, UInt16>>.size)      // 6

// 빈 배열: 크기 0이지만 stride는 1
print(MemoryLayout<InlineArray<0, Int>>.size)         // 0
print(MemoryLayout<InlineArray<0, Int>>.stride)       // 1

비교해 보면 재미있는데, 일반 Array<Int>는 요소 수에 관계없이 항상 8바이트(64비트 시스템 기준)입니다. Array가 실제로는 힙에 있는 버퍼에 대한 포인터만 저장하기 때문이죠.

// Array는 항상 동일한 크기 (포인터 크기)
print(MemoryLayout<Array<Int>>.size)  // 8 (64비트 시스템)

// InlineArray는 요소 수에 비례
print(MemoryLayout<InlineArray<100, Int>>.size)  // 800 (8바이트 × 100)

성능 이점

InlineArray가 제공하는 성능 이점을 정리하면 이렇습니다.

  • 힙 할당 없음: 메모리 할당/해제 비용이 완전히 제거됩니다.
  • 레퍼런스 카운팅 없음: 원자적 카운터 증감 연산이 필요 없습니다.
  • Copy-on-Write 검사 없음: 매 접근 시 유일성 검사가 불필요합니다.
  • 캐시 친화적: 요소들이 연속된 메모리에 저장되어 CPU 캐시 적중률이 높습니다.
  • 컴파일러 최적화: 크기가 컴파일 타임에 알려져 있어 루프 언롤링(unrolling) 같은 최적화가 가능합니다.

벤치마크 결과에 따르면, 타이트한 루프에서 InlineArray는 기존 Array 대비 20~30% 빠른 접근 속도를 보여줍니다. 특히 작은 크기의 고정 배열을 빈번하게 생성하고 파괴하는 패턴에서 그 차이가 확연히 드러나요.

// 성능 비교 시나리오: 3D 벡터 연산을 수백만 번 반복

// Array 사용 시 - 매번 힙 할당 발생
func addVectorsArray(_ a: [Double], _ b: [Double]) -> [Double] {
    return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}

// InlineArray 사용 시 - 스택에서 모든 연산 완료
func addVectorsInline(
    _ a: InlineArray<3, Double>,
    _ b: InlineArray<3, Double>
) -> InlineArray<3, Double> {
    return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}

Span과 함께 사용하기

Swift 6.2에서는 InlineArray와 함께 Span 타입도 도입되었는데요. Span은 연속된 메모리 블록에 대한 안전한 비소유 뷰(non-owning view)를 제공하는 타입으로, 기존의 UnsafeBufferPointer를 안전하게 대체할 수 있습니다.

Span의 핵심 특성

  • 비소유(non-owning): 메모리를 소유하지 않고 빌려서(borrow) 사용합니다.
  • 수명 안전성(lifetime safety): 컴파일러가 원본 데이터의 수명을 넘어선 접근을 방지합니다.
  • 스코프 탈출 불가: 함수에서 반환하거나 변수에 저장할 수 없습니다.
  • 클로저 캡처 불가: 클로저 내에서 캡처할 수 없습니다.

InlineArray와 Span의 시너지

InlineArrayspan 프로퍼티를 통해 Span을 생성할 수 있습니다. 이 조합은 성능이 중요한 코드에서 정말 강력합니다.

// InlineArray에서 Span 생성
var buffer: InlineArray<8, UInt8> = [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x21, 0x00, 0x00]

// Span을 활용한 안전한 메모리 접근
let span = buffer.span
for i in 0..<span.count {
    print(span[i], terminator: " ")
}
// 출력: 72 101 108 108 111 33 0 0

Span에는 읽기 전용인 Span, 쓰기 가능한 MutableSpan, 원시 바이트 접근을 위한 RawSpan 등 여러 변형이 있습니다. InlineArraySpan을 함께 쓰면, 힙 할당 없이 안전하면서도 고성능의 메모리 접근 패턴을 만들어낼 수 있어요.

// 함수 파라미터로 Span을 받아 다양한 소스와 호환
func processData(_ data: Span<Float>) {
    var sum: Float = 0
    for i in 0..<data.count {
        sum += data[i]
    }
    print("합계: \(sum)")
}

// InlineArray에서 생성한 Span 전달
var samples: InlineArray<4, Float> = [1.0, 2.0, 3.0, 4.0]
processData(samples.span)

// 일반 Array에서도 동일한 함수 사용 가능
let dynamicSamples: [Float] = [5.0, 6.0, 7.0]
processData(dynamicSamples.span)

이처럼 SpanInlineArrayArray 사이의 공통 인터페이스 역할을 해 줍니다. 함수를 한 번만 작성하면 두 타입 모두에서 쓸 수 있으니 코드 유연성이 확 올라가죠.

실전 활용 사례

이론은 충분히 살펴봤으니, 이제 실제 프로젝트에서 InlineArray를 어떻게 활용할 수 있는지 구체적인 예제를 통해 알아보겠습니다.

1. RGB 색상 표현

색상 값은 항상 정확히 3개(알파 채널을 포함하면 4개)의 구성 요소를 가집니다. InlineArray의 완벽한 사용 사례라고 할 수 있어요.

// RGB 색상을 InlineArray로 표현
struct RGBColor {
    // 각 채널은 0.0 ~ 1.0 범위의 Float 값
    var channels: InlineArray<3, Float>

    // 편의 프로퍼티
    var red: Float {
        get { channels[0] }
        set { channels[0] = newValue }
    }

    var green: Float {
        get { channels[1] }
        set { channels[1] = newValue }
    }

    var blue: Float {
        get { channels[2] }
        set { channels[2] = newValue }
    }

    // 생성자
    init(red: Float, green: Float, blue: Float) {
        channels = [red, green, blue]
    }

    // 두 색상의 혼합
    func mixed(with other: RGBColor, ratio: Float) -> RGBColor {
        let r = self.red * (1 - ratio) + other.red * ratio
        let g = self.green * (1 - ratio) + other.green * ratio
        let b = self.blue * (1 - ratio) + other.blue * ratio
        return RGBColor(red: r, green: g, blue: b)
    }

    // 밝기 계산 (ITU-R BT.709 가중치)
    var luminance: Float {
        return 0.2126 * red + 0.7152 * green + 0.0722 * blue
    }
}

// 사용 예시
var sunset = RGBColor(red: 1.0, green: 0.4, blue: 0.2)
let ocean = RGBColor(red: 0.0, green: 0.3, blue: 0.8)

// 색상 혼합
let blended = sunset.mixed(with: ocean, ratio: 0.5)
print("혼합 색상 - R: \(blended.red), G: \(blended.green), B: \(blended.blue)")

// 밝기 비교
print("석양 밝기: \(sunset.luminance)")
print("바다 밝기: \(ocean.luminance)")

2. 게임 개발: 고정 크기 파티클 시스템

게임에서는 성능이 곧 프레임 레이트이고, 고정된 수의 객체를 다루는 경우가 많습니다. 제가 프로토타입을 만들어 본 경험으로는, 작은 파티클 시스템에서 InlineArray의 효과가 꽤 체감됩니다.

// 2D 좌표
struct Vector2D {
    var x: Float
    var y: Float

    static func + (lhs: Vector2D, rhs: Vector2D) -> Vector2D {
        return Vector2D(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }

    static func * (lhs: Vector2D, rhs: Float) -> Vector2D {
        return Vector2D(x: lhs.x * rhs, y: lhs.y * rhs)
    }
}

// 파티클 구조체
struct Particle {
    var position: Vector2D
    var velocity: Vector2D
    var life: Float  // 남은 수명 (0.0 ~ 1.0)

    mutating func update(deltaTime: Float) {
        position = position + velocity * deltaTime
        life -= deltaTime * 0.1
    }

    var isAlive: Bool { life > 0 }
}

// 고정 크기 파티클 시스템 (최대 8개의 파티클)
struct MiniParticleSystem {
    var particles: InlineArray<8, Particle>

    init() {
        // 모든 파티클을 기본값으로 초기화
        particles = .init { i in
            Particle(
                position: Vector2D(x: 0, y: 0),
                velocity: Vector2D(
                    x: Float(i) * 0.5 - 2.0,
                    y: Float.random(in: 1.0...3.0)
                ),
                life: 1.0
            )
        }
    }

    // 모든 파티클 업데이트
    mutating func update(deltaTime: Float) {
        for i in particles.indices {
            particles[i].update(deltaTime: deltaTime)
        }
    }

    // 살아있는 파티클 수 계산
    var aliveCount: Int {
        var count = 0
        for i in particles.indices {
            if particles[i].isAlive {
                count += 1
            }
        }
        return count
    }
}

// 사용 예시
var system = MiniParticleSystem()
print("초기 파티클 수: \(system.aliveCount)")  // 8

// 프레임 업데이트 시뮬레이션
for frame in 1...60 {
    system.update(deltaTime: 1.0 / 60.0)
}
print("60프레임 후 생존 파티클: \(system.aliveCount)")

3. 행렬 및 벡터 수학

그래픽 프로그래밍과 과학 계산에서 행렬 연산은 빠질 수 없는 요소입니다. InlineArray로 행렬을 구현하면 힙 할당 없이 깔끔하게 처리할 수 있어요.

// 3x3 행렬을 InlineArray로 구현
struct Matrix3x3 {
    // 9개의 요소를 1차원으로 저장 (행 우선 순서)
    var elements: InlineArray<9, Float>

    // 단위 행렬 생성
    static var identity: Matrix3x3 {
        var m = Matrix3x3(elements: .init(repeating: 0.0))
        m[0, 0] = 1.0
        m[1, 1] = 1.0
        m[2, 2] = 1.0
        return m
    }

    // 2차원 인덱스로 접근
    subscript(row: Int, col: Int) -> Float {
        get { elements[row * 3 + col] }
        set { elements[row * 3 + col] = newValue }
    }

    // 행렬 곱셈
    static func * (lhs: Matrix3x3, rhs: Matrix3x3) -> Matrix3x3 {
        var result = Matrix3x3(elements: .init(repeating: 0.0))
        for i in 0..<3 {
            for j in 0..<3 {
                var sum: Float = 0
                for k in 0..<3 {
                    sum += lhs[i, k] * rhs[k, j]
                }
                result[i, j] = sum
            }
        }
        return result
    }

    // 전치 행렬
    var transposed: Matrix3x3 {
        var result = Matrix3x3(elements: .init(repeating: 0.0))
        for i in 0..<3 {
            for j in 0..<3 {
                result[j, i] = self[i, j]
            }
        }
        return result
    }

    // 행렬 출력
    func printMatrix() {
        for row in 0..<3 {
            let r0 = String(format: "%8.2f", self[row, 0])
            let r1 = String(format: "%8.2f", self[row, 1])
            let r2 = String(format: "%8.2f", self[row, 2])
            print("|\(r0) \(r1) \(r2) |")
        }
    }
}

// 3D 벡터
struct Vec3 {
    var components: InlineArray<3, Float>

    var x: Float { components[0] }
    var y: Float { components[1] }
    var z: Float { components[2] }

    init(_ x: Float, _ y: Float, _ z: Float) {
        components = [x, y, z]
    }

    // 내적 (dot product)
    func dot(_ other: Vec3) -> Float {
        var result: Float = 0
        for i in components.indices {
            result += components[i] * other.components[i]
        }
        return result
    }

    // 벡터 크기
    var magnitude: Float {
        return (dot(self)).squareRoot()
    }
}

// 사용 예시
let identity = Matrix3x3.identity
print("단위 행렬:")
identity.printMatrix()

var rotation = Matrix3x3.identity
let angle: Float = .pi / 4  // 45도
rotation[0, 0] = cos(angle)
rotation[0, 1] = -sin(angle)
rotation[1, 0] = sin(angle)
rotation[1, 1] = cos(angle)
print("\n45도 회전 행렬:")
rotation.printMatrix()

let v1 = Vec3(1.0, 2.0, 3.0)
let v2 = Vec3(4.0, 5.0, 6.0)
print("\n내적: \(v1.dot(v2))")      // 32.0
print("v1 크기: \(v1.magnitude)")   // 3.7416...

4. C 인터롭을 위한 고정 크기 버퍼

C 라이브러리와 상호작용할 때 고정 크기 버퍼는 정말 흔한 패턴입니다. InlineArray 덕분에 이전보다 훨씬 자연스럽게 처리할 수 있게 됐어요.

// C 스타일의 고정 크기 메시지 버퍼
struct FixedBuffer {
    // 256바이트 고정 버퍼
    var data: InlineArray<256, UInt8>
    var length: Int

    init() {
        data = .init(repeating: 0)
        length = 0
    }

    // 바이트 쓰기
    mutating func write(_ byte: UInt8) -> Bool {
        guard length < 256 else { return false }
        data[length] = byte
        length += 1
        return true
    }

    // 문자열에서 바이트 쓰기
    mutating func write(string: String) -> Bool {
        let utf8 = Array(string.utf8)
        guard length + utf8.count <= 256 else { return false }
        for byte in utf8 {
            data[length] = byte
            length += 1
        }
        return true
    }

    // 버퍼 초기화
    mutating func reset() {
        for i in 0..<length {
            data[i] = 0
        }
        length = 0
    }
}

// 사용 예시
var buffer = FixedBuffer()
_ = buffer.write(string: "Hello, Swift 6.2!")
print("버퍼 길이: \(buffer.length)")  // 17

// 메모리 크기 확인 - 힙 할당 없음
print("FixedBuffer 크기: \(MemoryLayout<FixedBuffer>.size) 바이트")
// 256 + 8(length) = 264바이트가 스택에 저장됩니다

5. 신호 처리 / DSP

디지털 신호 처리에서는 고정 크기의 필터 커널과 샘플 버퍼가 핵심입니다. 오디오 프로세싱을 해 본 분이라면 이 패턴이 익숙하실 거예요.

// 간단한 FIR(Finite Impulse Response) 필터
struct FIRFilter {
    // 5탭 필터 계수
    let coefficients: InlineArray<5, Float>
    // 최근 5개의 입력 샘플을 보관하는 순환 버퍼
    var history: InlineArray<5, Float>
    var historyIndex: Int

    init(coefficients: InlineArray<5, Float>) {
        self.coefficients = coefficients
        self.history = .init(repeating: 0.0)
        self.historyIndex = 0
    }

    // 로우패스 필터 (기본 평균 필터)
    static var lowPass: FIRFilter {
        FIRFilter(coefficients: [0.2, 0.2, 0.2, 0.2, 0.2])
    }

    // 가중 이동평균 필터
    static var weightedAverage: FIRFilter {
        FIRFilter(coefficients: [0.1, 0.15, 0.5, 0.15, 0.1])
    }

    // 새 샘플을 입력하고 필터링된 출력을 반환
    mutating func process(sample: Float) -> Float {
        // 새 샘플을 순환 버퍼에 저장
        history[historyIndex] = sample

        // 컨볼루션 수행
        var output: Float = 0.0
        for i in coefficients.indices {
            let bufferIndex = (historyIndex + coefficients.count - i) % coefficients.count
            output += coefficients[i] * history[bufferIndex]
        }

        // 인덱스 순환
        historyIndex = (historyIndex + 1) % coefficients.count

        return output
    }
}

// 사용 예시: 노이즈가 있는 신호를 필터링
var filter = FIRFilter.lowPass

// 시뮬레이션된 입력 신호 (사인파 + 노이즈)
let inputSignal: InlineArray<10, Float> = [
    1.0, 1.2, 0.8, 1.1, 0.9,
    1.3, 0.7, 1.15, 0.85, 1.05
]

print("입력 → 필터 출력:")
for i in inputSignal.indices {
    let filtered = filter.process(sample: inputSignal[i])
    let input = String(format: "%.2f", inputSignal[i])
    let output = String(format: "%.2f", filtered)
    print("  \(input) → \(output)")
}

제한 사항과 주의 사항

InlineArray는 강력한 도구이지만, 현재 버전에는 솔직히 몇 가지 아쉬운 제한 사항들이 있습니다. 사용하기 전에 꼭 알아두세요.

Sequence/Collection 미준수

가장 큰 제한 사항은 SequenceCollection 프로토콜을 준수하지 않는다는 것입니다. 이 때문에 다음과 같은 기능들을 직접 쓸 수 없어요.

  • for element in array 형태의 직접적인 for-in 반복
  • map, filter, reduce, compactMap 등의 고차 함수
  • contains, first(where:), min(), max() 등의 검색 메서드
  • sorted(), reversed() 등의 정렬/변환 메서드
  • zip, enumerated() 등의 시퀀스 결합

의도적인 설계 결정이라고는 하지만, Collection 준수를 위해서는 associatedtype Index와 관련된 복잡한 문제를 먼저 해결해야 하는 상황입니다. 향후 Swift 버전에서 다뤄질 예정이니 조금만 기다려 봅시다.

크기 변경 불가

var array: InlineArray<3, Int> = [1, 2, 3]

// 다음 연산들은 모두 사용할 수 없습니다:
// array.append(4)           // 컴파일 에러
// array.remove(at: 0)       // 컴파일 에러
// array.insert(0, at: 0)    // 컴파일 에러
// array.removeAll()         // 컴파일 에러

Equatable/Hashable 미지원

현재 InlineArrayEquatable이나 Hashable을 자동으로 준수하지 않습니다. 두 InlineArray==로 비교하거나 Set, Dictionary의 키로 사용하려면 직접 구현해야 합니다.

let a: InlineArray<3, Int> = [1, 2, 3]
let b: InlineArray<3, Int> = [1, 2, 3]

// 직접 비교 불가
// if a == b { }  // 컴파일 에러

// 수동으로 비교 함수 작성 필요
func isEqual(_ lhs: InlineArray<3, Int>, _ rhs: InlineArray<3, Int>) -> Bool {
    for i in lhs.indices {
        if lhs[i] != rhs[i] {
            return false
        }
    }
    return true
}
print(isEqual(a, b))  // true

CustomStringConvertible 미지원

print로 출력할 때 보기 좋은 형식을 기대하기 어렵습니다. 필요하면 직접 포맷팅 로직을 만들어야 해요.

// 직접 포맷팅 헬퍼 함수 구현
func describe<let N: Int, T>(_ array: InlineArray<N, T>) -> String {
    var parts: [String] = []
    for i in array.indices {
        parts.append("\(array[i])")
    }
    return "[\(parts.joined(separator: ", "))]"
}

let values: InlineArray<4, Int> = [10, 20, 30, 40]
print(describe(values))  // [10, 20, 30, 40]

스택 크기에 대한 주의

이건 꽤 중요한 부분인데요. InlineArray는 스택에 할당되므로, 너무 큰 크기로 만들면 스택 오버플로우가 발생할 수 있습니다. 일반적으로 스택 크기는 스레드당 수 MB로 제한되어 있으니, 수천 개 이상의 큰 요소를 저장하는 용도로는 적합하지 않습니다.

// 적절한 사용 예시
var smallBuffer: InlineArray<64, UInt8> = .init(repeating: 0)   // 64바이트 - 문제없음
var mediumBuffer: InlineArray<256, Float> = .init(repeating: 0) // 1KB - 문제없음

// 주의가 필요한 사용 예시
// var largeBuffer: InlineArray<1_000_000, Double> = .init(repeating: 0)
// 약 8MB - 스택 오버플로우 위험!
// 이런 경우에는 그냥 Array를 사용하세요.

향후 개선 방향

Swift 팀에서 계획 중인 개선 사항들이 있습니다. 하나하나 추가될 때마다 InlineArray의 활용도가 크게 높아질 거예요.

  • Collection 프로토콜 준수 추가
  • Equatable, Hashable, Comparable 조건부 준수
  • CustomStringConvertible, CustomDebugStringConvertible 준수
  • Codable 지원
  • 더 풍부한 API 표면

Array vs InlineArray: 언제 무엇을 사용할까?

자, 그러면 실제로 코드를 작성할 때 어떤 걸 써야 할까요? 다음 비교표를 참고하면 결정이 한결 수월해질 겁니다.

특성 Array InlineArray
크기 런타임에 동적으로 변경 가능 컴파일 타임에 고정
메모리 할당 힙 (heap) 인라인 (스택 또는 포함 타입 내부)
복사 의미론 Copy-on-Write Copy-on-Copy (값 복사)
레퍼런스 카운팅 필요 (원자적 연산) 불필요
성능 일반적 용도에 최적화 타이트한 루프에서 20~30% 빠름
Collection 준수 완전 준수 (map, filter 등 사용 가능) 미준수 (indices로 수동 반복)
append/remove 지원 미지원
Equatable/Hashable 조건부 자동 준수 미지원 (향후 추가 예정)
Codable 조건부 자동 준수 미지원 (향후 추가 예정)
대규모 데이터 적합 (힙에 저장) 부적합 (스택 오버플로우 위험)
C 인터롭 추가 변환 필요 메모리 레이아웃 호환
비복사 타입 지원 미지원 지원

InlineArray를 선택해야 하는 경우

  • 요소의 수가 컴파일 타임에 알려져 있고 변하지 않는 경우
  • 실시간 렌더링, 오디오 처리 등 성능이 극도로 중요한 경우
  • 힙 할당을 완전히 제거해야 하는 경우
  • C/C++ 라이브러리와의 인터롭에서 고정 크기 배열이 필요한 경우
  • 3D 벡터, 행렬, 색상 등 수학적 타입을 구현하는 경우
  • 비복사 타입의 요소를 저장해야 하는 경우

Array를 선택해야 하는 경우

  • 요소를 동적으로 추가/삭제해야 하는 경우
  • map, filter, reduce고차 함수가 필요한 경우
  • 요소의 수가 런타임에 결정되는 경우
  • 대량의 데이터를 저장해야 하는 경우 (수천 개 이상)
  • Equatable, Hashable, Codable 프로토콜 준수가 필요한 경우
  • 일반적인 앱 로직에서 편의 기능이 더 중요한 경우

SE-0483: InlineArray 타입 슈거

InlineArray를 더 편하게 쓸 수 있도록, SE-0483에서 타입 슈거(Type Sugar) 구문이 도입되었습니다. 이미 승인되어 Swift 6.2에 포함되어 있어요.

기존의 좀 장황한 제네릭 구문 대신 더 간결한 문법을 쓸 수 있는데, 보시면 바로 느낌이 올 겁니다.

// 기존 구문
let scores: InlineArray<5, Int> = .init(repeating: 0)

// SE-0483 타입 슈거 구문
let scores: [5 of Int] = .init(repeating: 0)

이 구문은 다양한 곳에서 활용할 수 있습니다.

// 기본 사용
let grades: [3 of Double] = [95.5, 88.0, 92.3]

// 중첩 배열 (다차원)
let matrix: [3 of [3 of Float]] = .init(repeating: .init(repeating: 0.0))
// 또는 평탄화된 구문:
let flatMatrix: [3 of 3 of Float] = .init(repeating: .init(repeating: 0.0))

// 타입 추론과 함께
let inferred: [_ of Int] = [1, 2, 3, 4]

// MemoryLayout에서 사용
print(MemoryLayout<[5 of Int]>.size)  // 40

// 함수 시그니처에서 사용
func processBuffer(_ data: [256 of UInt8]) {
    // ...
}

개인적으로 이 타입 슈거가 가장 마음에 드는 부분인데요, 특히 중첩된 다차원 배열을 선언할 때 가독성이 확 올라갑니다. InlineArray<3, InlineArray<3, Float>>보다 [3 of 3 of Float]이 훨씬 직관적이잖아요.

결론

InlineArray는 Swift 6.2에서 도입된 가장 중요한 저수준 성능 도구 중 하나입니다. SE-0452의 값 제네릭을 기반으로, SE-0453을 통해 탄생한 이 타입은 Swift가 시스템 프로그래밍 언어로서 한 단계 더 성장했다는 걸 잘 보여줍니다.

핵심 내용을 정리하면 이렇습니다.

  • InlineArray컴파일 타임에 크기가 결정되는 고정 크기 배열입니다.
  • 요소들이 인라인으로 저장되어 별도의 힙 할당이 발생하지 않습니다.
  • 레퍼런스 카운팅과 Copy-on-Write 오버헤드가 없어 타이트한 루프에서 20~30% 빠릅니다.
  • RGB 색상, 벡터/행렬 연산, 게임 개발, C 인터롭, 신호 처리 등 다양한 실전 시나리오에서 활용할 수 있습니다.
  • 현재는 Collection 미준수 등 일부 제한이 있지만, 향후 버전에서 꾸준히 개선될 예정입니다.
  • SE-0483의 [N of T] 타입 슈거로 더 간결하게 작성할 수 있습니다.

InlineArray가 모든 상황에서 기존 Array를 대체하는 건 아닙니다. 크기가 고정되어 있고 성능이 중요한 특정 시나리오에서 진가를 발휘하는 전문화된 도구예요. 두 타입의 특성을 잘 이해하고 상황에 맞게 선택한다면, Swift 코드의 성능과 안전성을 동시에 끌어올릴 수 있을 겁니다.

Swift는 계속 진화하고 있고, InlineArray를 포함한 새로운 기능들은 Swift를 앱 개발을 넘어 시스템 프로그래밍, 게임 엔진, 임베디드 시스템 등 훨씬 넓은 영역으로 확장시키고 있습니다. 여러분의 다음 프로젝트에서 InlineArray를 한번 활용해 보세요. 성능 차이를 직접 체감하면 돌아가기 어려울 겁니다!