JSON 파일의 Git 머지 충돌: merge=union이 깨진 JSON을 쓰는 이유
핵심 요약: merge=union은 깨진 JSON을 통과시키면서 성공을 보고합니다. JSON을 위한 키 인식 3방향 머지 드라이버와 충돌을 줄이는 세 가지 습관을 안내합니다.

핵심부터 말하면, 머지 전략을 논의하기 전에 직렬화와 키 순서를 표준화하세요. merge=union이 아끼는 모든 충돌은 나중에 빠진 쉼표, 중복 키, 또는 위아래로 쌓인 두 문서로 돌아옵니다. JSON 충돌은 키 인식 3방향 머지 드라이버의 일입니다.
두 사람이 같은 주에 번역 키 두 개를 더합니다. 하나는 tools.diff, 다른 하나는 tools.schema. 변경은 무관하고 의미상 호환됩니다. 그래도 Git은 충돌을 보고하고 충돌 블록에는 두 줄이 들어갑니다:
<<<<<<< HEAD
"diff": "对比"
=======
"schema": "Schema 生成"
>>>>>>> feat-b
이유는 간단합니다. 두 브랜치가 닫는 중괄호 바로 앞에 새 줄을 삽입했고, Git의 줄 기반 3방향 머지는 위치만 보고 의미는 보지 않습니다. "이 둘은 서로 다른 키"라는 개념이 없습니다. 그래서 누군가 .gitattributes 문서를 읽고 한 줄을 복사합니다:
i18n.json merge=union
충돌이 사라집니다. 그 비용이 이 글의 주제입니다.
1. Union은 병합하지 않고 양쪽 줄을 모두 유지
merge=union은 분명히 문서화되어 있습니다. 텍스트 파일에 3방향 파일 수준 머지를 실행하되, 충돌 표시를 남기는 대신 두 버전의 줄을 모두 취합니다. 문서는 이어서 경고합니다. 함의를 이해하지 못하면 쓰지 마세요. 그 경고는 상투적인 문구가 아닙니다.
Union은 객체, 키, 배열을 이해하지 않습니다. 줄을 이해합니다. 아래 결과는 모두 위의 i18n.json을 사용해 로컬 저장소에서 재현했습니다.
| 시나리오 | 기본 드라이버 | merge=union |
키 인식 드라이버 |
|---|---|---|---|
| 양쪽이 서로 다른 키 추가 | 충돌, 수동 | "병합됨", 쉼표 없음, 유효하지 않은 JSON | 병합됨, 두 키 유지 |
| 양쪽이 같은 키 변경 | 충돌, 수동 | "병합됨", 중복 키, 마지막 값 승리 | 종료 코드 1, 충돌 경로 보고 |
| 한 줄 JSON, 양쪽 한 변경씩 | 충돌, 파일 전체 선택 | "병합됨", 두 문서가 쌓임 | 병합되지만 재형식화됨 |
한쪽이 minify 삭제, 다른 쪽이 parse 수정 |
충돌, 수동 | "병합됨", 삭제 키가 돌아오고 쉼표 없음 | 병합됨, 삭제 유지, 수정 적용 |
오직 가운데 열에서만 git merge가 성공을 반환하고, 그 출력은 하나도 파싱되지 않습니다. JSON에 union을 쓰는 것의 반직관적인 점입니다. 조용할수록 더 위험합니다.
2. 결과 하나: 빠진 쉼표, 깨끗한 머지로 보고됨
같은 두 브랜치, 이제 merge=union이 설정된 상태:
$ git merge feat-b
Auto-merging i18n.json
Merge made by the 'ort' strategy.
i18n.json | 1 +
1 file changed, 1 insertion(+)
충돌 없음, 머지 커밋 생성. 파일은 이렇게 생겼습니다:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比"
"schema": "Schema 生成"
}
}
"diff": "对比" 뒤에 쉼표가 없습니다. Union은 우리 쪽 줄과 그들 쪽 줄을 모두 유지했고, 각 줄은 자기 끝 쉼표를 지녔는데 이음매에서 구분자 하나를 잃었습니다.
파싱해 보면:
$ python3 -c "import json;json.load(open('i18n.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 10 column 5
이것이 가장 나쁜 부분입니다. Git은 머지를 성공으로 간주하고, CI 머지 단계가 통과하며, 코드 리뷰는 "1 insertion"의 초록 diff를 봅니다. 실패는 런타임의 첫 JSON.parse까지 기다리고, 그때 머지는 이미 아주 뒤에 있습니다.
3. 결과 둘: 데이터를 조용히 버리는 중복 키
두 번째 실험은 다른 모양을 씁니다. 양쪽이 같은 키를 수정하고, 하나는 "解析 / 格式化", 다른 하나는 "解析器". Union 후:
{
"tools": {
"parse": "解析 / 格式化",
"parse": "解析器",
"minify": "压缩"
}
}
이번에는 JSON이 유효하고 JSON.parse가 아무것도 던지지 않습니다. 하지만 parse가 두 번 나타납니다. 객체 리터럴 의미에 따라 나중 항목이 승리해 최종 값은 "解析器"이고, 다른 수정은 로그도 오류도 없이 버려집니다.
중복 키 검사만이 이를 드러냅니다:
duplicate keys detected: ['parse']
JSON.parse succeeded, final parse value = '解析器'
이는 유효하지 않은 JSON보다 찾기 어렵습니다. 파일이 로드되고, 페이지가 렌더링되며, 한 문자열이 조용히 되돌아갑니다. 몇 주 뒤 누군가 번역이 왜 나가지 않았는지 묻습니다.
4. 결과 셋: 한 줄 JSON이 두 문서가 됨
일부 설정 파일은 빌드 산출물처럼 한 줄로 압축됩니다. Union이 병합할 줄이 없어 양쪽이 통째로 한 줄이고 결과가 쌓입니다:
{"tools":{"parse":"解析","minify":"压缩","diff":"对比"},"version":1}
{"tools":{"parse":"解析","minify":"压缩"},"version":2}
JSON.parse는 Unexpected non-whitespace character after JSON at position 63을 보고합니다. 첫 문서를 끝내고 두 번째를 발견한 것입니다. Git은 또 "Merge made"라고 말합니다.
압축된 JSON은 버전 관리에 두면 안 되지만, 한번 들어가면 union이 이를 두 개의 무관한 문서로 만듭니다.
5. 결과 넷: 삭제한 키가 돌아옴
마지막 모양이 가장 속임수입니다. 한쪽이 minify를 삭제하고, 다른 쪽이 인접한 parse를 수정합니다. 기본 드라이버는 충돌을 보고하고, union은 이렇게 만듭니다:
{
"tools": {
"parse": "解析"
"parse": "解析器",
"minify": "压缩"
}
}
한 번에 세 결함: minify가 되살아나고, parse가 두 번 나타나며, 첫 줄이 쉼표를 잃었습니다. Git은 여전히 "2 insertions"와 성공한 머지를 보고합니다.
일반 교훈: 삭제에서 "양쪽 줄을 모두 유지"는 삭제를 되돌리는 것과 같습니다. JSON에 union을 한동안 쓴 팀에는 "그건 지웠는데" 하는 설정이 조용히 이력에 살아 있는 세트가 있습니다.
6. 올바른 접근: 키 단위 3방향 머지
Git은 특정 경로에 사용자 정의 머지 드라이버를 할당하게 합니다. 드라이버는 세 파일 경로를 받습니다. %O는 공통 조상, %A는 우리 쪽(결과가 여기에 다시 쓰임), %B는 그들 쪽입니다. Node 수십 줄이면 충분합니다:
import { readFileSync, writeFileSync } from 'node:fs';
const [basePath, oursPath, theirsPath] = process.argv.slice(2);
const read = (filePath) => JSON.parse(readFileSync(filePath, 'utf8'));
const [base, ours, theirs] = [read(basePath), read(oursPath), read(theirsPath)];
const sortKeys = (value) => Array.isArray(value)
? value.map(sortKeys)
: value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]))
: value;
const same = (left, right) => JSON.stringify(sortKeys(left)) === JSON.stringify(sortKeys(right));
const conflicts = [];
function merge3(baseValue, oursValue, theirsValue, path) {
if (same(oursValue, theirsValue)) return oursValue;
if (same(oursValue, baseValue)) return theirsValue;
if (same(theirsValue, baseValue)) return oursValue;
const allObjects = [oursValue, theirsValue, baseValue].every(
(value) => value && typeof value === 'object' && !Array.isArray(value),
);
if (allObjects) {
const keys = new Set([...Object.keys(baseValue ?? {}), ...Object.keys(oursValue), ...Object.keys(theirsValue)]);
const merged = {};
const has = (source, key) => Boolean(source) && Object.hasOwn(source, key);
for (const key of keys) {
const child = merge3(
has(baseValue, key) ? baseValue[key] : undefined,
has(oursValue, key) ? oursValue[key] : undefined,
has(theirsValue, key) ? theirsValue[key] : undefined,
path ? `${path}.${key}` : key,
);
if (child !== undefined) merged[key] = child;
}
return merged;
}
conflicts.push({ path: path || '(root)', ours: oursValue, theirs: theirsValue });
return oursValue;
}
const merged = merge3(base, ours, theirs);
if (conflicts.length > 0) {
writeFileSync('merge-conflicts.json', `${JSON.stringify({ file: oursPath, conflicts }, null, 2)}\n`, 'utf8');
console.error(`${conflicts.length} real conflict(s), see merge-conflicts.json`);
process.exit(1);
}
writeFileSync(oursPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
process.exit(0);
세 규칙이 이를 지탱합니다. 양쪽이 같으면 취하고, 한쪽이 조상과 같으면 다른 쪽을 취하며, 양쪽이 다르면 진짜 충돌입니다. 나머지는 평범한 객체로의 재귀입니다.
등록은 두 단계입니다. .gitattributes가 경로별로 드라이버를 고르고, git config가 드라이버를 정의합니다:
git config merge.json-merge.name "JSON key merge"
git config merge.json-merge.driver "node merge-json.mjs %O %A %B"
echo "i18n.json merge=json-merge" >> .gitattributes
첫 번째는 커밋할 수 있습니다(코드와 함께 이동하고 CI에서 적용됩니다). 두 번째는 로컬 설정이며 README에 있어야 합니다. 사용자 정의 드라이버가 대개 여기서 무너집니다.
"양쪽이 서로 다른 키 추가" 사례에서 드라이버는 이렇게 만듭니다:
{
"tools": {
"parse": "解析",
"minify": "压缩",
"diff": "对比",
"schema": "Schema 生成"
}
}
종료 코드 0, 유효한 JSON, 두 키 모두 존재.
알아 둘 부작용 하나: 드라이버가 JSON.stringify(merged, null, 2)를 쓰므로 파일 전체의 형식을 재작성합니다. 압축된 63바이트 설정이 7줄, 105바이트로 돌아왔습니다. 내용은 같고 모양이 다릅니다. 그래서 드라이버는 손으로 유지하는 소스 파일에만 맞습니다. 압축되거나 생성된 JSON은 저장소에 두면 안 되고 이 드라이버를 쓰면 안 됩니다. 아니면 모든 머지가 형식 소음을 끌고 갑니다.
7. 충돌 시 다시 쓰지 말 것 — 이 함정은 보기보다 큼
양쪽이 같은 키를 변경할 때 드라이버는 무엇을 해야 할까? 직관은 "우리 쪽을 다시 쓰고 exit 1로 Git이 충돌을 보고하게 한다"입니다. 시험해 보면 위험합니다:
$ git merge conf-b
CONFLICT (content): Merge conflict in i18n.json
Automatic merge failed; fix conflicts and then commit the result.
$ grep -c '^<<<<<<<' i18n.json
0
Git은 충돌을 보고하지만 파일에 충돌 표시가 하나도 없습니다. 드라이버가 이미 결과를 썼습니다. 내용은 완전히 정상처럼 보이고 우리 쪽 값을 지닙니다. 한 번 성급히 git add i18n.json && git commit하면 그들의 변경이 사라지고 이력에는 "Merge conflict resolved"만 남습니다.
그래서 진짜 충돌에서는 드라이버가 %A를 쓰면 안 됩니다. 결정되지 않은 항목을 보고 파일에 기록하고 exit 1:
{
"file": "i18n.json",
"conflicts": [
{ "path": "tools.parse", "ours": "解析 / 格式化", "theirs": "解析器" }
]
}
가치는 git status가 UU에서 멈춰 사람이 행동해야 한다는 것입니다. 그리고 수십 JSON 줄 안에서 <<<<<<<를 찾을 필요가 없습니다. 보고서가 충돌한 경로를 정확히 말합니다.
시험한 발견 하나 더: 사용자 정의 드라이버를 등록한 뒤에는 git checkout --conflict=merge -- i18n.json이 충돌 표시를 복원할 수 없습니다. 드라이버를 다시 호출하며 여전히 표시가 0개입니다. 표시를 되찾으려면 일시적으로 git config --unset merge.json-merge.driver를 하거나 git show :1:file, :2:, :3:로 세 단계를 가져와야 합니다. "표시는 항상 재생성할 수 있다"에 의존하는 사람은 필요한 바로 그 순간 길이 닫힌 것을 발견합니다.
8. 충돌을 미리 줄이는 세 가지 습관
드라이버는 백스톱이지 주 도구가 아닙니다. 충돌 수 자체를 줄일 수 있습니다.
직렬화를 표준화하세요. 한 사람은 indent=2, 다른 사람은 indent=4, 세 번째는 끝 줄 바꿈을 생략합니다. 저장할 때마다 파일 전체가 재작성되어 머지가 붙잡을 것이 없습니다. 포매터 설정을 고정하거나 pre-commit 훅에서 실행하세요.
키 순서를 안정적으로 유지하세요. 키 순서는 JSON에 무의미하지만 Git에는 아주 실재합니다. 쓰기 전에 키를 정렬(JSON.stringify 전에 정렬하거나 jq -S를 거침)하면 한 수정을 한 헝크로 유지합니다. 이는 두 JSON 문서가 어떻게 다른지에서 비교 전에 정규화하는 것과 같은 개념입니다. 그쪽은 diff를 위해, 여기는 커밋을 위해.
생성 파일을 저장소 밖에 두세요. 압축 JSON, 타임스탬프가 붙은 빌드 매니페스트, 잠금 파일은 실행마다 바뀝니다. 소스를 버전 관리하고 산출물은 생성하세요.
9. 체크리스트
.gitattributes가 JSON을merge=union으로 매핑한다면git log -p로 이력을 표본 검사해 중복 키나 문법 오류를 찾은 뒤 교체하세요- 사용자 정의 드라이버를 등록한 뒤 모두가
git config단계를 실행하게 하거나make setup에 넣으세요 - 드라이버의 충돌 분기는
%A를 쓰지 말고 보고서를 내야 합니다 - CI에
node -e "JSON.parse(...)"나jq empty게이트를 더해 "유효하지만 틀린" JSON을 머지 후 배포 전에 잡으세요 - 압축 JSON과 생성 파일을
.gitignore에 더해 그 부류의 충돌을 완전히 없애세요
10. FAQ
merge=union은 언제 안전한가?
각 줄이 완전한 레코드이고 순서가 의미를 갖지 않는 append-only 파일에만, 예컨대 JSON Lines 로그. 줄을 제자리에서 편집할 수 있는 순간(기존 레코드에 필드 추가) union은 모순되는 레코드 두 개를 만듭니다. 그것은 설계 경계이지 조심히 쓰는 문제가 아닙니다.
사용자 정의 드라이버가 배열을 처리할 수 있나?
위 버전은 배열을 원자적으로 취급합니다. 양쪽이 배열을 바꾸면 충돌을 보고합니다. 인덱스 정렬은 옛 텍스트 diff 문제를 재현하고(항목 하나를 삽입하면 모두 밀림), id 정렬은 합의된 기본 키가 필요합니다. 배열 머지는 사람에게 맡기거나 "배열은 append-only" 관례로 막는 것이 낫습니다.
드라이버에 버그가 있으면?
파일을 조용히 오염시킵니다. 첫 버전을 스크래치 저장소에서 세 모양(양쪽이 키 추가, 둘이 같은 키 변경, 하나가 삭제하고 다른 하나가 수정)으로 시험한 뒤 출시하세요. 고장 난 드라이버는 파일을 조용히 재작성하며, 충돌 표시보다 알아채기 훨씬 어렵습니다.
그냥 jq 한 줄로 머지하면 안 되나?
jq -s '.[0] * .[1]'는 얕은 머지이고, 키 순서와 중첩 동작을 사례별로 확인해야 하며, 조상이 없습니다. base가 없으면 "삭제됨"과 "변경 없음"을 구분할 수 없습니다. 3방향 머지에는 세 입력이 필요합니다.
충돌 보고서를 커밋해야 하나?
아니요. .gitignore에 더하세요. 머지가 끝나면 만료되는 일회성 산출물입니다.