게임 개발자를 위한 메모리 구조 6 – 최근 엔진들이 SoA 구조를 사용하기 시작한 이유

지금까지 메모리 구조와 관련해서 Stack과 Heap, Cache Miss, allocator, placement new, Object Pool에 대해 살펴봤습니다.

이 내용들을 하나씩 따로 보면 서로 다른 주제처럼 느껴질 수도 있습니다.

스택과 힙은 메모리 영역에 대한 이야기이고, Cache Miss는 CPU가 데이터를 읽는 방식에 대한 이야기이며,
allocator와 Object Pool은 메모리를 직접 관리하거나 재사용하는 방법에 대한 이야기입니다.

그런데 이 주제들을 계속 따라가다 보면 결국 하나의 질문으로 수렴됩니다.

데이터를 메모리에 어떻게 배치해야 성능 측면에서 좋을까?

최근 게임 엔진 구조에서 ECS, Data Oriented Design, Job System 같은 키워드가 자주 등장하는 이유도 이 질문과 관련이 있습니다.

예전에는 객체를 어떻게 잘 설계할 것인가에 관심이 많았습니다.

어떤 클래스를 만들고, 어떤 상속 구조를 만들고, 어떤 인터페이스를 둘 것인가를 고민했습니다.
객체 사이의 의존성을 낮추기 위한 노력을 세심히 기울였습니다.

물론 이런 고민도 여전히 중요합니다.

하지만 최근 엔진 구조를 조금 더 깊게 살펴보면 객체 구조만큼이나 데이터 배치 방식이 중요하게 다뤄지는 것을 확인할 수 있습니다.

그리고 이때 자주 등장하는 개념이 바로 AoS와 SoA입니다.

처음 보는 분들에게는 굉장히 낯선 용어일 수 있습니다.

그런데 실제로는 그렇게 어려운 개념은 아닙니다.

핵심은 단순합니다.

데이터를 객체 단위로 묶어서 저장할 것인가, 아니면 같은 종류의 데이터끼리 따로 모아서 저장할 것인가의 차이입니다.


//
//

AoS는 객체 단위로 데이터를 저장하는 방식이다

AoS는 Array of Structures의 약자입니다. 구조체의 배열 정도로 이해할 수 있습니다.

C++에서 가장 자연스럽게 작성하는 방식이기도 합니다.

게임에서 여러 캐릭터의 위치, 속도, 체력을 관리해야 한다고 해보겠습니다.

일반적으로는 아래 코드와 같이 구조체나 클래스를 만들고, 그 객체들을 배열이나 vector에 저장하는 방식을 먼저 떠올립니다.

struct Character
{
    float x;
    float y;
    float z;

    float velocityX;
    float velocityY;
    float velocityZ;

    int hp;
};

std::vector<Character> characters;

이 구조가 바로 AoS입니다.

Character라는 구조체가 있고, 이 구조체 안에 위치, 속도, 체력 데이터가 함께 들어 있습니다.

그리고 Character 객체들이 배열 형태로 저장됩니다.

메모리에는 대략 아래와 같은 느낌으로 배치됩니다.

[Character][Character][Character][Character]

조금 더 풀어서 보면 각 Character 안에는 위치, 속도, 체력 정보가 모두 함께 들어 있습니다.

[x y z velocityX velocityY velocityZ hp]
[x y z velocityX velocityY velocityZ hp]
[x y z velocityX velocityY velocityZ hp]
[x y z velocityX velocityY velocityZ hp]

이 방식은 객체 단위로 생각하기 쉽게 만들어줍니다. 사람이 이해하기 쉬운 형태의 코드입니다.

캐릭터 하나를 가져오면 그 캐릭터에 필요한 데이터가 한 곳에 모여 있습니다.

그래서 코드를 작성하는 입장에서는 굉장히 자연스럽습니다.

for (Character& character : characters)
{
    character.x += character.velocityX;
    character.y += character.velocityY;
    character.z += character.velocityZ;
}

이 코드는 읽기도 쉽고, 객체지향 프로그래밍에 익숙한 분들에게는 굉장히 편한 구조입니다.

그래서 게임 개발을 처음 시작하거나, 일반적인 게임 로직을 작성할 때는 AoS 방식이 훨씬 익숙하게 느껴질 수 있습니다.

그리고 지금까지 이런 방식으로 오랫동안 개발을 이어왔습니다.


AoS는 항상 나쁜 구조가 아니다

