HomeSystemDeep Dive

AoS vs SoA — 데이터 레이아웃이 CPU 성능을 만든다

2026년 3월 21일14 min readavatarYejun Park
Performance
Memory
Cache

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에서 데이터를 기다린다.

파이프라인 정상:   IFIDEXMEMWBIFIDEX ...
Cache Miss 발생:   IFIDEX[STALL ×100 사이클]MEMWB
  • 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가 무의미해지는 경계 조건

Player 구조체: 24 bytes
24 bytes × 1,000= 24 KBL2 캐시(보통 256KB~1MB)에 완전히 들어감

N < ~500     → 차이 미미
N ~ 1,000    → 차이 체감 시작
N > 10,000SoA 효과 극대화

모든 필드를 다 사용하는 루프라면 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]; }; // 가끔