12 분 소요

데이터 지향적 설계와 성능과의 관계에 대하여 알아보자

김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글

1. 성능 사고의 기초 🍷

단순히 느리다는 느낌으로 받아들여서는 안됨
유저에게는 ‘짜증’과 연결되며
아무리 재밌더라도 느리고 버벅거리면 게임을 안함

  • 그러므로 게임 프로그래밍에서 성능은
    곧 생존과 직결

1-1. 왜 성능이 중요한가? - 게임 개발의 특수성

  • 게임 개발은 다른 소프트웨어 개발과 달리 실시간성이 핵심
    • 게임의 성능 요구사항
      • 60 FPS: 16.67ms 내에 한 프레임을 완성해야 함(초당 60 프레임)
      • 120 FPS: 8.33ms (고주사율 모니터, VR)
      • 일관된 프레임 타임: 끊김 없는 부드러운 경험
// 게임 루프의 기본 구조
void GameLoop() {
    while (isRunning) {
        float deltaTime = timer.GetDeltaTime(); // 목표: 16.67ms

        ProcessInput(deltaTime);    // ~1ms
        UpdateGameLogic(deltaTime); // ~10ms
        Render();                   // ~5ms
        Present();                  // ~0.67ms

        // 만약 16.67ms를 초과하면 프레임 드롭 발생!
    }
}
  • 성능이 게임에 미치는 영향
    • 플레이어 경험: 렉은 게임의 재미를 직접적으로 해침
    • 경쟁력: 60fps vs 30fps는 게임의 상업적 성공을 좌우
    • 플랫폼 제약: 콘솔, 모바일 등 제한된 하드웨어에서 동작해야 함

1-2. 성능 병목의 이해 - CPU vs GPU vs Memory vs I/O

  • 게임의 성능 병목은 다양한 곳에서 발생
  • CPU 병목
// 잘못된 예시: 매 프레임마다 복잡한 연산
void Update() {
    for (auto& enemy : enemies) {
        // 복잡한 AI 연산을 매 프레임마다 실행
        enemy.UpdateComplexAI();
    }
}

// 개선된 예시: 시간 분할 처리
void Update() {
    static int currentIndex = 0;
    int processCount = std::min(10, enemies.size()); // 한 프레임에 10개만 처리

    for (int i = 0; i < processCount; i++) {
        enemies[currentIndex % enemies.size()].UpdateComplexAI();
        currentIndex++;
    }
}
  • GPU 병목
    • 너무 많은 드로우 콜
    • 복잡한 셰이더 연산
    • 오버드로우 (같은 픽셀을 여러 번 그리기)
  • Memory 병목
    • 캐시 미스로 인한 대기 시간
    • 메모리 할당/해제 비용
    • 가상 메모리 페이징
  • I/O 병목
    • 파일 로딩 시간
    • 네트워크 지연

게임이 느려지는 이유는 이 4가지 요소 중 하나!

1-3. 추측하지 말고 측정하자 - 프로파일링의 중요성

“Premature optimization is the root of all evil” - Donald Knuth

성급한 최적화가 악의 근원

  • ‘느릴 것 같아 보이는 것’을 고치다가 진짜 병목 현상을 놓치는 경우가 많음
  • 프로파일링을 먼저 ‘돌리고’ 파악해야 함

프로파일링을 먼저, 이후 최적화 적용

프로파일링의 원칙:

  1. 측정 먼저: 추측하지 말고 실제로 측정하라 (이상한 곳에 시간 낭비하지 말기)
  2. 핫스팟 집중: 전체 시간의 80%를 차지하는 20% 코드를 찾아라 (코드 전체를 뜯어 고치지 말 것 - 집중하여 그것만 고치기)
  3. 개선 후 재측정: 최적화가 실제로 효과가 있는지 확인하라 (고친 것이 효과가 있었는가?)
// 간단한 성능 측정 도구
class SimpleProfiler {
public:
    void StartTiming(const std::string& name) {
        startTimes[name] = std::chrono::high_resolution_clock::now();
    }

    void EndTiming(const std::string& name) {
        auto endTime = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::microseconds>
                       (endTime - startTimes[name]).count();

        std::cout << name << ": " << duration << " μs\\n";
    }
};