여기서 오해하면 안 되는 부분이 있습니다.

미리 말씀드리자면 AoS가 나쁜 구조라는 뜻이 아닙니다.

오히려 많은 상황에서는 AoS가 훨씬 자연스럽고 관리하기 편합니다.

캐릭터 하나를 중심으로 데이터를 다뤄야 하는 경우에는 AoS가 이해하기 쉽고 훨씬 더 직관적입니다.

특정 캐릭터 하나를 선택해서 위치, 체력, 상태, 애니메이션 정보를 모두 확인해야 한다면 객체 단위로 데이터가 묶여 있는 구조가 편합니다.

게임 로직을 작성할 때도 마찬가지입니다.

플레이어 객체 하나를 중심으로 입력 처리, 이동 처리, 충돌 처리, 애니메이션 처리를 연결해서 생각하는 경우에는 AoS 방식이 훨씬 직관적일 수 있습니다.

그래서 AoS는 잘못된 방식이 아닙니다.

문제는 모든 상황에서 객체 단위 접근이 필요한 것은 아니라는 점입니다.

게임 엔진 내부에서는 캐릭터 하나를 자세히 들여다보는 것보다, 수천 개의 객체에서 특정 데이터만 반복적으로 읽어야 하는 경우가 굉장히 많습니다.

이때부터 AoS의 단점이 드러나기 시작합니다.


//

AoS는 필요 없는 데이터까지 함께 가져올 수 있다

이전 글에서 CPU Cache에 대해 살펴봤습니다.

CPU는 메모리에서 데이터를 읽을 때 딱 필요한 데이터 하나만 가져오는 것이 아니라, 주변 데이터까지 함께 가져오는 경우가 많습니다.

연속된 데이터를 순차적으로 사용할 가능성이 높다고 판단하기 때문입니다.

이 특징은 배열처럼 데이터가 연속적으로 배치된 구조에서는 굉장히 유리하게 작용합니다.

그런데 AoS 구조에서는 문제가 생길 수 있습니다.

예를 들어, 캐릭터들의 위치만 업데이트하는 상황을 생각해보겠습니다.

우리가 실제로 필요한 데이터는 x, y, z와 velocityX, velocityY, velocityZ입니다.

그런데 Character 구조체 안에는 hp도 있고, 실제 코드에서는 상태 값, 애니메이션 정보, 장비 정보, AI 관련 데이터까지 들어갈 수도 있습니다.

만약 Character 구조체가 커지면 커질수록, CPU는 위치 계산에 필요하지 않은 데이터까지 함께 Cache에 가져오게 될 가능성이 높습니다.

물론 캐시에 올라온 데이터가 나중에 사용된다면 괜찮을 수도 있습니다.

하지만 지금 당장 필요한 것이 위치와 속도 뿐이라면, 나머지 데이터는 Cache 공간을 차지하는 불필요한 데이터가 될 수 있습니다.

이런 상황이 수천 개, 수만 개의 객체를 순회하는 과정에서 반복되면 성능 차이가 생각보다 커질 수 있습니다.

게임 엔진이 데이터 배치 방식을 신경 쓰는 이유도 결국 여기에 있습니다.


SoA는 같은 종류의 데이터끼리 모아서 저장하는 방식이다

AoS와 SoA 메모리 구조 차이와 Cache Locality를 설명하는 게임 엔진 데이터 배치 이미지
AoS와 SoA의 메모리 배치 방식 차이와 Cache 효율, 데이터 중심 설계 흐름을 설명하는 이미지

SoA는 Structure of Arrays의 약자입니다. 배열들의 구조체 정도로 이해할 수 있습니다.

이름만 보면 조금 헷갈릴 수 있지만, 개념 자체는 어렵지 않습니다.

AoS가 객체 하나 안에 여러 데이터를 묶어두는 방식이라면, SoA는 같은 종류의 데이터끼리 따로 배열로 모아두는 방식입니다.

앞에서 작성한 Character 구조를 SoA 방식으로 바꾸면 대략 아래와 같은 형태가 됩니다.

struct CharacterData
{
    std::vector<float> x;
    std::vector<float> y;
    std::vector<float> z;

    std::vector<float> velocityX;
    std::vector<float> velocityY;
    std::vector<float> velocityZ;

    std::vector<int> hp;
};

CharacterData characters;

이 구조에서는 Character 객체 하나가 메모리에 연속적으로 저장되는 것이 아닙니다.

