데이터 지향 프로그래밍
데이터 지향 프로그래밍
데이터 지향 프로그래밍(Data-Oriented Programming, DOP)은 프로그램을 객체나 클래스의 계층보다 데이터의 형태, 흐름, 변환, 접근 패턴 중심으로 설계하려는 사고방식이다.
단, 이 용어는 두 갈래로 쓰인다.
1. Data-Oriented Design (DOD)
게임/엔진/성능 최적화 진영의 데이터 지향 설계
핵심: CPU cache, 메모리 배치, batch processing, ECS, hot/cold split
2. Data-Oriented Programming (DOP)
Yehonathan Sharvit이 정리한 불변 데이터 중심 프로그래밍
핵심: code와 data 분리, generic data structure, immutable data, pure transformation
둘은 강조점이 다르지만 공통점이 있다.
프로그램 = 데이터를 어떤 형태에서 다른 형태로 바꾸는 변환기객체지향이 "어떤 객체들이 메시지를 주고받는가"를 묻는다면, 데이터 지향은 먼저 이렇게 묻는다.
어떤 데이터가 있는가?
얼마나 많은가?
얼마나 자주 읽고 쓰는가?
어떤 순서로 접근하는가?
어떤 형태로 변환되는가?
어떤 데이터는 같이 움직이고, 어떤 데이터는 분리되어야 하는가?개인 메모: 객체지향이 "이 세계에는 어떤 명사가 있는가?"라고 묻는다면, 데이터 지향은 "그래서 메모리에는 뭐가 몇 개나 어떻게 놓이는데?"라고 묻는다.
객체지향은 세계를 사람이 이해하기 편하게 설명하고, 데이터 지향은 세계를 CPU가 이해하기 편하게 다시 설명한다.
사람 좋자고 프로그래밍을 하나 종국에는 CPU의 기분을 달래줘야하는 것이다.
이 모든 모순은, 런타임이라는 전장에 도메인 전문가가 아니라 CPU가 출근한다는 사실에서 기인한다
왜 중요한가
많은 코드가 처음에는 이런식으로 객체 모델로 보기 좋게 시작한다.
특히 게임쪽은 이렇다.
Player
Enemy
Bullet
Item
Particle하지만 실제 실행 시점에서는 이런 질문이 더 중요해질 때가 많다.
총알은 몇 만 개인가?
매 프레임 위치만 갱신하면 되는가?
충돌 검사에는 어떤 필드만 필요한가?
렌더링에는 어떤 데이터만 필요한가?
이 데이터는 연속된 메모리에 놓여 있는가?OOP 객체 하나에 위치, 속도, 렌더링 정보, 사운드, 네트워크 상태, 행동 상태가 모두 들어 있으면 읽기에는 자연스럽다. 하지만 특정 작업이 position과 velocity만 필요로 할 때도 CPU는 객체 전체 주변의 불필요한 데이터까지 끌고 다니게 된다.
가령 VFX 하나 넣을때 입자(particle) 갯수때문에 버벅이거나 애니메이션 넣을때 최적화하거나 이런 문제들이 있다. 그래서 오브젝트 풀링(Object Pooling)부터 시작해서 별의 별 짓을 다하지만 한계가 있다.
그렇기에 데이터 지향 사고는 이 낭비를 줄이려 한다.
객체 중심:
객체 하나가 여러 책임과 데이터를 함께 가진다.
데이터 중심:
같이 처리되는 데이터를 같이 놓고,
같이 처리되지 않는 데이터는 분리한다.DOD: 성능 중심 데이터 지향 설계
게임 엔진, 렌더링, 물리, 시뮬레이션, 대량 이벤트 처리에서는 같은 연산을 많은 데이터에 반복한다. Mike Acton의 "Data-Oriented Design and C++" 발표는 C++/게임 엔진 진영에서 이 사고방식을 널리 알린 대표 사례다.1
위치 10만 개 업데이트
총알 5만 개 충돌 검사
파티클 100만 개 수명 감소
NPC 상태 2만 개 평가
이때 핵심은 "객체를 예쁘게 모델링했는가"보다 "CPU가 데이터를 얼마나 예측 가능하고 연속적으로 읽는가"다.
AoS vs SoA
전통적인 객체 배열은 보통 AoS(Array of Structures)에 가깝다. 인간의 직관은 AoS(Array of Structures)를 만들고, CPU의 본능은 SoA(Structure of Arrays)를 원한다. 전통적인 객체 배열(OOP)은 보통 AoS에 가깝다
struct Particle {
float x;
float y;
float vx;
float vy;
float life;
int color;
};
Particle particles[100000];
이 구조는 입자 하나를 '통째로' 인지해야 하는 인간의 눈에 편하다. 하지만 매 프레임 위치만 갱신하는 함수가 이 배열을 순회할 때, CPU는 분통을 터뜨린다. 위치 업데이트에는 color나 life가 전혀 필요 없음에도, 메모리에서 데이터를 긁어올 때(Cache Line fill) 불필요한 속성들까지 억지로 함께 실려 오기 때문이다.
이게 바로 캐시 라인 오염(Cache Pollution)이다. 반면, 데이터 지향 설계는 SoA(Structure of Arrays)로 세계를 재배치한다.
struct Particles {
float x[100000];
float y[100000];
float vx[100000];
float vy[100000];
float life[100000];
int color[100000];
};
이제 위치 업데이트 시스템은 오직 필요한 궤적 배열만 메모리 순서대로, 정렬된 상태로 순차 주파한다.
for (int i = 0; i < count; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}
CPU가 한 번 가져온 cache line 안에 실제 연산에 필요한 값의 비율이 높아진다. 즉 같은 메모리 대역폭으로 더 많은 유효 연산을 수행할 수 있다.
핵심은 "구조체(AoS)가 나쁘다"가 아니다. 데이터의 절대적인 형태란 존재하지 않으며, '특정 작업(Job/System) 단위가 어떤 데이터 조각을 얼마나 자주 읽느냐'에 맞춰 언제든 배열의 모양을 뒤집을 수 있어야 한다는 것이다.
개인 메모: 옛날 사람들이 배나, 차 같은 물건들을 여성명사로 지칭하는 이유가 장비가 까탈스러워였다. 그런의미에서 컴퓨터 또한 '그녀'라고 불릴 자격이 충분하다.
현대 CPU는 메모리에서 한 바이트만 가져오지 않는다. 주변 데이터를 cache line 단위로 함께 가져온다.
그래서 연속된 배열을 순서대로 읽는 코드는 빠르다.
좋은 접근:
x[0], x[1], x[2], x[3] ...
나쁜 접근:
objectA.position
objectQ.position
objectM.position
objectZ.position
두 번째 방식은 코드상으로는 깔끔해도 CPU 입장에서는 여기저기 뛰어다니는 접근이다.
데이터 지향 설계의 핵심은 cache trick 하나가 아니다. Richard Fabian은 데이터 지향 설계가 cache miss 회피만이 아니라 데이터의 유형(type), 빈도(frequency), 수량(quantity), 형태(shape), 확률(probability)까지 보는 접근이라고 설명한다.[10]
type: 어떤 종류의 데이터인가?
frequency: 얼마나 자주 발생하는가?
quantity: 얼마나 많은가?
shape: 어떤 구조인가?
probability: 어떤 값/분기가 얼마나 자주 나타나는가?
즉 데이터 지향은 "자료구조 최적화"보다 넓다. 실제 데이터의 분포와 사용 패턴을 설계의 출발점으로 삼는다.
Hot Data와 Cold Data
자주 접근하는 데이터는 hot data, 드물게 접근하는 데이터는 cold data라고 부른다.
예를 들어 게임 캐릭터에 이런 데이터가 있다고 하자.
position
velocity
health
name
description
inventory
questHistory
lastDialogueText
매 프레임 필요한 것은 대개 position, velocity, health 정도다. 반면 name, description, questHistory, lastDialogueText는 UI를 열거나 대화 이벤트가 발생할 때만 필요하다.
이 둘을 같은 객체에 함께 묶어두면 hot loop가 cold data까지 끌고 다닐 수 있다. 실제 연산에는 position과 velocity만 필요한데, 객체의 메모리 배치 때문에 name, description, inventory 같은 드문 데이터가 같은 cache line 근처에 섞일 수 있다.
따라서 데이터 지향 설계에서는 데이터를 접근 빈도에 따라 분리한다.
hot:
- position
- velocity
- health
cold:
- name
- description
- questHistory
- dialogue state
핵심은 hot data를 작고 연속적으로 유지하는 것이다. 매 프레임 도는 루프가 읽는 데이터는 가능한 한 밀도 높게 배치하고, 드물게 필요한 데이터는 별도 구조로 분리한다.
즉 캐릭터 하나를 “현실적인 객체 하나”로 묶는 대신, 실행 시점의 접근 패턴에 맞춰 여러 데이터 집합으로 나눈다.
개인 메모: 컴퓨터가 '그녀'라고 불려야 하는 진짜 이유는, 시도 때도 없이 과거의 구질구질한 대화 기록(
lastDialogueText)과 가방 속 소지품(inventory)을 들추는 걸 끔찍하게 싫어하기 때문이다.지금 당장 어디로 갈지(
position), 얼마나 빨리 갈지(velocity)만 깔끔하게 물어보면 미친 속도로 대답해 주지만, 거기에 슬쩍 "근데 우리 처음 만났을 때 퀘스트 기억나?(questHistory)" 같은 쓰레기 데이터를 끼워 파는 순간 차단박히고 런타임이 얼어붙는다.중요한 것은 매일 대화하는 핵심 요점(Hot)만 콤팩트하게 요약해서 대령하고, 구질구질한 과거 이력(Cold)은 철저히 비밀번호 걸린 별도 외장하드로 격리해 둬야 안전하다.
어쩌면 DOP를 마스터 하는 프로그래머는 카사노바일지도 모르겠다.
ECS와 데이터 지향
ECS(Entity Component System)는 데이터 지향 설계가 자주 구현되는 형태다. 다만 ECS 자체가 데이터 지향의 전부는 아니다. ECS는 데이터를 객체 내부에 숨기지 않고, component 단위로 분리한 뒤, system이 같은 데이터 조합을 batch로 처리하게 만드는 구조다.
Entity:
ID. 보통 숫자일 뿐이다. Entity 자체는 로직도 데이터도 거의 가지지 않는다.
Component:
데이터. Position, Velocity, Health 같은 순수 데이터 묶음.
System:
로직. 특정 component 조합을 가진 entity들을 batch로 처리한다.
예:
MovementSystem:
Position + Velocity를 가진 모든 entity를 찾아
position += velocity * dt 수행
OOP에서는 Player.update(), Enemy.update(), Bullet.update()가 각자 자신의 내부 상태를 바꾼다.
반면 ECS에서는 MovementSystem이 “움직일 수 있는 모든 것”을 같은 데이터 형태로 모아(batch) 한 번에 처리한다.
객체 중심:
- 각 객체가 자기 update를 수행한다.
- 로직이 객체 내부에 분산된다.
- 같은 작업이 여러 타입에 흩어진다.
ECS 중심:
- 시스템이 같은 component 묶음을 batch 처리한다.
- 데이터와 로직이 분리된다.
- 같은 연산이 같은 형태의 데이터에 연속적으로 적용된다.
Unity의 DOTS(Data-Oriented Technology Stack)는 ECS를 중심으로 데이터 지향 설계를 지원하는 대표적 산업 사례다.2
개인 메모:Unity가 전면에 ECS를 내세우며 "이제 패러다임이 바뀐다"고 호객 행위를 하지만, 에셋 스토어의 99%는 모노헤이비어 기반이고, 하이브리드랍시고 섞어 쓰면 괴상한 괴물이 나온다.
게임을 만들자는 것인지, 아니면 컴파일러랑 기싸움 하는 것인지 자괴감이 든다. 문제는 그 기싸움을 이기는 날은 아마 내 살아생전 오지 않을 것이다.
DOP: 불변 데이터 중심 프로그래밍
Sharvit이 정리한 Data-Oriented Programming은 게임 엔진식 DOD와 조금 다르다. 여기서 중심 문제는 CPU cache가 아니라 정보 시스템의 복잡도다.
Manning의 책 소개도 DOP를 “immutable generic data structures”와 “non-mutating general-purpose functions”로 상태 관리를 단순화하는 패러다임이라고 설명한다.3
핵심 원칙은 다음 네 가지다.4
1. 코드와 데이터를 분리한다.
2. 데이터를 generic data structure로 표현한다.
3. 데이터를 직접 mutation하지 않는다.
4. 데이터 schema를 데이터 표현과 분리한다.
여기에 실무 기법으로 general-purpose function으로 데이터를 조작한다는 원칙이 따라붙는다. map, filter, reduce, pick, merge, assoc, update, groupBy 같은 일반 연산을 특정 클래스 전용 메서드보다 앞세우는 방식이다.
OOP식 질문:
"Book 객체에는 어떤 메서드가 있어야 하는가?"
DOP식 질문:
"Book 데이터는 어떤 shape이고,
이 shape를 어떤 순수 변환 함수들이 다루는가?"
OOP 방식:
class Book {
public constructor(
public title: string,
public author: string,
public checkedOut: boolean,
) {}
public checkout(): void {
this.checkedOut = true;
}
}DOP 방식:
개인 메모: 코드에
readonly를 도배해라. 타이핑 몇 번으로 코딩 고수처럼 보이는 가장 가성비 좋은 허세일 뿐만 아니라, 미래의 내가 납품 5분 전에 전역 상태를 오염시켜 런타임을 터뜨리는 대참사를 원천 봉쇄하는 물리적 구속구다.
type Book = {
readonly id: string;
readonly title: string;
readonly author: string;
readonly checkedOut: boolean;
};
type BookView = {
readonly title: string;
readonly status: "available" | "checkedOut";
};
const checkoutBook = (book: Book): Book => {
return {
...book,
checkedOut: true,
};
};
const toBookView = (book: Book): BookView => {
return {
title: book.title,
status: book.checkedOut ? "checkedOut" : "available",
};
};
// 외부로는 100% 순수 함수지만, 내부적으로는 메모리를 아끼기 위해 Local Mutation을 쓴다.
// 이것이 학파적 FP와 실용적 DOP의 차이다.
const groupBooksByAuthor = (
books: readonly Book[],
): ReadonlyMap<string, readonly Book[]> => {
const groups = new Map<string, Book[]>(); // 내부 임시 변이(Mutation) 허용
for (const book of books) {
let current = groups.get(book.author);
if (!current) {
current = [];
groups.set(book.author, current);
}
current.push(book); // 배열 재생성 없이 밀어 넣기
}
return groups as ReadonlyMap<string, readonly Book[]>; // 나갈 때는 불변성(Readonly)으로 닫아서 반환
};이 방식은 함수형 프로그래밍과 많이 닮았다. 다만 DOP는 함수형 프로그래밍의 모든 추상화, 예를 들어 monad, typeclass, higher-kinded type 같은 개념을 전면에 내세우기보다, 데이터 표현을 단순하게 유지하고 그 데이터를 비파괴 함수로 변환하는 데 초점을 둔다.
다르게 말해, schema를 데이터 표현과 분리한다는 것은 데이터가 반드시 특정 클래스 생성자나 메서드에 묶여 있어야 한다고 보지 않는다는 뜻이다. 데이터는 평범한 map/object로 표현될 수 있고, 그 데이터가 유효한지는 별도의 schema나 validator가 확인할 수 있다.
DOP 원칙 1: 코드와 데이터 분리
DOP에서 데이터는 메서드를 갖지 않는다. 데이터는 데이터고, 동작은 함수다.
// 데이터
type Member = {
id: string;
name: string;
borrowedBookIds: string[];
};
// 동작
function canBorrow(member: Member): boolean {
return member.borrowedBookIds.length < 5;
}
Sharvit은 code와 data를 섞는 구조보다, 둘을 분리한 구조가 더 단순한 부품들로 이루어지는 경향이 있다고 본다.5
이 관점에서는 객체가 “데이터 + 메서드”를 함께 묶는다는 점이 장점이기도 하지만, 동시에 결합도를 높이는 원인이 될 수 있다. 객체 내부 상태와 메서드가 강하게 붙으면, 데이터를 다른 문맥에서 재사용하거나 기록하거나 비교하거나 전송하기 어려워질 수 있다.
DOP는 데이터를 평범한 값으로 두고, 동작을 그 값을 입력받아 새 값을 반환하는 함수로 분리하려 한다.
장점:
함수를 독립적으로 테스트하기 쉽다.
같은 데이터를 여러 문맥에서 재사용하기 쉽다.
serialization, logging, diff, replay가 쉬워진다.
비용:
어떤 함수가 어떤 데이터에 접근하는지 통제가 약해진다.
객체처럼 메서드 목록으로 사용법을 발견하기 어렵다.
데이터와 함수를 나누면서 파일/모듈 수가 늘 수 있다.
개인 메모: DOP는 “캡슐화는 필요 없다”가 아니다. “캡슐화가 반드시 모든 데이터를 객체 안에 가둬야만 가능한가?”라고 묻는 쪽에 가깝다.
하지만 이런 심오한 고민도 결국 "야, 일정이 내일까지니까 일단 퍼블릭(public)으로 열고 빨리 붙여!"라는 기획자의 한마디 앞에서는 아무짝에도 쓸모없는 철학적 자위행위에 불과해진다. 철학은 제품을 배포한 자들만의 사치다.
DOP 원칙 2: Generic Data Structure로 표현
DOP는 도메인 데이터를 전용 클래스보다 map/dictionary/object/array/list 같은 일반 자료구조로 표현하려 한다.6
const book = {
id: "book-1",
title: "Data-Oriented Programming",
author: "Yehonathan Sharvit",
tags: ["programming", "architecture"],
};
핵심은 "타입을 아무렇게나 하자"가 아니다. 데이터 표현을 언어와 생태계의 범용 데이터 연산에 태우자는 것이다.
const publicBookView = {
title: book.title,
author: book.author,
};
const serialized = JSON.stringify(publicBookView);
전용 클래스가 많아질수록 클래스마다 전용 변환 코드가 필요해진다. 반대로 데이터가 plain object/map이면 직렬화, 부분 선택, 병합, 비교, diff 같은 작업을 범용 함수로 처리하기 쉽다.
클래스 중심:
Book.toJson()
Author.toJson()
Member.toJson()
Loan.toJson()
generic data 중심:
JSON.stringify(data)
pick(data, keys)
merge(dataA, dataB)
diff(before, after)
비용도 있다.
필드명 오타가 런타임까지 숨어 있을 수 있다.
IDE 자동완성과 정적 타입의 도움을 덜 받을 수 있다.
성능상 class/struct field 접근보다 불리할 수 있다.
따라서 TypeScript 같은 언어에서는 DOP를 무타입 객체로 쓰기보다, plain data shape를 타입으로 명시하는 절충이 실용적이다.
type BookData = {
id: string;
title: string;
author: string;
tags: string[];
};
DOP 원칙 3: 데이터는 불변 값으로 다룬다
DOP에서 데이터는 value다. 값 자체는 바뀌지 않고, 변경은 새 버전을 만드는 방식으로 표현한다.7
const before = {
id: "book-1",
checkedOut: false,
};
const after = {
...before,
checkedOut: true,
};
중요한 구분:
데이터 값은 바뀌지 않는다.
변수가 새 데이터 값을 가리키도록 바뀔 수는 있다.
이 구분은 함수형 프로그래밍의 불변성과 같다. 실무적으로는 다음 이점이 크다.
이전 상태와 새 상태를 비교하기 쉽다.
undo/redo, event replay, audit log가 쉬워진다.
동시성에서 공유 mutable state 문제가 줄어든다.
테스트에서 입력과 출력을 값 비교로 검증하기 쉽다.
예:
function checkoutBook(state: LibraryState, memberId: string, bookId: string): LibraryState {
return {
...state,
loans: [
...state.loans,
{ memberId, bookId, checkedOutAt: new Date().toISOString() },
],
books: state.books.map(book =>
book.id === bookId ? { ...book, checkedOut: true } : book
),
};
}
이 코드는 객체 내부 상태를 몰래 바꾸지 않는다. 입력 state와 출력 LibraryState가 명확하다. 다만 큰 데이터에서는 얕은 복사와 깊은 복사의 비용, 구조 공유 라이브러리 사용 여부를 신경 써야 한다. 여기서 영속 자료구조와 연결된다.
DOP 원칙 4: Schema와 데이터 표현 분리
DOP는 데이터를 generic structure로 표현하므로, 데이터의 모양(shape)을 클래스 정의에 묶지 않는다. 대신 schema를 별도로 둔다.8
const addBookRequestSchema = {
type: "object",
required: ["title", "author"],
properties: {
title: { type: "string" },
author: { type: "string" },
tags: {
type: "array",
items: { type: "string" },
},
},
};
이 접근은 JSON Schema 같은 도구와 잘 맞는다.9
데이터:
{ "title": "DOP", "author": "Sharvit" }
schema:
title은 필수 string
author는 필수 string
tags는 선택 array<string>
장점:
외부 요청/응답 데이터 검증이 명확해진다.
schema를 런타임 검증, 문서화, 테스트 데이터 생성에 재사용할 수 있다.
탐색 단계에서는 schema를 늦게 붙이고, 안정화 단계에서 엄격히 만들 수 있다.
비용:
데이터와 schema의 연결이 클래스보다 약하다.
schema 검증을 빼먹으면 런타임 오류가 늦게 발견된다.
정적 타입과 런타임 schema를 함께 쓰면 중복 관리 문제가 생긴다.
TypeScript에서는 보통 다음 선택지가 있다.
1. TypeScript type만 사용
컴파일 타임에는 좋지만 런타임 외부 입력 검증은 약하다.
2. JSON Schema/Zod/io-ts 같은 런타임 schema 사용
외부 입력 검증에 강하지만 schema 관리 비용이 생긴다.
3. type과 schema를 생성 도구로 동기화
가장 견고하지만 빌드 파이프라인이 복잡해진다.개인 메모: DOP의 “스키마와 데이터 분리”는 이상적이다. 데이터는 자유롭고, 스키마는 선택적이며, 검증은 명확하다. 개발자는 철학적 평화를 누린다.
그리고 방구석에서 커피를 마시며 생각한다. 이거 마감 3일 남았다라는 걸, 깨닫는 순간
any가 도배되고, DOP는 DROP이 된다.스키마 분리는 좋지만 마감앞에서는 분리가 안된다.
DOP에서 다형성은 어떻게 하는가
OOP에서는 다형성을 주로 class/interface와 method dispatch로 표현한다.
interface Shape {
area(): number;
}
DOP에서는 데이터에 kind나 type 필드를 두고, 일반 함수가 분기하거나 dispatch table을 사용한다.
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Shape = Circle | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius * shape.radius;
case "rectangle":
return shape.width * shape.height;
}
}
이 방식은 대수적 자료형과 닮았다. TypeScript의 discriminated union, Rust의 enum, F#의 DU, Haskell의 ADT가 이 문제를 강하게 지원한다.
DOP 관점에서는 객체 없이도 다형성을 만들 수 있다. Manning 책 소개도 "polymorphism without objects"를 학습 항목으로 언급한다.[3]
개인 메모: 학교에서는
switch문은 악폐습이니 피하고, '상속과 오버라이딩'으로 우아하게 풀라고 배웠다. 하지만 실무에서 수십 단계로 꼬여있는 그 놈의 '우아한 자바 엔터프라이즈 프로젝트'를 몇 번 디버깅해 보고 나면, 객체지향의 상속과는 영원한 이별을 준비하게 된다. 참으로 다행인 것은, 나는 현실에서도 부모님께 물려받을 재산이 별로 없기 때문에 코드에서조차 '상속'에 전혀 미련이 없다는 점이다.
DOP가 잘 맞는 영역
Sharvit식 DOP는 특히 정보 시스템에 잘 맞는다. 즉 CPU cache 최적화보다 데이터의 이동과 변환이 중요한 시스템이다.
REST/GraphQL API
JSON 요청/응답 처리
프론트엔드 application state
ETL / data pipeline
event enrichment
workflow state
설정 파일/정책 파일 처리
감사 로그/audit trail
이런 시스템에서는 데이터가 이미 JSON, map, record, table 형태로 흐른다. 억지로 깊은 class hierarchy로 감싸기보다, 데이터 shape와 변환 함수를 명확히 두는 편이 단순할 수 있다.
DOP가 위험해지는 지점
DOP는 OOP의 문제를 줄이지만, 자기 문제도 있다.
필드명이 문자열 key로 흩어져 typo에 취약해질 수 있다.
어떤 함수가 어떤 데이터 shape를 기대하는지 추적이 어려워질 수 있다.
schema 검증이 없으면 "느슨한 map 덩어리"가 된다.
불변 업데이트 비용을 이해하지 못하면 성능 문제가 생긴다.
도메인 불변식을 지키는 책임자가 사라질 수 있다.
그래서 실무 DOP는 보통 다음 장치와 같이 써야 한다.
TypeScript type / JSON Schema / Zod 같은 schema
pure function 중심의 테스트
상태 변경 경로 단일화
도메인 이벤트나 command handler
diff, snapshot, audit log
개인 메모: DOP를 잘못 쓰면 "객체지향의 복잡한 미로"에서 탈출해서 "아무나 만지는 JSON 늪"으로 들어간다.
자유를 얻어도 결국은 새로운 구속이 필요하다. 너무 과한 자유는 언제나 좋지 않다.
OOP와의 차이
OOP:
데이터와 행동을 객체 안에 함께 둔다.
객체의 내부 불변식을 메서드로 보호한다.
메시지/메서드 호출로 협력한다.
데이터 지향:
데이터와 행동을 분리한다.
데이터의 형태와 흐름을 먼저 본다.
같은 형태의 데이터를 batch로 처리한다.
OOP가 나쁜 것은 아니다. 특히 외부 자원, 드라이버, 파일 핸들, 네트워크 연결처럼 수명과 불변식을 강하게 관리해야 하는 대상은 객체가 자연스럽다. Richard Fabian도 파일 시스템 handle이나 그래픽 API 같은 안정된 큰 개념에서는 OOP가 더 나을 수 있다고 본다.10
문제는 모든 것을 객체로 시작할 때 생긴다.
객체가 너무 많은 데이터를 품는다.
상속 계층이 데이터 흐름을 가린다.
작업 단위가 아니라 개념 단위로 메모리가 배치된다.
같은 연산을 많은 객체에 수행할 때 cache locality가 나빠진다.
데이터 지향은 이 지점을 비판한다.
함수형 프로그래밍과의 관계
데이터 지향 프로그래밍은 함수형 프로그래밍과 겹치는 부분이 많다.
공통점:
- 데이터를 직접 바꾸지 않으려 한다.
- 변환 함수를 중심에 둔다.
- 입력과 출력을 명확히 하려 한다.
- 상태 변경을 줄이면 테스트가 쉬워진다.
차이점:
- 함수형은 순수성, 합성, 타입, 부수 효과 제어에 관심이 크다.
- 데이터 지향은 데이터의 모양, 양, 흐름, 배치, 접근 패턴에 관심이 크다.
쉽게 말하면:
함수형: 이 계산은 순수한가?
데이터 지향: 이 데이터는 어떤 모양으로 흐르는가?
성능 DOD: 이 데이터는 메모리에서 어떻게 읽히는가?
장점
1. 대량 데이터 처리 성능이 좋아질 수 있다.
2. 데이터 흐름이 명확해진다.
3. 불필요한 객체 그래프를 줄일 수 있다.
4. batch processing과 병렬화에 유리하다.
5. serialization, logging, replay, testing이 쉬워진다.
6. 비즈니스 데이터와 변환 로직을 분리하기 쉽다.
특히 게임, 시뮬레이션, 렌더링, 물리, 데이터 파이프라인, 이벤트 처리처럼 같은 연산을 많은 데이터에 반복하는 영역에서 강하다.
단점
1. 설계가 덜 "현실 세계 명사"처럼 보인다.
2. 작은 프로그램에서는 과한 최적화가 될 수 있다.
3. 데이터와 로직이 분리되어 추적이 어려울 수 있다.
4. 잘못 쓰면 전역 데이터 테이블과 절차 코드 덩어리가 된다.
5. 불변 데이터 방식은 할당과 복사 비용을 신경 써야 한다.
6. 성능 DOD는 CPU/cache/메모리 모델 이해가 필요하다.
개인 메모: 데이터 지향을 배운 직후에는 모든 클래스를 배열로 찢고 싶어진다. 그 충동은 잠깐 참는 게 좋다.
모든 프로그램이 게임 엔진은 아니고, 모든 객체가 범죄자는 아니다.
언제 쓰면 좋은가
대량의 비슷한 데이터를 처리한다.
같은 연산을 반복해서 수행한다.
성능 병목이 메모리 접근에서 나온다.
객체 그래프가 너무 복잡해서 상태 추적이 어렵다.
직렬화/저장/전송/replay가 중요하다.
테스트에서 입력 데이터와 출력 데이터를 비교하고 싶다.
언제 조심해야 하는가
데이터 양이 작다.
성능 병목이 없다.
도메인 불변식을 객체가 강하게 지켜야 한다.
외부 자원의 수명 관리가 핵심이다.
팀이 메모리 배치나 batch processing에 익숙하지 않다.
추상화보다 단순한 CRUD가 중요한 업무 앱이다.
업무 앱에서도 데이터 지향이 쓸모없다는 뜻은 아니다. 다만 CPU cache 최적화식 DOD를 그대로 가져오면 과하다. 업무 앱에서는 Sharvit식 DOP, 즉 데이터와 변환을 분리하고 mutation을 줄이는 방식이 더 실용적인 경우가 많다.
안티패턴
1. 모든 것을 배열로 찢기
데이터 지향은 무조건 SoA를 쓰라는 뜻이 아니다. 작업 단위가 객체 전체를 자주 필요로 하면 AoS가 더 낫다.
질문:
이 작업은 어떤 필드를 실제로 읽는가?
그 필드들은 얼마나 자주 같이 읽히는가?
이 질문 없이 구조를 바꾸면 그냥 읽기 어려운 코드가 된다.
2. 데이터와 불변식의 분리 실패
데이터와 로직을 분리하면 좋지만, 불변식까지 흩어지면 위험하다.
나쁜 예:
여러 시스템이 health를 마음대로 수정한다.
어디서 0 이하가 되는지 알 수 없다.
죽음 처리, UI 갱신, 이벤트 발행이 서로 어긋난다.
데이터 지향에서도 불변식의 소유권은 필요하다.
좋은 예:
DamageSystem만 health 감소를 담당한다.
DeathSystem은 health <= 0 상태를 처리한다.
HealthChanged 이벤트를 명시적으로 발행한다.
3. 측정 없는 최적화
데이터 지향 설계는 실제 데이터와 접근 패턴을 중시한다. 측정 없이 "cache friendly할 것 같다"만으로 바꾸면 설계 놀이가 된다.
먼저 측정:
- 데이터 개수
- 접근 빈도
- hot path
- cache miss
- allocation
- branch miss
- frame time / latency
4. 객체지향을 무조건 배척하기
OOP는 불변식과 수명 관리를 표현하는 데 강하다. 데이터 지향은 대량 처리와 데이터 흐름에 강하다.
객체가 좋은 곳:
파일 핸들, DB connection, transaction, UI widget, 외부 API client
데이터 지향이 좋은 곳:
particle, transform, physics body, telemetry event, order rows, batch job
굳이 하나만 쓰지말고, 둘이 같이 써라.
우리네 부모님도 친구들과 친하게 지내라고 했듯이.
5.성능적 DOD 환경에서 Sharvit식 DOP(불변성)의 남용
데이터 지향이라는 이름표를 달고 있다고 해서 DOD와 DOP가 완벽히 호환되는 것은 아니다.
Sharvit식 DOP의 핵심인 '불변 데이터(Immutable Data)' 처리는 전개 연산자(...spread)나 새로운 객체 할당을 필연적으로 동반한다. 만약 매 프레임 수만 번 실행되는 게임 엔진의 Hot Loop(DOD의 영역) 안에서 상태를 변경한답시고 매번 새로운 불변 객체를 찍어내면, CPU 캐시를 최적화하기도 전에 가비지 컬렉터(GC)가 비명을 지르며 런타임을 멈춰버린다.
정보 시스템(DOP): 상태의 복잡도를 잡기 위해 불변성과 GC의 오버헤드를 기꺼이 지불한다.
고성능 루프(DOD): GC의 개입을 원천 차단하기 위해, 이미 할당된 연속된 메모리 공간(배열) 안에서 데이터를 자비 없이 덮어쓴다(In-place Mutation).두 패러다임의 타겟 병목(인지 부하 vs 하드웨어 한계)을 정확히 구분하지 않고 무작정 섞으면, 유지보수도 안 되고 성능도 박살 나는 끔찍한 키메라가 탄생한다.
실무 체크리스트
데이터 지향으로 볼 때는 다음을 먼저 적는다.
[What]
이 시스템이 변환하는 데이터는 무엇인가?
[Why]
왜 객체 모델보다 데이터 흐름을 먼저 봐야 하는가?
[Shape]
데이터의 형태는 무엇인가? row인가, tree인가, graph인가, event stream인가?
[Volume]
데이터는 몇 개인가? 10개인가, 10만 개인가, 1000만 개인가?
[Frequency]
얼마나 자주 읽고 쓰는가?
[Hot Path]
가장 자주 실행되는 루프는 무엇인가?
[Invariant]
절대 깨지면 안 되는 규칙은 무엇인가?
[Layout]
같이 읽히는 데이터는 같이 있는가?
같이 읽히지 않는 데이터는 분리되어 있는가?
[Next]
측정 결과가 바뀌면 어떤 구조를 바꿀 것인가?
개인 메모: 보통 체크리스트의 가장 큰 문제점은 결국 쓰지 않는다는 점이다. 다음 프로젝트 할 때는 항상 잊고 다시 한다. 그게 체크리스트의 숙명이다.
최종 요약
데이터 지향 프로그래밍은 객체를 싫어하는 운동이 아니다.
개인 메모: 물론 어떤 이에게는 객체를 싫어하는 운동이기도 하다.
핵심은 이것이다.
프로그램의 중심을 추상 객체가 아니라 실제 데이터와 그 변환 흐름에 둔다.성능 중심 DOD에서는 다음이 중요하다.
데이터 양
접근 빈도
메모리 배치
cache locality
batch processing
hot/cold split
복잡도 중심 DOP에서는 다음이 중요하다.
code/data 분리
generic data structure
immutable data
non-mutating function
schema
따라서 데이터 지향의 한 줄 정의는 이렇게 적을 수 있다.
데이터 지향 프로그래밍은
"무엇이 존재하는가"보다
"어떤 데이터가 어떤 형태로 얼마나 자주 변환되는가"를 먼저 보는 프로그래밍 방식이다.개인 메모: 다만, 내가 지향하는 엔지니어란 특정 패러다임의 광신도가 아니다.
비즈니스 요건과 런타임의 환경(가용 메모리, CPU 캐시, 마감 기한)이라는 냉혹한 현실 앞에서, 기꺼이 원칙을 훼손하며 최적의 '타협점'을 찾아내는 용병이어야 한다
같이 보기
영속 자료구조
데이터 지역성
ECS
JSON Schema
대수적 자료형
각주
- Mike Acton. Data-Oriented Design and C++. CppCon 2014 발표. C++/게임 엔진 진영에서 DOD 논의를 대중화한 대표 발표다. ↩
- Unity. Introduction to the Data-Oriented Technology Stack. Unity DOTS는 ECS를 중심으로 데이터 지향 설계를 적용하는 대표적 사례다. ↩
- Yehonathan Sharvit. *Data-Oriented Programming*. Manning, 2022. Manning 소개는 DOP를 immutable generic data structures와 non-mutating general-purpose functions로 설명한다. ↩
- Yehonathan Sharvit. "Principles of Data-Oriented Programming". DOP의 네 원칙을 정리한 글: code/data 분리, generic data structure, immutable data, schema/representation 분리. ↩
- Yehonathan Sharvit. "Separate code from data". DOP 원칙 1. code와 data 분리의 이점과 비용을 설명한다. ↩
- Yehonathan Sharvit. "Represent data with generic data structures". DOP 원칙 2. map/array 같은 generic data structure와 그 trade-off를 설명한다. ↩
- Yehonathan Sharvit. "Data is immutable". DOP 원칙 3. mutation 대신 새 데이터 버전을 만드는 방식을 설명한다. ↩
- Yehonathan Sharvit. "Separate data schema from data representation". DOP 원칙 4. 데이터 표현과 schema를 분리하는 이유를 설명한다. ↩
- JSON Schema. Official website. JSON 데이터의 shape와 validation 규칙을 표현하는 vocabulary. ↩
- Richard Fabian. *Data-Oriented Design*. Fabian은 DOD가 cache miss만의 문제가 아니라 데이터의 type, frequency, quantity, shape, probability까지 보는 접근이라고 설명한다. ↩