// 사용 예시
void GameLoop() {
    profiler.StartTiming("GameLogic");
    UpdateGameLogic();
    profiler.EndTiming("GameLogic");
}

2. 메모리와 캐시의 이해 🤔

CS 에서 배운 내용들
페이지 폴트 와 페이지 테이블

2-1. 메모리 계층구조와 접근 비용

  • 현대 컴퓨터의 메모리는 계층적으로 구성
CPU Register:    0 cycles    (4 bytes)
L1 Cache:        1 cycle     (32KB)
L2 Cache:        3 cycles    (256KB)
L3 Cache:        12 cycles   (8MB)
Main Memory:     200 cycles  (16GB)
SSD:             100,000 cycles
HDD:             10,000,000 cycles
  • 캐시의 작동 원리:
    • CPU가 데이터를 요청하면 L1 → L2 → L3 → RAM 순으로 찾음
    • 찾은 데이터는 상위 캐시로 복사됨 (캐시 라인 단위, 보통 64바이트)
    • 캐시 히트: 캐시에서 데이터를 찾은 경우 (빠름)
    • 캐시 미스: 캐시에서 데이터를 찾지 못한 경우 (느림)

64바이트를 통째로 긁어옴
(캐시 라인)

근처의 데이터를 한 번에 긁어옴

2-2. 캐시 미스가 성능에 미치는 영향

// 캐시 미스가 많이 발생하는 코드
struct Player {
    int id;
    std::string name;        // 가변 크기, 힙 할당
    Vector3 position;
    Vector3 velocity;
    float health;
    // ... 많은 다른 데이터들
    bool isAlive;           // 자주 접근하는 데이터
};

std::vector<Player> players;

// 살아있는 플레이어만 업데이트 - 캐시 효율 나쁨
for (auto& player : players) {
    if (player.isAlive) {  // 전체 Player 구조체를 캐시로 로드해야 함
        UpdatePosition(player);
    }
}
  • Player 자체는 하나로 관리하는데
    계속 불필요한 데이터 까지 같이 끌어옴
// 캐시 친화적인 개선된 코드
struct PlayerSoA {
    std::vector<int> ids;
    std::vector<Vector3> positions;
    std::vector<Vector3> velocities;
    std::vector<float> healths;
    std::vector<bool> isAlives;        // 자주 접근하는 데이터끼리 모음
    std::vector<std::string> names;    // 덜 자주 접근하는 데이터
};

PlayerSoA players;

// 살아있는 플레이어만 업데이트 - 캐시 효율 좋음
for (size_t i = 0; i < players.isAlives.size(); ++i) {
    if (players.isAlives[i]) {  // bool 배열만 캐시로 로드
        UpdatePosition(players.positions[i], players.velocities[i]);
    }
}
  • 살아있는 플레이어만을 계속 가져옴

2-3. 메모리 레이아웃과 데이터 지역성

  • 공간적 지역성 (Spatial Locality): 연속된 메모리 위치의 데이터에 접근할 가능성이 높음
// 좋은 예시: 연속된 메모리 접근
int sum = 0;
for (int i = 0; i < 1000; ++i) {
    sum += array[i];  // 연속된 메모리 위치 접근
}

// 나쁜 예시: 불연속적인 메모리 접근
int sum = 0;
for (int i = 0; i < 1000; i += 7) {  // 7 간격으로 점프
    sum += array[i];
}

  • 하나씩 접근해야 캐시 히트가 가능한 많이 발생

  • 시간적 지역성 (Temporal Locality): 최근에 접근한 데이터에 다시 접근할 가능성이 높음

// 시간적 지역성 활용
Vector3 playerPos = player.GetPosition();  // 한 번만 호출
float distanceSq = (playerPos - targetPos).LengthSquared();
if (distanceSq < attackRangeSq) {
    // playerPos를 재사용
    LaunchProjectile(playerPos, targetPos);
}
  • 최근에 갱신된 페이지 테이블에서 바로 가져오면 되기에 유용

3. 데이터 지향 설계 vs 객체 지향 설계 🐣

