본문으로 건너뛰기

파일 레이아웃

「바이너리 형식」으로 돌아가기


레이아웃

핵심 결정은 행 지향이 아니라 컬럼 지향입니다.

그 선택으로 무엇을 얻고 무엇을 포기했는지, 어떤 상황에 적합하고 어떤 상황에 그렇지 않은지는 왜 컬럼 지향인가에 따로 적었습니다.

컬럼마다 값이 연속 블록이고 그 블록의 바이트 길이가 헤더에 있으므로, 모르는 컬럼을 건너뛰는 것은 advance(길이) 한 번입니다.

타입별 스킵 로직이 없으니 테이블 리더가 각자 다르게 잘못 구현할 지점도 없습니다.

같은 아홉 개 값을 행 지향과 컬럼 지향으로 배치했을 때의 차이

이게 왜 중요하냐면, 테이블 리더가 자기가 만들어진 뒤에 추가된 컬럼을 만나는 일이 실제로 생기기 때문입니다. 그때 할 일이 「길이만큼 건너뛴다」 하나로 끝나야 합니다.

프로토버프처럼 값마다 태그를 달면 스키마 정보가 행 수만큼 반복됩니다. 정적 테이블의 행은 동종이라 그럴 이유가 없습니다 — 로컬라이제이션 20,000행 × 8칸이면 값별 태그는 156 KB, 테이블 디스크립터는 56 B입니다.

bytes4 magic = 54 43 42 00 "TCB\0" — 파일 시그니처
fixed32 version = 107
fixed8 flags bit0 = 암호화(아래 「파일 암호화」), bit1 = 압축(예약, 아직 0)
fixed8 cipher 0 = 없음, 1 = ChaCha20
bytes12 nonce 암호화가 아니면 0
bytes16 mac MAC이 없으면 0(아래 「변조 검출」)
bytes4 keyCheck = 54 43 42 00 암호화되면 암호문
counter32 rowCount
counter32 columnCount
columnCount ×: ── 디스크립터 ──
counter32 tag 1 이상, 테이블 내 유일
fixed8 wire 하위 4비트 = 원소 타입, 5~6비트 = 종류,
7비트 = 옵셔널(로우별 presence 비트맵 있음),
8비트 = 원소 옵셔널(원소별 비트맵 있음)
fixed8 encoding 블록의 배치. 아래 「컬럼 인코딩」
fixed32 byteLength 인코딩된 이 컬럼 블록의 총 바이트
columnCount ×: ── 데이터 ──
블록: [옵셔널이면 fixed8 presenceEncoding + 그 인코딩대로 담긴 presence 비트맵]
(비트맵은 로우당 1비트, 하위 비트부터, 바이트 단위 패딩 — 곧 폭 1 비트팩)
encoding이 정한 배치로 담긴 rowCount개의 값

헤더는 42바이트 고정입니다 — 암호화 여부·MAC 여부와 무관하게 모든 필드가 같은 자리에 있습니다. 쓰지 않는 필드는 0으로 남습니다. 평문 파일이 치르는 대가는 파일당 37바이트이고(74개 테이블 기준 2.7 KB), 얻는 것은 모든 리더에서 오프셋 계산이 사라지는 것입니다. 근거는 MAC과 파일 시그니처에 있습니다.

디스크립터는 컬럼당 7바이트(태그가 1바이트로 끝나는 보통의 경우)이고, 테이블당 한 번입니다. 행이 늘어도 늘지 않습니다.

byteLengthcounter32가 아니라 fixed32입니다. writer가 블록을 다 쓴 뒤 그 자리로 돌아가 값을 채우는데(backpatch), varint는 길이가 값에 따라 달라져서 되채울 수 없기 때문입니다.

실제 파일 한 개, 전부

SecondTable(1행 3컬럼, 76바이트)입니다. 이 바이트열은 테스트에 그대로 적혀 있습니다 — 검증 게이트의 형식 고정 항목.

54 43 42 00 magic "TCB\0" ── 헤더 42바이트, 여기부터 ──
6b 00 00 00 version 107
00 flags 암호화도 압축도 아님
00 cipher 암호화가 아니므로 0
00 × 12 nonce 암호화가 아니므로 0
00 × 16 mac MAC이 없으므로 0
54 43 42 00 keyCheck 암호화되면 이 4바이트가 암호문
── 여기까지가 헤더, 아래가 본문 ──
02 rowCount counter32, 지그재그 2 → 1
06 columnCount counter32, 지그재그 6 → 3