대신 x 값들은 x 값끼리 모여 있고, y 값들은 y 값끼리 모여 있고, hp 값들은 hp 값끼리 모여 있습니다.

메모리 배치를 단순하게 표현하면 아래와 같은 느낌입니다.

x          [x x x x x x x x]
y          [y y y y y y y y]
z          [z z z z z z z z]

velocityX  [vx vx vx vx vx vx vx vx]
velocityY  [vy vy vy vy vy vy vy vy]
velocityZ  [vz vz vz vz vz vz vz vz]

hp         [hp hp hp hp hp hp hp hp]

객체지향적인 관점에서는 조금 어색하게 느껴질 수 있습니다.

캐릭터 하나의 데이터가 한 곳에 모여 있는 것이 아니라, 여러 배열에 흩어져 있기 때문입니다.

하지만 특정 작업을 반복적으로 수행할 때는 이 구조가 굉장히 강력해질 수 있습니다.


SoA는 필요한 데이터만 연속적으로 읽기 좋다

캐릭터들의 위치를 업데이트하는 상황을 다시 생각해보겠습니다.

AoS에서는 Character 객체 전체를 순회하면서 그 안에 있는 위치와 속도 데이터에 접근했습니다.

반면 SoA에서는 위치와 속도 배열만 순회하면 됩니다.

for (int ix = 0; ix < count; ++ix)
{
    characters.x[ix] += characters.velocityX[ix];
    characters.y[ix] += characters.velocityY[ix];
    characters.z[ix] += characters.velocityZ[ix];
}

이 경우 CPU는 x 배열, y 배열, z 배열, velocity 배열처럼 실제 계산에 필요한 데이터만 연속적으로 읽을 수 있습니다.

hp 데이터가 필요 없다면 hp 배열은 접근하지 않습니다.

즉 불필요한 데이터를 Cache에 가져올 가능성이 줄어듭니다.

이게 SoA가 Cache Locality 측면에서 유리하다고 말하는 이유입니다.

물론 실제 성능은 데이터 크기, 접근 패턴, CPU 구조, 컴파일러 최적화, SIMD 사용 여부에 따라 달라질 수 있습니다.

하지만 SoA는 특정 데이터만 대량으로 순회하는 상황에서 굉장히 좋은 구조가 될 수 있습니다.

게임 엔진에서는 이런 상황이 생각보다 자주 발생합니다.

렌더링 시스템은 렌더링에 필요한 데이터만 순회합니다.

물리 시스템은 위치, 속도, 충돌 정보처럼 물리 계산에 필요한 데이터만 순회합니다.

애니메이션 시스템은 본 행렬이나 애니메이션 상태처럼 필요한 데이터만 반복적으로 읽습니다.

이런 구조에서는 객체 하나를 통째로 다루는 것보다, 시스템이 필요한 데이터만 모아서 읽는 방식이 더 효율적일 수 있습니다.


AoS와 SoA의 차이는 사고방식의 차이에 가깝다

AoS와 SoA는 단순히 코드 형태의 차이처럼 보일 수 있습니다.

하지만 실제로는 프로그램을 바라보는 사고방식의 차이에 더 가깝습니다.

AoS는 객체 중심으로 생각합니다.

캐릭터가 있고, 캐릭터가 위치를 가지고 있고, 캐릭터가 체력을 가지고 있고, 캐릭터가 속도를 가지고 있다고 생각합니다.

반면 SoA는 데이터 중심으로 생각합니다.

위치 데이터들이 있고, 속도 데이터들이 있고, 체력 데이터들이 있으며, 각 시스템은 자신에게 필요한 데이터를 순회하며 처리한다고 생각합니다.

이 차이는 게임 엔진에서 굉장히 중요합니다.

객체 중심 사고는 코드를 이해하기 쉽고, 개별 객체를 다루기 편합니다.

하지만 대량의 데이터를 반복적으로 처리해야 하는 시스템에서는 데이터 중심 사고가 훨씬 유리할 수 있습니다.

최근 게임 엔진에서 Data Oriented Design이 자주 이야기되는 이유도 여기에 있습니다.

객체를 예쁘게 설계하는 것만으로는 충분하지 않고, 데이터가 실제로 메모리에 어떻게 배치되고 어떤 순서로 접근되는지도 성능에 큰 영향을 주기 때문입니다.


모든 구조를 SoA로 바꿔야 하는 것은 아니다

