C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드

C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드

이 글의 핵심

C++ 데이터 지향 설계(DOD): SoA, 캐시 친화적 레이아웃, ECS, 핫/콜드 데이터 분리. 문제 시나리오, 완전한 예제, 흔한 에러, 모범 사례, 프로덕션 패턴까지 실전 코드로 다룹니다.

들어가며: “객체”가 아니라 “데이터”로 생각하기

”10만 개 엔티티 업데이트가 60fps를 넘지 못해요”

#39-1 데이터 지향 설계#51-4 캐시 최적화에서 기초를 다뤘다면, 이 글은 실전 데이터 지향 설계(Data-Oriented Design, DOD) 를 집중적으로 다룹니다. OOP는 “객체와 행동”을 중심으로 설계하지만, DOD는 “데이터 배치와 접근 패턴” 을 먼저 설계해 CPU 캐시·SIMD·메모리 대역폭을 최대한 활용합니다.

비유: OOP는 “각 직원에게 업무 지시서를 나눠주는 것”이고, DOD는 “같은 업무를 하는 직원들을 한 줄로 세워 한 번에 지시하는 것”입니다. 후자가 훨씬 효율적입니다.

이 글을 읽으면:

  • SoA(Structure of Arrays)를 완전한 예제로 설계·구현할 수 있습니다.
  • 캐시 친화적 레이아웃을 선택할 수 있습니다.
  • ECS(Entity Component System)를 DOD 관점에서 적용할 수 있습니다.
  • 핫/콜드 데이터 분리로 메모리 효율을 높일 수 있습니다.
  • 자주 하는 실수와 프로덕션 패턴을 알 수 있습니다.

요구 환경: C++17 이상


문제 시나리오

시나리오 1: 파티클 시스템이 프레임 드랍을 유발할 때

"10만 개 파티클의 위치만 매 프레임 갱신하는데 16ms를 넘어요."
"프로파일러에서 연산 자체는 O(n)인데, 메모리 접근이 병목이에요."

상황: Particle 구조체에 위치(x,y,z), 속도(vx,vy,vz), 색상(r,g,b), 수명 등이 한 덩어리로 있습니다. “위치만 갱신”해도 캐시 라인(64바이트)당 실제로 쓰는 데이터는 12바이트뿐이고, 나머지는 낭비됩니다.

해결 포인트: SoA(Structure of Arrays)로 바꿔 위치 배열만 순차 접근하면 캐시 효율이 5배 이상 올라갑니다.

시나리오 2: 게임 오브젝트 상속이 복잡해질 때

"적을 처치하면 아이템으로 변하고, 플레이어가 몬스터에 빙의해요."
"다중 상속, dynamic_cast가 난무해요."

상황: OOP 상속으로 EnemyItem 변환이나 Player + Monster 조합을 표현하기 어렵습니다. 타입이 바뀔 때마다 객체 재생성·참조 갱신이 필요합니다.

해결 포인트: ECS로 엔티티는 ID만 갖고, 컴포넌트 조합으로 “무엇인가”를 표현합니다. 컴포넌트 추가/제거만으로 타입 변환이 가능합니다.

시나리오 3: 특정 필드만 조회하는데 전체 구조체를 로드할 때

"100만 건의 '나이' 필드만 집계하는데, 전체 행을 메모리에 올려요."
"이름, 주소 등 안 쓰는 필드까지 캐시에 올라와 대역폭을 낭비해요."

상황: 행(row) 단위 저장은 AoS와 같습니다. 열(column) 단위 저장이나 SoA 형태로 “나이 배열만” 순회하면 필요한 바이트만 읽어 캐시 효율이 좋아집니다.

해결 포인트: SoA 또는 컬럼형 스토리지로 전환합니다.

시나리오 4: 자주 쓰는 데이터와 가끔 쓰는 데이터가 섞여 있을 때

"엔티티 위치는 매 프레임 갱신하는데, 이름·설명은 가끔만 조회해요."
"이름 접근 시 위치까지 캐시에 올라와 낭비예요."

