C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드
들어가며: 객체가 아니라 데이터로 생각하기
데이터 지향 설계 기초(#39-1)와 캐시 최적화(#51-4)에서 캐시의 동작 원리를 다뤘다면, 이 글은 그 원리를 실제 데이터 구조 설계에 적용합니다. 객체 지향 설계는 “이 객체가 무엇이고 무엇을 하는가”에서 출발하지만, 데이터 지향 설계(Data-Oriented Design, DOD)는 “어떤 데이터를 어떤 순서로 얼마나 자주 읽고 쓰는가”에서 출발해 메모리 배치를 정합니다.
이렇게 접근하는 이유는 현대 CPU에서 연산보다 메모리 접근이 훨씬 비싸기 때문입니다. 메모리는 64바이트 캐시 라인 단위로 읽히므로, 루프가 쓰는 데이터가 캐시 라인을 얼마나 꽉 채우는지가 성능을 좌우합니다. 같은 O(n) 루프라도 필요 없는 바이트를 함께 끌어오는 배치와, 필요한 바이트만 연속으로 읽는 배치는 메모리 대역폭을 쓰는 효율이 다릅니다.
이 글은 같은 문제를 세 가지 각도로 다룹니다. 일부 필드만 반복해서 처리하는 루프에는 SoA(Structure of Arrays), 자주 쓰는 데이터와 가끔 쓰는 데이터가 섞여 있으면 핫/콜드 분리, 상속 계층이 복잡해지는 게임 객체에는 ECS(Entity-Component-System)를 적용합니다. 예제는 C++17(-std=c++17)로 작성했습니다.
AoS에서 SoA로 바꾸는 예제
무엇이 낭비되는가
struct ParticleAoS {
float x, y, z; // 위치 12바이트
float vx, vy, vz; // 속도 12바이트
float r, g, b; // 색상 12바이트
float life; // 수명 4바이트
}; // 40바이트
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;
}
}
위치 갱신 루프가 쓰는 것은 위치와 속도 24바이트뿐인데, 메모리에서는 파티클 하나당 40바이트가 함께 올라옵니다. 캐시로 가져온 바이트 중 40%(색상과 수명)는 이 루프에서 한 번도 쓰이지 않습니다. 파티클 수가 적어 전체가 캐시에 들어가면 차이가 작지만, 캐시보다 큰 데이터를 매 프레임 순회한다면 이 낭비가 그대로 메모리 대역폭 낭비가 됩니다.
SoA: 필드마다 배열 하나
flowchart TB
subgraph AoS["AoS (Array of Structures)"]
direction LR
A1[["x0,y0,z0,vx0,vy0,vz0,r0,g0,b0,life0"]]
A2[["x1,y1,z1,vx1,vy1,vz1,r1,g1,b1,life1"]]
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
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& p, float dt) {
const size_t n = p.size();
float* x = p.x.data();
float* y = p.y.data();
float* z = p.z.data();
const float* vx = p.vx.data();
const float* vy = p.vy.data();
const float* vz = p.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;
}
}
SoA에서는 루프가 읽는 여섯 개 배열이 모두 연속이고, 가져온 캐시 라인의 모든 바이트를 씁니다. 색상과 수명 배열은 아예 건드리지 않습니다. x[i], x[i+1], x[i+2], x[i+3]이 메모리에 붙어 있으므로 컴파일러가 루프를 SIMD 명령으로 바꾸기도 쉽습니다. AoS에서는 같은 필드가 구조체 크기(여기서는 40바이트)만큼 떨어져 있어, 벡터 레지스터 하나에 연속으로 로드할 수 없습니다.
포인터를 지역 변수로 꺼낸 것도 의도가 있습니다. p.x[i]처럼 매번 벡터를 통해 접근하면, 컴파일러는 x에 쓰는 것이 vx의 데이터 포인터를 바꾸지 않는다는 것을 증명하기 어려워 벡터화를 포기하기도 합니다. GCC는 -O2에서 12 버전부터 기본으로 벡터화를 하고, 그 이전에는 -O3가 필요합니다. -fopt-info-vec(GCC)나 -Rpass=loop-vectorize(Clang)로 벡터화 여부를 확인할 수 있습니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 같은 필드만 대량 처리 (위치 갱신, 필드별 집계) | SoA | 가져온 캐시 라인을 전부 사용, SIMD 용이 |
| 한 원소의 여러 필드를 함께, 특히 임의 순서로 사용 | AoS | 원소 하나가 캐시 라인 하나에 모여 있음 |
| 원소 수가 적어 전체가 캐시에 들어감 | AoS | 레이아웃 차이의 이득이 거의 없고 코드가 단순 |
캐시 친화적 레이아웃의 기본
연속 메모리
struct Node {
int value;
Node* next;
};
int sum_list(const Node* head) {
int sum = 0;
for (const 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;
}
연결 리스트는 노드가 힙 여기저기에 흩어져 있고, 무엇보다 다음 노드의 주소가 현재 노드를 읽어야 나온다는 것이 문제입니다. CPU는 이전 로드가 끝날 때까지 다음 로드를 시작할 수 없어, 캐시 미스마다 메모리 지연을 그대로 기다립니다. 배열은 다음 주소가 계산 가능해 하드웨어 프리페처가 앞서 가져오고, 여러 로드를 동시에 진행할 수 있습니다.
2차원 데이터는 1차원 배열에 행 우선으로
const int N = 2048;
// 열 우선 순회: 다음 원소가 한 행(N × 4바이트 = 8KB) 뒤에 있음
void col_major(std::vector<int>& m) {
for (int c = 0; c < N; ++c)
for (int r = 0; r < N; ++r)
m[r * N + c] = r + c;
}
// 행 우선 순회: 주소가 4바이트씩 증가
void row_major(std::vector<int>& m) {
for (int r = 0; r < N; ++r)
for (int c = 0; c < N; ++c)
m[r * N + c] = r + c;
}
C++의 다차원 배열과 r * N + c 인덱싱은 행 우선(row-major) 배치이므로, 안쪽 루프가 열 인덱스를 돌아야 연속으로 접근합니다. 열 우선으로 돌면 매 접근이 다른 캐시 라인이고, 행렬이 캐시보다 크면 한 번 가져온 캐시 라인을 나머지 15개 원소에 쓰기 전에 쫓겨나 버립니다.
구조체 패딩
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바이트
각 멤버는 자기 정렬 요구에 맞는 주소에 놓이고, 구조체 크기는 가장 큰 정렬 요구의 배수가 됩니다. 정렬 요구가 큰 멤버부터 선언하면 패딩이 줄어, 배열로 쌓았을 때 캐시 라인 하나에 더 많은 원소가 들어갑니다. 실제 크기는 sizeof와 offsetof로, 또는 pahole 같은 도구로 확인합니다. 자세한 규칙은 C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드 글을 참고하세요.
핫/콜드 데이터 분리
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 -->|연속 배열| Loop["갱신 루프"]
Cold -->|별도 저장| Lookup["UI·저장 시 조회"]
엔티티 구조체에 매 프레임 갱신하는 위치·속도와, UI에 표시할 때만 쓰는 이름·설명이 함께 있으면, 갱신 루프가 이름 문자열까지 캐시로 끌어옵니다. std::string은 그 자체로 32바이트(libstdc++ 기준) 안팎이라, 문자열 필드 두 개만 있어도 구조체 크기가 위치·속도의 몇 배가 됩니다. 자주 쓰는 핫 데이터와 가끔 쓰는 콜드 데이터를 다른 저장소로 나누면 핫 루프가 읽는 바이트가 크게 줄어듭니다.
#include <string>
#include <unordered_map>
#include <vector>
// 핫: 매 프레임 갱신 (연속 배열, 28바이트)
struct EntityHot {
float x, y, z;
float vx, vy, vz;
int id;
};
std::vector<EntityHot> hot_entities;
// 콜드: 가끔 조회 (id로 찾음)
struct EntityCold {
std::string name;
std::string description;
};
std::unordered_map<int, EntityCold> cold_by_id;
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;
}
}
const std::string& name_of(int id) {
return cold_by_id.at(id).name;
}
콜드 데이터를 id 기반 맵에 두면, 핫 배열에서 원소를 지우거나 순서를 바꿔도 콜드 쪽 인덱스를 맞출 필요가 없습니다. 콜드 데이터도 순회가 잦다면 핫 배열과 같은 인덱스를 쓰는 병렬 배열로 두는 방법이 있는데, 이 경우 삭제·정렬 때 두 배열을 항상 함께 바꿔야 합니다. 핫 데이터 안에서도 다시 SoA로 나눌 수 있으므로, 핫/콜드 분리와 SoA는 함께 쓰는 경우가 많습니다.
ECS와 데이터 지향 설계
상속 대신 조합
게임 객체를 상속으로 설계하면 “처치된 적이 아이템으로 바뀐다”거나 “플레이어가 몬스터에 빙의한다” 같은 요구가 나올 때마다 계층을 고치거나 객체를 새로 만들어 참조를 갱신해야 합니다. ECS는 엔티티를 단순한 ID로 두고, 엔티티가 어떤 컴포넌트(위치, 속도, 체력…)를 가졌는지로 정체성을 표현합니다. 적이 아이템이 되는 것은 Health 컴포넌트를 빼고 Pickup 컴포넌트를 붙이는 일이 됩니다. 시스템은 특정 컴포넌트 조합을 가진 엔티티만 골라 처리합니다.
flowchart TB
subgraph ECS[ECS]
E[Entity: ID만 가짐]
subgraph Components["컴포넌트 저장소 (타입별 연속 배열)"]
P["Position"]
V["Velocity"]
H["Health"]
end
subgraph Systems[시스템]
S1["MovementSystem: Position + Velocity"]
S2["DamageSystem: Health"]
end
E -.-> P
E -.-> V
E -.-> H
P --> S1
V --> S1
H --> S2
end
데이터 지향 관점에서 ECS의 장점은, 컴포넌트를 타입별로 연속 배열에 저장하므로 시스템이 필요한 컴포넌트만 순차로 읽는다는 점입니다. 이동 시스템은 위치와 속도 배열만 읽고 체력이나 렌더링 데이터는 건드리지 않습니다. 컴포넌트 하나(예: Position {x, y, z})는 보통 구조체로 저장되므로 컴포넌트 내부는 AoS, 컴포넌트 타입 사이는 SoA인 셈입니다. EnTT는 컴포넌트 타입마다 sparse set(엔티티 ID → 밀집 배열 인덱스)을 두고, flecs나 Unity DOTS는 같은 컴포넌트 조합을 가진 엔티티를 한 테이블(archetype)에 모아 컴포넌트별 열로 저장합니다.
컴포넌트 저장과 엔티티 매칭
#include <cstdint>
#include <vector>
using EntityId = uint32_t;
// 컴포넌트 저장소: 필드별 배열 + 각 원소의 소유 엔티티
struct PositionStore {
std::vector<float> x, y, z;
std::vector<EntityId> owner;
};
struct VelocityStore {
std::vector<float> vx, vy, vz;
std::vector<EntityId> owner;
};
여기서 가장 흔한 버그는 pos.x[i]와 vel.vx[i]가 같은 엔티티라고 가정하는 것입니다. 엔티티마다 가진 컴포넌트가 다르고, 추가·삭제 순서도 다르므로 두 저장소의 인덱스는 일반적으로 맞지 않습니다.
// 잘못된 가정: 같은 인덱스가 같은 엔티티
void movement_bad(PositionStore& pos, const VelocityStore& vel, float dt) {
for (size_t i = 0; i < pos.x.size() && i < vel.vx.size(); ++i) {
pos.x[i] += vel.vx[i] * dt; // 다른 엔티티의 속도를 더할 수 있음
}
}
정확하게 하려면 엔티티 ID로 매칭해야 합니다. 단순하게는 “엔티티 ID → 각 저장소의 인덱스” 맵을 두고 한쪽 저장소를 순회하며 다른 쪽을 찾지만, 해시 맵 조회는 그 자체로 임의 접근이라 SoA로 얻은 순차 접근의 이점을 많이 잃습니다. 그래서 실제 ECS 라이브러리는 두 가지 방식을 씁니다. sparse set 방식은 엔티티 ID를 인덱스로 쓰는 희소 배열로 O(1) 조회를 하고, 자주 함께 쓰는 컴포넌트들을 같은 순서로 정렬해 두는 그룹 기능으로 순차 접근을 되살립니다. archetype 방식은 Position과 Velocity를 모두 가진 엔티티끼리 한 테이블에 모으므로, 그 테이블 안에서는 같은 인덱스가 같은 엔티티라는 가정이 실제로 성립합니다. 대신 컴포넌트를 붙이거나 뗄 때 엔티티가 다른 테이블로 옮겨지는 비용이 듭니다.
컴포넌트를 연속 배열에 두고 swap-and-pop으로 지우는 구조에서는 엔티티 수명 관리에서 생기는 버그도 함께 따라옵니다. 시스템이 배열을 순회하는 도중에 엔티티를 지우면 원소가 옮겨져 일부를 건너뛰거나 무효화된 위치를 읽으므로, 삭제 요청은 목록에 모아 두었다가 모든 시스템이 끝난 프레임 끝에 한꺼번에 처리합니다. 같은 이유로 컴포넌트 포인터를 프레임을 넘겨 보관하면 안 되고, 다음에는 엔티티 ID로 다시 조회합니다. 삭제된 ID를 재사용한다면 ID에 세대(generation) 번호를 붙여, 지워진 엔티티를 가리키던 오래된 핸들이 같은 번호를 받은 새 엔티티를 잘못 참조하지 않도록 조회 시 세대를 확인합니다. 시스템 실행 순서도 명시해야 합니다. 이동 시스템이 입력 시스템보다 먼저 돌면 이번 프레임의 입력이 한 프레임 늦게 반영됩니다.
직접 ECS를 구현할 이유가 없다면 EnTT나 flecs 같은 검증된 라이브러리를 쓰는 편이 이런 세부 사항을 신경 쓰지 않아도 되어 안전합니다.
SoA 역효과, 인덱스 불일치, vector<vector>
SoA로 바꿨는데 오히려 느려짐
SoA가 불리해지는 전형적인 경우는 원소 하나의 여러 필드를 임의 순서로 읽는 접근입니다.
// 선택된 엔티티들(임의의 인덱스)의 모든 필드를 함께 사용
for (size_t id : selected) {
float d = x[id] * x[id] + y[id] * y[id] + z[id] * z[id];
if (d < radius2) {
vx[id] = -vx[id]; vy[id] = -vy[id]; vz[id] = -vz[id];
}
}
AoS라면 원소 하나의 필드가 한 캐시 라인에 모여 있어 원소당 캐시 미스가 한 번이지만, SoA에서는 필드 배열마다 다른 캐시 라인을 읽어야 해 원소당 캐시 미스가 최대 여섯 번이 됩니다. 순차 순회라면 필드 배열이 여러 개여도 프리페처가 각 스트림을 따라가므로 문제가 덜하지만, 한 루프에서 동시에 읽는 배열이 수십 개로 늘어나면 하드웨어가 추적할 수 있는 스트림 수와 TLB 항목을 넘어서 효율이 떨어질 수 있습니다.
해결은 접근 패턴에 맞춰 묶는 것입니다. 항상 함께 쓰는 필드(예: 위치 x, y, z)는 작은 구조체로 묶어 배열로 두고, 따로 쓰는 필드끼리만 분리하는 하이브리드 배치가 흔한 절충입니다. SIMD 폭에 맞춰 원소를 4개·8개씩 묶는 AoSoA(예: struct { float x[8], y[8], z[8]; }의 배열)도 같은 목적의 배치입니다. 어느 쪽이든 바꾸기 전후를 실제 데이터로 측정해서 판단해야 합니다.
핫/콜드 또는 SoA 배열 사이의 인덱스 어긋남
원소를 삭제할 때 마지막 원소를 그 자리로 옮기고 줄이는(swap-and-pop) 방식은 O(1)이라 자주 쓰이지만, 병렬 배열 중 하나만 이렇게 처리하면 그 뒤로 hot[i]와 cold[i]가 다른 엔티티를 가리킵니다. 컴파일 에러도 크래시도 없이 엉뚱한 이름이 표시되는 식으로 나타나 원인을 찾기 어렵습니다. 모든 배열의 추가·삭제를 한 곳에서 처리하는 래퍼를 두거나, 콜드 데이터는 id 기반으로 저장해 인덱스 의존 자체를 없앱니다.
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) { // 모든 배열에 같은 swap-and-pop 적용
auto swap_pop = [i](std::vector<float>& v) {
v[i] = v.back();
v.pop_back();
};
swap_pop(x); swap_pop(y); swap_pop(z);
swap_pop(vx); swap_pop(vy); swap_pop(vz);
swap_pop(life);
}
};
새 필드를 추가할 때 add와 remove 양쪽에 빠짐없이 넣어야 한다는 점은 여전히 실수하기 쉬운 부분이므로, 디버그 빌드에서 모든 배열의 크기가 같은지 assert로 확인해 두면 좋습니다. swap-and-pop은 원소 순서를 바꾸므로, 순서가 의미 있는 데이터(그리기 순서 등)에는 맞지 않습니다.
2차원 배열을 vector<vector>로 저장
std::vector<std::vector<int>> m(N, std::vector<int>(N)); // 행마다 별도 힙 할당
std::vector<int> flat(N * N); // 한 덩어리
std::vector<std::vector<int>>는 행마다 따로 할당되므로 행과 행 사이가 메모리에서 이어지지 않고, 원소에 접근할 때마다 바깥 벡터에서 행 포인터를 한 번 더 읽습니다. 행 우선으로 순회하면 행 안에서는 연속이라 큰 차이가 나지 않을 수 있지만, 열 방향 접근이나 행 크기가 작은 경우에는 손해가 커지고, 할당 횟수도 행 수만큼 늘어납니다. 크기가 고정된 행렬은 1차원 배열 하나에 r * N + c로 접근하는 편이 낫습니다.
AoS vs SoA, 행 우선 vs 열 우선 벤치마크
// benchmark_aos_soa.cpp - g++ -std=c++17 -O3 -march=native -o bench benchmark_aos_soa.cpp
#include <algorithm>
#include <chrono>
#include <iostream>
#include <vector>
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 = 1'000'000; // 캐시보다 커야 레이아웃 차이가 드러남
const int ITERS = 100;
std::vector<ParticleAoS> aos(N, ParticleAoS{0, 0, 0, 1, 1, 1, 0, 0, 0});
ParticleSoA soa;
soa.resize(N);
std::fill(soa.vx.begin(), soa.vx.end(), 1.0f);
std::fill(soa.vy.begin(), soa.vy.end(), 1.0f);
std::fill(soa.vz.begin(), soa.vz.end(), 1.0f);
using clock = std::chrono::steady_clock;
auto t0 = clock::now();
for (int it = 0; it < ITERS; ++it)
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 t1 = clock::now();
for (int it = 0; it < ITERS; ++it)
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;
}
auto t2 = clock::now();
auto ms = [](auto d) { return std::chrono::duration_cast<std::chrono::milliseconds>(d).count(); };
// 결과를 출력해야 컴파일러가 계산을 없애지 못함
std::cout << "AoS: " << ms(t1 - t0) << " ms, SoA: " << ms(t2 - t1) << " ms"
<< " (check " << aos[N / 2].x + soa.x[N / 2] << ")\n";
}
구체적인 ms 값은 CPU, 캐시 크기, 컴파일 옵션에 따라 크게 달라 고정값을 적지 않았습니다. 위 코드에서 AoS는 파티클마다 9개 필드(36바이트)를 캐시로 가져오지만 위치 갱신에 쓰는 것은 6개(24바이트)뿐이고, SoA는 필요한 6개 배열만 순차로 읽습니다. 그래서 SoA 쪽이 대역폭 낭비가 적고 벡터화도 쉬워 대개 더 빠르게 나옵니다. 갱신하지 않는 필드가 많을수록 차이는 커지고, 모든 필드를 함께 쓰는 루프라면 차이가 거의 없습니다. N을 캐시에 다 들어가는 크기(예: 1만)로 줄여 다시 재 보면, 레이아웃 차이가 메모리 대역폭 문제라는 것이 드러납니다.
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./bench
perf stat으로 캐시 미스를 함께 보면, 시간 차이가 실제로 메모리 접근에서 오는지 확인할 수 있습니다. 최적화 전에 이 측정을 먼저 해서, 병목이 메모리 접근인지부터 확인하는 것이 순서입니다.
| 순회 방식 | 메모리 접근 모양 | 기대되는 경향 |
|---|---|---|
| 행 우선 (1차원 연속) | 주소가 4바이트씩 증가, 캐시 라인 하나로 16개 원소 처리 | 가장 빠름, 프리페처와 벡터화가 잘 동작 |
행 우선 (vector<vector>) | 행 안에서는 연속이지만 행마다 별도 힙 블록 | 행이 바뀔 때마다 포인터를 한 번 더 따라가므로 약간 느림 |
| 열 우선 | 다음 원소가 한 행(2048 × 4바이트 = 8KB) 뒤에 있음 | 매 접근이 다른 캐시 라인이라 가장 느림. 행렬이 캐시보다 클수록 차이가 커짐 |
다른 분야의 적용 예
파티클 풀
struct ParticlePool {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
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;
}
// 수명이 끝난 파티클은 별도 패스에서 swap-and-pop으로 제거
}
};
갱신 루프 안에서 바로 원소를 지우면 인덱스가 흔들리고 분기가 생겨 벡터화가 깨지므로, 갱신과 제거를 별도 패스로 나누는 것이 일반적입니다.
스레드별 통계와 거짓 공유
#include <atomic>
#include <cstdint>
#include <vector>
struct alignas(64) WorkerStats {
std::atomic<uint64_t> requests{0};
std::atomic<uint64_t> bytes{0};
};
std::vector<WorkerStats> stats(num_threads); // 각 워커는 자기 원소만 갱신
데이터 지향 설계에서 레이아웃은 단일 스레드 캐시 효율만의 문제가 아닙니다. 스레드마다 자기 카운터만 갱신해도, 서로 다른 스레드의 카운터가 같은 캐시 라인에 있으면 한 스레드가 쓸 때마다 다른 코어의 캐시 라인이 무효화되는 거짓 공유(false sharing)가 생깁니다. alignas(64)로 원소마다 캐시 라인을 따로 쓰게 하면 이를 막을 수 있습니다. C++17의 std::hardware_destructive_interference_size가 이 값을 제공하지만, 컴파일러에 따라 지원 시점이 다르고 ABI 경고가 나기도 해 64를 직접 쓰는 코드도 많습니다. 정렬된 타입을 std::vector에 담으려면 C++17의 정렬 지원 new가 필요합니다.
로그 집계 (컬럼형)
struct LogBatch {
std::vector<int64_t> timestamp;
std::vector<int> level;
std::vector<std::string> message;
std::vector<int> user_id;
size_t count_by_level(int lv) const {
size_t count = 0;
for (int l : level) count += (l == lv); // level 배열만 순회
return count;
}
};
분석용 데이터베이스가 행 대신 열 단위로 저장하는 것(컬럼형 스토리지)도 같은 원리입니다. 레벨별 집계는 level 배열만 읽으므로, 메시지 문자열은 전혀 메모리로 올라오지 않습니다. 반대로 레코드 하나를 통째로 읽고 쓰는 일이 많은 트랜잭션 처리에는 행 단위 저장이 맞습니다.