부제: 종족은 공유하고, 개체는 각자 갖는다 — 데이터 주도 설계의 가장 오래된 출발점
게임에 몬스터를 하나 추가한다고 해 봅시다. 이무기를 넣기로 했습니다.
가장 먼저 손이 가는 코드는 대개 이런 모양입니다.
class Monster { int hp; void attack() { ... } }
class Imugi extends Monster { Imugi() { hp = 320; } }
class Goblin extends Monster { Goblin() { hp = 40; } }
class Wraith extends Monster { Wraith() { hp = 90; } }
동작은 합니다. 문제는 몬스터가 세 종류일 때가 아니라 300종류일 때
드러납니다. 클래스가 300개로 늘고, 밸런스 패치 때마다 숫자 하나 고치려고
코드를 다시 빌드해야 하며, 기획자는 능력치를 직접 만질 수 없습니다.
이 문제는 워낙 오래되고 흔해서, 이미 이름이 붙어 있습니다.
패턴의 이름 — Type Object
Type Object(타입 객체) 는 Robert Nystrom의 Game Programming
Patterns에 정리된 패턴입니다. 하필 책의 예제도 Monster와
Breed(종족)를 나누는 것이라, 위 상황과 정확히 겹칩니다.
핵심 문장은 한 줄로 줄어듭니다.
몬스터 종류마다 클래스를 만들면 종류가 늘 때마다 클래스가 늡니다.
대신 종족을 객체로 만들고 몬스터가 그것을 가리키게 하면,
종류가 아무리 늘어도 클래스는 하나입니다.
즉 상속으로 표현하던 "무엇인가"를, 참조로 들고 있는 데이터로
내립니다. 타입이 컴파일 시점의 클래스가 아니라 런타임의 객체가 되는 것,
그래서 이름이 Type Object입니다.
// ① 종족 — "이무기란 무엇인가". 불변, 전 세계 이무기가 공유한다.
record Breed(String name, int baseHp, int baseAtk, int baseSpd, String[] skills) { }
// ② 개체 — "지금 이 이무기의 상태". 개체마다 다르다.
class Monster {
private final Breed breed; // 종족을 가리킨다 (공유 참조)
private int level;
private int currentHp;
Monster(Breed breed, int level) {
this.breed = breed;
this.level = level;
this.currentHp = maxHp();
}
int maxHp() { return breed.baseHp() * level / 50 + 10; } // 계산은 그때그때
}
클래스는 Monster 하나입니다. 이무기든 고블린이든 Breed 인스턴스가
다를 뿐이고, 그 Breed들은 JSON·CSV·스프레드시트에서 읽어 옵니다.
능력치는 코드가 아니라 데이터가 됩니다.
같은 패턴, 다른 이름표
이 구조는 엔진과 회사마다 다른 이름으로 불립니다. 처음 게임 쪽 코드를
보면 이름이 제각각이라 다른 것처럼 보이지만, 속은 전부 Type Object입니다.
| 부르는 이름 | 어디서 |
|---|---|
| DataTable + RowStruct | 언리얼 엔진 — CSV를 그대로 임포트해 테이블로 |
| ScriptableObject | 유니티 — 몬스터 하나당 에셋 파일 하나 |
| 마스터 데이터 / 마스터 시트 | 일본계 모바일 게임 — 기획자가 스프레드시트로 관리 |
| Bestiary / Monster Manual | 로그라이크·TRPG 계열. NetHack은 monst.c의 mons[] 배열 하나에 전부 |
| MonStats.txt | 디아블로 2 — 텍스트 파일이라 유저 모드도 여기를 고친다 |
| Archetype / Prefab / Template | 엔진 중립 일반 용어 |
이름은 여섯 개지만 목적은 하나입니다. 다시 컴파일하지 않고 숫자를
고칠 수 있어야 합니다. 밸런싱은 한 번에 끝나는 작업이 아니라 수백 번
반복되는 루프고, 그 루프의 회전 속도가 곧 게임의 완성도이기 때문입니다.
포켓몬은 실제로 2층 구조다
이 패턴이 실제로 어떻게 생겼는지 보려면 포켓몬이 가장 좋은 표본입니다.
흔히 "포켓몬은 시트로 관리한다"고 뭉뚱그려 말하지만, 정확히는
서로 다른 두 개의 시트가 있습니다.
① 종족 시트 — "피카츄란 무엇인가"
포켓몬 용어로 종족값(種族値, Base Stats) 입니다. 1세대
디스어셈블리(pokered)를 보면 data/pokemon/base_stats/ 아래에
종족마다 파일이 하나씩 있고, 그 안에 도감번호·HP·공격·방어·스피드·
특수·타입·포획률·기본경험치·기술배치표가 들어 있습니다.
피카츄의 HP 종족값 35는 전 세계 모든 피카츄가 공유하는 불변
데이터입니다. 개체마다 다른 것은 따로 있습니다 — 개체값(IV), 노력치(EV),
레벨, 성격. 최종 능력치는 이 둘을 합친 공식으로 그때그때 계산됩니다.
최종 능력치 = f(종족값[공유], 개체값·노력치·레벨[개체])
↑ ↑
Breed 객체 하나 Monster 인스턴스마다
잡은 피카츄 100마리가 종족 데이터 하나를 함께 가리킵니다. 이게 곧
Type Object입니다. 100마리분의 능력치 표를 100번 복사해 들고 있지 않다는
점에서 메모리 이득도 따라오지만, 진짜 이득은 피카츄를 너프할 때
고칠 자리가 한 곳뿐이라는 데 있습니다.
② 인카운터 테이블 — "어디에 얼마나 나오는가"
"맵에서 랜덤으로 튀어나오는" 쪽은 종족 시트와 완전히 별개의
파일입니다. 1세대는 data/wild/maps/ 아래 맵마다 파일이 하나이고,
구조가 이렇습니다.
풀숲: 조우율 25
슬롯1 L3 구구 ← 20%
슬롯2 L3 라타 ← 20%
슬롯3 L4 구구 ← 15%
... (10칸, 확률 20/20/15/10/10/10/5/5/4/1)
물 위: 조우율 5
슬롯1 ~ 10 ← 서핑·낚시용 별도 테이블
칸이 10개고 칸마다 뽑힐 확률이 고정되어 있습니다. 그래서 같은 풀숲에서도
흔한 놈과 귀한 놈이 갈립니다. "영역을 갈라서 다르게 나오게 한다"는
아이디어는 여기에 두 겹으로 들어 있습니다 — 맵마다 테이블이 다르고,
같은 맵 안에서도 풀숲·물 위·낚시가 각각 다른 테이블입니다.
이 둘이 분리되어 있다는 것
핵심은 ①과 ②가 서로 모른다는 점입니다.
- "이무기가 세다"는 종족 시트의 사실입니다.
- "이무기가 강가에 나온다"는 인카운터 테이블의 사실입니다.
하나를 고쳐도 다른 하나는 건드리지 않습니다. 이무기를 너프하는 일과
이무기를 늪지대에도 등장시키는 일은 서로 다른 파일을 여는 서로 다른
작업이 됩니다. 반대로 이 둘을 한 클래스에 몰아 두면, 등장 지역을
바꾸려다 능력치 코드를 다시 빌드하는 상황이 옵니다.
설계할 때 실제로 걸리는 것들
패턴 이름을 알았다고 바로 잘 굴러가지는 않습니다. 직접 데이터 주도
구조를 짜 보면 대개 아래에서 걸립니다.
- 종족 데이터는 반드시 불변이어야 합니다. 여러 개체가 같은 객체를
가리키므로, 전투 중에breed.baseHp를 깎으면 그 종족 전체가
같이 약해집니다. 종족은 읽기 전용으로 잠그고, 변하는 값은 개체
쪽에만 둡니다. 이 성질은
값 그 자체가 정체성인 객체(VO)의
불변성과 정확히 같은 이유에서 나옵니다. - 로딩 시점에 검증합니다. 슬롯 확률 합이 100이 아닌 테이블,
존재하지 않는 종족 ID를 가리키는 슬롯, 오타 난 타입 이름은
런타임에 터지면 원인 추적이 지독하게 어렵습니다. 데이터를 읽어 들이는
그 자리에서 한 번에 검사하고 죽는 편이 훨씬 쌉니다. - 개체가 종족을 덮어써야 할 때가 옵니다. "이 던전의 이무기는
보스라 HP가 3배" 같은 요구는 반드시 생깁니다. 종족을 복사해 수정본을
만들 것인지, 개체에 배수(modifier) 필드를 둘 것인지 미리 정해
두는 편이 낫습니다. 저는 대개 후자를 택했습니다 — 종족 데이터의 수가
늘어나는 쪽이 관리 비용이 훨씬 빨리 커지기 때문입니다. - 참조 관계를 그림으로 확인해 둡니다.
Monster가Breed를
들고 있는 관계는 소유가 아니라 공유 참조입니다. 몬스터가 죽어도
종족 데이터는 남습니다. 이 구분을 대충 넘기면 데이터 로더의
생명주기 설계가 흔들립니다.
백엔드 쪽 눈으로 다시 보기
지금의 저는 이 구조가 게임만의 이야기로 보이지 않습니다. 나눠 놓은
두 축이 결국 "변하지 않는 정의"와 "변하는 상태" 이기 때문입니다.
- 상품 카탈로그와 주문. 상품 정의는 공유되고, 주문 항목은 그 시점의
개체입니다. - 요금제와 가입자. 요금제 하나를 수만 명이 가리킵니다.
- 워크플로 템플릿과 실행 인스턴스.
전부 같은 모양입니다. 그래서 저는 새 도메인을 잡을 때 가장 먼저
이렇게 묻는 편입니다. "이 데이터는 정의인가, 상태인가." 이 질문을
건너뛰면 정의와 상태가 한 테이블에 섞여 들어가고, 나중에 정의를
바꾸는 순간 과거 데이터의 의미까지 함께 바뀌어 버립니다. 게임에서
종족값을 코드 밖으로 꺼내는 이유와, 주문에 상품명을 스냅샷으로
박아 두는 이유는 결국 같은 곳에서 나옵니다. 이 구분을 어디에 둘지가
계층별로 객체의 역할을 나누는 문제와
곧장 이어집니다.
마무리
몬스터 300종을 클래스 300개로 만들지 않는 방법은, 결국 "무엇인가"를
데이터로 내리는 것 하나로 요약됩니다. 종족은 공유하고, 개체는 각자
갖습니다. 등장 확률은 또 다른 테이블에 둡니다.
포켓몬이 30년 가까이 같은 구조를 유지하는 이유도 특별하지 않습니다.
숫자를 고치는 일이 코드를 고치는 일이 아니게 되면, 밸런싱은 그때부터
빠르게 돌아가기 시작합니다. 어떤 재미를 어느 플레이어에게 줄지를
플레이어 유형별로 설계하는 일도,
그 숫자를 값싸게 바꿀 수 있을 때에야 실제로 시도해 볼 수 있습니다.
관련 글 · 출처
- 게임 카테고리의 다른 글 더 보기
- 플레이어는 왜 같은 게임을 다르게 즐길까 — Bartle의 플레이어 유형 분류
- VO(Value Object) — 값 그 자체가 정체성인 객체
- DAO·DTO·VO — 헷갈리는 세 글자, ‘역할’로 구분하기
- Robert Nystrom, Game Programming Patterns — Type Object 章 원문
- 포켓몬 1세대 디스어셈블리 — pokered (base_stats · wild/maps)
답글 남기기