상황: Entityx, y, z(핫)와 name, description(콜드)이 함께 있으면, 콜드 접근 시 핫 데이터까지 캐시에 올라옵니다.

해결 포인트: 핫/콜드 데이터 분리로 자주 접근하는 데이터만 연속 배열에 두고, 콜드는 별도 저장소에 둡니다.

시나리오 5: SIMD 벡터화가 안 될 때

"컴파일러가 자동 벡터화를 못 해준다고 나와요."
"x, y, z가 연속이 아니라 구조체마다 떨어져 있어서요."

상황: AoS에서는 entities[i].x, entities[i+1].x가 메모리에서 28바이트 이상씩 떨어져 있어, 4개 float를 한 번에 로드하는 SIMD 명령에 맞지 않습니다.

해결 포인트: SoA에서는 x[i], x[i+1], x[i+2], x[i+3]가 연속이므로 벡터화가 쉽습니다.

시나리오 6: 타입별로 다른 처리인데 한 배열에 섞여 있을 때

"플레이어, 적, 아이템이 한 Entity 배열에 있어요."
"플레이어만 업데이트할 때도 적·아이템 데이터가 캐시에 올라와요."

상황: 타입별로 분리된 배열을 쓰면, “플레이어 위치 갱신” 시 플레이어 데이터만 순차 접근해 캐시가 낭비되지 않습니다.

해결 포인트: 타입별 SoA 또는 ECS로 컴포넌트별·타입별 배열 분리.

시나리오별 권장 기법

시나리오특징권장 기법
파티클·엔티티 대량 업데이트특정 필드만 반복 처리SoA
상속 복잡도·타입 변환조합 기반 설계ECS
필드별 집계·조회일부 필드만 사용SoA, 컬럼형
핫/콜드 접근 빈도 차이자주 쓰는 vs 가끔 쓰는핫/콜드 분리
SIMD 적용 필요연속 float/int 배열SoA
타입별 다른 처리플레이어/적/아이템 분리타입별 SoA, ECS

목차

  1. SoA 완전 예제
  2. 캐시 친화적 레이아웃
  3. ECS와 데이터 지향 설계
  4. 핫/콜드 데이터 분리
  5. 자주 발생하는 에러와 해결법
  6. 모범 사례와 체크리스트
  7. 성능 벤치마크
  8. 프로덕션 패턴
  9. 정리

1. SoA 완전 예제

AoS vs SoA 메모리 배치

flowchart TB
    subgraph AoS["AoS (Array of Structures)"]
        direction LR
        A1["[x0,y0,z0,vx0,vy0,vz0,r0,g0,b0]"]
        A2["[x1,y1,z1,vx1,vy1,vz1,r1,g1,b1]"]
        A1 --> A2
    end
    subgraph SoA["SoA (Structure of Arrays)"]
        direction TB
        X["x: [x0,x1,x2,...]"]
        Y["y: [y0,y1,y2,...]"]
        Z["z: [z0,z1,z2,...]"]
        VX["vx: [vx0,vx1,vx2,...]"]
        X --> Y --> Z --> VX
    end

AoS 구현 (캐시 비효율)

// aos_particles.cpp - g++ -std=c++17 -O2 -o aos aos_particles.cpp
#include <vector>
#include <chrono>
#include <iostream>

struct ParticleAoS {
    float x, y, z;      // 위치 12바이트
    float vx, vy, vz;   // 속도 12바이트
    float r, g, b;      // 색상 12바이트
    float life;         // 수명 4바이트
    // 총 40바이트, 캐시 라인(64B)에 1.6개
};

void updatePositionsAoS(std::vector<ParticleAoS>& particles, float dt) {
    for (auto& p : particles) {
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.z += p.vz * dt;
    }
}

int main() {
    const size_t N = 100000;
    std::vector<ParticleAoS> particles(N);
    for (size_t i = 0; i < N; ++i) {
        particles[i].x = particles[i].y = particles[i].z = 0;
        particles[i].vx = particles[i].vy = particles[i].vz = 1.0f;
    }

    auto start = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 100; ++iter) {
        updatePositionsAoS(particles, 0.016f);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "AoS: " << ms << " ms\n";
    return 0;
}

