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 상속으로 Enemy → Item 변환이나 Player + Monster 조합을 표현하기 어렵습니다. 타입이 바뀔 때마다 객체 재생성·참조 갱신이 필요합니다.
해결 포인트: ECS로 엔티티는 ID만 갖고, 컴포넌트 조합으로 “무엇인가”를 표현합니다. 컴포넌트 추가/제거만으로 타입 변환이 가능합니다.
시나리오 3: 특정 필드만 조회하는데 전체 구조체를 로드할 때
"100만 건의 '나이' 필드만 집계하는데, 전체 행을 메모리에 올려요."
"이름, 주소 등 안 쓰는 필드까지 캐시에 올라와 대역폭을 낭비해요."
상황: 행(row) 단위 저장은 AoS와 같습니다. 열(column) 단위 저장이나 SoA 형태로 “나이 배열만” 순회하면 필요한 바이트만 읽어 캐시 효율이 좋아집니다.
해결 포인트: SoA 또는 컬럼형 스토리지로 전환합니다.
시나리오 4: 자주 쓰는 데이터와 가끔 쓰는 데이터가 섞여 있을 때
"엔티티 위치는 매 프레임 갱신하는데, 이름·설명은 가끔만 조회해요."
"이름 접근 시 위치까지 캐시에 올라와 낭비예요."
상황: Entity에 x, 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 벡터화 적용 | SoA | 4/8개 float 연속 로드 |
| 개별 엔티티 조회·수정 | AoS | entities[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와 컴포넌트 인덱스 불일치
증상: PositionComponent와 VelocityComponent의 인덱스가 엔티티별로 맞지 않아 잘못된 데이터가 매칭됨.
원인: 엔티티 추가/삭제 시 컴포넌트 배열의 인덱스가 엔티티 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 용이 | 코드 복잡, 인덱스 관리 | 같은 필드 대량 순회 |
| Hybrid | AoS+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 | ~80 | 1x |
| 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차원 연속) | ~5 | 1x |
| 행 우선 (vector | ~8 | 1.6x |
| 열 우선 | ~50 | 10x |
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 컴포넌트 | 유연성 + 성능 |
| 핫/콜드 분리 | 접근 빈도 차이 | 상황에 따라 |
| 타입별 분리 | 플레이어/적/아이템 | 캐시 낭비 감소 |
핵심 원칙:
- 프로파일링 먼저: cache-misses 확인 후 최적화
- 같은 필드만 대량 처리 → SoA
- 한 객체 여러 필드 조합 → AoS 또는 Hybrid
- 자주 vs 가끔 → 핫/콜드 분리
- 타입별 다른 처리 → 타입별 SoA 또는 ECS
- 2차원 → 1차원 연속 + 행 우선
- 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++ 메모리 정렬 |