3-1. 객체 지향의 한계와 성능 문제

  • 객체 지향 설계는 코드의 가독성과 유지보수성에는 뛰어남
  • 그러나 성능 측면에서는 한계가 존재
  • 객체 지향의 성능 문제
// 전형적인 객체 지향 설계
class GameObject {
public:
    virtual void Update(float deltaTime) = 0;  // 가상 함수 호출 비용
    virtual void Render() = 0;

protected:
    Transform transform;
    std::string name;           // 사용하지 않을 수도 있는 데이터
    bool isActive;
    // ... 많은 다른 멤버들
};

class Enemy : public GameObject {
    AI* aiComponent;            // 포인터 -> 캐시 미스 가능성
    Renderer* renderer;
    Physics* physics;

public:
    void Update(float deltaTime) override {
        // 여러 컴포넌트에 접근 -> 메모리 점프
        aiComponent->Update(deltaTime);
        physics->Update(deltaTime);
    }
};

// 사용
std::vector<std::unique_ptr<GameObject>> gameObjects;
for (auto& obj : gameObjects) {
    obj->Update(deltaTime);  // 가상 함수 호출, 예측 불가능한 메모리 접근
}
  • 성능 문제는 무엇?
    1. 가상 함수 호출 비용: vtable 룩업, 분기 예측 실패
    2. 메모리 단편화: 객체들이 메모리에 흩어져 있음
    3. 불필요한 데이터 로드: 사용하지 않는 멤버 변수도 캐시에 로드
    4. 컴포넌트 접근: 포인터를 통한 간접 접근으로 캐시 미스 증가

일반 함수는 컴파일 시점에 메모리 주소 고정
동적 메모리 참조는 CPU의 분기 예측 최적화를 실패시켜 성능 하락

new는 비어 있는 힙 공간을 찾기에 파편화가 됨

매우 무거운 객체는
한번에 많은 양의 데이터를 로딩해야 하기에
매번 캐시 실패가 발생

매번 포인터를 계속 따라가면서 계속 캐시가 미스됨
-> 포인터 체이닝

객체 지향은 성능적인 문제를 안고 가고 있다!

3-2. 데이터 지향 설계의 핵심 원칙 (Data-Oriented Design)

  • 데이터 지향 설계는 데이터의 변환에 집중하는 것
  • 핵심 원칙
    1. 데이터가 코드를 이끈다: 데이터 레이아웃을 먼저 고려
    2. 변환 중심 사고: Input → Process → Output
    3. 캐시 친화성: 함께 사용되는 데이터는 함께 배치
    4. 배치 처리: 같은 연산을 여러 데이터에 일괄 적용

OOP는 도시락 통
DOD는 반찬 뷔페

// 데이터 지향 설계 예시
struct TransformData {
    std::vector<Vector3> positions;
    std::vector<Vector3> velocities;
    std::vector<Vector3> accelerations;
    size_t count;
};

struct RenderData {
    std::vector<Matrix4> worldMatrices;
    std::vector<MaterialID> materials;
    std::vector<MeshID> meshes;
    size_t count;
};

// 시스템: 순수 함수로 데이터 변환
void UpdatePhysics(TransformData& transforms, float deltaTime) {
    // SIMD 최적화 가능, 예측 가능한 메모리 접근
    for (size_t i = 0; i < transforms.count; ++i) {
        transforms.velocities[i] += transforms.accelerations[i] * deltaTime;
        transforms.positions[i] += transforms.velocities[i] * deltaTime;
    }
}

void PrepareRenderData(const TransformData& transforms, RenderData& renderData) {
    for (size_t i = 0; i < transforms.count; ++i) {
        renderData.worldMatrices[i] = CreateWorldMatrix(transforms.positions[i]);
    }
}

필요한 정보들끼리 따로 관리하여
연속적인 메모리 접근

  • 무겁지 않고, 캐시 친화적
  • 성능적으로 빠르다!

  • 또한 다형성이 없기에
    CPU 최적화가 됨
    (분기 최적화에 따라 최적화 가능)

3-3. AoS vs SoA: 구조체 배열 vs 배열 구조체

  • 데이터 지향 설계의 핵심 개념 중 하나
  • AoS (Array of Structures) - 구조체 배열