SoA 구현 (캐시 효율)

// soa_particles.cpp - g++ -std=c++17 -O2 -o soa soa_particles.cpp
#include <vector>
#include <chrono>
#include <iostream>

struct ParticleSystemSoA {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> r, g, b;
    std::vector<float> life;

    void resize(size_t n) {
        x.resize(n); y.resize(n); z.resize(n);
        vx.resize(n); vy.resize(n); vz.resize(n);
        r.resize(n); g.resize(n); b.resize(n);
        life.resize(n);
    }
    size_t size() const { return x.size(); }
};

void updatePositionsSoA(ParticleSystemSoA& particles, float dt) {
    const size_t n = particles.size();
    float* x = particles.x.data();
    float* y = particles.y.data();
    float* z = particles.z.data();
    const float* vx = particles.vx.data();
    const float* vy = particles.vy.data();
    const float* vz = particles.vz.data();

    for (size_t i = 0; i < n; ++i) {
        x[i] += vx[i] * dt;
        y[i] += vy[i] * dt;
        z[i] += vz[i] * dt;
    }
}

int main() {
    const size_t N = 100000;
    ParticleSystemSoA particles;
    particles.resize(N);
    for (size_t i = 0; i < N; ++i) {
        particles.x[i] = particles.y[i] = particles.z[i] = 0;
        particles.vx[i] = particles.vy[i] = particles.vz[i] = 1.0f;
    }

    auto start = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 100; ++iter) {
        updatePositionsSoA(particles, 0.016f);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "SoA: " << ms << " ms\n";
    return 0;
}

SoA 선택 가이드

상황권장이유
같은 필드만 대량 처리 (위치 업데이트)SoA캐시 효율 최대, SIMD 용이
한 객체의 여러 필드를 함께 사용AoS코드 단순, 인덱스 하나로 접근
SIMD 벡터화 적용SoA4/8개 float 연속 로드
개별 엔티티 조회·수정AoSentities[i] 한 번에 접근
필드별 집계·통계SoA필요한 배열만 순회

2. 캐시 친화적 레이아웃

원칙 1: 연속 메모리 사용

// ❌ 나쁜 예: 링크드 리스트 (노드마다 캐시 미스)
struct Node {
    int value;
    Node* next;
};
int sum_list(Node* head) {
    int sum = 0;
    for (Node* p = head; p; p = p->next) {
        sum += p->value;  // 매 노드마다 다른 메모리 블록
    }
    return sum;
}

// ✅ 좋은 예: 벡터 (연속 메모리, 캐시 히트)
int sum_vector(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) {
        sum += x;  // 연속 접근, 캐시 효율 최고
    }
    return sum;
}

원칙 2: 행 우선 순회

// g++ -std=c++17 -O2 -o matrix_order matrix_order.cpp
#include <vector>
#include <chrono>
#include <iostream>

const int N = 2048;

// ❌ 열 우선 순회 (느림): 매 접근마다 N*4바이트 건너뜀
void col_major(std::vector<std::vector<int>>& m) {
    for (int c = 0; c < N; ++c) {
        for (int r = 0; r < N; ++r) {
            m[r][c] = r + c;
        }
    }
}

// ✅ 행 우선 순회 (빠름): 연속 접근
void row_major(std::vector<std::vector<int>>& m) {
    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            m[r][c] = r + c;
        }
    }
}

// ✅✅ 더 좋은 예: 1차원 연속 배열 (행이 메모리에 연속)
void row_major_flat(std::vector<int>& m) {
    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            m[r * N + c] = r + c;
        }
    }
}

원칙 3: 구조체 패딩 최소화

// ❌ 나쁜 예: 패딩으로 크기 증가
struct Bad {
    char a;      // 1바이트 + 3바이트 패딩
    int b;       // 4바이트
    char c;      // 1바이트 + 3바이트 패딩
};  // 총 12바이트

