산출물과 원본을 함께 쓸 때
2. 산출물의 공동 커밋
2.1 병합이 불가능한 이유
산출물은 결정적입니다. 같은 커밋의 같은 시트를 두 번 변환하면 바이트가 동일합니다
(근거 · SummaryDocument의 Run/Data 분리).
그래서 충돌이 발생한다면 그것은 잡음이 아니라 실제로 서로 다른 입력에서 나온 두 결과입니다.
문제는 그 충돌을 해소할 수단이 없다는 점입니다.
| 산출물 | 충돌 시 |
|---|---|
.tcb | 바이너리입니다. 3-way 병합이 성립하지 않습니다 |
| 생성 코드 | 모든 언어 × 테이블 수. 실제 프로젝트 규모에서 파일 1,400개 단위입니다 |
manifest-binary.json | MasterHash 1줄이 매번 달라집니다 |
schema-baseline.json | 병합은 되지만, 잘못 병합된 베이스라인은 검사를 무력화합니다 |
validation/lib/*.dll | 바이너리입니다 |
생성 코드는 텍스트여서 git이 병합을 시도하지만, 성공한 병합이 더 위험합니다 — 두 사람의 스키마가 섞인 코드는 컴파일되면서 어느 쪽 데이터와도 일치하지 않습니다.
2.2 처방 — 기록 주체의 단일화
첫 판단은 「출력 트리 전체를 추적에서 제외한다」였습니다. 그 판단은 소비 쪽 사정 하나를 놓친 것입니다 — 소비 프로젝트의 코드는 생성 코드를 참조하므로, 생성 코드가 저장소에 없으면 clone이 컴파일되지 않습니다. 컴파일하려면 모든 개발자가 도구·워크북·시트 접근 권한을 갖추고 변환부터 실행해야 하고, 그 비용이 제외로 절약되는 것보다 큽니다.
그래서 기준을 파일이 아니라 기록 주체에 둡니다. 문제는 산출물이 커밋되는 것이 아니라 여러 사람이 각자 커밋하는 것이므로, 산출물을 커밋하는 주체를 CI 하나로 고정합니다.
| 무엇 | 주체 | 근거 |
|---|---|---|
| recipe · 검증 규칙 · 워크북 스냅샷(§4.4) | 사 람 | 입력과 판정 기준입니다 |
생성 코드 · schema-baseline.json(§2.5) | CI만 | 소비 코드가 참조하므로 저장소에 있어야 합니다. 결정적이므로 스키마가 바뀌지 않은 실행은 diff를 만들지 않고, 바뀐 실행의 diff는 그 자체가 리뷰 대상입니다 — 컬럼 하나의 타입 변경이 모든 언어에서 무엇을 바꾸는지가 PR에 보입니다 |
.tcb · .json · manifest-*.json · summary.json · encoding-report.txt | 커밋하지 않습니다 | 데이터는 §2.3의 경로가 전달합니다. 코드와 달리 저장소에 있어야 할 이유가 없습니다 |
이 저장소의 .gitignore가 같은 판단을 먼저 하였습니다 — 생성 액세서를 「파생물이지만
파생 없이 갖는 값이 있다」는 이유로 커밋하고, validation/lib/도 같은 자리입니다.
2가지가 따라옵니다.
- 저장소 안 경로를 가리키는 recipe는 CI 전용입니다. 사람의 로컬 실행은 저장소 밖 경로를 가리키는 별도 recipe를 사용합니다(§2.6) — 로컬 실행이 커밋된 산출물을 덮어써 워킹카피가 더러워지는 일 자체가 없어집니다.
- CI의 커밋이 CI를 다시 기동하지 않아야 합니다. 커밋 메시지의
[skip ci]나 경로 필터 한 줄이면 됩니다.
2.3 산출물의 전달 경로
데이터는 저장소 대신 다음 경로로 전달합니다. 도구가 이미 갖춘 것으로 구성됩니다.
- CI가 변환합니다. 워크북이 바뀐 커밋마다 1회 (예시).
manifest-binary.json이 판정합니다.MasterHash하나로 「이번 빌드와 지난 빌드가 같은 데이터인가」가 결정됩니다 (매니페스트).- 생성된 업데이터가 배포합니다.
WriteUpdater를 켜면 원격 매니페스트와 로컬 사본을 대조해 바뀐 파일만 내려받고 MD5로 검증합니다. 모든 언어에 있습니다.
이 경로에서는 저장소가 데이터를 나르지 않으므로 충돌이 발생할 자리가 없습니다.
2.4 데이터까지 커밋해야 하는 경우
유니티의 StreamingAssets처럼 엔진이 저장소 안의 파일을 요구하면 데이터도 커밋 대상이
됩니다. 그때는 3가지를 함께 적용합니다.
-
CI만 push합니다. 규약으로 부족하면 데이터 전용 저장소로 분리하고 브랜치 보호 규칙으로 강제합니다 — 기억에 의존하는 금지는 언젠가 누락됩니다. 분리는 강제가 필요할 때 하는 것이지 기본이 아닙니다.
-
.gitattributes에*.tcb binary를 등재합니다. 텍스트 병합 시도를 차단하여 손상된 파일 대신 명확한 충돌로 나타나게 합니다. -
충돌 해소는 병합이 아니라 재생성입니다. 산출물이 결정적이므로 이 절차는 항상 성립합니다.
git checkout --theirs -- <출력 경로>tabbit --recipe recipe.jsoncgit add <출력 경로>
2.5 SchemaBaseline — 별도로 다루어야 하는 파일
schema-baseline.json은 커밋 대상이면서 실행마다 갱신되는 유일한 파일입니다
(설정). 2명이 각자 컬럼을 추가하고 각자 변환하면 양쪽의
베이스라인이 서로를 모르는 상태로 커밋됩니다.
이 파일의 값은 「직전에 배포된 스키마」라는 단일한 사실에 있습니다. 두 사람이 각자 갱신한 시점에서 그 사실이 성립하지 않고, 병합으로 만들어진 베이스라인은 어느 배포와도 대응하지 않습니다.
처방 — 갱신 주체를 CI로 고정합니다.
| 누가 | 무엇을 |
|---|---|
| 사람 | AcceptSchemaChanges에 "테이블.컬럼"을 추가해 의도를 선언합니다. 이것은 텍스트이므로 병합됩니다 |
| CI | 변환하고 갱신된 베이스라인을 커밋합니다. 통과한 항목은 AcceptSchemaChanges에서 제거해도 됩니다 |
로컬 실행이 성공할 때마다 베이스라인이 갱신되어 워킹카피가 더러워지는 문제도 같이
없어집니다 — 로컬 recipe는 SchemaBaseline을 비워 둡니다. 비우면 검사가 꺼지지만, 그
검사는 CI에서 수행되며 로컬의 것은 같은 검사의 중복 실행입니다.
2.6 동시 실행과 Sweep
Sweep은 이번 실행이 쓰지 않은 생성 파일을 삭제합니다
(설정). 두 실행이 같은 출력 폴더를 가리키면 서로의
결과를 삭제합니다.
CI의 잡을 저장소·브랜치 단위로 직렬화하거나, 실행마다 다른 출력 폴더를 사용합니다. 로컬 확인용 실행은 저장소 밖 경로를 가리키는 별도 recipe로 분리합니다.
3. 원본 쪽의 동시 편집
산출물보다 앞에 원본이 있고, 원본의 동시 편집 문제는 도구가 관여하지 않는 영역입니다.
3.1 엑셀 — 파일 단위 소유
.xlsx는 바이너리이므로 git이 병합하지 못합니다. 2명이 같은 워크북을 수정하면 한쪽의
작업이 소실됩니다.
- 워크북을 시스템 단위로 분할하고 담당자를 배정합니다. 도구 쪽 제약은 없습니다 — 소스
항목 하나가 폴더 하나를 훑고,
IncludeWorkbooks로 범위를 지정합니다. - 엑셀에도 시트·범위 보호가 있지만 여기서는 값이 작습니다. 잘못 입력하는 것은 막아도, 이 절이 다루는 소실은 편집이 아니라 병합에서 발생하기 때문입니다. 보호가 실효를 갖는 것은 상태가 하나뿐인 구글 시트 쪽입니다(§3.2).
- 엑셀의 잠금 파일(
~$*)은 이미 무시 대상입니다. - 한 커밋에 2명의 수정이 섞이면 히스토리의 귀속이 1명으로 기록됩니다. 이것은 xlsx가 바이너리인 데서 오는 상한이며, 도구가 해소할 수 없다고 기재되어 있습니다.
3.2 구글 스프레드시트 — 보호된 범위와 id 대역
소유를 강제할 수 있습니다. 「데이터 → 시트 및 범위 보호」가 시트 단위로도 범위 단위로도 편집자를 지정하므로, §3.1이 엑셀에서 파일 단위로만 할 수 있는 배분을 여기서는 표의 일부까지 내려서 할 수 있습니다.
이 문서의 앞 판에는 「잠금이 없다」고 적혀 있었는데 사실이 아닙니다. 그 오류가 처방을 한 단계 약하게 만들고 있었습니다 — 강제할 수 있는 것을 규약으로만 두고 있었습니다.
| 무엇 | 어떻게 |
|---|---|
| 시스템별 소유 | 담당 테이블의 시트를 보호하고 편집자를 그 담당자로 지정합니다 |
| 같은 표의 컬럼 분담 | 밸런스 컬럼과 참조 컬럼처럼 담당이 갈리는 자리를 범위 보호로 나눕니다 |
| 헤더 3줄 | 보호 대상입니다. 컬럼 이름과 타입 선언이 바뀌면 산출물의 스키마가 바뀌고, 그것은 다른 사람의 빌드를 중단시킵니다(§6.1) |
| 경고만 | 보호에는 「편집 시 경고 표시」도 있습니다. 소유를 나누기 애매한 자리에 씁니다 — 막지는 않고 자기 담당이 아니라는 것만 알립니다 |
보호가 덮지 못하는 것이 남고, 그것이 id입니다. 두 사람이 각자 자기 범위 안에서 마지막 행에 행을 추가하면 두 행 모두 정당한 편집이고 id만 겹칩니다.
- id 대역을 담당자·시스템 단위로 배분합니다. 도구는 관여하지 않으며 시트의 규약입니다.
OnDuplicateIndex는error를 유지합니다. 대역 배분이 지켜지지 않은 것을 변환이 검출하는 자리가 여기뿐입니다.keep-first·keep-last는 원본을 수정할 수 없는 동안 나머지를 변환하기 위한 것이며, 공동 작업에서 사용하면 누구의 행이 삭제되었는지 아무도 확인하지 않는 상태가 됩니다.sprout이keep-first를 쓰는 것은 그 코퍼스가 복사 오류를 일부러 담고 있기 때문이고 — 그 분기에 게이트를 세우려고 — recipe에 그렇게 기재되어 있습니다.
보호는 실수를 막는 것이지 권한 체계가 아닙니다. 문서의 편집 권한을 가진 사람은 보호를 해제할 수 있고, 소유자는 언제나 할 수 있습니다. 막는 것은 「남의 표에 잘못 입력하는 것」이고, 막지 못하는 것은 「고의로 바꾸는 것」입니다 — 후자를 보는 것은 §5의 히스토리입니다.
3.3 브랜치 운용 — 파일 원본과 문서 원본의 차이
엑셀은 브랜치를 따라갑니다. 워크북이 저장소 안의 파일이므로 피처 브랜치가 자기 사본을 갖고, 릴리스 브랜치마다 다른 데이터가 성립합니다. 다만 바이너리라 병합이 없으므로 브랜치가 갈라진 동안에도 §3.1의 파일 단위 소유가 유지되어야 합니다 — 두 브랜치가 같은 워크북을 고치면 합칠 때 한쪽을 선택하는 것 외의 수단이 없습니다.
구글 시트에는 브랜치가 없습니다. 문서는 git 밖에 있고 상태가 하나뿐이며, 모든 편집이 그 하나를 즉시 바꿉니다. 브랜치를 흉내 내는 수단(문서 사본)은 있지만 병합 수단이 없으므로, 사본으로 브랜치를 재현하려는 시도는 지양합니다. 대신 다음 규약으로 갑니다.
| 무엇 | 어떻게 |
|---|---|
| 시트의 역할 | trunk 전용입니다. 시트는 언제나 「최신 개발 상태」 하나만 나타냅니다 |
| 작업 중인 것의 격리 | 브랜치 대신 시트 안의 수단으로 — 레이아웃의 주석 표기(#)로 미완성 컬럼을 제외하고, 작업 탭은 ExcludeSheets로 제외하며, 남이 건드리면 안 되는 자리는 §3.2의 범위 보호로 나눕니다 |
| 릴리스 라인 | 스냅샷 커밋이 담당합니다. 브랜치 의미론은 §4.4의 스냅샷이 git에 들어오는 순간부터 시작됩니다 — 스냅샷은 파일이므로 브랜치·태그·리뷰가 전부 적용됩니다 |
| 옛 릴리스 브랜치의 핫픽스 | 그 브랜치에서는 시트가 더 이상 정본이 아니므로 스냅샷 xlsx를 직접 수정하고, 같은 수정을 시트에도 반영합니다 — 코드의 핫픽스 전방 이식과 같은 규칙입니다 |
| 문서 사본 | 실험용·일방향으로만 씁니다. 결과는 사본을 버리거나 손으로 본 시트에 옮기는 것이지, 사본과 본 시트의 병합이 아닙니다 |
이 구성의 값은 어려운 문제를 만들지 않는 데 있습니다 — 「시트를 어떻게 브랜치하는가」라는 답이 없는 질문이, 「스냅샷을 언제 뜨는가」라는 답이 있는 질문으로 바뀝니다.