반응형

[PART6.배열과 문자열 기본(13/14)] 인라인 배열 — unsafe 없이 구조체 안에 고정 크기 배열을 박는다

[InlineArray(N)] + 단일 필드 struct → 컴파일러가 N칸으로 확장 / unsafe·fixed 없이 안전 / 인덱서·foreach 자연스럽게 / 런타임이 자동으로 Span<T>로 노출 / C# 12 컬렉션 식 Span<int> = [1,2,3]이 내부적으로 사용 / 클래스 안에 박아 GC 객체 수 줄이기


1. C++ int buf[8];이 C#에는 왜 없었는가

C/C++에서는 구조체 안에 고정 크기 배열을 그대로 박는 게 자연스럽습니다.

struct PlayerSlots {
    int items[8];   // 8칸이 PlayerSlots 안에 직접 들어 있음
};

C#에서 같은 일을 하려면 두 가지 선택뿐이었고, 둘 다 단점이 있었습니다.

C#
// 옵션 1: 일반 배열 필드 — 별도 힙 객체
public class PlayerSlots {
    public int[] items = new int[8];   // PlayerSlots 객체 1개 + int[] 객체 1개 = 2개
}

// 옵션 2: unsafe + fixed — 안전성 포기
public unsafe struct PlayerSlots {
    public fixed int items[8];          // unsafe 컨텍스트 강제, 포인터 위험
}

C# 12가 도입한 인라인 배열은 이 두 옵션의 좋은 점만 모은 세 번째 선택지입니다.

C#
[System.Runtime.CompilerServices.InlineArray(8)]
public struct PlayerSlots {
    private int _e;     // 단일 필드만 선언, 컴파일러가 8칸으로 확장
}

// 사용
var slots = new PlayerSlots();
slots[0] = 100;
foreach (var v in slots) Console.WriteLine(v);
// → unsafe 없음, GC 객체 1개 (struct 자체뿐)

unsafe도 없고, 별도 힙 객체도 없고, 인덱서·foreach까지 일반 배열처럼 동작합니다. 이 글의 목표는 이 신문법이 IL에서 어떻게 동작하는지, 언제 쓸 가치가 있는지를 풀어 보는 것입니다.


2. 인라인 배열의 정체

비유 — 한 동짜리 빌딩에 객실을 N개 짓다

배열이 별도의 동(다른 객체)이라면, 인라인 배열은 같은 빌딩 안에 객실을 N개 두는 방식입니다. 빌딩 도면(struct) 한 장에 객실(요소) 8개가 한 줄로 배치되어 있고, 도면 자체가 변형 없이 그대로 빌딩이 됩니다. 별도 동을 짓지 않으니 출입구를 추가로 만들 필요도 없고, 도면을 다른 도면 안에 끼워 넣을 수도 있습니다.

선언 형식

C#
[System.Runtime.CompilerServices.InlineArray(N)]
public struct MyBuffer
{
    private T _element0;     // 단일 필드만, 이름은 무엇이든 OK
}

규칙:

  • 정확히 [InlineArray(N)] 특성 1개 + 단일 필드 1개.
  • 필드 타입 T가 인라인 배열의 요소 타입.
  • 길이 N은 컴파일 타임 상수.
  • 다른 메서드·프로퍼티는 추가해도 OK이지만 다른 인스턴스 필드 추가 시 컴파일 오류.

IL에 박힌 메타데이터

C#
[InlineArray(8)]
public struct Buffer8 { private int _element0; }
IL
.class public sequential ansi sealed beforefieldinit InlineArrayDemo.Buffer8
    extends System.ValueType
{
    .custom instance void System.Runtime.CompilerServices.InlineArrayAttribute::.ctor(int32) = (
        01 00 08 00 00 00 00 00       // ← 8 (배열 길이) 메타데이터
    )
    .field private int32 _element0
}

IL에 보이는 것은 단일 필드 _element0 + [InlineArray(8)] 메타데이터가 전부입니다. 컴파일러와 런타임이 이 메타데이터를 보고 "이 struct는 사실 8칸짜리 배열로 다뤄라"라고 약속합니다 — IL 차원에는 추가 필드가 박히지 않고, 런타임이 8칸을 위한 메모리를 계산해 줍니다.

메모리 레이아웃

Buffer8 (struct, 8칸 인라인)