SoA가 성능 측면에서 유리할 수 있다고 해서 모든 코드를 SoA로 작성해야 한다는 뜻은 아닙니다.

이 부분은 정말 중요합니다.

SoA는 데이터 접근 패턴이 명확하고, 같은 종류의 데이터를 대량으로 반복 처리하는 경우에 강합니다.

하지만 개별 객체를 중심으로 다양한 데이터를 자주 접근해야 하는 경우에는 AoS가 더 편하고 자연스러울 수 있습니다.

예를 들어 플레이어 캐릭터 하나의 상태를 관리하거나, 특정 보스 몬스터 하나의 복잡한 패턴을 구현하는 경우에는 SoA로 작성하는 것이 오히려 더 복잡할 수도 있습니다.

결국 중요한 것은 어떤 구조가 더 좋아 보이는가가 아닙니다.

내가 다루는 데이터가 어떤 방식으로 사용되는지를 먼저 봐야 합니다.

데이터를 객체 단위로 자주 다루는지, 아니면 특정 필드만 대량으로 순회하는지에 따라 적절한 구조가 달라질 수 있습니다.

그래서 AoS와 SoA는 어느 하나가 정답이라기보다, 상황에 따라 선택해야 하는 데이터 배치 방식이라고 보는 것이 좋습니다.


왜 최근 엔진들은 SoA 구조에 관심을 가지기 시작했을까?

최근 엔진들이 SoA 구조에 관심을 가지는 이유는 결국 처리해야 하는 데이터가 많아졌기 때문입니다.

오픈월드 게임에서는 수많은 객체가 동시에 존재합니다.

대규모 전투 게임에서는 수많은 유닛이 동시에 움직입니다.

파티클, 애니메이션, 물리, 렌더링, AI 시스템은 매 프레임마다 많은 데이터를 반복해서 처리합니다.

이런 상황에서는 객체 하나를 예쁘게 설계하는 것보다, 데이터가 메모리에 어떻게 배치되어 있고 CPU가 얼마나 효율적으로 읽을 수 있는지가 더 중요해질 수 있습니다.

그리고 SoA 구조는 이런 상황에서 Cache Locality를 높이는 데 도움이 됩니다.

또한 SIMD 같은 최적화와도 잘 맞는 경우가 많습니다.

같은 종류의 데이터가 연속적으로 배치되어 있으면 여러 값을 한 번에 처리하는 구조를 만들기 쉬워지기 때문입니다.

이런 이유로 최근 엔진에서는 AoS보다 SoA에 가까운 구조가 점점 더 자주 등장하고 있습니다.

ECS나 Unity DOTS 같은 구조도 결국 이런 데이터 중심 설계 흐름과 강하게 연결되어 있습니다.


마무리

AoS와 SoA는 처음 보면 낯선 용어처럼 느껴질 수 있습니다.

하지만 결국 핵심은 데이터를 어떻게 배치할 것인가입니다.

AoS는 객체 단위로 데이터를 묶어서 저장하는 방식입니다.

그래서 코드를 이해하기 쉽고, 개별 객체를 다루기 편합니다.

반면 SoA는 같은 종류의 데이터끼리 모아서 저장하는 방식입니다.

그래서 특정 데이터를 대량으로 순회하거나, Cache 효율을 높이고 싶은 상황에서 유리할 수 있습니다.

게임 엔진 구조를 조금 더 깊게 살펴보면 생각보다 많은 문제들이 결국 데이터 배치 방식과 연결된다는 것을 알게 됩니다.

왜 Cache Miss가 문제가 되는지, 왜 allocator와 Object Pool이 필요한지, 왜 ECS와 Data Oriented Design이 등장했는지는 결국 이전 글에서 설명한 메모리 재활용과 메모리 배치에 대한 고민의 결과입니다.

그리고 이런 트렌드를 살펴보면 최근 게임 엔진은 객체를 어떻게 설계할 것인가뿐 아니라, 데이터를 메모리에 어떻게 배치하고 처리할 것인가를 점점 더 중요하게 보기 시작했다는 것을 이해할 수 있을 겁니다.


👉 게임 엔진 구조를 더 깊이 이해하고 싶다면

아래 강의를 통해 직접 구현해보는 것을 추천드립니다.

C++로 만드는 게임 엔진 프레임워크 강의

👉 C++로 만드는 게임 엔진 프레임워크 강의 바로가기

//
   

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다