왜 고정 크기 버퍼가 필요한가?
게임 로직을 짜다 보면 "최대 8개 스킬", "최대 4명 파티원", "최대 16개 투사체" 같이 상한이 명확한 소규모 컬렉션을 수시로 만나게 된다. 가장 손쉬운 선택은 List<T>나 T[]지만, 둘 다 힙 객체를 만들어 GC 추적 대상이 된다. 캐릭터가 1000명이고 스킬이 8개라면 별것 아닌 float[]가 1000개의 소형 힙 오브젝트로 쌓인다.
그렇다고 unsafe fixed float[8]로 가자니 unsafe 블록이 코드 전반에 퍼지고, Unity DOTS의 IComponentData처럼 unmanaged 제약이 강한 컨텍스트에서는 fixed 버퍼조차 쓸 수 없는 경우가 생긴다.
C# 12에서 도입된 [InlineArray] 어트리뷰트는 이 딜레마를 깔끔하게 해결한다. 단일 필드 구조체 하나에 어트리뷰트를 달면, unsafe 없이, 힙 할당 없이 고정 크기 연속 버퍼를 구조체 내부에 인라인으로 올릴 수 있다. Unity 6(.NET 8 타겟) 이상 프로젝트라면 지금 당장 적용 가능하다.
[InlineArray] 기본 문법
선언이 놀라울 만큼 단순하다. struct에 private 필드를 딱 하나 두고 [InlineArray(N)]을 붙이면 끝이다.
using System.Runtime.CompilerServices;
[InlineArray(8)]
public struct SkillCooldownBuffer
{
private float _element; // 필드명 무관. 이 타입이 곧 원소 타입.
}
컴파일러는 이 구조체를 float[8]과 동일한 메모리 레이아웃으로 처리한다. 인덱서와 Span<T> 변환이 자동 생성되므로 일반 배열처럼 읽고 쓸 수 있다.
var buf = new SkillCooldownBuffer();
buf[0] = 1.5f; // 첫 번째 스킬 쿨다운
buf[3] = 2.0f; // 네 번째 스킬 쿨다운
// ref 순회 — 복사 없이 원소 직접 수정
Span<float> span = buf;
foreach (ref float cd in span)
cd = MathF.Max(0f, cd - Time.deltaTime);
ref float로 직접 순회하기 때문에 값 복사 없이 원소를 제자리에서 수정할 수 있다는 점이 T[] 대비 추가 이점이다.
실전 예제 1 — 캐릭터 스킬 쿨다운 관리
기존에 float[] 배열을 필드로 갖던 컴포넌트를 [InlineArray]로 교체해 보자.
// 기존: MonoBehaviour 생성마다 float[] 힙 할당
public class SkillSystem_Old : MonoBehaviour
{
private float[] _cooldowns = new float[8]; // GC 추적 대상
}
// 개선: 버퍼가 MonoBehaviour 객체 내부에 인라인
public class SkillSystem : MonoBehaviour
{
private SkillCooldownBuffer _cooldowns; // 별도 배열 오브젝트 없음
void Update()
{
Span<float> span = _cooldowns;
for (int i = 0; i < span.Length; i++)
{
if (span[i] > 0f)
span[i] -= Time.deltaTime;
}
}
public bool TryUseSkill(int index, float cooldown)
{
if (_cooldowns[index] > 0f) return false;
_cooldowns[index] = cooldown;
return true;
}
}
float[] 오브젝트가 사라지면서 GC 루트가 하나 줄고, 씬에 캐릭터가 많을수록 힙 절약 효과가 선형으로 커진다.
실전 예제 2 — DOTS IComponentData와 조합
[InlineArray]가 가장 빛나는 순간은 DOTS 워크플로우다. IComponentData는 반드시 unmanaged 타입이어야 하기 때문에 관리형 T[]는 원천 불가다. 기존에는 unsafe fixed T[N]을 쓰거나, 청크 외부 힙에 데이터를 두는 DynamicBuffer<T>로 우회해야 했다.
[InlineArray]는 unmanaged 제약을 만족하면서 컴포넌트 청크 내부에 인라인으로 데이터를 둔다. 캐시 접근성 면에서 DynamicBuffer보다 유리하다.
[InlineArray(4)]
public struct HitDirectionBuffer
{
private float3 _element;
}
// IComponentData 안에 float3 x 4 = 48바이트 인라인
public struct HitRecordComponent : IComponentData
{
public HitDirectionBuffer RecentHits;
public int HitCount;
}
[BurstCompile]
public partial struct RecordHitJob : IJobEntity
{
public float3 IncomingDir;
void Execute(ref HitRecordComponent hit)
{
int slot = hit.HitCount % 4; // 링 버퍼
hit.RecentHits[slot] = math.normalize(IncomingDir);
hit.HitCount++;
}
}
Burst는 [InlineArray]를 포함한 unmanaged 구조체를 정상적으로 처리한다(Burst 1.8.12+ 기준).
주의사항
- Unity 버전: C# 12 / .NET 8 기능이다. Unity 6 이상에서 Player Settings의 API Compatibility Level을
.NET Framework가 아닌.NET으로 설정해야 한다. 구버전 Unity에서는 컴파일 오류가 발생한다. - 스택 크기 주의: 로컬 변수로 선언할 때는 N이 크면 스택 프레임이 커진다. 대략 원소 타입 크기 x N이 256바이트 이내일 때만 로컬에 두자. 구조체 필드로 쓸 때는 이 제한이 완화된다.
- 오픈 제네릭 불가:
[InlineArray]구조체의 원소 타입은 컴파일 타임에 확정돼야 한다.InlineBuffer<T>형태의 오픈 제네릭 선언은 컴파일 오류다. 타입별로 별도 구조체를 선언하거나, Source Generator로 생성하는 패턴을 쓴다. - 직렬화: Unity의
[Serializable]이나JsonSerializer는[InlineArray]를 배열로 자동 인식하지 않는다. 직렬화가 필요하면Span<T>변환 후 수동 처리하거나 커스텀 컨버터를 추가해야 한다.
결론
[InlineArray]는 C# 12에서 조용히 추가됐지만 게임 개발자에게는 매우 실용적인 기능이다. 상한이 고정된 소규모 버퍼를 T[] 없이 구조체 안에 인라인으로 담을 수 있고, Span<T>와 자연스럽게 연동된다. 특히 DOTS처럼 unmanaged 제약이 강한 환경에서 unsafe fixed 없이 IComponentData에 배열 데이터를 박아 넣을 수 있다는 점이 핵심 가치다. Span, stackalloc과 함께 힙 제로 핫패스 도구 상자에 반드시 추가해 두자.
'C#·C++ 모던' 카테고리의 다른 글
| C++ std::pmr로 게임 프레임 아레나 만들기: 한 프레임 메모리를 통째로 잡고 통째로 버리기 (0) | 2026.06.21 |
|---|---|
| C++ 23 std::generator로 게임 웨이브와 절차적 콘텐츠를 lazy 시퀀스로 뽑아내기 (1) | 2026.06.16 |
| C++23 deducing this로 CRTP 없이 게임 컴포넌트 체이닝 구현하기 (0) | 2026.06.09 |
| C# PriorityQueue로 A* 경로탐색과 게임 이벤트 스케줄러 구현하기 (0) | 2026.06.07 |
| C# 12 컬렉션 표현식(Collection Expressions)으로 Unity 게임 초기화 코드 반으로 줄이기 (0) | 2026.06.02 |