C#·C++ 모던

C# 12 [InlineArray]로 Unity 핫패스에서 힙 없이 고정 크기 버퍼 구현하기

kr-gamedev 2026. 6. 14. 09:02

왜 고정 크기 버퍼가 필요한가?

게임 로직을 짜다 보면 "최대 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] 기본 문법

선언이 놀라울 만큼 단순하다. structprivate 필드를 딱 하나 두고 [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과 함께 힙 제로 핫패스 도구 상자에 반드시 추가해 두자.