본문으로 건너뛰기

스키마가 바뀔 때

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

이 문서의 「태그」는 전부 와이어 태그입니다 — 컬럼의 식별자인 @N 입니다. 마커 열에 적는 행 태그와 이름만 같고 관계가 없습니다.


테이블 리더의 동작

로드 시작에 한 번 계획을 세우고, 그다음 행 루프는 값을 읽는 것 외에 아무 판단도 하지 않습니다.

  1. open을 부른다. 시그니처를 확인하고, MAC이 있으면 검증하고, 암호화되어 있으면 복호합니다. 아무것도 걸리지 않은 파일은 그대로 돌아오므로 이 단계는 조건이 아니라 경로의 일부입니다.
  2. 헤더와 디스크립터를 읽고, 파일 자신과 대조한다. 블록은 헤더 뒤에 오는 전부이므로 byteLength의 합이 남은 바이트와 정확히 같아야 합니다. RAW 블록은 행 수보다 짧을 수 없습니다 — 어느 컬럼이든 한 행이 최소 1바이트입니다. 인코딩된 블록에는 그 하한이 없으므로 — 런 하나가 10만 행을 덮습니다 — 대신 디코드가 런 길이의 합과 사전 인덱스 범위를 검사합니다. 어긋나면 행을 할당하기 전에 멈춥니다 — 손상된 행 수가 20억 행 할당이 되는 경로가 여기서 끊깁니다.
  3. 디스크립터마다 태그로 자기 멤버를 찾는다.
    • 모르는 태그advance(byteLength). 신 데이터의 새 컬럼, 또는 코드에서 지운 컬럼.
    • 아는 태그, 종류 불일치 → 필드 이름을 적고 실패.
    • 아는 태그, count 불일치 → 그 길이를 주장하는 멤버만 실패합니다. 컬럼 하나가 배열 하나를 갖는 경우에는 생성 코드가 길이를 주장하지 않고(호출에 -1이 적힙니다) 파일이 적은 수로 읽습니다. 컬럼 여럿이 배열 하나를 채우는 레코드 그룹은 그 수가 생성된 코드의 일부이므로 대조합니다 (설계).
    • 아는 태그, 원소가 자기 것이거나 승격 가능 → 그 멤버로 읽는다.
    • 아는 태그, 그 외 → 필드 이름과 양쪽 타입을 적고 실패.
  4. 블록을 다 읽었으면 소비량이 byteLength와 같은지 확인한다. 다르면 형식 불일치이므로, 다음 컬럼을 엉뚱한 바이트에서 읽기 전에 그 컬럼과 함께 멈춥니다.
  5. 파일에 없던 멤버는 기본값 그대로입니다. 그 기본값은 어느 언어에서도 널이 아닙니다 — 문자열은 빈 문자열, 배열은 빈 배열, 숫자는 0입니다. 지운 컬럼이 널로 남아 한 필드 뒤에서 크래시하는 것은 답이 아니라 다른 실패입니다.
  6. 상호참조 해석은 모든 테이블을 읽은 뒤 한 번에 이뤄집니다.

컬럼 순서는 계약이 아닙니다 — 태그가 식별합니다.

타입 변경: 두 지점에서 검사

“wire가 다르면 무조건 실패”는 위험을 테이블 리더에게, 즉 이미 배포된 클라이언트에게 미루는 것입니다. 그래서 두 군데서 봅니다.

1. 테이블 리더의 승격 표 — 안전한 변환의 수용

수학적으로 무손실인 방향만, 모든 언어가 같은 표 하나를 씁니다.

파일의 원소멤버의 원소판정근거
varinti32읽음지그재그 해독이 곧 int32
varinti64읽음위와 같음, 더 넓게
i32i64읽음무손실 확장
f32f64읽음무손실 확장
i32f64읽음i32 전 범위가 f64에서 정확
그 외 전부이름과 함께 실패손실 가능성이 있으면 읽지 않음

역방향(i64 파일 → i32 멤버 등)은 절대 읽지 않습니다. 잘릴 수 있는 값을 “대개는 맞겠지”로 읽는 것이 이 도구가 계속 없애온 실패 방식입니다.

datetimetimespan은 i64지만 i64만 받습니다. int 컬럼을 datetime 멤버로 읽는 것은 비트로는 무손실이고 의미로는 틀린 값이므로 승격에서 빠집니다.

