본문으로 건너뛰기

배치와 설계

「워크북 3-way 병합」으로 돌아가기


2. 배치 — 독립한 프로그램

저장소를 같이 쓸 뿐, tabbit과 아무것도 공유하지 않습니다. 코드도, 솔루션도, 테스트도, 패키지도 각각입니다.

tools/mabbit/
Mabbit.slnx 자체 솔루션. Tabbit.slnx에 등재하지 않습니다
src/ 프로그램. 패키지 1개(Sylvan.Data.Excel)
test/ 자체 테스트와 자체 워크북 픽스처

폴더 이름은 소문자입니다. 실행 파일 이름과 같은 표기이므로 어느 플랫폼의 셸에서도 대문자를 기억할 필요가 없습니다. 이름은 merge + tabbit이고, 계열이 유지되므로 완성된 브랜드 자산에서 아이콘과 로고를 파생만 하면 됩니다.

2.1 공유하지 않는 근거

처음에는 tabbit의 격자 리더를 참조하였습니다. 「같은 코드로 읽어야 변환과 답이 갈리지 않는다」는 것이 근거였는데, 그 근거가 성립하지 않습니다 — 병합은 이 프로그램이 한 실행에서 읽은 파일 3개를 서로 비교할 뿐이고, 그 결과를 변환이 읽은 것과 대조하는 자리가 없습니다. §4.7의 검증조차 별도 프로세스가 파일을 직접 다시 읽습니다.

그리고 그 참조의 값이 실측으로 드러났습니다.

항목tabbit 참조독립
DLL29개2개
배포 크기39 MB369 KB
시작 시간69 ms32 ms

병합 드라이버는 충돌한 파일마다 1회, 사람이 프롬프트에서 기다리는 동안 실행됩니다. 구글 API 클라이언트·데이터베이스 드라이버 4종·C# 컴파일러를 그 자리에 끌고 갈 이유가 없습니다 — 셀 격자를 읽는 데 하나도 쓰지 않습니다.

인자 파싱 라이브러리도 쓰지 않습니다. 옵션이 고정된 몇 개이고 전부 경로이며, 한 번 설정 파일에 적히고 나면 다시 타이핑되지 않습니다.

2.2 지키는 것

  • 크로스플랫폼을 유지합니다. 병합 드라이버는 CI에서 리눅스로 실행되어야 합니다.
  • 엑셀 설치를 요구하지 않습니다. 참고 구현이 그것을 요구하여 CI에서 사용할 수 없게 된 자리입니다.
  • TreatWarningsAsErrors와 nullable을 켭니다. 새로 작성하는 코드이므로 지금은 비용이 0이고, 나중에 켜는 비용이 662개였던 전례가 있습니다.
  • CI가 dotnet test Tabbit.slnx로 도는 범위 밖입니다. 자체 솔루션이므로 게이트를 따로 걸어야 합니다 — §8.

3. 참고한 구현 — XlsxMerge

넥슨코리아가 공개한 XlsxMerge(MIT)를 참고 대상으로 확인하였습니다. 엑셀 문서를 행 단위로 비교하고 3-way 병합하는 도구이며, SCM 연동 가이드를 함께 제공합니다.

3.1 가져오는 것

무엇근거
행 단위를 병합의 단위로 삼는 판단표 형태의 문서에서 변경은 대부분 행 방향으로 발생합니다. 셀 단위 정합만으로는 행 삽입 하나가 그 아래 전부를 변경으로 보고합니다
값과 수식으로 범위를 한정하는 판단차트·도형·매크로·주석을 병합 대상에서 제외하고 그 사실을 먼저 밝힙니다. 경계를 명시한 것이 그 도구가 신뢰를 얻는 방식입니다
SCM 연동을 별도 문서로 제공하는 구성병합 도구는 SCM에 등록되지 않으면 사용되지 않습니다. 등록 절차가 기능의 일부입니다
병합 결과를 커밋 전에 확인하도록 안내하는 것자동 병합을 무조건 신뢰하게 만들지 않습니다

3.2 가져오지 않는 것

무엇근거
diff.exe·diff3.exe 외부 프로세스 의존행을 텍스트 줄로 직렬화해 범용 diff3에 넘기는 방식입니다. 정합이 위치 기반이 되므로 행 순서가 변경된 워크북에서 정확도가 낮아지고, 실행 파일 2개를 배포본에 동봉해야 합니다. 키로 정합하면 그 단계가 필요하지 않습니다
엑셀 설치와 Windows 요구§2.2

4. 설계

4.1 병합이 요구하는 범위 — 레이아웃까지

필요한 것은 레이아웃까지이고 쿠킹 전체가 아닙니다. 이 판단이 나머지 설계를 결정합니다.

워크북을 Model까지 쿠킹하여 ModelFingerprint·SnapshotDiff를 재사용하는 구성은 성립하지 않습니다 — enum 선언과 참조는 워크북을 가로질러 해석되므로, 병합 대상 파일 하나만 쿠킹하면 다른 워크북에 선언된 enum에서 실패합니다. 전체 소스를 쿠킹하면 성립하나, 병합 1회마다 워크북 전부를 읽는 비용이 됩니다.

