AoS vs SoA — 데이터 레이아웃이 CPU 성능을 만든다
AoS와 SoA의 메모리 레이아웃 차이가 CPU 캐시 효율에 어떤 영향을 주는지 정리한 글입니다. Data-Oriented Design이 왜 게임 엔진과 데이터베이스에서 표준이 됐는지 CPU 구조 수준에서 살펴보았습니다.
'컴퓨터 밑바닥의 비밀'의 'Ch4. 트랜지스터에서 CPU로, 이보다 더 중요한 것은 없다'를 읽으며 성능 병목이 알고리즘이 아닌 메모리 접근 패턴에서 온다는 것을 배웠고, 앞으로 개발 중에 고민해볼 만한 포인트를 찾게 되었다. 그 중 OOP로 설계된 시스템에서 성능 병목이 알고리즘이 아닌 메모리 접근 패턴에서 온다는 사실을 알게 되었고, 해당 내용을 정리한 글이다.
AoS와 SoA란
// [1] AoS (Array of Structures)
struct Player {
float x, y, z; // 12 bytes
float hp; // 4 bytes
float speed; // 4 bytes
int pid; // 4 bytes
}; // 합계 24 bytes
Player players[10000];
// [2] SoA (Structure of Arrays)
struct PlayerSystem {
float x[10000];
float y[10000];
float z[10000];
float hp[10000];
float speed[10000];
int pid[10000];
};
- 구조 [1]: 구조체 타입의 배열 → AoS (Array of Structure)
- 구조 [2]: 배열을 멤버로 갖는 구조체 → SoA (Structure of Array)
두 코드는 같은 데이터를 저장하지만 메모리 배치가 다르다. 이 차이는 CPU 성능을 결정한다.
메모리 구조적 차이
위치 정보(x, y, z)를 업데이트하는 상황을 가정해보자.
fyi. CPU는 데이터를 캐시 라인 단위로 읽으며, 하나의 캐시 라인은 보통 64 bytes다.
AoS
메모리 레이아웃:
[ x y z hp speed pid ][ x y z hp speed pid ][ x y z hp speed pid ] ...
캐시에 올라오는 것 → [ x | y | z | hp | speed | pid ] (24 bytes)
실제 필요한 것 → [ x | y | z ] (12 bytes)
- 한 cache line에 들어가는
Player: 2개 (floor(64 / 24) = 2, 16 bytes 낭비) - 10,000개 순회 시 로드하는 cache lines: 5,000개
- 캐시 활용률: (2 × 12) / 64 = 37.5% — 나머지 62.5%는 hp, speed, pid
for (int i = 0; i < 10000; i++) {
players[i].x += vx;
players[i].y += vy;
players[i].z += vz;
// hp, speed, pid는 쓰지 않지만 같은 cache line에 올라옴
}
객체 중심(Object-centric) 레이아웃 — 특정 필드만 순회할 때 불필요한 데이터 로딩이 따라온다.
SoA
메모리 레이아웃:
x[] → [ x x x x x x x x x x x x x x x x ... ]
y[] → [ y y y y y y y y y y y y y y y y ... ]
z[] → [ z z z z z z z z z z z z z z z z ... ]
- 한 cache line에 들어가는
x값: 16개 (64 / 4 = 16) x[]10,000개 순회 시 로드하는 cache lines: 625개- 캐시 활용률: 100% — 로드한 모든 바이트가 x 값
for (int i = 0; i < 10000; i++) {
x[i] += vx; // Sequential access → cache line 100% 활용
y[i] += vy;
z[i] += vz;
}
x, y, z 세 배열 모두 접근하면 625 × 3 = 1,875 cache lines. AoS 5,000 대비 약 2.7배 감소다.
(단일 필드만 접근하는 경우라면 625 vs 5,000 = 8배 감소)
데이터 접근 패턴 중심(Data-access-centric) 레이아웃 — 캐시 효율이 높고 SIMD 자동 벡터화에도 유리하다.
CPU 내부에서 실제로 어떤 차이가 생길까?
캐시 효율과 파이프라인 Stall
Cache Miss가 발생하면 CPU는 파이프라인을 멈추고 RAM에서 데이터를 기다린다.
파이프라인 정상: IF → ID → EX → MEM → WB → IF → ID → EX ...
Cache Miss 발생: IF → ID → EX → [STALL ×100 사이클] → MEM → WB
메모리 계층 레이턴시
CPU cycle ~1 ns / L1 cache ~1 ns / RAM access ~100 ns (링크)
RAM 접근은 CPU 연산 대비 100배 느리다. 성능 문제의 대부분은 연산이 아닌 메모리 대기에서 온다.
- AoS: 구조체의 산발적 필드 접근은 prefetcher가 다음 주소를 예측하기 어렵게 만든다. CPU가 데이터를 기다리며 Stall이 발생한다.
- SoA: 배열 순회는 선형 접근 패턴이라 CPU가 다음 데이터를 미리 캐시에 올린다 (Prefetching)
SIMD 벡터화
현대 CPU는 SIMD(Single Instruction Multiple Data)로 한 번에 여러 데이터를 처리한다.
// 스칼라 연산: 4번의 명령어
x[0] += vx; x[1] += vx; x[2] += vx; x[3] += vx;
// SIMD 연산: 1번의 명령어
[x[0] x[1] x[2] x[3]] += [vx vx vx vx]
SIMD가 동작하려면 데이터가 메모리에 연속으로 배치되어 있어야 한다.
- AoS:
players[i].x,players[i].y가 섞여 있어 CPU가 레지스터 간 셔플링이 필요하다. - SoA: 컴파일러가
x[0]~x[7]을 한 번의 명령어로 벡터 레지스터에 로드하고,vx를 더하는 연산을 단 한 번의 명령어로 처리할 수 있다 (AVX2 기준 float 8개 동시 처리).
SoA의 Trade-off
1. 엔티티 생성/삭제가 복잡해진다
// AoS: 배열에서 하나 제거
players.erase(players.begin() + i); // 끝
// SoA: 모든 배열에서 같은 인덱스를 동기화해야 함
x.erase(x.begin() + i);
y.erase(y.begin() + i);
z.erase(z.begin() + i);
hp.erase(hp.begin() + i);
speed.erase(speed.begin() + i);
pid.erase(pid.begin() + i);
// → 필드 추가할 때마다 여기도 추가해야 함 → 실수하면 데이터 불일치
실무에서는 보통 swap-and-pop 패턴으로 해결한다고 한다.
void remove(int i, int last) {
x[i] = x[last];
y[i] = y[last];
z[i] = z[last];
// ... 모든 배열 동일하게
size--;
}
// 순서 보장이 필요하면 사용 불가
2. 코드 가독성이 나빠진다
// AoS: 의도가 명확하다
if (players[i].hp <= 0) {
players[i].state = DEAD;
players[i].deathTime = now;
}
// SoA: i라는 인덱스가 여러 배열을 묶는 암묵적 계약이 된다
if (hp[i] <= 0) {
state[i] = DEAD;
deathTime[i] = now;
}
// → 이 계약이 깨지는 순간 찾기 어려운 버그가 생긴다
3. 멀티스레드 락 관리가 복잡해진다
// AoS: 엔티티 하나에 락 하나
mutex player_lock[N];
player_lock[i].lock();
players[i].hp -= damage;
player_lock[i].unlock();
// SoA: 배열별로 락 범위가 달라지거나, 교착 상태(Deadlock) 가능성 증가
// → ECS 엔진(Unity DOTS Job System 등)이 프레임워크 레벨에서 해결하는 이유
SoA가 무의미해지는 경계 조건
엔티티 수가 적으면 SoA는 오히려 손해다
전체 데이터가 이미 L2/L3 캐시 안에 들어간다면 AoS든 SoA든 실측 차이가 거의 없다. 코드 가독성만 나빠진 수준이다.
Player 구조체: 24 bytes
24 bytes × 1,000개 = 24 KB → L2 캐시(보통 256KB~1MB)에 완전히 들어감
N < ~500 → 차이 미미
N ~ 1,000 → 차이 체감 시작
N > 10,000 → SoA 효과 극대화
모든 필드를 다 사용하는 루프라면 SoA가 오히려 불리하다.
// 이런 루프라면 AoS가 더 낫다
for (int i = 0; i < N; i++) {
players[i].x += vx;
players[i].y += vy;
players[i].z += vz;
players[i].hp -= damage[i];
players[i].speed *= friction;
}
// → AoS는 players[i] 하나만 캐시에 올리면 끝
// → SoA는 x[], y[], z[], hp[], speed[] 배열을 각각 따로 접근
관련 사례
Insomniac Games — Data-Oriented Design 도입
핵심 주장:
- 성능 문제의 대부분은 "어떻게 계산하는가"가 아니라 "어디서 데이터를 읽는가"에서 온다
- OOP의 추상화는 개발자 편의를 위한 것이고, CPU는 추상화를 모른다
- 데이터 접근 방식에 맞게 데이터를 먼저 설계해야 한다 → Data-Oriented Design(DOD)
Unity DOTS — OOP 구조에서 ECS로
Unity는 MonoBehaviour 기반 OOP(사실상 AoS)에서 ECS(Entity Component System)로 전환했다.
// 전통 Unity (AoS)
GameObject → [Transform][Renderer][Collider][Script ...]
// ECS / DOTS (SoA)
Position[] → [p p p p p p p p ...]
Velocity[] → [v v v v v v v v ...]
Health[] → [h h h h h h h h ...]
| 전통 OOP (MonoBehaviour) | ECS (DOTS) | |
|---|---|---|
| 메모리 구조 | AoS — 객체 안에 모든 데이터 | SoA — 컴포넌트별 분리 |
| 캐시 효율 | 낮음 (불필요한 필드 로딩) | 높음 (필요한 컴포넌트만 접근) |
| SIMD 적용 | 어려움 | 자동 벡터화 가능 |
| 멀티스레드 | 공유 상태 문제 많음 | Job System으로 안전한 병렬화 |
동일한 씬에서 오브젝트 수만 개를 60fps로 처리 가능. OOP 구조 대비 수십 배 성능 향상 사례가 보고됐다.
Columnar Database
Columnar DB는 SoA와 동일한 원리다.
-- Row DB: 모든 컬럼(name, age, salary)을 캐시에 올려야 age를 읽을 수 있음
-- Column DB: age[] 배열만 Sequential Read → 캐시 효율 극대화
SELECT AVG(age) FROM users;
- 느린 경우:
SELECT * FROM users WHERE id = 123— 단건 조회는 각 컬럼이 흩어져 있어 비효율 - 대표 예: Apache Parquet, ClickHouse, BigQuery, Amazon Redshift
OLTP(트랜잭션)에는 Row DB, OLAP(분석)에는 Column DB가 적합한 이유다.
OOP와 DOD의 공존 전략
외부 OOP / 내부 SoA
// 외부 인터페이스는 OOP 그대로 유지
class Player {
public:
float getX() const { return system.x[index]; }
void setX(float v) { system.x[index] = v; }
private:
PlayerSystem& system; // 내부는 SoA 참조
int index;
};
OOP API를 통해 접근하면서 실제 메모리는 SoA로 관리된다. Getter/Setter가 필요한 이유 중 하나다.
Hot / Cold 분리
자주 접근하는 데이터(hot)와 가끔 접근하는 데이터(cold)를 구조체에서 분리하는 방식이다.
| 구분 | 영역 | 전략 |
|---|---|---|
| Hot Path | 렌더링, 물리 연산, AI 업데이트 | DOD (SoA 기반) |
| Cold Path | 인벤토리, 퀘스트, UI 로직 | OOP (AoS) |
// 한 구조체에 다 넣으면 hot loop에서 cold 데이터까지 캐시에 올라옴
struct Player {
float x, y, z; // 매 프레임 (hot)
float hp, mana; // 전투 시 (warm)
char name[32]; // 거의 안 씀 (cold)
int achievement[50]; // 매우 가끔 (cold)
};
// Hot/Cold 분리
struct PlayerTransform { float x, y, z; }; // 매 프레임
struct PlayerCombat { float hp, mana; }; // 전투 시
struct PlayerProfile { char name[32]; int ach[50]; }; // 가끔
결론
OOP를 버리는 게 아니라 성능이 중요한 곳에서만 메모리 배치를 의식하면 된다.
먼저 OOP로 설계하고, 프로파일러가 가리키는 곳을 SoA로 바꾸기.
알고리즘보다 메모리 접근 패턴이 먼저다.