struct Particle {
    Vector3 position;    // 12 bytes
    Vector3 velocity;    // 12 bytes
    float life;          // 4 bytes
    Color color;         // 16 bytes (패딩 포함)
};                       // 총 44 bytes per particle

std::vector<Particle> particles(1000);

// 위치만 업데이트하는 경우
for (auto& particle : particles) {
    particle.position += particle.velocity * deltaTime;
    // 44바이트 전체를 캐시로 로드하지만 24바이트만 사용
}

객체 개념에 따라 하나로 묶은 후
배열로 만들어서 관리함

파티클이 업데이트 될때마다
Paricle 자체를 읽어야 하며
그 중 일부만 사용함

  • SoA (Structure of Arrays) - 배열 구조체
struct ParticleSystem {
    std::vector<Vector3> positions;   // 연속된 위치 데이터
    std::vector<Vector3> velocities;  // 연속된 속도 데이터
    std::vector<float> lives;         // 연속된 생명 데이터
    std::vector<Color> colors;        // 연속된 색상 데이터
    size_t count;
};

ParticleSystem particles;
particles.positions.resize(1000);
particles.velocities.resize(1000);

// 위치만 업데이트하는 경우
for (size_t i = 0; i < particles.count; ++i) {
    particles.positions[i] += particles.velocities[i] * deltaTime;
    // 필요한 24바이트만 캐시로 로드
}

공통된 개념들의 각 요소들을 배열로 관리하고
하나의 구조체로 관리함

특정 요소를 업데이트할 때
그 요소 배열들만 업데이트 할 수 있음
(캐시 효율 상승)

  • 성능 비교 실험
// 벤치마크 코드
void BenchmarkAoS() {
    std::vector<Particle> particles(100000);

    auto start = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 1000; ++iter) {
        for (auto& p : particles) {
            p.position += p.velocity * 0.016f;
        }
    }
    auto end = std::chrono::high_resolution_clock::now();

    std::cout << "AoS: " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms\\n";
}

void BenchmarkSoA() {
    ParticleSystem particles;
    particles.count = 100000;
    particles.positions.resize(particles.count);
    particles.velocities.resize(particles.count);

    auto start = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 1000; ++iter) {
        for (size_t i = 0; i < particles.count; ++i) {
            particles.positions[i] += particles.velocities[i] * 0.016f;
        }
    }
    auto end = std::chrono::high_resolution_clock::now();

    std::cout << "SoA: " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms\\n";
}

// 일반적인 결과: SoA가 2-3배 빠름

성능이 중요한 경우,
필요한 요소만 묶어서 SoA 방식을 고려할 수 있음

3-4. ECS (Entity Component System) 패턴

  • ECS는 데이터 지향 설계의 대표적인 아키텍처 패턴
  • 전통적인 상속 vs ECS
// 전통적인 상속 기반
class GameObject { ... };
class Character : public GameObject { ... };
class Player : public Character { ... };
class Enemy : public Character { ... };
class FlyingEnemy : public Enemy { ... };  // 다중 상속 문제 발생 가능

// ECS 방식
struct Entity {
    uint32_t id;
};

struct Position { Vector3 value; };
struct Velocity { Vector3 value; };
struct Health { float current, max; };
struct Renderer { MeshID mesh; MaterialID material; };
struct AI { AIType type; float aggroRange; };

class World {
    // 컴포넌트별로 데이터를 분리 저장
    std::vector<Position> positions;
    std::vector<Velocity> velocities;
    std::vector<Health> healths;
    std::vector<Renderer> renderers;
    std::vector<AI> ais;

    // 엔티티가 어떤 컴포넌트를 가지고 있는지 추적
    std::vector<std::bitset<32>> componentMasks;
};

// 시스템: 특정 컴포넌트 조합을 가진 엔티티들을 일괄 처리
void MovementSystem(World& world) {
    for (size_t i = 0; i < world.entities.size(); ++i) {
        if (world.HasComponents<Position, Velocity>(i)) {
            world.positions[i].value += world.velocities[i].value * deltaTime;
        }
    }
}
  • ECS의 장점
    • 구성의 유연성: 런타임에 컴포넌트 추가/제거 가능
    • 캐시 친화성: 같은 타입의 컴포넌트들이 연속적으로 배치
    • 병렬 처리: 시스템별로 독립적인 처리 가능
    • 메모리 효율성: 필요한 컴포넌트만 메모리에 존재