이 표가 뜻하는 것: 타입을 넓히는 변경(int→bigint, float→double, enum→int)을 하면 신 코드가 구 데이터를 그대로 읽습니다. 데이터 롤백과 “클라이언트 먼저 업데이트” 순서가 안전해집니다.

2. 변환기의 베이스라인 검사 — 배포 전 검출

승격 표가 못 지키는 방향이 남습니다: 구 클라이언트가 넓어진 새 데이터를 받는 경우(i64 파일 → i32 멤버). 테이블 리더는 깨끗하게 실패하지만, 라이브에서는 그 깨끗한 실패도 장애입니다. 그래서 변환 시점에 검출합니다.

recipe의 binary 타깃 항목에 베이스라인 경로를 적으면 켜집니다. 그 파일은 커밋하세요 — 지난 스키마의 기록이 비교 대상입니다.

"Binary": [
{
"Path": "./build/data",
"SchemaBaseline": "./schema-baseline.json",
"AcceptSchemaChanges": []
}
]

매 실행이 스키마를 그 파일과 비교하고, 이미 배포된 테이블 리더가 읽지 못할 변경이면 아무것도 쓰기 전에 컬럼 이름과 할 일을 적고 멈춥니다.

변경판정
컬럼 추가 (새 태그)통과. 모르는 테이블 리더는 건너뜁니다
이름 변경통과. 식별자는 태그입니다
순서 변경통과. 파일에서 위치는 의미가 없습니다
컬럼 삭제, 시트에 #이름@태그 tombstone 있음통과
컬럼 삭제, tombstone 없음오류. 태그가 비어 다른 컬럼이 가져가면, 삭제 전에 만들어진 테이블 리더가 새 컬럼을 옛 필드로 읽습니다. 시트에 tombstone을 남기거나 AcceptSchemaChanges로 승인
타입 변경 (승격 방향 포함)오류, 승인 시 통과. 넓히는 변경조차 구 테이블 리더는 설계상 거부하므로, 재생성된 코드와 함께 나가야 하는 변경입니다
고정배열 count 변경오류, 승인 시 통과. 이 변경 이후에 생성된 테이블 리더는 파일이 적은 수로 읽지만, 그 전에 생성된 것은 구조 불일치로 거부하고 소비하는 쪽에서는 배열의 길이가 달라집니다
태그 재사용 (한 번 쓰이고 버려진 태그가 재등장)오류, 우회 없음. 다른 변경은 구 테이블 리더가 맞게 읽거나 거부하지만, 이것만은 틀린 컬럼을 성공적으로 읽습니다
태그가 없는 테이블에서 컬럼이 밀림오류, 승인 시 통과. 아래 「태그」 참고
  • 베이스라인이 없으면(첫 실행) 만들기만 하고 검사하지 않습니다. 비교 대상이 없는 것이지 위험이 아닙니다 — 새 파일이니 리뷰하고 커밋하세요.
  • 읽을 수 없는 베이스라인은 멈춤입니다. 누가 파일을 깨뜨린 바로 그 순간에 말없이 새로 쓰는 것은 검사를 건너뛰는 것입니다.
  • 승인은 AcceptSchemaChanges: ["Item.Price"]처럼 컬럼 단위입니다. 한 번 통과하면 베이스라인이 새 스키마로 갱신되므로 목록에서 빼도 됩니다.
  • 지워진 태그는 베이스라인에 retired로 남습니다. 삭제를 승인해도 남습니다 — 남기는 것이 목적이기 때문입니다. 한 번 데이터를 실은 태그는 다시 쓸 수 없습니다.
  • 모델에서 사라진 테이블의 기록도 남습니다. 그 테이블이 쓴 파일은 여전히 밖에 있습니다.

태그 — 시트가 결정하는 것