// ✅ 좋은 예: 큰 타입 먼저 배치
struct Good {
    int b;       // 4바이트
    char a;      // 1바이트
    char c;      // 1바이트 + 2바이트 패딩
};  // 총 8바이트

캐시 친화적 레이아웃 체크리스트

- [ ] 연속 메모리: vector 우선, list는 꼭 필요할 때만
- [ ] 2차원 데이터: 행 우선 순회, 1차원 배열로 연속 저장
- [ ] 구조체: 큰 타입 먼저, 패딩 최소화
- [ ] SoA: 같은 필드만 대량 처리 시

3. ECS와 데이터 지향 설계

ECS 핵심: 컴포넌트를 SoA로 저장

flowchart TB
    subgraph ECS["ECS 데이터 지향 설계"]
        E[Entity ID만]
        subgraph Components["컴포넌트 (SoA)"]
            P[Position: x[], y[], z[]]
            V[Velocity: vx[], vy[], vz[]]
            H[Health: hp[]]
        end
        subgraph Systems["시스템"]
            S1["MovementSystem: pos + vel"]
            S2["DamageSystem: hp 감소"]
        end
        E --> E
        P --> S1
        V --> S1
        H --> S2
    end

완전한 ECS 예제 (SoA)

// ecs_dod_example.cpp - g++ -std=c++17 -O2 -o ecs ecs_dod_example.cpp
#include <vector>
#include <cstdint>
#include <cassert>

using EntityId = uint32_t;
constexpr EntityId INVALID = ~0u;

// 컴포넌트: SoA (Structure of Arrays)
struct PositionComponent {
    std::vector<float> x, y, z;
    std::vector<EntityId> entity;  // entity[i] -> 이 데이터의 소유자

    void add(EntityId id, float x_, float y_, float z_) {
        entity.push_back(id);
        x.push_back(x_);
        y.push_back(y_);
        z.push_back(z_);
    }
    size_t size() const { return x.size(); }
};

struct VelocityComponent {
    std::vector<float> vx, vy, vz;
    std::vector<EntityId> entity;

    void add(EntityId id, float vx_, float vy_, float vz_) {
        entity.push_back(id);
        vx.push_back(vx_);
        vy.push_back(vy_);
        vz.push_back(vz_);
    }
    size_t size() const { return vx.size(); }
};

// 시스템: MovementSystem - Position + Velocity 모두 가진 엔티티만 처리
void movement_system(PositionComponent& pos, const VelocityComponent& vel, float dt) {
    // Position과 Velocity가 같은 entity 순서로 저장된다고 가정
    // (실제 ECS는 entity ID로 매칭)
    const size_t n = std::min(pos.size(), vel.size());
    for (size_t i = 0; i < n; ++i) {
        pos.x[i] += vel.vx[i] * dt;
        pos.y[i] += vel.vy[i] * dt;
        pos.z[i] += vel.vz[i] * dt;
    }
}

ECS + 타입별 분리

// 타입별로 분리된 컴포넌트 배열
struct EnemyPool {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<int> hp;
};

struct PlayerPool {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<int> hp;
    std::vector<std::string> name;  // 플레이어만 이름
};

// 플레이어만 업데이트할 때: Enemy 데이터는 캐시에 안 올라옴
void update_players(PlayerPool& players, float dt) {
    for (size_t i = 0; i < players.x.size(); ++i) {
        players.x[i] += players.vx[i] * dt;
        players.y[i] += players.vy[i] * dt;
        players.z[i] += players.vz[i] * dt;
    }
}

4. 핫/콜드 데이터 분리

개념

flowchart LR
    subgraph Hot["핫 데이터 (자주 접근)"]
        H1[x, y, z]
        H2[vx, vy, vz]
        H3[id]
    end
    subgraph Cold["콜드 데이터 (가끔 접근)"]
        C1[name]
        C2[description]
        C3[metadata]
    end
    Hot -->|순차 배열| Cache["캐시 효율"]
    Cold -->|별도 저장| Save["메모리 절약"]

핫/콜드 분리 예제

