본문으로 건너뛰기

TCB v103 — presence 비트맵

상태: 구현 완료 · 대상 버전: 103 (102를 대체) · 102는 0.1.0으로 배포되었으므로 그 형식의 파일이 실재합니다 — 호환 경로는 없습니다(§5)

옵셔널 컬럼이 「어느 로우에 값이 있는지」를 파일에 담습니다. 와이어 바이트의 비트 6이 그 사실을 선언하고, 컬럼 블록 앞에 로우당 1비트의 비트맵이 붙습니다. 값 블록과 인코딩 9종은 그대로입니다.

이 문서의 범위는 형식으로 한정합니다. 시트 표기·모델·모든 언어의 Has{필드} 액세서와 그 선택의 근거는 옵셔널 필드에 있습니다.

1. 변경 근거 — 값이 없는 것과 0의 구별

v102까지 파일에는 「없음」을 적을 자리가 없었습니다. int?의 빈 칸은 0으로 기록되었고, 읽는 쪽은 비어 있던 칸0이라고 적은 칸을 구별할 수 없었습니다. 0이 뜻을 갖는 컬럼에서는 그 구별이 필요한 것의 전부입니다.

stringbool이 그 사정을 가장 분명하게 보여줍니다 — 빈 칸이 각각 ""false로 읽히므로 값만으로는 두 로우가 같습니다. 두 로우를 가르는 것은 presence뿐입니다.

2. 와이어 바이트 — 비트 6

7 6 5 4 3 2 1 0
[ ? ] [ kind ] [ element ]

v103이 쓰기 시작한 비트
마스크의미
0x0F엘리먼트 타입
0x30kind (스칼라 · 고정 배열 · 가변 배열)
0x40옵셔널 — 블록 앞에 presence 비트맵이 있음 (TcbFormat.WireNullable)
0x80예약. 0

kind 마스크는 0x03입니다((wire >> 4) & 0x03). 롤아웃 기간에 아직 지원하지 않는 리더들이 이것을 0x07로 넓혀 두었던 것은, 그러지 않으면 비트 6을 무시하고 presence 비트맵을 값으로 읽기 때문입니다. 전부가 비트 6을 nullability로 읽는 지금은 다시 0x03입니다.

kind 값을 쓰지 않은 이유. kind는 2비트이고 남은 값이 3 하나뿐입니다. 옵셔널은 kind와 직교합니다 — 스칼라도, 고정 배열도, 가변 배열도 옵셔널일 수 있습니다. 직교하는 것을 kind에 넣으면 마지막 한 자리를 쓰고도 조합을 표현하지 못합니다. 남은 kind 값은 진짜 새로운 종류를 위해 유지합니다.

3. 블록 배치 — 비트맵은 앞에, 값은 그대로

블록: [옵셔널이면 presence 비트맵: 로우당 1비트, 하위 비트부터, 바이트 단위 패딩]
encoding이 정한 배치로 담긴 rowCount개의 값
규칙근거
비트맵은 인코딩을 고른 뒤 그 결과 앞에 붙습니다인코딩 9종이 비트맵을 보지 않으므로, 어느 것도 이 변경을 반영하지 않았습니다
값은 모든 로우에 대해 기록합니다. 없는 로우는 타입의 빈 값을 차지합니다9종 × kind 3종의 디코드 경로가 하나도 바뀌지 않았습니다
비트맵 자체는 RAW입니다presence가 변하는 컬럼의 비트맵은 압축되지 않고, 변하지 않는 컬럼은 애초에 비트맵을 갖지 않습니다
byteLength는 비트맵을 포함한 블록의 총 바이트입니다모르는 컬럼 건너뛰기가 advance(byteLength) 한 번이라는 불변식이 유지됩니다
required 컬럼에는 비트맵이 없습니다전부 1인 비트맵은 로우당 1비트를 내고 아무것도 나타내지 않습니다

값을 압축해 담지 않은 것은 판단입니다. present인 로우의 값만 담으면 바이트는 줄지만, 9종 × 3종의 디코드를 전부 「present인 로우만 세면서」 다시 쓰게 됩니다. 그것을 모든 언어에서 반복합니다. 로우당 1비트를 내고 그 작업을 사지 않았습니다.

4. 실제 파일 한 개 — optional 골든의 Drop.tcb

3행 13컬럼, 296바이트. 로우 1이 모든 옵셔널 컬럼을 채우고 로우 2·3은 전부 비어 있습니다.

67 00 00 00 version 103
00 flags
06 rowCount counter32, 지그재그 6 → 3
1a columnCount counter32, 지그재그 26 → 13

디스크립터 13개 중 넷입니다.

tagwire해석encodingbyteLength
102i32 · 스칼라 · 필수VARINT3
1162i32 · 가변 배열 · 옵셔널RAW12
1246string · 스칼라 · 옵셔널RAW9
1341bool · 스칼라 · 옵셔널RAW4

tag 1은 필수이므로 3바이트가 값 셋뿐입니다. 나머지 셋은 첫 바이트가 비트맵입니다.