Buffer8 buf = default;이 선언되면 스택 프레임에 32바이트(int 8개)가 그대로 잡힙니다. 별도 객체가 없으니 객체 헤더 비용도 없고, GC도 관여하지 않습니다.


3. 인덱서·foreach — 일반 배열처럼 보이는 IL

사용 코드

C#
public static int Sum8()
{
    Buffer8 buf = default;
    for (int i = 0; i < 8; i++) buf[i] = i + 1;
    int s = 0;
    foreach (var v in buf) s += v;
    return s;
}

IL — 컴파일러가 헬퍼 메서드를 호출

IL
IL_0001: ldloca.s 0
IL_0003: initobj InlineArrayDemo.Buffer8                      // 스택에 buf, 0으로 초기화

// for 루프 안: buf[i] = i + 1
IL_000d: ldloca.s 0
IL_000f: ldc.i4.8
IL_0010: call ... '<PrivateImplementationDetails>'
              ::InlineArrayAsSpan<Buffer8, int32>(...)        // ← 인라인 배열 → Span<int> 변환 헬퍼
IL_0019: call instance !0& Span`1<int32>::get_Item(int32)     // span[i]
IL_0021: stind.i4                                             // = i + 1

// foreach: 인라인 배열을 직접 순회
IL_0040: call !!1& '<PrivateImplementationDetails>'
              ::InlineArrayElementRef<Buffer8, int32>(...)    // ← 각 요소의 ref
IL_0045: ldind.i4                                             // 값 로드

핵심:

  1. 컴파일러가 컴파일러 생성 헬퍼 메서드(<PrivateImplementationDetails>::InlineArrayAsSpan 등)를 호출합니다. 이 메서드들은 인라인 배열의 시작 주소를 받아 Span<T>을 만들어 줍니다.
  2. 변환된 Span<T> 위에서 일반 인덱서·foreach 가 동작합니다.
  3. 힙 alloc은 0 — 모든 작업이 스택 위에서 끝납니다.

Span<T>로 자연스럽게 노출

C#
public static int FirstViaSpan()
{
    Buffer8 buf = default;
    buf[0] = 42;

    Span<int> view = buf;          // 인라인 배열 → Span<int> 자동 변환
    return view[0];                 // 42
}

Span<int> view = buf라는 한 줄이 가능한 것은 인라인 배열이 Span으로 암시적 변환되기 때문입니다. 이 다리를 통해 Sort·BinarySearch·MemoryExtensions의 모든 표준 API에 인라인 배열을 그대로 넘길 수 있습니다.


4. C# 12 컬렉션 식과의 시너지 — 자동 인라인 배열 생성

PART 6 #5에서 이미 본 그림 — Span<int> values = [10, 20, 30];이 zero-alloc인 이유가 바로 인라인 배열입니다.

C#
public static int SumOfThree()
{
    Span<int> values = [10, 20, 30];   // 컴파일러가 <>y__InlineArray3<int>를 자동 생성
    int s = 0;
    for (int i = 0; i < values.Length; i++) s += values[i];
    return s;
}
IL
.locals init (
    ...
    [2] valuetype '<>y__InlineArray3`1'<int32>,         // ← 컴파일러 자동 생성 인라인 배열 (3칸)
    ...
)
IL_0001: ldloca.s 2
IL_0003: initobj '<>y__InlineArray3`1'<int32>           // 스택에 3칸
IL_0011: stind.i4                                       // [0] = 10
IL_001b: stind.i4                                       // [1] = 20
IL_0025: stind.i4                                       // [2] = 30
IL_002a: call valuetype Span`1<!!1> '<PrivateImplementationDetails>'
              ::InlineArrayAsSpan<...>(...)             // → Span<int> 변환

<>y__InlineArray3<T>라는 컴파일러가 자동 생성하는 제네릭 인라인 배열 struct가 있고, 그것을 메서드 지역 변수로 잡습니다. 사용자가 직접 [InlineArray(3)] struct를 선언하지 않아도 컴파일러가 알아서 만들어 주는 것 — 컬렉션 식이 zero-alloc인 메커니즘이 정확히 이것입니다.

따라서 컬렉션 식을 자주 쓰는 사용자는 간접적으로 인라인 배열을 매일 사용하고 있는 셈입니다. 직접 [InlineArray(N)] struct를 선언할 일은 그보다 훨씬 적습니다.


5. 직접 정의하는 인라인 배열은 언제 쓰는가

패턴 1 — 클래스 안에 고정 크기 버퍼

C#
public class Inventory
{
    [InlineArray(16)]
    public struct Slots { private int _e; }

    private Slots _items;        // 16칸이 Inventory 인스턴스 안에 직접 박힘

    public int Get(int i) => _items[i];
    public void Set(int i, int v) => _items[i] = v;
}

Inventory 인스턴스를 만들면 그 안에 16칸이 그대로 들어 있습니다 — 별도 int[] 객체가 없어 GC가 추적할 객체 수가 1개(Inventory 자체뿐)입니다. 100명의 플레이어 인벤토리를 만들면 옛 방식은 200개 객체(인벤토리 100 + 내부 배열 100), 인라인 배열로는 100개 객체.

패턴 2 — Native interop (구조체 직렬화)

C#
[InlineArray(64)]
public struct UnmanagedBuf { private byte _e; }

[StructLayout(LayoutKind.Sequential)]
public struct PacketHeader
{
    public int Version;
    public int Length;
    public UnmanagedBuf Payload;     // 64바이트가 PacketHeader 안에 박힘
}

C++ 구조체와 메모리 레이아웃을 정확히 일치시켜야 하는 자리에서 unsafe + fixed 없이 선언할 수 있습니다. P/Invoke나 네트워크 패킷 직렬화에 유용합니다.

패턴 3 — 작은 고정 캐시 (성능 핫패스)

C#
public class FrameStats
{
    [InlineArray(60)]
    public struct Last60Frames { private float _e; }

    private Last60Frames _samples;
    private int _idx;

    public void Push(float dt)
    {
        _samples[_idx] = dt;
        _idx = (_idx + 1) % 60;
    }

    public float Avg()
    {
        Span<float> s = _samples;
        float sum = 0;
        for (int i = 0; i < s.Length; i++) sum += s[i];
        return sum / 60f;
    }
}

60프레임 통계 같은 작고 고정된 크기의 데이터를 클래스 안에 직접 박을 때. 이전엔 float[60]을 별도 할당해야 했지만 인라인 배열로 GC 객체가 한 개 줄어듭니다.

패턴 4 — 라이브러리가 제공하는 인라인 배열을 그냥 쓰는 쪽

대부분의 일반 코드에서는 직접 [InlineArray(N)]을 선언할 일이 거의 없습니다. BCL과 라이브러리가 이미 인라인 배열을 사용해 만든 API를 그냥 쓰면 됩니다 — Span<T> 컬렉션 식, params ReadOnlySpan<T>(C# 13), string.Create 등.

신입 개발자가 이 신문법을 만나는 자리는 거의 항상 간접 사용(컬렉션 식이나 라이브러리 API 너머)입니다. 직접 선언이 필요한 자리는 라이브러리 작성자나 극한 최적화 코드입니다.


6. 함정과 주의사항

함정 1 — 단일 필드 규칙

C#
// ❌ 컴파일 오류 — 여러 필드 불가
[InlineArray(8)]
public struct Bad
{
    private int _e0;
    private int _e1;       // 추가 필드 X
}

// ❌ 컴파일 오류 — 정적 필드와 메서드는 OK이지만 인스턴스 필드는 1개
[InlineArray(8)]
public struct AlsoBad
{
    private int _e;
    private string _name;  // 추가 인스턴스 필드 X
}

// ✅ 메서드·프로퍼티·정적 필드는 추가 가능
[InlineArray(8)]
public struct OK
{
    private int _e;
    public static int Default = 0;
    public int Sum() { /* ... */ return 0; }
}

함정 2 — default가 진짜 0으로 초기화되는지 확인

C#
Buffer8 buf = default;        // 모든 칸 0
Buffer8 buf = new();          // 동등 — 모든 칸 0

// vs
Buffer8 buf;                  // 명시적 초기화 안 함 → 컴파일러가 사용 시 경고/오류

값 타입이라 default/new()로 명시적 초기화. 이 동작은 일반 struct와 같습니다.

함정 3 — 길이 제한과 큰 인라인 배열의 부담

C#
[InlineArray(1_000_000)]      // ❌ 위험! 1M*4 = 4MB가 struct 자체에 박힘
public struct Huge { private int _e; }

인라인 배열은 struct에 직접 박히는 메모리입니다. 1M칸을 잡으면 4MB짜리 struct가 됩니다 — 메서드 지역 변수로 두면 스택 오버플로 위험, 클래스 필드로 두면 객체 자체가 매우 무거워집니다. 고정 크기 작은 버퍼(보통 수 칸~수백 칸)에 적합합니다.

함정 4 — 클래스 필드로 사용 가능 (Span과 다름)

C#
public class Game
{
    private Slots _items;     // ✅ 인라인 배열 struct는 클래스 필드 OK
    private Span<int> _data;  // ❌ Span은 클래스 필드 불가
}

인라인 배열 struct는 일반 값 타입이라 클래스 필드로 자유롭게 사용 가능합니다. Span<T>이 ref struct라 못 가는 자리를 인라인 배열이 메워 줍니다.

함정 5 — 인덱싱이 컴파일러 헬퍼 호출이라 작은 비용 있음

buf[i]는 단일 IL 명령이 아니라 InlineArrayAsSpan 헬퍼 호출 + Span 인덱서로 컴파일됩니다. 일반 int[]ldelem.i4보다 명령 수가 많습니다 — 그래도 JIT가 잘 인라이닝해 실측 차이는 작지만, 매우 빈번한 자리에서는 한 번 Span으로 받아 두는 게 좋습니다.

C#
// ❌ 매 접근 헬퍼 호출
for (int i = 0; i < 8; i++) sum += buf[i];

// ✅ Span으로 한 번 받기
Span<int> s = buf;
for (int i = 0; i < s.Length; i++) sum += s[i];

7. C# 버전별 변화

버전 변화 비고
1.x 일반 배열 필드 (별도 힙 객체) 기본
2.x unsafe + fixed로 인라인 고정 크기 배열 위험·제약
7.2 Span<T> 도입 인프라
12 인라인 배열 [InlineArray(N)] unsafe 없는 안전 패턴
12 컬렉션 식 [1,2,3] (PART 6 #5) — Span 대상에서 자동 인라인 배열  
13 params ReadOnlySpan<T> — 호출자 인라인 배열로 zero-alloc  
14 T[]Span<T> 암시적 변환 강화 (PART 6 #12)  

C# 12의 인라인 배열은 단독으로보다 Span<T> + 컬렉션 식과 짝일 때 진가를 발휘합니다. 직접 [InlineArray(N)]을 쓰는 자리는 적지만, 그 메커니즘 위에 만들어진 신문법(params ReadOnlySpan<T>·컬렉션 식)이 일반 코드의 핵심 패턴이 됩니다.


8. 정리

  • [ ] [InlineArray(N)] + 단일 필드 struct → 컴파일러가 N칸 배열로 확장.
  • [ ] unsafe·fixed 없이 안전하게 고정 크기 인라인 배열을 가진다.
  • [ ] 인덱서 buf[i]·foreach 자연스럽게 동작 — 일반 배열처럼 보임.
  • [ ] Span<T> 자동 변환Span<int> view = buf; 한 줄로 표준 API와 연결.
  • [ ] 메모리 레이아웃: struct 안에 N개 요소가 그대로 박힘. 객체 헤더·힙 alloc 0.
  • [ ] C# 12 컬렉션 식 Span<int> = [1,2,3] 이 내부적으로 <>y__InlineArray3<int>를 자동 생성 → zero-alloc의 핵심 메커니즘.
  • [ ] 클래스 필드로 사용 가능Span<T>(ref struct, 클래스 필드 X)이 못 가는 자리에 들어간다.
  • [ ] 언제 직접 선언하는가: 클래스 안에 고정 크기 버퍼·Native interop 구조체·작은 캐시. 대부분의 일반 코드에서는 간접 사용(컬렉션 식·params ReadOnlySpan<T>)으로 충분.
  • [ ] 단일 필드 규칙: 인스턴스 필드는 정확히 1개만. 메서드·프로퍼티·정적 필드는 추가 OK.
  • [ ] 큰 인라인 배열 주의 — struct에 직접 박히므로 수 칸~수백 칸이 적당.
  • [ ] 인덱싱이 컴파일러 헬퍼 호출이라 매우 빈번한 자리에서는 Span<T>으로 한 번 받아 두기.
  • [ ] Unity 활용: 인벤토리 슬롯·프레임 통계·네트워크 패킷 헤더 등 고정 크기 데이터를 GC 객체 없이 클래스 안에 박을 때.
  • [ ] stackalloc과 짝지어 쓰는 패턴은 PART 6 #14에서 다룬다.
반응형

+ Recent posts