// hot_cold_example.cpp
#include <vector>
#include <string>
#include <unordered_map>

// 핫: 매 프레임 갱신하는 데이터 (연속 배열)
struct EntityHot {
    float x, y, z;
    float vx, vy, vz;
    int id;
};
std::vector<EntityHot> hot_entities;

// 콜드: 가끔만 조회하는 데이터 (별도 저장)
struct EntityCold {
    std::string name;
    std::string description;
};
std::unordered_map<int, EntityCold> cold_by_id;

// 위치 업데이트: hot만 순차 접근 → 캐시 효율 최고
void update_positions(std::vector<EntityHot>& hot, float dt) {
    for (auto& e : hot) {
        e.x += e.vx * dt;
        e.y += e.vy * dt;
        e.z += e.vz * dt;
    }
}

// 이름 조회: 가끔만 호출, cold만 접근
void set_name(int id, const std::string& name) {
    cold_by_id[id].name = name;
}

하이브리드: SoA + 핫/콜드

// SoA 형태로 핫 데이터, 맵으로 콜드
struct HotData {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<int> id;
};

struct ColdData {
    std::vector<std::string> name;
    std::vector<std::string> description;
};

// id[i] == hot.id[i] 인덱스로 cold 접근
struct World {
    HotData hot;
    std::unordered_map<int, size_t> id_to_index;  // id -> hot 배열 인덱스
    std::vector<std::string> cold_name;   // cold_name[i] = hot[i]의 이름
    std::vector<std::string> cold_desc;
};

5. 자주 발생하는 에러와 해결법

에러 1: SoA로 바꿨는데 오히려 느려짐

증상: AoS에서 SoA로 전환했는데 성능이 떨어짐.

원인: (1) 한 객체의 여러 필드를 함께 사용하는 패턴인데 SoA로 바꿔서, 매번 다른 배열을 접근해 캐시가 오히려 더 자주 바뀜. (2) 인덱스 계산이 추가되어 오버헤드가 커짐.

// ❌ SoA가 불리한 경우: 한 객체의 모든 필드를 함께 사용
for (size_t i = 0; i < n; ++i) {
    if (x[i] > 0 && y[i] > 0 && z[i] > 0) {
        vx[i] *= 0.5f;
        vy[i] *= 0.5f;
        vz[i] *= 0.5f;
    }
}
// 6개 배열을 오가며 접근 → 캐시 효율 나쁨

해결법: “같은 필드만 대량 순회”할 때는 SoA, “한 객체의 여러 필드를 함께 사용”할 때는 AoS 유지. 또는 Hybrid: 자주 함께 쓰는 필드만 AoS로 묶고 나머지는 SoA.

에러 2: ECS에서 entity ID와 컴포넌트 인덱스 불일치

증상: PositionComponentVelocityComponent의 인덱스가 엔티티별로 맞지 않아 잘못된 데이터가 매칭됨.

원인: 엔티티 추가/삭제 시 컴포넌트 배열의 인덱스가 엔티티 ID와 1:1 대응되지 않음.

// ❌ 잘못된 가정: pos[i]와 vel[i]가 같은 엔티티
void movement_bad(PositionComponent& pos, const VelocityComponent& vel, float dt) {
    for (size_t i = 0; i < pos.size(); ++i) {
        pos.x[i] += vel.vx[i] * dt;  // vel에 해당 엔티티가 없을 수 있음!
    }
}

해결법: entity ID → 컴포넌트 인덱스 매핑을 유지하거나, Archetype 방식으로 같은 컴포넌트 조합을 가진 엔티티만 한 배열에 모읍니다.

// ✅ 올바른 예: entity ID로 매칭
void movement_ok(PositionComponent& pos, const VelocityComponent& vel,
                 const std::unordered_map<EntityId, size_t>& pos_idx,
                 const std::unordered_map<EntityId, size_t>& vel_idx,
                 float dt) {
    for (const auto& [eid, pi] : pos_idx) {
        auto it = vel_idx.find(eid);
        if (it == vel_idx.end()) continue;
        size_t vi = it->second;
        pos.x[pi] += vel.vx[vi] * dt;
        pos.y[pi] += vel.vy[vi] * dt;
        pos.z[pi] += vel.vz[vi] * dt;
    }
}