클래스 상속은 시간이 지날수록
이름이 ‘문장’이 됨…
(하늘을 날아다니고 마법을 쏘며… 근접 공격도 하는 적?)

ECS를 통해
데이터 만을 관리하는 구조체 이용

  • 공통된 개념에 옵션 체크해주는 방식
  • Lyra도 ECS 기반임
    (필요한 데이터만을 바꿔끼는)

  • Unreal의 Mass Entity도 이러한 방식
    (UE5의 매트릭스도 이러한 결과물 중 하나)

  • Niagara System도 이것에 기반함
    (굳이 파티클을 각각 처리하진 않음)
    (ECS와 완전히 일치하진 않지만, 데이터 기반 설계임)

-> 성능!

4. 기초적인 최적화 기법의 이해 🎯

4-1. 핫스팟 식별과 병목 해결

  • 80-20 법칙: 전체 실행 시간의 80%는 코드의 20%에서 소모
// 프로파일링으로 발견한 핫스팟 예시
void GameUpdate() {
    profiler.Start("AI");
    UpdateAI();           // 35% 시간 소모
    profiler.End("AI");

    profiler.Start("Physics");
    UpdatePhysics();      // 25% 시간 소모
    profiler.End("Physics");

    profiler.Start("Rendering");
    UpdateRendering();    // 15% 시간 소모
    profiler.End("Rendering");

    profiler.Start("Audio");
    UpdateAudio();        // 5% 시간 소모
    profiler.End("Audio");

    // AI 최적화가 가장 큰 효과를 가져올 것!
}
  • 단계적 최적화 접근
    1. 알고리즘 개선: O(n²) → O(n log n)
    2. 미시 최적화: 캐시 친화적 레이아웃, 루프 언롤링, SIMD 등

ex) 특정 범위 내의 요소들만 검사하는 분할 정복
(전투 로직 측면에서 범위를 체크하는 방식을 고려할 수 있음)

ex) sqrt 쓰지 말고 제곱값끼리 비교, 나눗셈 대신 곱셈 사용
(사소한 개선이 쌓이면 충분히 좋음)

4-2. 배치 처리와 SIMD 활용

  • 배치 처리의 힘
// 개별 처리 - 비효율적
for (auto& enemy : enemies) {
    enemy.Update();
    enemy.CheckCollision();
    enemy.UpdateAnimation();
}

// 배치 처리 - 효율적
UpdateAllEnemyPositions(enemies);    // 모든 위치를 한 번에
CheckAllCollisions(enemies);         // 모든 충돌을 한 번에
UpdateAllAnimations(enemies);        // 모든 애니메이션을 한 번에

각각 처리하지 말고 모아서 처리하기

  • 조금 더 캐시 친화적
  • CPU 분기 예측 가능성 증가

  • SIMD (Single Instruction, Multiple Data) 예시
// 일반적인 벡터 덧셈
for (int i = 0; i < count; ++i) {
    result[i] = a[i] + b[i];
}

// SIMD를 활용한 벡터 덧셈 (4개씩 동시 처리)
#include <immintrin.h>
for (int i = 0; i < count; i += 4) {
    __m128 va = _mm_load_ps(&a[i]);
    __m128 vb = _mm_load_ps(&b[i]);
    __m128 vr = _mm_add_ps(va, vb);
    _mm_store_ps(&result[i], vr);
}

행렬 처리를 통하여
한번의 명령에 한번에 처리

  • 딥러닝 쪽에서 매우 잘 활용
    (벡터와 행렬을 통한 병렬처리)

4-3. 메모리 풀링과 할당 최적화

  • 동적 메모리 할당은 성능의 적
class Bullet {
public:
    Vector3 position;
    Vector3 velocity;
    float damage;
    float lifeTime;
    bool isActive;
};

std::vector<Bullet*> bullets;

