TCB v102 — 컬럼 인코딩과 암호화
상태: 구현 완료 (암호화 제외) · 대상 버전: 102 (101을 대체, 101은 배포된 적이 없으므로 호환 경로 없음)
현재 형식 버전은 104입니다 — TCB v103이 presence 비트맵으로 이 버전을 대체하였고, TCB v104가 인코딩 4종을 더하며 다시 대체하였습니다. 이 문서의 인코딩 9종과 디스크립터 배치는 그 뒤로도 그대로입니다 — 바뀐 것은 배열도 인코딩된다는 것이고(v104 3절), 그래서 아래 「스칼라만 인코딩합니다」는 v102 시점의 결정입니다.
.tcb의 컬럼 블록에 값 인코딩을 도입하고, 파일 단위 대칭키 암호화의 자리를 확정합니다.
지금까지 블록은 값을 원문 그대로 나열하였고(v101), 이 스펙은 블록마다 인코딩 하나를 골라 적을
수 있게 합니다. 무엇을 왜 고르는지는 변환기가 결정하고, 파일은 그 결정을 디스크립터에 적으며,
리더는 적힌 대로 풉니다. 암호화는 이 버전에서 스펙만 확정하고 구현은 뒤로 미룹니다 — 자리를
먼저 잡아 두는 것은, 나중에 붙일 때 형식 버전을 다시 올리지 않기 위해서입니다.
이름 — LiteBinary에서 Tcb로
이 작업과 함께 런타임·변환기의 LiteBinary* 이름을 전부 Tcb*로 바꿉니다. 형식의 이름이
TCB(Tabbit Compiled Binary)인데 코드가 다른 이름을 쓰는 것은 역사적 우연일 뿐입니다.
| 지금 | 바뀐 뒤 |
|---|---|
LiteBinaryFormat / LiteBinaryWriter (변환기) | TcbFormat / TcbWriter |
LiteBinaryReader / LiteBinaryTable / LiteBinaryColumn / LiteBinaryException (모든 언어 런타임) | TcbReader / TcbTable / TcbColumn / TcbException |
lib/*/tabbit/LiteBinaryReader.*, lite_binary_reader.* 등 파일 이름 | 각 언 어 관례로 TcbReader.*, tcb_reader.* 등 |
1. 변경 근거 — 측정 결과
한 데이터셋(67개 테이블 · 109,218행 · 7.83 MB, 벤치마크)의 바이트 구성:
| 타입 | 현재 바이트 | 왜 큰가 |
|---|---|---|
| string | 5.40 MB (69%) | 행마다 원문 그대로 반복. 최대 컬럼은 유니크 3개를 103,395번 적음 |
| i32 | 2.21 MB (28%) | 값이 0~750이어도 고정 4바이트. id 컬럼의 델타는 대부분 1 |
| varint(enum) | 0.12 MB | 유니크 3개가 3개의 런으로 정렬돼 있는데 행마다 1바이트 |
| 기타(f32·i64·bool) | 0.10 MB |
값 하나하나는 조밀하지만 값 사이의 중복을 전혀 모델링하지 않는 것이 v101이고, 그 중복이 파일의 대부분입니다. 컬럼 지향 배치는 같은 성질의 값을 이미 한 블록에 모아 두었으므로, 인코딩은 그 블록 안에서만 닫히면 됩니다.
같은 데이터셋에 이 스펙의 인코딩을 전부 적용한 실측은 7.83 MB → 166 KB입니다. gzip한 JSON(1.10 MB)보다 6.6배 작고, 외부 압축 라이브러리 없이 그렇습니다.
거기까지 두 단계였습니다. 정수·문자열의 중복을 지우는 첫 묶음(RAW·VARINT·DELTA·RLE·DELTA_RLE·DICT·DICT_RLE)이 360 KB까지 데려갔고, 남은 것을 다시 측정해 보니 중복이 아니라 접두사였습니다 — 문자열 7,568개 중 둘 이상의 컬럼에 등장하는 것은 412개뿐이라 전역 문자열 풀은 1.8%밖에 못 줄였을 것이고, 대신 정렬 후 접두사 공유가 62%를 줄였습니다. 실수 컬럼이 통째로 RAW에 남아 있던 것도 그때 드러났습니다.
배치가 애초에 왜 컬럼 지향인지 — 그리고 그 선택이 이 인코딩들을 어떻게 가능하게 했는지 — 는 왜 컬럼 지향인가에 있습니다.
2. 바꾸지 않는 것
바이너리 형식의 불변식은 전부 유지됩니다.
- 모르는 컬럼 건너뛰기 =
advance(byteLength)한 번. 인코딩 메타데이터(사전 포함)는 전부 컬럼 블록 안에 있고,byteLength는 인코딩된 블록의 총 바이트입니다. 리더가 인코딩을 몰라도 건너뛰기는 성립합니다. - 어떤 스키마 변경도 감지되지 않는 읽기 오류가 되지 않습니다. 아는 컬럼에 모르는 인코딩이 적혀 있으면 필드 이름과 인코딩 번호와 함께 멈춥니다.
- 승격 표는 그대로입니다. 인코딩은 디코드 후 원소 타입의 값으로 풀리므로 승격과 직교합니다. varint 컬럼이 RLE로 적혀 있어도 i32 멤버가 읽습니다.
- 스키마 베이스라인은 무관합니다. 인코딩은 스키마가 아니라 매 내보내기의 선택이고, 같은 스키마의 두 내보내기가 다른 인코딩을 골라도 계약 위반이 아닙니다.
- 행 순서는 파일에 적힌 그대로입니다. 압축률을 위한 행 재배열은 하지 않습니다 — 행 순서는 리더가 리스트를 만드는 순서라 관찰 가능한 의미입니다.
버전 정수는 101 → 102입니다. 형식 버전은 하나뿐이라는 정책도 그대로입니다 — 102가 101을 대체하고, 101을 읽는 경로는 없습니다.
3. 헤더와 디스크립터
디스크립터에 encoding 바이트 하나가 들어갑니다. flags는 예약이 아니라 정의된 비트를
갖게 됩니다(4절의 암호화). 그 외 배치는 v101과 같습니다.
fixed32 version = 102
fixed8 flags bit0 = 암호화(4절), bit1 = 압축(예약), 나머지 = 예약(0)
[flags bit0가 서 있으면 여기부터 암호화 envelope — 4절]
counter32 rowCount
counter32 columnCount
columnCount ×: ── 디스크립터 ──
counter32 tag
fixed8 wire v101과 동일 (하위 4비트 원소, 5~6비트 종류)
fixed8 encoding 이 스펙의 5절. 리더가 모르는 값이면 거부
counter32 count
fixed32 byteLength 인코딩된 블록의 총 바이트 (backpatch 때문에 fixed32)
columnCount ×: ── 데이터 ──
블록: encoding이 정한 배치
디스크립터는 보통의 경우 컬럼당 8바이트가 됩니다(v101은 7바이트).
헤더 검증 규칙의 변경
v101의 「어느 컬럼이든 한 행이 최소 1바이트」 하한은 encoding = 0(RAW)인 컬럼에만
적용합니다. 인코딩된 블록은 행 수와 무관하게 작아질 수 있으므로(RLE 한 런이 10만 행을 덮음) 그
하한이 성립하지 않습니다.
byteLength합 = 헤더 뒤 남은 바이트 전부 — 유지.- RAW 컬럼의
rowCount ≤ byteLength하한 — 유지. - 인코딩된 컬럼의 하한 — 없음. 대신 디코드가 행 수·런 합계·사전 인덱스를 검사하며(7절), 어긋나면 그 컬럼과 함께 멈춥니다.
이 완화로 남는 위험: 모든 컬럼이 인코딩된 손상 파일이 큰 rowCount를 선언하면, 행 리스트
할당이 디코드 검사보다 먼저 일어납니다. v101이 막던 「손상된 행 수 = 20억 행 할당」의 방어선이
그만큼 얇아지는 것이고, 이는 신뢰할 수 없는 입력을 읽는 형식이 아니라는 전제(자기 파이프라인이
만든 파일을 읽음)에서 수용합니다.
4. 암호화 — 대칭키, 스펙만 확정
클라이언트에 배포되는 기획 데이터의 캐주얼한 데이터마이닝과 수정을 차단하는 layer입니다.
위협 모델은 다음과 같습니다 — 대칭키는 클라이언트 안에 실리므로, 바이너리에서 키를 꺼낼 수 있는
공격자에게는 장벽이 아닙니다. 그 공격자를 차단하는 형식은 존재하지 않고, 이 layer가 얻는 것은
「파일을 에디터로 열면 다 보인다」와 「값을 바꿔 넣어도 그대로 로드된다」 두 가지를 없애는
것입니다.
v102에서는 형식만 확정하고 구현하지 않습니다. 확정해 두는 이유는 envelope이 헤더 배치에
관여하기 때문입니다 — 자리를 지금 정해야 암호화를 붙이는 날 버전을 다시 올리지 않습니다.
fixed32 version = 102 평문 — 리더는 버전과 flags를 먼저 봅니다
fixed8 flags bit0 = 1
fixed8 cipher 1 = ChaCha20. 그 외 값은 거부
bytes12 nonce
── 여기부터 암호문 (keystream XOR, 길이 불변) ──
bytes4 magic = 54 43 42 00 "TCB\0" — 복호가 맞았는지의 즉답
counter32 rowCount
counter32 columnCount
…이하 3절과 동일…
- 암호는 ChaCha20 (RFC 8439, 256비트 키)입니다. 모든 언어에 외부 의존성 없이 수십 줄로
구현되고, 하드웨어 가속이 없는 환경에서도 빠르며, 스트림 XOR이라 길이가 변하지 않습니다 —
byteLength합계 검증을 비롯한 구조 검사가 평문 기준 그대로 성립합니다. - nonce는 난수가 아니라 평문의 SHA-256 앞 12바이트입니다. 난수 nonce는 같은 입력에서 다른 파일을 만들어 골든 트리와 결정적 출력을 깨뜨립니다. 내용이 같다는 사실(같은 파일 = 같은 바이트)이 노출되는 것은 이 위협 모델에서 수용합니다. 키가 같고 내용이 다르면 nonce가 달라지므로 keystream 재사용이 없습니다.
- MAC은 없습니다. 키가 클라이언트에 있는 이상 MAC은 무결성 보장이 아니라 손상 감지인데,
손상 감지는 구조 검증(블록 길이 합,
CheckBlockEnd)이 이미 합니다. 대신 암호문 헤더의magic4바이트가 「키가 다르다」와 「파일이 손상됐다」를 구분해 줍니다 — 잘못된 키는 magic에서 그 말로 멈춥니다. - 키는 recipe에 평문으로 적지 않습니다. recipe는 환경 변수 이름 또는 키 파일 경로를
받고(hex 64자 = 32바이트), 리더 쪽은
ReadAsync가 키를 인자로 받습니다. 키를 어디에 보관하고 어떻게 클라이언트에 넣을지는 사용자의 결정입니다. layer순서는 인코딩 → (압축, 예약) → 암호화입니다. 압축은 반복이 있어야 일하므로 암호문 위에서는 무의미하고, 그래서 암호화가 항상 바깥입니다.- flags bit0가 서 있는데 키가 없거나 cipher 값을 모르면, 테이블 리더는 그 자리에서 멈춥니다. v102 구현이 릴리스되는 시점에는 「bit0 = 미구현 기능」이므로 flags ≠ 0 거부이라는 v101의 동작과 실질적으로 같습니다.
5. 인코딩
번호와 적용 대상
| 번호 | 이름 | 적용 가능한 원소 (스칼라만) |
|---|---|---|
| 0 | RAW | 전부 |
| 1 | VARINT | i32 |
| 2 | DELTA | i32 |
| 3 | RLE | i32, varint, bool |
| 4 | DELTA_RLE | i32 |
| 5 | DICT | string, i64, f32, f64 |
| 6 | DICT_RLE | string, i64, f32, f64 |
| 7 | DICT_FRONT | string |
| 8 | DICT_FRONT_RLE | string |
사전은 원소로 매개변수화됩니다. DICT 계열의 사전 항목은 그 컬럼 원소의 RAW 형태
그대로입니다 — string은 counter32 길이 + UTF-8, i64·f64는 fixed64, f32는 fixed32.
그래서 인코딩 번호가 늘지 않고도 사전이 문자열 밖으로 나갑니다. 실측에서 f32 컬럼은 값의 5.7%만
유니크였고(0.0이 2,450번), i64도 마찬가지였습니다 — 「실수는 압축이 안 된다」는 것은 값
하나를 볼 때의 이야기이고, 컬럼으로 보면 기획 데이터의 실수는 몇 개의 값이 반복됩니다.
- 스칼라만 인코딩합니다(종류 0). 고정 배열·가변 배열은 항상 RAW입니다. 가변 배열의 원소를 varint로 옮기면 6.4 KB를 더 줄일 수 있지만(전체의 1.8%), 인코딩에 종류 의존성을 들이는 값으로는 모자랍니다.
- uuid는 항상 RAW입니다. 16바이트짜리 사전 항목은 반복이 아주 심하지 않으면 손해이고, 실측 데이터셋에 uuid 컬럼이 없습니다.
- foreign 참조 인덱스는 i32이므로 i32 규칙을 그대로 따릅니다.
datetime·timespan은 i64 원소이므로 사전이 걸릴 수 있고, 그때도 리더는 틱을 받아 자기 타입을 만듭니다. - 리더는 위 표에 없는 (원소, 인코딩) 조합을 만나면 — 예: string 컬럼에 DELTA, uuid 컬럼에 DICT — 필드 이름과 함께 멈춥니다. 모르는 번호도 같습니다.
공통 원자
기존 용어를 그대로 씁니다: counter32는 지그재그로 부호를 접은 뒤 varint32, string은
counter32 바이트 길이 + UTF-8. 아래의 「값」은 전부 counter32로 적습니다 — i32 값, enum 값,
런 길이, 사전 인덱스 모두.
각 인코딩의 블록 배치
0 — RAW. v101과 동일. i32는 fixed32, varint는 counter32, string은 길이 + UTF-8, 행 순서대로 rowCount개.
1 — VARINT.
rowCount ×: counter32 value
2 — DELTA.
counter32 first (rowCount ≥ 1일 때)
(rowCount − 1) ×: counter32 delta
delta[i] = value[i+1] − value[i]를 32비트 wrapping 뺄셈으로 계산하고, 복원은 wrapping
덧셈입니다. int32 두 값의 차는 int32를 넘칠 수 있지만(최솟값→최댓값), 2의 보수에서 wrapping
연산은 모든 쌍을 정확히 왕복시킵니다. 64비트 정수가 기본인
언어(Python·PHP·Ruby·Dart·TypeScript)는 덧셈 뒤 하위 32비트로 자르고 부호 확장해야 합니다 — 이
규칙이 적합성 코퍼스의 경계값 케이스로 고정됩니다.
3 — RLE.
반복 ×:
counter32 runLength ≥ 1
counter32 value
런 길이의 합이 정확히 rowCount여야 합 니다. 넘치거나 모자라면 그 컬럼과 함께 멈춥니다.
4 — DELTA_RLE. 델타 스트림(위 2번 인코딩의 정의)의 RLE입니다.
counter32 first (rowCount ≥ 1일 때)
반복 ×:
counter32 runLength ≥ 1
counter32 delta
런 길이의 합이 정확히 rowCount − 1이어야 합니다. rowCount ≤ 1이면 런이 없습니다.
5 — DICT.
counter32 dictCount ≥ 0
dictCount ×: entry 원소의 RAW 형태. 첫 등장 순서
rowCount ×: counter32 index 0 ≤ index < dictCount
사전 항목은 값의 첫 등장 순서로 적습니다 — 정렬이 아니라 등장 순서인 것은 출력을 결정적으로 만들면서 writer가 정렬 비용 없이 한 번의 순회로 사전을 만들 수 있기 때문입니다. 범위를 벗어난 인덱스는 그 컬럼과 함께 멈춥니다.
6 — DICT_RLE. 사전은 5번과 같고, 인덱스 스트림이 RLE입니다.
counter32 dictCount
dictCount ×: entry
반복 ×:
counter32 runLength ≥ 1
counter32 index
런 길이의 합이 정확히 rowCount여야 합니다.
7 — DICT_FRONT. string 전용. 사전이 바이트 오름차순으로 정렬되고, 각 항목은 앞 항목과 공유하는 접두사 길이만 적고 나머지만 씁니다.
counter32 dictCount ≥ 0
dictCount ×: 바이트 오름차순
counter32 shared 앞 항목과 공유하는 바이트 수. 첫 항목은 0
counter32 restLength
restLength 바이트 UTF-8의 나머지
rowCount ×: counter32 index
이게 있는 이유는 기획 데이터의 문자열이 중복이 아니라 접두사를 공유하기 때문입니다.
02_BLOCK_INT·02_CRI_DAMAGE_FLOAT·02_CRI_INT, 또는
N등급 근거리 0성급·N등급 근거리 10성급 — 서로 다른 값이라 사전은 전부 보관해야 하지만
앞부분이 계속 겹칩니다. 실측 데이터셋에서 문자열 바이트의 62%가 이것으로 사라집니다.
shared는 앞 항목의 길이를 넘을 수 없습니다. 넘으면 그 컬럼과 함께 멈춥니다. 이 항목
자신의 길이는 shared + restLength로 정의되므로 따로 검사할 것이 없습니다. 정렬은 UTF-8
바이트열의 사전식 순서입니다 — 로케일이나 문자 단위가 아니라 바이트이므로, 어느 언어에서
인코딩해도 같은 순서가 나옵니다.
8 — DICT_FRONT_RLE. 사전은 7번과 같고, 인덱스 스트림이 RLE입니다.
counter32 dictCount
dictCount ×: shared / restLength / 바이트
반복 ×:
counter32 runLength ≥ 1
counter32 index
사전 순서가 인코딩마다 다른 이유. DICT·DICT_RLE는 첫 등장 순서, DICT_FRONT 계열은 바이트 오름차순입니다. 정렬은 접두사 공유를 위해서만 필요하고, 정렬하지 않는 쪽은 순회 한 번으로 사전을 만들 수 있습니다. 어느 쪽이든 런 구조는 바뀌지 않습니다 — 어느 행끼리 값이 같은지는 사전의 번호 매김과 무관하므로, 같은 컬럼에 대해 RLE가 만드는 런의 개수는 순서와 상관없이 같습니다.
경계 사례
- rowCount = 0: 모든 인코딩의 블록이 0바이트로 같아지므로, 아래 선택 규칙(크기가 같으면 낮은 번호)에 따라 항상 RAW가 됩니다.
- rowCount = 1: DELTA·DELTA_RLE는
first하나뿐입니다. - 빈 문자열은 사전의 정상 항목입니다.
6. Writer — 후보 전수 시도
변환기는 통계 휴리스틱을 쓰지 않습니다. 적용 가능한 인코딩을 전부 인코딩해 보고 가장 작은 것을 고릅니다. 크기가 같으면 낮은 번호를 고릅니다.
- 인코딩 시간은 이 형식의 설계 조건에서 중요하지 않고(서버·클라 시작 시 읽기만 빠르면 됨), 전수 시도는 휴리스틱보다 단순하며 정의상 최적입니다.
- 선택이 결정적이므로 같은 입력은 같은 바이트를 냅니다 — 골든 트리와 형식 고정 테스트가 이 성질에 기댑니다.
- 후보 인코딩은 임시 버퍼에 쓰고, 선택된 것만 본 버퍼로 갑니다. 컬럼 값을 한 번 모아 두 번 이상 순회하는 비용은 내보내기 시간으로 지불합니다.
7. Reader — 컬럼 커서
생성되는 테이블 리더의 행 루프는 유지하고, 값을 꺼내는 자리만 커서로 바뀝니다. 커서는
런타임(각 언어의 TcbReader 곁)에 한 벌 있고, 컬럼을 열 때 (원소, 인코딩)을 받아 상태를
갖습니다.
- 정수 커서 (varint·i32 원소, 인코딩 0~4):
next()가 int32를 돌려줍니다. DELTA 계열은 누적기, RLE 계열은 (남은 런 길이, 현재 값)이 상태의 전부입니다. i64·f64 멤버로의 승격은 커서가 돌려준 값을 생성 코드가 넓히는 것이므로 커서와 무관합니다. - 문자열 커 서 (string 원소, 인코딩 0·5·6): DICT 계열은 블록 헤더에서 사전을 한 번
디코드해 문자열 배열로 들고,
next()는 그 배열을 인덱싱합니다. 참조를 공유할 수 있는 언어에서는 유니크당 한 번만 문자열을 만듭니다 — 10만 행 컬럼의 문자열 할당이 유니크 개수로 줄어드는 것은 파일 크기와 별개의 이득입니다. - 그 외 원소는 커서 없이 v101처럼 직접 읽습니다.
디코드 검사는 5절의 규칙(런 합계, 사전 인덱스 범위)에 더해, 블록 소비량이 byteLength와 정확히
같은지 확인하는 기존 CheckBlockEnd가 그대로 마지막 방어선입니다.
8. 형식 고정 — 실제 파일 한 개, 전부
v101 문서의 SecondTable(1행 3컬럼)을 v102로 다시 적으면 39바이트입니다. 이 바이트열이
형식 고정 테스트의 새 내용이 됩니다.
66 00 00 00 version 102
00 flags
02 rowCount counter32, 지그재그 2 → 1
06 columnCount counter32, 지그재그 6 → 3
02 tag 1
02 wire: 원소 i32(2), 종류 스칼라(0)
01 encoding: VARINT — 값 1은 varint 1바이트 < fixed32 4바이트
02 count 1
01 00 00 00 byteLength 1
04 tag 2
06 wire: 원소 string(6), 종류 스칼라(0)
00 encoding: RAW — 유니크 1개짜리 1행 컬럼은 사전이 오히려 큼
02 count 1
06 00 00 00 byteLength 6
06 tag 3
02 wire: 원소 i32(2), 종류 스칼라(0)
01 encoding: VARINT
02 count 1
01 00 00 00 byteLength 1
02 index 블록: 지그재그 2 → 1
0a 67 61 6d 6d 61 label 블록: 길이 5 + "gamma"
3c amount 블록: 지그재그 60 → 30
1행짜리 테이블에서도 선택 규칙이 그대로 작동하는 것을 보여줍니다: i32 컬럼은 VARINT가 RAW보다 작고(1바이트 < 4바이트), 문자열 컬럼은 RAW가 DICT보다 작습니다(6바이트 < 8바이트).
9. 검증 게이트
| 게이트 | 변경 |
|---|---|
| 형식 고정 | 8절의 39바이트로 교체. 스펙에서 손으로 적어 테스트 소스에 적어 두는 성질 유지 |
| 적합성 코퍼스 ×13 | 인코딩 커버리지: 9개 인코딩 각각이 실제로 선택되는 컬럼을 코퍼스에 넣고, 모든 언어가 왕복. wrapping 델타의 경계값(int32 최솟값·최댓값 인접 행) 포함 |
| 인코딩 선택 단정 | writer 단위 테스트가 커버리지 테이블의 컬럼별 선택 결과를 단정 — 선택 규칙의 회귀를 여기서 잡음 |
| 스큐 코퍼스 (C#) | evolution-v1/v2 재생성. 모르는 인코딩 거부 케이스 추가 |
| 디스크립터 정합성 | 커밋된 .tcb 검사를 3절의 새 규칙(RAW만 행 하한)으로 갱신 |
| 골든 트리 | 변환기 출력에서 재기록 |
10. 예상 효과 (측정 기반)
그 데이터셋의 컬럼별 통계로 계산한 추정입니다.
원소별로 어디에 바이트가 남았는지:
| 원소 | v101 | v102 실측 | 주 인코딩 |
|---|---|---|---|
| string | 5.40 MB | 100.3 KB | DICT_FRONT·DICT |
| f32 | 75 KB | 26.4 KB | DICT·DICT_RLE |
| i32 | 2.21 MB | 24.5 KB | DELTA_RLE·RLE·VARINT |
| varint(enum) | 0.12 MB | 3.8 KB | RLE |
| i64 | 20.8 KB | 3.5 KB | DICT_RLE |
| bool | 1.3 KB | 0.1 KB | RLE |
| 배열·uuid | 13 KB | 13 KB | RAW 유지 |
| 합계 | 7.83 MB | 166 KB |
인코딩별 실제 사용량(852개 컬럼):
| 인코딩 | 컬럼 수 |
|---|