tag 12 (string, 9B) 01 0a 66 69 72 73 74 00 00
└┬┘ └──────┬───────┘ └┬┘ └┬┘
│ │ │ └ 로우 3: 길이 0
│ │ └ 로우 2: 길이 0
│ └ 로우 1: 길이 5 + "first"
└ 비트맵 0000_0001 — 로우 1만 present

tag 13 (bool, 4B) 01 01 00 00
└┬┘ └───┬──┘
│ └ 값 셋: true · false · false
└ 비트맵 0000_0001

tag 11 (int[], 12B) 01 04 0a000000 14000000 00 00
└┬┘ └────────┬─────────┘ └┬┘ └┬┘
│ │ │ └ 로우 3: 길이 0
│ │ └ 로우 2: 길이 0
│ └ 로우 1: 길이 2 + 값 10, 20
└ 비트맵 0000_0001

string과 bool이 이 형식이 무엇을 산 것인지 보여줍니다. 로우 2·3의 값은 ""false이고, 그것은 로우 1이 실제로 그렇게 적었을 때와 같은 바이트입니다. 비트맵이 없으면 그 두 경우는 파일에서 구별되지 않습니다.

5. 버전 — 102 파일의 거부

버전배포대체 사유
101없음디스크립터가 인코딩 바이트를 갖게 됨 → 102
1020.1.0 (2026-08-07)컬럼이 로우별 값 유무를 담게 됨 → 103
103미배포 (Unreleased)

102 파일은 실재합니다. 101과 달리 배포된 형식이므로, 이 변경은 실제 사용자의 파일을 읽지 못하게 만듭니다. 그래도 호환 경로를 두지 않은 이유는 비트 6을 검사하지 않는 리더가 presence 비트맵을 값으로 읽기 때문입니다 — 감지되지 않는 읽기 오류보다 버전 거부가 낫습니다.

table format version 102 is not supported (expected 103)

옵셔널 컬럼이 없는 파일은 버전 필드만 달라집니다. 그 필드는 4바이트이고 값이 102에서 103으로 1 늘었으므로, 골든의 .tcb 하나를 v102 산출물과 비교하면 실제로 다른 바이트는 하나(6667)입니다. 그래도 그 필드가 다른 파일을 읽지 않는 것이 이 형식의 규칙이고, 대응은 데이터를 다시 내보내고 코드를 다시 생성하는 것입니다. 진단과 조치는 문제 해결에 있습니다.

6. 담지 않는 것

  • 레코드 멤버의 presence. 레코드는 소비하는 쪽에 하나의 값이고, 「Id는 있는데 Count는 없다」는 그 API에 없는 형태입니다. 레코드 배열이 표현하는 없음은 원소 개수이고, 그것은 비트맵이 아니라 길이입니다. 이것을 담으려면 형식과 모든 생성기를 함께 수정해야 하므로, 실제로 그 형태를 쓰는 시트를 확인한 뒤에 판단합니다(로드맵 5d).
  • 배열 원소별 presence. 배열은 첫 원소가 배열 전체를 정합니다. §4의 tag 11이 그 형태이고, 로우가 통째로 없는 것과 길이 0인 배열을 비트맵이 가릅니다.
  • 압축된 값 블록. §3의 마지막 항목입니다.

7. 검증 게이트

게이트확인하는 것
BinaryFormatTests버전 상수와 파일 바이트 전체. 67 00 00 00이 테스트에 적혀 있습니다
optional 골든모든 언어의 산출물과 binary/Drop.tcb의 바이트. §4가 그 파일입니다
OptionalRoundTripTestsTypeScript가 JSON과 바이너리 두 경로로 읽어 값과 presence를 대조. 두 형식이 없음을 담는 방법이 완전히 다르므로(JSON은 null, 바이너리는 비트맵) 일치는 우연이 아닙니다
*NestedAndOptionalTests 12개C · C++ · C# · Dart · Go · Java · Kotlin · PHP · Python · Ruby · Rust · Unreal이 각자 컴파일·실행하여 presence를 판독
OptionalFieldRefusalTests거부해야 하는 자리 — 인덱스 컬럼의 ?는 변환을 멈추고 셀을 지시합니다

stringbool이 게이트에 반드시 포함됩니다. 그 둘은 값이 같아 비트맵이 틀려도 값 대조로는 드러나지 않기 때문입니다.

8. 비용

옵셔널 컬럼당 ceil(rowCount / 8) 바이트입니다. §4의 파일은 11개 옵셔널 컬럼 × 1바이트 = 296바이트 중 11바이트입니다.

로우가 많은 테이블에서는 컬럼당 행 수의 1/8이므로, 103,395행짜리 컬럼이면 12.6 KB입니다. 그 컬럼이 옵셔널일 때만 발생하고, 인코딩이 값 블록을 줄인 뒤에는 상대 비중이 커질 수 있습니다 — 값이 잘 접히는 컬럼일수록 비트맵이 블록에서 차지하는 몫이 큽니다. 비트맵을 접지 않은 판단(§3)은 그 조건에서 다시 볼 여지가 있습니다.