본문으로 건너뛰기 C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드

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

C++ 데이터 지향 설계 실전 | 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 완전 예제

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++, 데이터지향설계, SoA, AoS, ECS, 캐시친화, 핫콜드분리 등으로 검색하시면 이 글이 도움이 됩니다.