병합에 필요한 것은 셋뿐입니다.

필요한 것어디서
표 사각형src/Cooking/Layouts/ — 레이아웃 파서가 격자 위에서 산출합니다
키 컬럼과 컬럼 이름같은 자리
셀의 값src/Importers/ — 격자 그대로입니다

값 비교에 정규화가 필요하지 않습니다. CanonicalValue가 존재하는 이유는 서로 다른 시점의 두 모델을 대조하기 위해서인데, 병합은 base·mine·theirs같은 코드가 같은 실행에서 읽으므로 원시 셀 텍스트의 비교로 충분합니다. 결과로 타입 해석·enum 해석·참조 해석이 병합 경로에서 전부 제거됩니다.

tabbit의 코드는 하나도 쓰지 않습니다(§2.1). 격자 리더는 Sylvan을 직접 부르는 자체 구현이고, 판정도 SnapshotDiff가 아니라 §4.3·§4.4의 표를 그대로 옮긴 새 코드입니다.

tabbit의 자리왜 쓰지 않는가
Importers/Xlsx/SheetGridReader.cs하는 일이 200줄이고, 그것을 위해 29개 어셈블리를 끌고 가게 됩니다
History/SnapshotDiff.cs · ModelFingerprint.csModel 위에서 동작합니다. 병합은 격자 위에서 동작하므로 층이 다릅니다
Cooking/Layouts/RecipeSchema가 필요해지면 다시 판단합니다 — §8. 레이아웃 파서는 사각형을 내지 않고 Model을 만들며 그 과정에서 타입을 해석합니다(CookingContext.csunsupported type)

앞선 판단을 두 번 정정한 자리입니다. 초안은 「1·2단계는 기존 비교 경로에 상대를 하나 더 붙이는 작업」이라고 하였으나 단일 파일 쿠킹이 성립하지 않습니다. 그 다음 판본은 격자 리더만 공유하기로 하였으나, 그 공유로 얻는 것이 없다는 것이 §2.1의 실측으로 드러났습니다.

4.2 스키마의 출처 — 주입 가능한 자리

병합 엔진은 인터페이스 하나만 참조합니다.

interface ITableSchema
{
IReadOnlyList<TableRegion> TablesIn(WorkbookGrid workbook);
}

record TableRegion(
string Name, string Sheet,
int HeaderRow, int FirstDataRow, int LastDataRow,
int FirstColumn, int LastColumn,
int KeyColumn);

컬럼 이름은 여기 없습니다 — 헤더 행과 컬럼 범위가 정해지면 격자에서 읽으면 되고, 빈 헤더와 중복 헤더의 처리는 두 구현에서 같아야 하기 때문입니다.

구현상태
HeuristicSchema됨. 시트 하나를 표 하나로, 내용이 있는 사각형의 첫 행을 헤더로, 첫 컬럼을 키로 간주합니다. --key <시트>:<컬럼>으로 덮어씁니다 — 컬럼은 헤더 이름과 열 문자 둘 다 받습니다
SchemaFile됨. --schematabbit --dump-schema가 쓴 JSON을 읽습니다 — §4.2.1

스키마 없이도 동작하므로 이 도구는 이 저장소 밖의 워크북에도 적용되고, 나중에 분리하여 공개하는 선택지가 열린 채로 남습니다.

4.2.1 스키마의 전달 — 파일 한 장

tabbit의 레이아웃 파서를 부르지 않습니다. 부를 수도 없습니다 — mabbit은 tabbit에 링크하지 않고(§2.1), 레이아웃 파서는 사각형이 아니라 Model을 만들면서 타입을 해석하므로 워크북 하나만으로는 성립하지도 않습니다(§4.1).

그래서 아는 쪽이 적어서 넘깁니다.

tabbit --recipe recipe.jsonc --dump-schema schema.json # 표마다 사각형과 키 컬럼
mabbit --merge ... --schema schema.json --path <저장소 경로>
성질근거
기하 정보만타입도, enum 라벨도, 참조도 없습니다. 그것이 필요한 도구는 워크북을 직접 쿠킹해야 하는 도구입니다
검증 앞에서 씁니다값이 깨진 워크북도 표는 같은 자리에 있습니다. 병합이 그 답을 기다리는데 상관없는 문제로 막힐 이유가 없습니다
파일 이름으로 대조합니다병합 드라이버가 받는 것은 생성된 이름의 임시 파일이라, 뜻이 있는 이름은 --path가 주는 저장소 경로뿐입니다
다른 프로젝트의 스키마를 받으면테이블 0개가 아니라 「이 스키마가 설명하는 워크북은 이것들이다」라고 보고합니다. 「빈 워크북」으로 읽히지 않게 하기 위해서입니다

실측된 효과: 스키마 없이 픽스처 워크북을 병합하면 참고용 시트 때문에 「따라갈 수 없는 행」 주석이 172건 나옵니다. 스키마를 물리면 0건입니다.

4.3 셀의 3-way 판정

base를 O, mine을 A, theirs를 B로 둡니다.

