[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#에서 같은 일을 하려면 두 가지 선택뿐이었고, 둘 다 단점이 있었습니다.
// 옵션 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가 도입한 인라인 배열은 이 두 옵션의 좋은 점만 모은 세 번째 선택지입니다.
[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개가 한 줄로 배치되어 있고, 도면 자체가 변형 없이 그대로 빌딩이 됩니다. 별도 동을 짓지 않으니 출입구를 추가로 만들 필요도 없고, 도면을 다른 도면 안에 끼워 넣을 수도 있습니다.
선언 형식
[System.Runtime.CompilerServices.InlineArray(N)]
public struct MyBuffer
{
private T _element0; // 단일 필드만, 이름은 무엇이든 OK
}
규칙:
- 정확히
[InlineArray(N)]특성 1개 + 단일 필드 1개. - 필드 타입
T가 인라인 배열의 요소 타입. - 길이
N은 컴파일 타임 상수. - 다른 메서드·프로퍼티는 추가해도 OK이지만 다른 인스턴스 필드 추가 시 컴파일 오류.
IL에 박힌 메타데이터
[InlineArray(8)]
public struct Buffer8 { private int _element0; }
.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 buf = default;이 선언되면 스택 프레임에 32바이트(int 8개)가 그대로 잡힙니다. 별도 객체가 없으니 객체 헤더 비용도 없고, GC도 관여하지 않습니다.
3. 인덱서·foreach — 일반 배열처럼 보이는 IL
사용 코드
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_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 // 값 로드
핵심:
- 컴파일러가 컴파일러 생성 헬퍼 메서드(
<PrivateImplementationDetails>::InlineArrayAsSpan등)를 호출합니다. 이 메서드들은 인라인 배열의 시작 주소를 받아Span<T>을 만들어 줍니다. - 변환된
Span<T>위에서 일반 인덱서·foreach가 동작합니다. - 힙 alloc은 0 — 모든 작업이 스택 위에서 끝납니다.
Span<T>로 자연스럽게 노출
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인 이유가 바로 인라인 배열입니다.
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;
}
.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 — 클래스 안에 고정 크기 버퍼
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 (구조체 직렬화)
[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 — 작은 고정 캐시 (성능 핫패스)
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 — 단일 필드 규칙
// ❌ 컴파일 오류 — 여러 필드 불가
[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으로 초기화되는지 확인
Buffer8 buf = default; // 모든 칸 0
Buffer8 buf = new(); // 동등 — 모든 칸 0
// vs
Buffer8 buf; // 명시적 초기화 안 함 → 컴파일러가 사용 시 경고/오류
값 타입이라 default/new()로 명시적 초기화. 이 동작은 일반 struct와 같습니다.
함정 3 — 길이 제한과 큰 인라인 배열의 부담
[InlineArray(1_000_000)] // ❌ 위험! 1M*4 = 4MB가 struct 자체에 박힘
public struct Huge { private int _e; }
인라인 배열은 struct에 직접 박히는 메모리입니다. 1M칸을 잡으면 4MB짜리 struct가 됩니다 — 메서드 지역 변수로 두면 스택 오버플로 위험, 클래스 필드로 두면 객체 자체가 매우 무거워집니다. 고정 크기 작은 버퍼(보통 수 칸~수백 칸)에 적합합니다.
함정 4 — 클래스 필드로 사용 가능 (Span과 다름)
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으로 받아 두는 게 좋습니다.
// ❌ 매 접근 헬퍼 호출
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에서 다룬다.