void SpawnBullet(Vector3 position, Vector3 velocity) {
    Bullet* bullet = new Bullet();  // 힙 할당 - 느림!
    bullet->position = position;
    bullet->velocity = velocity;
    bullet->damage = 25.0f;
    bullet->lifeTime = 3.0f;
    bullet->isActive = true;
    
    bullets.push_back(bullet);
}

void UpdateBullets(float deltaTime) {
    for (auto it = bullets.begin(); it != bullets.end();) {
        Bullet* bullet = *it;
        
        bullet->position += bullet->velocity * deltaTime;
        bullet->lifeTime -= deltaTime;
        
        if (bullet->lifeTime <= 0 || bullet->position.y < 0) {
            delete bullet;  // 힙 해제 - 느림!
            it = bullets.erase(it);
        } else {
            ++it;
        }
    }
}

매번 총알을 계속 생성?
New 생성은 시스템 콜 호출임
(운영체제에게 연락해야 함)

  • 오브젝트 풀링 적용
class BulletPool {
private:
    std::vector<Bullet> bullets;      // 미리 할당된 총알들
    std::queue<size_t> available;     // 사용 가능한 인덱스들
    std::vector<bool> isActive;       // 각 총알의 활성화 상태

public:
    BulletPool(size_t maxCount) : bullets(maxCount), isActive(maxCount, false) {
        // 모든 인덱스를 사용 가능 목록에 추가
        for (size_t i = 0; i < maxCount; ++i) {
            available.push(i);
        }
    }

    Bullet* GetBullet() {
        if (available.empty()) {
            return nullptr;  // 풀이 가득 참
        }
        
        size_t index = available.front();
        available.pop();
        isActive[index] = true;
        
        return &bullets[index];  // 이미 할당된 메모리 재사용
    }

    void ReturnBullet(Bullet* bullet) {
        // 포인터로부터 인덱스 계산
        size_t index = bullet - &bullets[0];
        
        // 상태 초기화
        bullet->Reset();
        isActive[index] = false;
        
        // 풀에 반환
        available.push(index);
    }
    
    void UpdateAll(float deltaTime) {
        for (size_t i = 0; i < bullets.size(); ++i) {
            if (isActive[i]) {
                bullets[i].position += bullets[i].velocity * deltaTime;
                bullets[i].lifeTime -= deltaTime;
                
                if (bullets[i].lifeTime <= 0) {
                    ReturnBullet(&bullets[i]);
                }
            }
        }
    }
};

게임 시작 시, 미리 할당을 해둠
(Init 시점에 비용이 큰 연산을 하는 것은 괜찮은 편)

// 전역 풀
BulletPool bulletPool(1000);  // 최대 1000발

void SpawnBullet(Vector3 position, Vector3 velocity) {
    Bullet* bullet = bulletPool.GetBullet();  // 빠름!
    if (bullet) {
        bullet->Initialize(position, velocity, 25.0f, 3.0f);
    }
}

void UpdateBullets(float deltaTime) {
    bulletPool.UpdateAll(deltaTime);  // 모든 활성 총알 업데이트
}

필요한 Bullet을 반환하는 방식
이미 생성 비용을 초기에 사용했기에
사용할때는 매우 저렴하게 사용 가능

  • 대량으로 무언가를 해야하는 상황인 경우,
    데이터 지향 설계 고려

  • 프로파일링을 통해 병목 현상을 발견
    다양한 개선을 했음에도 개선이 더 필요한 경우

게임마다 최적화할 방향이 다를 수 있음
(겜바겜)
(프로파일링을 해봐야 알만한 것들)

  • 대규모 적이 나오는 게임 -> AI 최적화
  • 지속적인 이동이 필요한 게임 -> 길찾기 최적화
  • 모바일 게임 -> 메모리 제약(비동기 로딩 및 레이지 로딩)

물론
단점도 존재

  • 매우 높은 개발 난이도
    (엔진 프로그래밍 급의 난이도가 됨)

  • 유지보수가 안좋아짐
    (데이터 레이아웃이 바뀌면
    코드를 죄다 뜯어고쳐야 함)

  • DOD에 너무 심취하면
    개발 속도가 느려질 수 있다

  • 진짜 필요할때 안쓰면
    유지보수가 박살나서 새로운 컨텐츠 개발이 느려짐

댓글남기기