필드 이름 뒤에 @N을 붙입니다. 기존 문법(* 보조 인덱스, # 제외)과 합성됩니다.

index@1 Name@2 Price@3 #OldColor@4 *Grade@5 FutureUse@6
  • 테이블 안에서 전부 달거나 전부 안 달거나입니다. 섞이면 오류 — 반쯤 단 표는 어느 쪽 보장도 못 합니다.
  • #이름@태그는 모델에서 빠지지만 태그는 예약됩니다. 지운 컬럼의 tombstone이자, 미래를 위해 미리 잡아두는 자리이기도 합니다.
  • serial field(Slot1, Slot2 …)는 파일에서 컬럼 하나이므로 태그는 첫 멤버에만 답니다. 나머지에 달면 오류입니다.
  • 태그를 안 달면 서수(1..N)가 태그가 됩니다. 끝에 추가하는 것만 안전합니다 — 중간 삭제·삽입은 그 뒤 컬럼 전부의 태그를 밀어버리고, 타입이 우연히 맞으면 구 테이블 리더가 엉뚱한 컬럼을 성공적으로 읽습니다. 베이스라인 검사는 이 모드에서 “태그 2가 A였는데 B”를 그 증상으로 보고 거부합니다.
  • 중복 태그, 0 이하는 오류입니다.

진화시킬 생각이 있는 테이블에는 @N을 다세요. 안 다는 것은 “끝에만 추가한다”는 약속입니다.

라이브 서비스 시나리오 — 보장 요약

시나리오구 클라이언트 + 신 데이터신 클라이언트 + 구 데이터
컬럼 추가건너뜀, 정상 동작기본값(널 아님), 정상 동작
컬럼 삭제 (tombstone)기본값, 정상 동작건너뜀, 정상 동작
순서 변경 · 이름 변경무관 (태그가 식별)무관
타입 넓힘 (int→bigint 등)이름과 함께 실패 — 변환기가 배포 전에 승인을 요구그대로 읽음 (승격 표)
그 외 타입 변경이름과 함께 실패 — 변환기가 승인을 요구이름과 함께 실패
태그 재사용변환기가 원천 차단

어느 칸에도 “드러나지 않는 다른 값”이 없습니다.

이 표는 테이블 컬럼에 대한 것입니다. enum의 레이블과 상수 세트는 이 파일이 아니라 생성된 코드에 적히므로, 그것을 바꾸는 변경은 데이터만 올려서는 반영되지 않습니다 — 무엇이 데이터만으로 끝나고 무엇이 코드와 함께 나가야 하는지는 언어별 가이드에 정리해 두었습니다.

그중 하나는 이 형식이 차단하지 못합니다.

컬럼에 태그 재사용이 있듯 enum에는 레이블 값의 재사용이 있습니다. 레이블을 중간에 끼워 넣어 뒤의 값이 밀리면, 옛 데이터의 3이 어제와 다른 레이블을 가리킵니다.

파일에 실리는 것은 숫자뿐이라 컬럼 쪽처럼 거부할 근거가 없고, 베이스라인도 컬럼만 기록합니다.

태그 재사용을 도구가 원천 차단하는 것과 달리 이쪽은 규율로 막아야 합니다.

검증 게이트

게이트무엇을 증명하나
적합성 코퍼스 ×13같은 스키마의 왕복. 모든 타입·경계값을 writer가 쓰고 각 언어의 테이블 리더가 읽어 익스포터 JSON과 대조
스큐 코퍼스 (C#)evolution-v1 / v2 워크북 — 한쪽 세대의 코드로 다른 세대의 데이터를 읽습니다. 추가·삭제·이름·순서·승격·좁힘·비호환 변경 각각의 결과를 단정
형식 고정가장 작은 테이블 76바이트를 스펙에서 적어 테스트 소스에 적어 둠. 골든 트리는 변환기 출력에서 기록되므로 형식이 움직이면 함께 움직이지만, 이쪽은 사람이 고쳐야 움직입니다
인코딩 선택 고정적합성 코퍼스의 컬럼마다 어느 인코딩이 선택되는지를 이름으로 단정. 코퍼스 데이터는 13종의 인코딩이 각각 어딘가에서 선택되도록 구성되어 있고, 이 단정이 그 커버리지가 모르는 사이에 좁아지지 않게 고정합니다
모르는 인코딩 거부(원소, 인코딩) 표에 없는 조합과 모르는 번호를 리더가 필드 이름과 함께 거부하는지. 조합 인코딩의 안쪽 인코딩도 같습니다 — 디스크립터가 담는 것은 바깥 인코딩뿐이라 안쪽은 읽는 자리에서 검사됩니다
암호화 왕복변환기의 봉인과 리더의 열기를 서로에 대해 단정하고, ChaCha20은 RFC 8439의 공표된 시험 벡터로 따로 확인합니다. 평문·암호화만·MAC만·둘 다 4개 조합 전부
변조 검출내보낸 파일의 값 4바이트를 바꾸고 ① MAC이 없으면 바뀐 값이 그대로 읽히는 것과 ② MAC이 있으면 거부되는 것을 함께 단정합니다. ①이 없으면 이 기능의 근거가 기록되지 않습니다. 벗기기(mac을 0으로 덮기)와 헤더 변조(nonce·flags·version)도 각각
MAC 시험 벡터고정된 파일과 고정된 키가 내는 16바이트를 상수로 단정합니다 — 알고리즘·덮는 범위·절단 위치가 한 번에 고정됩니다
**언어별 변조 거부 **적합성 코퍼스가 서명되어 나오고, 값 4바이트를 바꾼 사본을 모든 언어가 각각 MAC을 이유로 거부합니다. 검사를 건너뛰는 리더는 여기서 걸립니다
디스크립터 정합성커밋된 모든 .tcb의 블록 길이 합과 행 수 하한 검사
베이스라인 검사 테스트위 판정 표의 각 항목 + 태그 재사용 차단 + 손상된 베이스라인 + 검사가 꺼진 경우
태그 검증 테스트중복·혼용·# 예약·serial field 규칙

모르는 컬럼 건너뛰기는 모든 언어에서 확인합니다 — 적합성 코퍼스의 드라이버들을 컬럼이 하나 더 붙은 다음 세대의 데이터에 물려서, 나오는 값이 자기 세대에서 읽은 것과 한 글자도 다르지 않은지 봅니다. 다만 삭제·이름 변경·타입 승격·거부는 여전히 C#만 확인합니다.

현시점에서 남는 한계

  • 옵셔널 컬럼의 값은 모든 로우에 대해 쓰입니다. 없는 로우는 타입의 빈 값을 차지합니다. 그래서 presence 비트맵이 들어올 때 인코딩들의 디코드 경로가 하나도 바뀌지 않았습니다 — 비트맵은 블록 앞에 붙을 뿐이고, 그 뒤는 예전과 같은 스트림입니다. 값을 압축해 담는 편이 바이트는 적지만, 그러려면 인코딩 × 종류의 디코드를 전부 「present인 로우만 세면서」 다시 쓰게 됩니다. 로우당 1비트를 내고 그것을 사지 않았습니다. v105부터 그 1비트도 인코딩됩니다 — 비트맵은 폭 1 비트팩이라 값 블록과 같은 선택을 받고, 옵셔널 컬럼은 대개 거의 전부 있거나 거의 전부 없어서 런 하나가 됩니다.

  • flags의 bit1(압축)은 여전히 자리로만 있습니다. bit0(암호화)은 이제 쓰이고 읽힙니다. 범용 압축은 컬럼 인코딩 뒤에도 더 줄일 것이 남아 있다고 계측에 나타나지만, 그것은 모든 런타임에 압축 해제기를 들이는 값입니다 — 자리를 미리 정해 둔 덕분에 그 판단을 나중으로 미뤄도 형식 버전이 다시 오르지 않습니다.

  • 베이스라인은 옵션입니다. 경로를 적지 않으면 검사가 없고, 그러면 테이블 리더의 거부만 남습니다 — 그것은 클라이언트에서 문제를 일으킵니다.

  • 서수 모드는 여전히 append-only입니다. 형식이 차단하는 것이 아니라 규율입니다. 베이스라인 검사가 증상을 검출하지만, 검사를 켜야 검출습니다.

  • 깊은 스큐는 C# 하나입니다. 나머지 12개는 모르는 컬럼을 건너뛰는 것까지 확인하였고, 승격·거부·삭제는 아직 C#만입니다. 그리고 그 검사의 모르는 컬럼은 파일의 마지막에 있습니다 — 첫 모르는 태그에서 멈추는 리더는 통과합니다.

  • TargetSide 스큐는 구조적으로 견딥니다(클라 테이블 리더가 서버 컷 파일의 서버 전용 컬럼을 건너뜀). 의도한 기능이 아니라 태그의 자연스러운 결과이므로, 그것에 기대어 설계하지는 마세요.