컬럼 지향을 고른 이유 — TCB의 배치 결정
이 문서는
.tcb가 왜 지금의 형식인지를 적습니다. 무엇인지(바이트 배치)는 바이너리 형식에, 인코딩 규격은 v102 스펙에 있습니다.
한 줄 요약
게임 기획 데이터는 「같은 형태의 행이 많은 정적 테이블」이고, 그 세 성질이 컬럼 지향을 거의 강제합니다. 행이 동종이라 스키마를 행마다 반복할 이유가 없고, 값이 많아 값 사이의 중복이 파일의 대부분이 되며, 정적이라 전체를 한 번에 읽습니다. 컬럼 지향은 이 셋을 각각 「태그를 컬럼당 한 번」·「같은 성질의 값이 한 블록에」·「모르는 컬럼을 한 번의 advance로 건너뛰기」로 바꿉니다.
0. 측정 대상
이 형식이 사는 조건을 먼저 못 박습니다. 여기서 벗어나면 아래 논증의 절반이 성립하지 않습니다.
| 조건 | 이 형식의 답 |
|---|---|
| 데이터가 정적이다 (빌드 시점에 고정, 런타임 쓰기 없음) | 쓰기 경로가 없어도 됨 |
| 전체를 한 번에 로드한다 (서버 시작·클라 부팅) | 랜덤 액세스가 필요 없음 |
| 테이블 간 참조가 있다 | 전부 읽은 뒤 한 번에 연결 |
| 인코딩은 느려도 된다 | 후보를 전부 시도해 최적을 고름 |
| 디코딩은 빨라야 한다 | 파싱 없음, 판단은 컬럼당 한 번 |
| 스키마가 코드와 따로 배포될 수 있다 | 자기 기술적이어야 함 |
| 리더가 모든 언어로 생성된다 | 모든 결정이 13번 구현되고 13번 증명됨 |
마지막 줄이 다른 어떤 조건보다 설계를 지배합니다. 영리한 최적화 하나는 13번의 구현과 13번의 검증이고, 13곳 중 하나가 미묘하게 어긋날 가능성입니다. 그래서 이 형식의 판단 기준은 「가장 빠른가」나 「가장 작은가」가 아니라 「13번 구현해도 잘못 구현할 지점이 없는가」입니다.
1. 첫 번째 이득 — 스키마의 행 단위 반복 제거
프로토콜 버퍼는 값마다 태그를 붙입니다. 필드가 어떤 순서로 오든, 없어도, 몰라도 읽히게 하려는 설계이고 메시지 하나하나가 다른 형태일 수 있는 세계에서는 옳습니다.
정적 테이블은 그런 세계가 아닙니다. 행이 전부 같은 형태이므로, 행 지향으로 태그를 달면 같은 스키마 정보를 행 수만큼 반복하게 됩니다.
로컬라이제이션 20,000행 × 8컬럼:
| 배치 | 스키마 정보의 크기 |
|---|---|
| 값마다 태그 (프로토버프식) | 20,000 × 8 × 1바이트 = 156 KB |
| 컬럼마다 디스크립터 (TCB) | 8 × 8바이트 = 64 B |
2,400배 차이입니다. 한 데이터셋 전체(67개 테이블 · 852컬럼 · 109,218행)에서 실제로 측정한 값도 같은 이야기를 합니다 — 헤더와 디스크립터를 다 합쳐 7,302 B. 인코딩이 데이터를 166 KB까지 줄여 놓은 지금 그게 파일의 4.4%인데, 비율이 올라간 것은 디스크립터가 커져서가 아니라 나머지가 줄어서입니다. 행이 늘어도 이 7,302 B는 그대로입니다.
여기서 중요한 것은 크기 자체가 아니라 무엇을 포기하지 않고 얻었느냐입니다. 프로토버프가 값마다 태그를 다는 이유(모르는 필드를 건너뛸 수 있어야 스키마가 바뀌어도 읽힌다)는 그대로 유지됩니다. 태그를 컬럼당 한 번 달아도 그 성질이 유지되기 때문입니다 — 행이 동종이라 컬럼 하나에 대한 판단이 그 컬럼의 모든 행에 적용됩니다.
2. 두 번째 이득 — 모르는 컬럼 건너뛰기의 advance 한 번
라이브 서비스에서 실제로 일어나는 일입니다.
- CDN에 데이터만 올려 패치 → 구버전 클라이언트가 새 컬럼이 든 데이터를 받습니다.
- 데이터만 롤백 → 신버전 클라이언트가 그 컬럼이 없는 데이터를 받습니다.
행 지향이었다면, 모르는 필드를 만난 리더는 그 값의 타입을 알아야 얼마나 건너뛸지 압니다. 타입별 스킵 로직이 필요하고, 그것이 모든 언어 × 8개 원소 타입 × 3개 종류만큼의 자리입니다. 그중 하나가 가변 배열의 길이 접두사를 빼먹거나 uuid를 15바이트로 세면, 그 뒤 전부가 밀립니다 — 실패가 아니라 드러나지 않은 채 다른 값으로.
컬럼 지 향에서는 컬럼의 값들이 연속 블록이고 그 블록의 총 바이트가 디스크립터에 있습니다. 그래서 모르는 컬럼을 건너뛰는 일이 전부 이것입니다:
reader.Skip(column.ByteLength);
타입을 볼 필요가 없습니다. 종류도, 행 수도, 인코딩도. 잘못 구현할 지점이 없습니다. 이것이 이 형식이 컬럼 지향인 가장 큰 이유이고, 크기와는 아무 상관이 없습니다.
인코딩이 들어온 뒤로 이 성질은 더 중요해졌습니다. 이제 블록은 사전일 수도, 런 길이의 나열일
수도 있는데 — 모르는 컬럼에게는 여전히 그냥 바이트 뭉치입니다. byteLength가 인코딩된
길이를 가리키므로 건너뛰기는 인코딩과 무관하게 성립합니다. 새 인코딩을 나중에 추가해도 구
리더의 건너뛰기는 그대로 맞습니다.
3. 세 번째 이득 — 압축 적용 지점의 확보
행 지향 파일에서 한 행은 int, string, float, enum, …의 뒤섞임입니다. 이웃한 바이트끼리 아무
관계가 없어서 값 사이의 중복을 모델링할 방법이 없습니다. 남는 수단은 파일 전체에 범용
압축(gzip·zstd)을 씌우는 것뿐이고, 그건 모든 언어에 압축 의존성을 다는 일입니다.
컬럼 지향에서는 한 블록이 같은 컬럼의 값들입니다. 그리고 게임 기획 데이터에서 한 컬럼의 값들은 서로 지독하게 닮았습니다.
sprout의 LevelCurve(95,490행 × 9컬럼)가 그 전형입니다:
| 컬럼 | 실제 내용 |
|---|---|
| id | 1,401,000부터 1씩 증가 (델타 95,489개가 전부 1) |
| 스킬 이름 | 유니크 165개 |
| 설명 문자열 | 유니크 3개 |
| 타입 enum | 유니크 3개, 정렬되어 있어 런도 3개 |
| 레벨·등급 정수 | 델타의 99.8%가 0 아니면 1 |
| 효과 키 | 유니크 3개 |
같은 컬럼에 모아 놓지 않았다면 이 사실들은 보이지도, 쓸 수도 없습니다. 모아 놓았기 때문에 컬럼마다 사전·델타·런 길이·접두사 공유 중 가장 작은 것을 고를 수 있고, 그 결과가 7.16 MB → 4,467 B입니다.
전체 데이터셋에서 어느 인코딩이 얼마나 쓰이는지:
| 인코딩 | 컬럼 수 | 차지하는 바이트 |
|---|---|---|
| DICT_FRONT | 113 | 91.2 KB |
| RAW | 156 | 27.7 KB |
| DICT | 40 | 16.9 KB |
| DELTA_RLE | 118 | 6.1 KB |
| DICT_FRONT_RLE | 16 | 5.4 KB |
| RLE | 239 | 5.4 KB |
| DICT_RLE | 99 | 3.6 KB |
| DELTA | 24 | 1.8 KB |
| VARINT | 47 | 0.6 KB |
RAW가 컬럼 수의 18%에 바이트의 17%입니다 — 압축할 반복이 없는 컬럼(uuid·배열·전부 유니크한 문자열)이 남은 부피의 대부분이라는 뜻이고, 이 데이터에서 더 줄일 수 없는 바닥입니다.
그리고 컬럼으로 모아 놓지 않았다면 보이지도 않았을 사실이 하나 더 있습니다. 첫 묶음의
인코딩으로 360 KB까지 간 뒤 남은 문자열을 다시 측정해 보니, 중복은 이미 거의 없었습니다 —
7,568개 중 둘 이상의 컬럼에 등장하는 것이 412개뿐이라 흔히 떠올리는 전역 문자열 풀은 1.8%밖에
못 줄였을 것입니다. 대신 그 문자열들은 서로 이웃이었습니다: 02_CRI_DAMAGE_FLOAT 옆에
02_CRI_INT. 정렬해서 겹치는 앞부분을 한 번만 적자 62%가 사라졌습니다. 같은 컬럼의 값을 모아
정렬해 볼 수 있었기 때문에 알 수 있었던 것입니다.
여기서 얻은 것을 다시 강조할 가치가 있습니다: 압축 라이브러리 없이 gzip한 JSON보다 6.6배 작습니다. 166 KB 대 1.10 MB. 모든 언어에 zstd를 다는 대신 형식이 스스로 한 것이고, 그것이 가능하였던 이유는 배치가 컬럼 지향이었기 때문입니다.
4. 네 번째 이득 — 상주 메모리의 감소
설계할 때 노린 것은 아니지만, 런타임 의 문자열 관리 방식에서 예측되는 결과입니다.
.NET의 string은 참조 타입이고 불변입니다. 그리고 런타임이 만든 문자열은 자동으로 합쳐지지
않습니다 — 인터닝은 컴파일 타임 리터럴의 것이고, string.Intern은 명시적 호출이며 그 테이블은
프로세스가 끝날 때까지 유지됩니다. 그래서 파서가 같은 내용을 다시 만날 때마다 새 인스턴스가
생깁니다.
유니크 값이 3개인 문자열 컬럼이 103,395행이면, 행 지향으로 읽는 어느 형식이든 문자열 객체를 103,395개 만듭니다. 같은 내용의 문자열 10만 개가 힙에 있는 것입니다.
컬럼 지향 + 사전 인코딩이면 사전을 한 번 풀어 3개를 만들고, 행마다 그 참조를 대입합니다. 참조 타입에 대해 그것이 대입의 의미이므로 힙에 남는 것도 3개입니다. 벤치마크에서 로드 후 상주 메모리가 27.9 MB → 11.9 MB로 떨어진 것의 대부분이 이것입니다.
「형식은 도착점이 아니라 거기까지 가는 비용을 바꾼다」는 것이 이 도구가 오래 해온 설명인데, 그 설명은 도착점이 형식과 무관하다는 전제에 기대고 있었습니다. 사전 인코딩에서는 그 전제가 성립하지 않습니다 — 반복되는 값은 파일에서도 힙에서도 한 번만 있으면 됩니다.
이 이득은 언어를 가립니다. 위 수치는 .NET에서 측정한 것이고, 문자열이 참조인 런타임에서
성립합니다. 값으로 담는 언어 — C++의 std::string이 그렇습니다 — 에서는 사전 항목이 행마다
복사되므로 파일 크기 쪽 이득만 남습니다.
5. 다섯 번째 이득 — 컬럼당 1회의 디코드 판단
행 지향 리더의 안쪽 루프는 행마다 「이 필드가 뭐였지」를 다시 확인합니다. 컬럼 지향 리더는 컬럼을 열 때 한 번 확인하고, 그 뒤 행 루프는 같은 일만 반복합니다.
컬럼을 연다: 태그를 찾고, 타입이 맞는지 보고, 인코딩에 맞는 커서를 만든다 ← 컬럼당 1회
행 루프: cursor.NextSameI32(...) ← 런당 1회, 분기 없음
분기 예측이 잘 맞고, 접근이 순차적이라 캐시가 잘 듣고, 무엇보다 읽을 바이트 자체가 훨씬 적습니다. 셋이 합쳐진 결과가 전체 로드 24 ms → 8 ms였고, 행 루프가 커서를 행당 1회에서 런당 1회로 바꾼 뒤 5 ms입니다 — RLE 계열 컬럼은 「이 값이 N번」이라고 이미 적혀 있으므로, 커서가 N을 그대로 넘기면 나머지는 평범한 대입입니다.