02 tag 1
02 wire: 원소 i32(2), 종류 스칼라(0)
01 encoding: VARINT — 값 1은 1바이트, 고정폭이면 4바이트
02 count 1
01 00 00 00 byteLength 1

04 tag 2
06 wire: 원소 string(6), 종류 스칼라(0)
00 encoding: RAW — 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

한 행짜리 테이블에서도 인코딩 선택이 그대로 돕니다 — 정수 두 컬럼은 varint가 고정폭보다 작고, 문자열 컬럼은 사전을 만들어 봐야 원문보다 커지므로 RAW가 남습니다.

원소 타입 (wire 하위 4비트)

원소시트 타입
0varint (지그재그)enum
1fixed8bool
2i32 (fixed32)int, foreign 인덱스
3i64 (fixed64)bigint, datetime, timespan
4f32 (fixed32)float
5f64 (fixed64)double
6string (counter32 길이 + UTF-8)string
7bytes16uuid
8~15예약

i32와 f32는 크기가 같지만 의미가 다르므로 구분합니다. 구분하지 않으면 int→double 승격에서 비트 패턴을 잘못 해석합니다.

종류 (wire 5~6비트)

종류블록 내용
0스칼라rowCount개의 원소
1배열행마다 counter32 n + n개의 원소

배열은 한 종류입니다. 고정 길이 종류가 v106까지 값 1에 있었고, 개수를 디스크립터에 한 번만 적었습니다. 없앤 이유와 그 비용은 TCB v107에 있습니다 — 요약하면, 모든 행의 길이가 같은 컬럼의 길이 스트림은 런 하나입니다.

원소 옵셔널(비트 7)이 켜진 컬럼은 값 앞에 비트맵이 하나 더 붙습니다 — 로우별 비트맵 다음, 값 앞입니다. 배치는 counter32 원소 총수 + 인코딩 1바이트 + 비트맵이고, 총수를 앞에 적는 것은 가변 배열의 총수가 행 길이의 합이라 그 길이들이 값 블록 안에 있기 때문입니다 — 비트맵을 먼저 만난 리더에게는 크기를 잴 것이 없습니다. 배치는 TCB v106에, 시트 표기와 설계는 원소가 없을 수 있는 배열에 있습니다.

가변 배열의 행별 길이도 블록 안이므로 byteLength가 전부 덮습니다. 건너뛰기는 여전히 advance 하나입니다.

위 표는 RAW일 때의 배치입니다. 인코딩이 걸리면(아래 9번) 길이들이 행 사이에 흩어지는 대신 자기 스트림으로 모입니다. 그것도 같은 블록 안입니다.

값 인코딩

이름인코딩
fixed81바이트
fixed324바이트, 리틀엔디안
fixed648바이트, 리틀엔디안
varint32바이트당 7비트, 낮은 자리부터. 더 있으면 최상위 비트를 세움. 최대 5바이트
counter32지그재그로 부호를 접은 뒤 varint32
stringcounter32 바이트 길이, 그다음 그만큼의 UTF-8 바이트
uuid16바이트, .NET Guid 배치

floatdouble은 IEEE-754 비트 패턴을 각각 fixed32fixed64로 씁니다. datetimetimespan은 .NET 틱(100ns)을 fixed64로 씁니다.

어디에 varint를 쓰고 어디에 고정폭을 쓰는지와 그 이유는 내보내기에 있습니다.

디스크립터가 차지하는 몫

컬럼당 8바이트, 테이블당 한 번입니다. 3행짜리 픽스처에서는 그게 눈에 띄고(TestFieldTypes는 11컬럼이라 88바이트), 행이 늘면 비율이 0으로 갑니다. 이 표를 절대 크기로 읽지 마세요 — 실제 테이블은 수천~수만 행입니다.

인코딩이 들어온 뒤로는 오히려 반대쪽이 눈에 띕니다.

잘 압축되는 큰 테이블에서는 디스크립터가 파일의 상당 부분이 됩니다. 이전 대규모 코퍼스의 최대 테이블은 값이 워낙 잘 접혀서 4,467바이트가 되었고, 그중 헤더와 디스크립터가 81바이트였습니다.

그래도 테이블당 한 번인 것은 그대로이고, 그 81바이트가 아까워지는 지점은 없습니다.