에러 3: 핫/콜드 분리 시 인덱스 동기화 실수

증상: hot[i]cold[i]가 같은 엔티티를 가리키지 않음.

원인: 엔티티 삭제 시 hot 배열에서 swap-and-pop으로 제거했는데, cold 배열은 갱신하지 않음.

해결법: 삭제 시 hot과 cold를 동시에 갱신하거나, ID 기반으로 cold를 unordered_map<int, ColdData>로 저장해 인덱스 의존을 제거합니다.

에러 4: SoA 구조체 크기 불균형

증상: x, y, z 배열은 10만 개인데 life 배열은 1만 개만 사용. 메모리 낭비 또는 인덱스 오류.

원인: 컴포넌트 추가/제거 시 일부 배열만 갱신함.

해결법: SoA의 모든 배열을 동시에 resize/add/remove. 래퍼 클래스로 add(), remove()를 한 번에 처리합니다.

// ✅ 래퍼로 add/remove 일관성 보장
struct ParticleSoA {
    std::vector<float> x, y, z, vx, vy, vz, life;
    void add(float x_, float y_, float z_, float vx_, float vy_, float vz_, float life_) {
        x.push_back(x_);
        y.push_back(y_);
        z.push_back(z_);
        vx.push_back(vx_);
        vy.push_back(vy_);
        vz.push_back(vz_);
        life.push_back(life_);
    }
    void remove(size_t i) {
        std::swap(x[i], x.back()); x.pop_back();
        std::swap(y[i], y.back()); y.pop_back();
        // ... 모든 배열 동일 처리
    }
};

에러 5: 2차원 배열을 vector로 저장

증상: 행 우선 순회인데도 느림.

원인: std::vector<std::vector<int>>행마다 별도 할당이라, 행이 메모리에 연속하지 않습니다.

// ❌ 행이 연속이 아님
std::vector<std::vector<int>> m(N, std::vector<int>(N));
for (int r = 0; r < N; ++r) {
    for (int c = 0; c < N; ++c) {
        m[r][c] = 0;  // m[r]과 m[r+1]이 다른 메모리 블록
    }
}

해결법:

// ✅ 1차원 배열로 연속 저장
std::vector<int> m(N * N);
for (int r = 0; r < N; ++r) {
    for (int c = 0; c < N; ++c) {
        m[r * N + c] = 0;
    }
}

6. 모범 사례와 체크리스트

데이터 지향 설계 체크리스트

- [ ] 프로파일러로 cache-misses 확인 후 최적화 (perf stat -e cache-misses)
- [ ] 같은 필드만 대량 처리: SoA 검토
- [ ] 한 객체의 여러 필드 함께 사용: AoS 유지 또는 Hybrid
- [ ] 자주 쓰는 vs 가끔 쓰는: 핫/콜드 분리
- [ ] 타입별 다른 처리: 타입별 배열 또는 ECS
- [ ] 2차원 데이터: 1차원 연속 + 행 우선
- [ ] 구조체: 큰 타입 먼저, 패딩 최소화
- [ ] ECS: entity ID ↔ 컴포넌트 인덱스 매핑 일관성
- [ ] SoA add/remove: 모든 배열 동시 갱신

의사 결정 플로우

flowchart TD
    A[성능 병목] --> B{접근 패턴}
    B -->|같은 필드만 대량 순회| C[SoA]
    B -->|한 객체 여러 필드 조합| D[AoS 또는 Hybrid]
    B -->|자주 vs 가끔 접근| E[핫/콜드 분리]
    B -->|타입별 다른 처리| F[타입별 SoA 또는 ECS]
    C --> G[캐시·SIMD 유리]
    D --> H[코드 단순]
    E --> I[캐시 효율]
    F --> J[캐시 분리]

AoS vs SoA vs Hybrid 비교

