본문으로 건너뛰기

컬럼 지향을 고른 이유 — 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)을 씌우는 것뿐이고, 그건 모든 언어에 압축 의존성을 다는 일입니다.

컬럼 지향에서는 한 블록이 같은 컬럼의 값들입니다. 그리고 게임 기획 데이터에서 한 컬럼의 값들은 서로 지독하게 닮았습니다.

sproutLevelCurve(95,490행 × 9컬럼)가 그 전형입니다:

컬럼실제 내용
id1,401,000부터 1씩 증가 (델타 95,489개가 전부 1)
스킬 이름유니크 165개
설명 문자열유니크 3개
타입 enum유니크 3개, 정렬되어 있어 런도 3개
레벨·등급 정수델타의 99.8%가 0 아니면 1
효과 키유니크 3개

같은 컬럼에 모아 놓지 않았다면 이 사실들은 보이지도, 쓸 수도 없습니다. 모아 놓았기 때문에 컬럼마다 사전·델타·런 길이·접두사 공유 중 가장 작은 것을 고를 수 있고, 그 결과가 7.16 MB → 4,467 B입니다.

전체 데이터셋에서 어느 인코딩이 얼마나 쓰이는지:

인코딩컬럼 수차지하는 바이트
DICT_FRONT11391.2 KB
RAW15627.7 KB
DICT4016.9 KB
DELTA_RLE1186.1 KB
DICT_FRONT_RLE165.4 KB
RLE2395.4 KB
DICT_RLE993.6 KB
DELTA241.8 KB
VARINT470.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을 그대로 넘기면 나머지는 평범한 대입입니다.

6. 포기한 것

컬럼 지향에는 대가가 따릅니다.

한 행만 읽을 수 없습니다. 행 하나의 값들이 파일 곳곳에 흩어져 있으므로, 「id=5000인 행」을 얻으려면 모든 컬럼 블록을 다 지나야 합니다. 그래서 이 형식은 부분 로드에 쓸 수 없습니다. 전체 로딩 전제가 조건표의 맨 위에 있는 이유입니다. 인코딩이 들어온 뒤로는 더 강해졌습니다 — 델타와 런은 정의상 앞에서부터 읽어야 풀립니다.

스트리밍 쓰기가 안 됩니다. 컬럼 블록의 길이를 디스크립터에 먼저 적어야 하므로, 변환기는 모든 컬럼을 각자의 버퍼에 인코딩한 뒤에야 파일의 첫 바이트를 씁니다. 인코딩 시간이 중요하지 않다는 조건이 이걸 허용합니다.

행이 적으면 이득이 없습니다. 3행짜리 테이블에서는 디스크립터가 눈에 띄고, 압축할 반복도 없습니다. 실제로 내보내기 문서의 크기 표에서는 인코딩이 거의 일하지 않습니다. 이 형식은 행이 많은 쪽에서 값을 냅니다.

메모리 지역성이 「행 단위 처리」와 어긋납니다. 디코드 중에는 컬럼 하나를 훑으며 레코드 배열의 같은 필드를 건너뛰며 씁니다(스트라이드 접근). 행 지향이었다면 한 레코드를 연속으로 채웠을 것입니다. 실측에서 이 손해는 「읽을 바이트가 훨씬 적다」는 이득에 완전히 묻혔지만, 비용이 없다고 말할 수는 없습니다.

7. 적합한 조건과 적합하지 않은 조건

옳고 그름의 문제가 아니라 적합성의 문제입니다. 아래에서 부적합으로 적은 것들은 이 배치의 결함이 아니라, 그 용도가 요구하는 것을 이 배치가 제공하지 않는다는 뜻입니다.

상황컬럼 지향의 적합성근거
게임 기획 데이터, 전체 로드적합이 문서 전체
행이 수만 개, 컬럼의 카디널리티가 낮음가장 적합인코딩이 최대로 일함
스키마가 코드와 따로 배포됨적합건너뛰기가 advance 하나
리더 구현이 여럿적합잘못 구현할 지점이 적음
행 하나만 꺼내 쓰고 싶음부적합전부 읽어야 함
런타임에 행을 쓰거나 고침부적합쓰기 경로가 없음
행마다 형태가 다름 (문서·이벤트)부적합컬럼이라는 개념이 성립 안 함
수 GB라 메모리에 안 올라감부적합부분 로드가 안 됨
행이 수십 개뿐무관어느 쪽이든 같음

부적합으로 적은 마지막 세 줄이 이 형식을 범용 직렬화 형식으로 쓰지 말라는 말입니다. 네트워크 메시지에도, 세이브 데이터에도, 로그에도 맞지 않습니다. 정적 기획 테이블 하나를 위해 만든 형식이고, 그 하나를 아주 잘합니다.

8. 같은 결론에 도달한 다른 형식들

컬럼 지향은 새로운 발상이 아닙니다. 데이터 분석 쪽에서는 표준에 가깝습니다.

형식왜 컬럼 지향인가TCB와 다른 점
Apache Parquet컬럼 하나만 읽어 스캔 비용을 줄이려고랜덤 액세스와 술어 푸시다운이 목적. 행 그룹·페이지·통계 등 TCB가 필요 없는 계층이 있고, 압축은 범용 코덱에 맡김
Apache Arrow프로세스 간 제로카피 공유메모리 표현이 목적이라 압축을 하지 않음. TCB와 정반대의 트레이드오프
ClickHouse의 MergeTree분석 쿼리가 컬럼 몇 개만 읽으므로TCB와 같은 인코딩(델타·RLE·사전)을 쓰지만, 정렬 키와 파티션이라는 훨씬 큰 기계 위에 있음
FlatBuffers / Cap'n Proto(행 지향) 파싱 없이 바로 접근하려고랜덤 액세스가 목적이라 압축을 포기함. TCB는 그 반대를 골랐음 — 전체 로드니까

TCB가 이들과 다른 점은 의도적으로 작다는 것입니다. 인코딩 13개(그중 셋은 새 배치가 아니라 기존 인코딩의 조합), 원소 타입 8개, 종류 3개가 전부입니다. Parquet의 기능 중 TCB에 없는 것 — 행 그룹, 페이지 통계, 중첩 스키마(Dremel), 술어 푸시다운, 블룸 필터 — 은 전부 부분 읽기를 위한 것이고, 전체 로드에는 값이 없습니다. 그것들을 넣지 않은 것이 13번 구현할 수 있는 형식을 만든 방법입니다.

9. 검증이 이 결정을 지키는 방식

배치가 옳다는 주장은 테스트로만 유지됩니다.

게이트이 문서의 어느 주장을 지키나
스큐 코퍼스 (evolution-v1/v2)2절 — 한 세대의 코드로 다른 세대의 데이터를 읽어, 건너뛰기와 승격이 실제로 되는지
적합성 코퍼스 ×131·5절 — 같은 스키마를 모든 언어가 왕복. 코퍼스는 13종의 인코딩이 각각 선택되도록 구성되어 있음
인코딩 선택 고정3절 — 컬럼마다 어느 인코딩이 선택되는지를 이름으로 단정. 커버리지가 모르는 사이에 좁아지지 않게
형식 고정 (39바이트)스펙에서 손으로 적은 바이트열. 배치가 움직이면 사람이 고쳐야 함
디스크립터 정합성2절 — 커밋된 모든 .tcb의 블록 길이 합
벤치마크 verify전체 — 109,218행을 세 형식으로 읽어 필드 단위 대조