기존 객체 지향 프로그래밍(OOP)의 한계를 극복하고, 데이터 중심 설계(Data-Oriented Design)를 통해 모바일 및 PC 환경에서 극한의 성능을 이끌어내는 Unity DOTS의 핵심 원리와 실전 메모리 최적화 기법을 심층적으로 분석합니다.
서론: 객체 지향 프로그래밍(OOP)의 한계와 데이터 중심 설계(DOD)의 대두
현대의 비디오 게임 개발, 특히 Unity 엔진을 활용한 대규모 프로젝트에서 성능 최적화는 언제나 가장 중요하고도 해결하기 어려운 과제 중 하나로 손꼽힙니다. 기존의 Unity 아키텍처는 MonoBehaviour를 기반으로 한 객체 지향 프로그래밍(OOP) 방식을 채택하고 있습니다. 이는 캡슐화, 상속, 다형성이라는 강력한 무기를 바탕으로 직관적이고 빠른 프로토타이핑을 가능하게 하지만, 씬 내에 활성화된 게임 오브젝트의 수가 수천, 수만 개로 늘어날 때 심각한 성능 병목 현상을 유발합니다. 그 근본적인 이유는 바로 '메모리 파편화(Memory Fragmentation)'에 있습니다. C#의 매니지드 힙(Managed Heap)에 할당된 클래스 인스턴스들은 메모리 상의 임의의 위치에 무작위로 흩어지게 됩니다. CPU가 로직을 처리하기 위해 이러한 데이터에 접근할 때, 메모리에 산재된 주소를 추적해야 하므로 필연적으로 캐시 미스(Cache Miss)가 발생합니다. CPU는 RAM에서 데이터를 가져오는 데 엄청난 클럭 사이클을 낭비하게 되며, 이는 곧 치명적인 프레임 드랍으로 이어집니다. 데이터 중심 설계(Data-Oriented Design)는 이러한 하드웨어적 병목을 해결하기 위해 등장한 패러다임입니다. 데이터의 형태와 메모리 상의 배치를 최우선으로 고려하여, CPU가 가장 효율적으로 데이터를 읽고 쓸 수 있도록 구조화하는 것이 핵심입니다. Unity DOTS(Data-Oriented Technology Stack)는 이러한 DOD 철학을 엔진 차원에서 구현한 차세대 아키텍처로, 극한의 연산 성능과 메모리 최적화를 동시에 달성할 수 있게 해주는 혁신적인 솔루션입니다.
CPU 캐시 계층 구조와 캐시 라인(Cache Line)의 이해
DOTS의 작동 원리를 깊이 이해하기 위해서는 먼저 현대 CPU의 메모리 계층 구조를 명확히 파악해야 합니다. CPU는 주기억장치(RAM)에 직접 접근하여 데이터를 가져오는 속도보다 수백 배 빠른 속도로 동작합니다. 이 속도 차이로 인한 병목 현상(Memory Wall)을 줄이기 위해 CPU 내부에 L1, L2, L3라는 고속의 캐시 메모리가 단계별로 존재합니다. CPU가 특정 데이터를 요청할 때, 해당 데이터가 캐시에 존재하면(Cache Hit) 즉각적으로 연산이 수행되지만, 캐시에 존재하지 않으면(Cache Miss) 상대적으로 느린 RAM에서 데이터를 가져올 때까지 프로세서는 대기 상태에 빠지게 됩니다. 중요한 점은, 하드웨어 프리페처(Hardware Prefetcher)는 요청된 단일 데이터(예: 4바이트 float)만을 가져오는 것이 아니라, 인접해 있는 보통 64바이트 크기의 데이터 블록인 '캐시 라인(Cache Line)'을 통째로 읽어 들인다는 것입니다. 따라서 우리가 연산해야 할 데이터 배열이 메모리 상에 연속적으로 배치(Struct of Arrays 구조 등)되어 있다면, 단 한 번의 메모리 접근만으로 이어지는 여러 개의 데이터를 미리 캐시에 적재할 수 있어 캐시 적중률이 비약적으로 상승합니다. DOTS의 ECS(Entity Component System)는 컴포넌트 데이터를 메모리에 완벽하게 선형적으로 배열함으로써 이러한 캐시 효율을 극대화하도록 설계되었습니다. 이것이 바로 DOTS가 수만 개의 독립적인 엔티티가 등장하는 게임에서도 60프레임을 안정적으로 방어할 수 있는 근본적인 하드웨어적 원리입니다.
ECS 아키텍처: Entity, Component, System의 완전한 분리
ECS는 게임을 구성하는 요소를 Entity, Component, System 세 가지의 철저히 분리된 개념으로 재정의합니다. 먼저 Entity는 어떠한 로직이나 데이터도 직접 소유하지 않는, 단순히 고유한 정수형 ID만을 가지는 빈 식별자(인덱스)입니다. Component는 행동이나 로직을 전혀 포함하지 않는 순수한 데이터(블록 데이터 형식의 C# 구조체, Struct)들의 집합입니다. 그리고 System은 이러한 Component 데이터들을 입력으로 받아 실제 로직과 행동을 수행하는 유일한 주체입니다. 기존 MonoBehaviour가 데이터(필드)와 로직(Update 메서드)을 하나의 클래스 인스턴스 안에 묶어두었던 것과는 정반대의 접근 방식입니다. 이 구조의 가장 큰 장점은 다형성이나 가상 함수 테이블(vtable) 참조로 인한 간접 호출(Indirect Call) 오버헤드가 완전히 제거된다는 것입니다. System은 자신이 관심 있는 Component들의 조합(아키타입)을 지정하는 쿼리(Query)를 만들고, 해당 조건에 맞는 Entity들의 배열을 메모리에서 통째로 순회하며 일괄적으로 연산을 수행합니다. 이 과정에서 불필요한 포인터 추적(Pointer Chasing)이 발생하지 않으므로, CPU 코어는 오직 순수 데이터의 덧셈과 곱셈 연산에만 전력을 다할 수 있습니다. 또한 데이터와 로직의 완벽한 분리는 코드의 결합도를 낮추고 재사용성을 획기적으로 높여주어 대규모 프로젝트 유지보수에 큰 이점을 제공합니다.
아키타입(Archetype)과 청크(Chunk) 기반의 메모리 레이아웃
ECS 시스템의 극한 성능을 뒷받침하는 핵심 기술은 메모리를 '청크(Chunk)' 단위로 관리하는 아키타입(Archetype) 기반의 메모리 할당 알고리즘입니다. Unity에서 특정 Entity에 여러 개의 Component를 부여하면, 이들의 조합에 따라 고유한 아키타입이 생성됩니다. 동일한 아키타입을 가진 Entity들은 메모리 상에서 고정된 크기(일반적으로 16KB)의 연속된 블록인 청크 내에 함께 묶여 저장됩니다. 예를 들어 Transform, Velocity, RenderMesh를 컴포넌트로 가지는 수만 마리의 좀비 엔티티가 있다면, 이들의 Transform 데이터끼리, Velocity 데이터끼리 뭉쳐서 청크 내에 배열 형태로 빼곡하게 기록됩니다. 이동 연산을 담당하는 Movement System이 Velocity 값을 참조해 Transform 위치를 업데이트해야 할 때, 시스템은 메모리에 산재된 좀비 객체를 찾아다닐 필요가 전혀 없습니다. 단지 해당 아키타입을 포함하는 청크들의 시작 주소 포인터만 알아내어, 포인터를 1씩 증가시키며 연속적으로 데이터를 읽고 쓰면 그만입니다. 이 구조는 메모리 프래그먼테이션을 방지할 뿐만 아니라, CPU의 벡터화 연산(SIMD)을 적용하기에 가장 완벽한 데이터 레이아웃을 제공합니다. 개발자가 해야 할 유일한 고민은 각 시스템이 필요로 하는 데이터 스키마를 어떻게 가장 콤팩트하게 구성할 것인가 하는 데이터 디자인뿐입니다.
Burst Compiler와 SIMD 가속 최적화
메모리 레이아웃이 완벽하게 최적화되었다 하더라도, 작성된 C# 코드를 기계어로 번역하는 컴파일러의 최적화가 동반되지 않으면 하드웨어의 성능을 100% 끌어낼 수 없습니다. Unity DOTS 패키지의 또 다른 핵심 축을 담당하는 Burst Compiler는 C# 코드를 고도로 최적화된 네이티브 기계어로 즉시 컴파일하는 LLVM 기반의 수학 및 연산 특화 컴파일러입니다. Burst Compiler는 개발자가 작성한 C# 코드 중 가비지 컬렉터(GC)에 의존하지 않는 안전한 코드, 즉 Value Type 위주의 Unsafe 코드만을 추출하여 타겟 플랫폼(ARM, x86 등)의 아키텍처에 맞게 코드를 극한으로 재구성합니다. 특히 앞서 설명한 청크 구조처럼 배열 형태의 선형 데이터가 주어졌을 때, Burst Compiler는 루프 언롤링(Loop Unrolling)을 수행하고 여러 개의 데이터를 한 번의 CPU 명령어로 동시에 처리하는 SIMD(Single Instruction Multiple Data) 인스트럭션을 자동으로 생성해 냅니다. 예를 들어, 4개의 플로트(float) 값을 가지는 벡터 연산을 4번 반복하는 대신, 128비트 또는 256비트 SIMD 레지스터(AVX2, NEON 등)에 한꺼번에 데이터를 올려 단 1~2 클럭 사이클 만에 계산을 끝내버리는 방식입니다. 개발자는 System이나 Job 구조체 위에 단순하게 [BurstCompile] 어트리뷰트 하나를 추가하는 것만으로 이러한 기적적인 수준의 런타임 성능 향상을 무상으로 얻을 수 있습니다.
구조적 변경(Structural Changes)과 프래그먼테이션 방지 전략
강력한 ECS의 청크 기반 할당 시스템도 치명적인 약점을 하나 가지고 있습니다. 바로 엔티티의 아키타입이 실시간으로 변경될 때 발생하는 오버헤드입니다. 런타임 중에 특정 Entity에 Component를 새로 추가하거나 제거하면, 해당 Entity는 기존 아키타입의 청크에서 새로운 아키타입의 청크로 메모리를 통째로 이주(Migration)해야 합니다. 이를 구조적 변경(Structural Change)이라고 부르며, 이 과정에서 내부적으로 동기화(Sync Point)가 발생하여 비동기 작업들이 모두 멈추고 메모리 복사가 일어나는 엄청난 낭비가 초래됩니다. 최악의 경우, 이러한 설계 오판은 오히려 기존 MonoBehaviour 방식보다 더 심각한 프레임 스파이크를 유발할 수 있습니다. 이를 방지하기 위해서는 게임 설계 단계에서 아키타입의 파편화를 최소화해야 합니다. 동적으로 컴포넌트를 추가/삭제하는 대신, Entity 생성 시점에 모든 잠재적 컴포넌트를 미리 부여하되 bool 플래그나 Enableable Component(IEnableableComponent) 기능을 활용하여 로직 상에서 상태만 토글(Toggle)하는 방식이 적극 권장됩니다. 또한, 다형성을 흉내 내기 위해 수십 가지의 자잘한 태그(Tag) 컴포넌트를 남발하는 것을 지양하고, 시스템의 작업 단위에 맞춰 데이터를 굵직하게 통합하는 설계가 필요합니다. 메모리 버스는 무한하지 않으며, 결국 고효율의 데이터 스키마 설계만이 DOTS의 성능 잠재력을 온전히 폭발시킬 수 있는 마스터키입니다.
using Unity.Entities;
using Unity.Burst;
using Unity.Transforms;
using Unity.Mathematics;
// Burst 컴파일러 최적화를 받기 위한 어트리뷰트 선언
[BurstCompile]
public partial struct MoveForwardJob : IJobEntity
{
// 외부에서 전달받는 델타 타임
public float DeltaTime;
// in 키워드를 통한 읽기 전용 접근(메모리 복사 방지)과
// ref 키워드를 통한 참조 업데이트를 명확히 구분하여 성능 극대화
public void Execute(ref LocalTransform transform, in MoveSpeed speed)
{
// SIMD 명령어로 자동 최적화되는 수학 연산
transform.Position += transform.Forward() * speed.Value * DeltaTime;
}
}
// 시스템이 실행될 그룹핑 단계 지정 (일반적인 시뮬레이션 페이즈)
[UpdateInGroup(typeof(SimulationSystemGroup))]
[BurstCompile]
public partial struct MoveForwardSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// 이 시스템이 실행되기 위해 최소한 하나 이상의 MoveSpeed 컴포넌트가 존재해야 함을 엔진에 알림
// 이를 통해 불필요한 빈 틱(Tick) 연산을 방지
state.RequireForUpdate();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Job 구조체 생성 및 필요한 데이터 주입
var job = new MoveForwardJob
{
DeltaTime = SystemAPI.Time.DeltaTime
};
// 메인 스레드를 블로킹하지 않고, 멀티코어 워커 스레드 풀에 병렬 처리(Parallel) 작업으로 스케줄링
// IJobEntity 인터페이스가 아키타입에 맞는 청크 배열을 자동으로 분할하여 연산함
job.ScheduleParallel();
}
}