패턴장점단점사용 시기
AoS코드 단순, 인덱스 하나캐시 낭비, SIMD 불리엔티티 수 적음, 여러 필드 조합
SoA캐시 효율, SIMD 용이코드 복잡, 인덱스 관리같은 필드 대량 순회
HybridAoS+SoA 장점 조합설계 복잡자주 함께 쓰는 필드만 AoS
ECS유연한 조합, SoA구현 복잡게임·시뮬레이션

7. 성능 벤치마크

AoS vs SoA 벤치마크

// benchmark_aos_soa.cpp - g++ -std=c++17 -O2 -o bench benchmark_aos_soa.cpp
#include <vector>
#include <chrono>
#include <iostream>

struct ParticleAoS {
    float x, y, z, vx, vy, vz, r, g, b;
};

struct ParticleSoA {
    std::vector<float> x, y, z, vx, vy, vz, r, g, b;
    void resize(size_t n) {
        x.resize(n); y.resize(n); z.resize(n);
        vx.resize(n); vy.resize(n); vz.resize(n);
        r.resize(n); g.resize(n); b.resize(n);
    }
};

int main() {
    const size_t N = 100000;
    const int ITERS = 100;

    std::vector<ParticleAoS> aos(N);
    ParticleSoA soa;
    soa.resize(N);

    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < ITERS; ++i) {
        for (size_t j = 0; j < N; ++j) {
            aos[j].x += aos[j].vx * 0.016f;
            aos[j].y += aos[j].vy * 0.016f;
            aos[j].z += aos[j].vz * 0.016f;
        }
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto ms_aos = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < ITERS; ++i) {
        for (size_t j = 0; j < N; ++j) {
            soa.x[j] += soa.vx[j] * 0.016f;
            soa.y[j] += soa.vy[j] * 0.016f;
            soa.z[j] += soa.vz[j] * 0.016f;
        }
    }
    end = std::chrono::high_resolution_clock::now();
    auto ms_soa = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "AoS: " << ms_aos << " ms, SoA: " << ms_soa << " ms\n";
    return 0;
}

예상 결과 (참고용)

구성10만 파티클 × 100회 (ms)상대
AoS~801x
SoA~15~5.3x

perf로 캐시 미스 측정

# g++ -std=c++17 -O2 -o bench benchmark_aos_soa.cpp
# perf stat -e cache-misses,cache-references ./bench
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./my_program

행 우선 vs 열 우선 벤치마크

순회 방식2048×2048 (ms)상대
행 우선 (1차원 연속)~51x
행 우선 (vector)~81.6x
열 우선~5010x

8. 프로덕션 패턴

패턴 1: 게임 파티클 시스템 (SoA)

// 프로덕션: 파티클 풀 + SoA
struct ParticlePool {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> r, g, b, a;
    std::vector<float> life;

    void update(float dt) {
        const size_t n = x.size();
        for (size_t i = 0; i < n; ++i) {
            x[i] += vx[i] * dt;
            y[i] += vy[i] * dt;
            z[i] += vz[i] * dt;
            life[i] -= dt;
        }
    }
};

패턴 2: HTTP 서버 워커 통계 (캐시 라인 정렬)

// 프로덕션: per-thread 데이터 캐시 라인 정렬
#include <atomic>
#include <new>

constexpr size_t CACHE_LINE = 64;

struct alignas(CACHE_LINE) WorkerStats {
    std::atomic<uint64_t> requests{0};
    std::atomic<uint64_t> bytes{0};
};

std::vector<WorkerStats> workers(num_threads);
// 각 워커 스레드가 자신의 인덱스만 수정 → False Sharing 방지

패턴 3: 로그 파이프라인 (컬럼형 SoA)

// 프로덕션: 로그 필드별 SoA
struct LogBatch {
    std::vector<int64_t> timestamp;
    std::vector<int> level;
    std::vector<std::string> message;
    std::vector<int> user_id;

    // 레벨별 집계: level 배열만 순회
    int count_by_level(int lv) const {
        int count = 0;
        for (int l : level) if (l == lv) ++count;
        return count;
    }
};