조건결과
A = B그 값
A = O, B ≠ OB를 채택
B = O, A ≠ OA를 유지
A ≠ O, B ≠ O, A ≠ B충돌

4.4 행과 컬럼의 판정

정합 기준은 테이블 이름 → 기본 인덱스의 셀 텍스트 → 컬럼 이름입니다. 행 순서는 mine 쪽을 유지하고, theirs에만 있는 행은 그 테이블 사각형의 마지막 행 다음에 추가합니다 — 순서가 산출물의 바이트에 영향을 주므로 임의로 정렬하지 않습니다.

상황결과
한쪽만 행을 추가추가합니다
양쪽이 같은 키의 행을 추가하고 내용이 다름충돌. 구글 시트 쪽에서 이미 확인된 사고와 같은 것이며, 파일 워크북에서는 이 자리에서 검출됩니다
한쪽이 삭제, 다른 쪽이 수정충돌
양쪽이 삭제삭제합니다
한쪽만 컬럼을 추가추가하고 상대 쪽 행의 그 칸은 빈 칸입니다
양쪽이 같은 이름의 컬럼을 추가헤더 셀이 다르면 충돌
한쪽이 컬럼 이름을 변경, 다른 쪽이 그 컬럼의 셀을 수정충돌. 이름 변경은 삭제와 추가로 나타나므로 값 변경과 겹친 자리에서는 대응을 확정할 수 없습니다

4.5 표 밖의 영역

레이아웃이 덮지 않는 시트와 셀은 의미 병합의 대상이 아닙니다. 그러나 변경이 있었다는 사실은 검출합니다 — 표 사각형 밖의 셀들을 시트마다 해시하여 3개 파일을 대조하고, 양쪽이 서로 다르게 변경하였으면 충돌로 보고합니다.

이 장치가 없으면 「병합에 성공하였으나 남의 수정이 사라진 파일」이 산출됩니다. 감지되지 않는 소실은 충돌보다 나쁩니다.

4.6 결과 워크북의 작성

mine 파일을 운반체로 삼습니다. 새 워크북을 생성하지 않고 mine의 패키지를 복사한 뒤 필요한 파트만 다시 씁니다. 서식·차트·조건부 서식·매크로·병합된 셀은 손대지 않은 바이트로 남습니다.

의존을 자유롭게 선택할 수 있게 되었으나, 이 자리에서는 그것이 자동으로 이득이 아닙니다.

후보이득손실
직접 스트리밍 패치 (System.IO.Compression + XmlReader/XmlWriter)표 사각형 밖이 바이트 단위로 동일합니다. 그것이 그대로 게이트가 됩니다. 메모리 특성이 현재 리더와 같습니다행 삽입의 부수 갱신(정의된 이름·병합 셀·수식 참조)을 직접 구현해야 합니다
NPOI (저장소에 이미 있습니다)기존 워크북을 열어 수정할 수 있고, 행 삽입의 부수 갱신을 라이브러리가 담당합니다워크북 전체가 재작성되므로 「표 밖 바이트 동일」 게이트가 성립하지 않습니다. 손대지 않은 시트의 XML도 변경되고, 라이브러리가 보존하지 못하는 파트는 감지 없이 사라집니다. 61 MiB 워크북에서 DOM이 3.4 GB실측된 자리이기도 합니다
OpenXML SDKMS 공식이고 SAX 경로가 있습니다SAX 경로에서는 sharedStrings·styles 인덱스를 직접 다루게 되므로 직접 패치와 작업량이 비슷해집니다

3단계는 직접 패치로 진행합니다. 라이브러리가 실제로 크게 줄이는 것은 4단계의 부수 갱신이므로, 도입 여부는 §5.4의 실측 이후에 판단합니다. 판단 기준은 「표 밖 바이트 동일 게이트를 유지할 수 있는가」입니다.

세부 처리는 다음과 같습니다.

사항처리근거
수정 범위표 사각형 안의 셀만 씁니다. 그 밖은 토큰 단위로 통과시킵니다검사 가능한 불변식입니다
문자열 값t="inlineStr"로 적습니다sharedStrings.xml을 재작성하지 않습니다. 항목을 추가하면 인덱스 체계 전체가 수정 대상이 됩니다
수식 셀<f>가 있는 셀이 병합 대상이면 충돌로 보고합니다수식의 결과만 변경하면 다음 재계산에서 되돌아갑니다. 수식 자체의 병합은 §8의 미결입니다
캐시된 수식 결과xl/calcChain.xml을 삭제하고 <calcPr fullCalcOnLoad="1"/>을 설정합니다셀 값이 변경되면 다른 수식의 캐시된 결과가 낡습니다

4.7 결과의 검증

병합 결과 파일에 tabbit --validate-only를 이어서 실행합니다. 중복 id, 범위 위반, 깨진 참조가 커밋 전에 검출되고, 보고에는 셀 위치가 포함됩니다.

범용 엑셀 병합 도구가 육안 확인에 의존하는 자리이며, 이 저장소는 읽는 쪽을 이미 갖고 있습니다. 별도 프로세스 호출이므로 병합기가 쿠킹 전체를 끌어안지 않아도 성립합니다.