패턴 4: 물리 엔진 (Rigid Body SoA)

// 프로덕션: 물리 엔진 Rigid Body
struct RigidBodySystem {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> ax, ay, az;
    std::vector<float> mass;
    std::vector<float> inv_mass;

    void integrate(float dt) {
        for (size_t i = 0; i < x.size(); ++i) {
            vx[i] += ax[i] * dt;
            vy[i] += ay[i] * dt;
            vz[i] += az[i] * dt;
            x[i] += vx[i] * dt;
            y[i] += vy[i] * dt;
            z[i] += vz[i] * dt;
        }
    }
};

패턴 5: 구현 체크리스트

- [ ] perf stat으로 cache-misses 확인
- [ ] AoS vs SoA: 접근 패턴에 맞게 선택
- [ ] ECS: entity ID 매핑 일관성
- [ ] 핫/콜드: 자주 접근 데이터 분리
- [ ] 2차원 데이터: 1차원 연속 + 행 우선
- [ ] SoA add/remove: 래퍼로 일관성 보장
- [ ] 프로파일링 없이 최적화하지 않기

9. 정리

기법용도효과
SoA같은 필드만 대량 처리2~5배
캐시 친화적 레이아웃연속 메모리, 행 우선5~10배
ECS조합 기반, SoA 컴포넌트유연성 + 성능
핫/콜드 분리접근 빈도 차이상황에 따라
타입별 분리플레이어/적/아이템캐시 낭비 감소

핵심 원칙:

  1. 프로파일링 먼저: cache-misses 확인 후 최적화
  2. 같은 필드만 대량 처리 → SoA
  3. 한 객체 여러 필드 조합 → AoS 또는 Hybrid
  4. 자주 vs 가끔 → 핫/콜드 분리
  5. 타입별 다른 처리 → 타입별 SoA 또는 ECS
  6. 2차원 → 1차원 연속 + 행 우선
  7. SoA add/remove → 모든 배열 동시 갱신

자주 묻는 질문 (FAQ)

Q. SoA vs AoS, 언제 어느 쪽을 써야 하나요?

A. 같은 필드만 대량 순회(위치 업데이트, 필드별 집계)하면 SoA. 한 객체의 여러 필드를 함께 사용(개별 엔티티 조회·수정)하면 AoS. Hybrid(자주 함께 쓰는 필드만 AoS)도 가능합니다.

Q. ECS와 SoA의 관계는?

A. ECS는 아키텍처 패턴(Entity-Component-System)이고, SoA는 데이터 배치 패턴입니다. ECS에서 컴포넌트를 SoA로 저장하면 DOD와 잘 맞습니다. EnTT, flecs 등 ECS 라이브러리도 내부적으로 SoA를 사용합니다.

Q. 핫/콜드 분리 시 인덱스는 어떻게 관리하나요?

A. (1) cold를 unordered_map<id, ColdData>로 저장해 인덱스 의존을 제거하거나, (2) hot과 cold를 항상 동시에 add/remove하는 래퍼를 두거나, (3) 삭제 시 swap-and-pop 후 cold도 동일 인덱스로 갱신합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 데이터 지향 설계 #39-1, 캐시 최적화 #51-4, ECS 패턴 #48-2, 캐시 친화적 코드 #15-2를 먼저 읽으면 좋습니다.

Q. 더 깊이 공부하려면?

A. Mike Acton의 “Data-Oriented Design and C++” (CppCon), Overwatch 기술 블로그의 ECS 아티클, What Every Programmer Should Know About Memory를 참고하세요.

한 줄 요약: 프로파일링으로 메모리 병목을 확인한 뒤, SoA·ECS·핫/콜드 분리를 접근 패턴에 맞게 적용합니다.

이전 글: C++ 캐시 최적화 #51-4

다음 글: C++ I/O 최적화 #51-6


관련 글

  • C++ SIMD 최적화 실전 | SSE·AVX2·NEON 인트린직으로 4배 빠르게 [#51-2]
  • C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
  • C++ Cache Optimization |
  • C++ 메모리 정렬 |
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3