-
git 머지 드라이버는 '파일명 순서'로 협력하지 않는다 — pnpm-lock 충돌 자동화 심화기Git 2026. 6. 29. 19:23728x90반응형
1. 도입부 (Why This Matters)
pnpm-lock.yaml은package.json의 파생물이다. 그런데 git의 기본 머지는 이 파일을 '줄 단위 텍스트'로 본다. 두 PR이 각자 의존성을 건드리면 lockfile은 거의 항상 충돌하고, 텍스트 3-way 병합은 lockfile의 해시·트리 구조를 깨뜨린다. 결국 사람이 lockfile을 통째로 다시 만들어 커밋한다.2. 핵심 개념 (What & Why)
git 커스텀 머지 드라이버란,. gitattributes로 특정 파일에 지정해 두면 그 파일이 충돌할 때 git이 기본 텍스트 병합 대신 우리가 만든 스크립트를 호출하게 하는 장치다(첫 등장이니 한 줄 정의).
비유하자면, 기본 머지는 '줄 단위로만 비교하는 직원'이다. JSON이나 lockfile처럼 구조와 의미가 있는 파일에서는 이 직원이 자주 틀린다. 그래서 "이 파일만큼은 내가 직접 본다"며 전문 직원(드라이버)을 붙이는 것이다.
lockfile에는 한 단계 더 들어간다. lockfile은
package.json에서 재생성되는 산출물이므로, "병합"하는 게 아니라 "병합된package.json에서 다시 만든다"가 정석이다. 여기서 자연스러운 설계가 나온다.package.json→ JSON 구조를 이해하는 3-way 병합 드라이버pnpm-lock.yaml→ lockfile 재생성 드라이버
그런데 lock 재생성은 병합된
package.json이 먼저 있어야 한다. 그래서 거의 반사적으로 이런 가정을 한다 — *"package.json이pnpm-lock.yaml보다 알파벳상 앞서니, 드라이버도 먼저 돌 거고, lock 드라이버는 이미 병합된package.json을 보겠지."* 이 가정이 이 글의 핵심 표적이다.3. 동작 원리 (How It Works)
먼저 드라이버 인터페이스부터. git은 충돌한 파일마다 세 버전의 임시파일을 만들어 드라이버에 넘긴다 —
%O(공통 조상=base),%A(현재=ours),%B(상대=theirs). 드라이버는 병합 결과를%A위치에 쓰고, git이 그 파일을 회수한다.# .gitattributes — 어떤 파일에 어떤 드라이버를 붙일지 선언 package.json merge=package-json pnpm-lock.yaml merge=pnpm-lock#!/usr/bin/env bash # setup-git-merge-drivers.sh — 드라이버를 git config 에 등록(멱등: 재실행해도 같은 키 덮어쓰기). # 로컬은 pnpm 의 prepare 훅에서, CI 는 composite action 에서 동일하게 호출한다. set -euo pipefail # package.json: JSON 인식 3-way 병합. git 이 %O %A %B(=base ours theirs)를 넘긴다. git config merge.package-json.name "package.json JSON-aware merge" git config merge.package-json.driver "scripts/git-merge-package-json.sh %O %A %B" git config merge.package-json.recursive binary # pnpm-lock: lockfile 재생성. (※ 본문에서 이 '드라이버 안 재생성' 방식의 한계를 다룬다) git config merge.pnpm-lock.name "pnpm-lock.yaml regeneration" git config merge.pnpm-lock.driver "scripts/git-merge-pnpm-lock.sh %A" git config merge.pnpm-lock.recursive binary기대한 모델 vs 실제 동작

가정이 맞는지 추측하지 말고 측정하자. 두 파일에 '아무 일도 안 하고 자기가 본 워킹트리만 기록하는' 드라이버를 붙여 실제 머지를 돌려본다. 복사해서 그대로 실행할 수 있다.
# 머지 드라이버가 '언제' 어떤 워킹트리를 보는지 직접 측정한다. tmp=$(mktemp -d); cd "$tmp"; git init -q git config user.email t@t; git config user.name t cat > probe.sh <<'EOF' #!/usr/bin/env bash # $1 = 라벨. 호출 시점의 워킹트리 package.json 을 그대로 기록하고 끝낸다. echo "[$1] working-tree package.json = $(tr -d '\n' < package.json)" >> "$PWD/LOG" exit 0 EOF chmod +x probe.sh git config merge.pj.driver "$tmp/probe.sh package.json-driver" git config merge.lock.driver "$tmp/probe.sh pnpm-lock-driver" printf 'package.json merge=pj\npnpm-lock.yaml merge=lock\n' > .gitattributes echo base > package.json; echo base > pnpm-lock.yaml; git add -A; git commit -qm base git switch -qc feat; echo theirs > package.json; echo theirs > pnpm-lock.yaml; git commit -qam t git switch -q -; echo ours > package.json; echo ours > pnpm-lock.yaml; git commit -qam o git merge feat --no-edit >/dev/null 2>&1 cat LOGgit 2.34.1(기본 병합 전략
ort)에서의 출력:[pnpm-lock-driver] working-tree package.json = ours [package.json-driver] working-tree package.json = ours두 가지가 가정과 다르다.
- 호출 순서가 거꾸로다.
package.json이 아니라pnpm-lock드라이버가 먼저 호출됐다(5회 반복·recursive전략에서도 동일). 파일명 알파벳 순서는 드라이버 호출 순서를 보장하지 않는다. - 더 결정적인 건 타이밍이다. 두 드라이버 모두
ours(병합 전)package.json을 봤다. git은 모든 드라이버가 끝난 뒤에야 병합 결과를 워킹트리에 쓴다.
실제 JSON 병합 드라이버로 바꿔
theirs가 의존성depC를 새로 추가한 상황을 만들어도 결과는 같다 — lock 드라이버가 도는 시점의 워킹트리package.json에는depC가 없고(ours상태),depC가 합쳐진 최종package.json은 머지가 완전히 끝난 다음에야 나타난다.
즉 머지 드라이버는 git이 건넨 임시파일만 입출력한다. "워킹트리를 통해 다른 파일의 병합 결과를 본다"는 발상 자체가 git 구조상 성립하지 않는다. 파일명 순서를 바꿔서 해결될 문제가 아니다.
위 측정은 git 2.34.1 기준이다. CI 러너 등 기본 병합 전략이
ort인 git(2.34+)도 같을 가능성이 높지만, 환경별 재현을 권한다. 한 가지 더 — lock 드라이버에 흔히 넣는grep '<<<<<<<' package.json가드는 이 타이밍 문제를 못 잡는다. 병합 전ours에는 충돌 마커가 아예 없기 때문이다.4. 실무 적용 (Practical Examples)
✅ 권장 패턴 —
package.json: JSON 인식 머지 드라이버이 드라이버는
%O/%A/%B임시파일만 다룬다. 워킹트리도, 호출 순서도, 다른 드라이버도 보지 않는다. 그래서 항상 옳고 멱등하다.#!/usr/bin/env bash # git-merge-package-json.sh — JSON 인식 3-way 병합 (의존성은 'semver 높은 쪽' 정책). # git 인자: %O %A %B = base / ours / theirs. 결과는 %A(=$2)에 기록한다. set -euo pipefail node - "$1" "$2" "$3" <<'NODE' const fs = require('node:fs'); const [base, ours, theirs] = process.argv.slice(2).map(p => JSON.parse(fs.readFileSync(p, 'utf8'))); const DEP = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; const parse = s => { const m = String(s).match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)$/); return m && [+m[2], +m[3], +m[4]]; }; const higher = (a, b) => { // 둘 다 파싱되면 높은 range, 아니면 null(=진짜 충돌) const x = parse(a), y = parse(b); if (!x || !y) return null; for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? a : b; return a; }; function mergeField(f) { const o = ours[f] || {}, t = theirs[f] || {}, b = base[f] || {}; const out = {}; for (const k of new Set([...Object.keys(o), ...Object.keys(t)])) { if (o[k] === undefined) { out[k] = t[k]; continue; } // 한쪽만 추가 → 유지 if (t[k] === undefined) { out[k] = o[k]; continue; } if (o[k] === t[k]) { out[k] = o[k]; continue; } // 동일 if (o[k] === b[k]) { out[k] = t[k]; continue; } // ours 만 그대로 → theirs 채택 if (t[k] === b[k]) { out[k] = o[k]; continue; } // theirs 만 그대로 → ours 채택 const h = higher(o[k], t[k]); // 양쪽 변경 → 높은 semver if (h === null) { console.error(`conflict ${f}.${k}: ${o[k]} vs ${t[k]}`); process.exit(1); } out[k] = h; } return Object.keys(out).sort().reduce((a, k) => (a[k] = out[k], a), {}); // 정렬 → diff 안정화 } const result = { ...ours }; for (const f of DEP) if (ours[f] || theirs[f]) result[f] = mergeField(f); fs.writeFileSync(process.argv[2], JSON.stringify(result, null, 2) + '\n'); // %A 에 기록 NODE지면상 의존성 필드 위주로 줄였다. 실제로는
packageManager의 semver 비교, 의존성 외 최상위 필드의 표준 3-way 처리(양쪽 변경 시 경고 후 ours 우선)도 같은 파일에서 다룬다. 핵심은 워킹트리를 건드리지 않는다는 점이다.❌ 안티패턴 — lock 재생성을 '머지 드라이버 안'에서
#!/usr/bin/env bash # git-merge-pnpm-lock.sh (안티패턴) — 드라이버 안에서 워킹트리 package.json 을 읽어 재생성. set -euo pipefail cp -f "$1" pnpm-lock.yaml # %A(ours) lock 으로 시드 pnpm install --lockfile-only # ← 워킹트리 package.json 을 읽는다. 그런데 그건 '병합 전'이다 cp -f pnpm-lock.yaml "$1"3절에서 측정했듯, 이
pnpm install이 읽는 워킹트리package.json은 병합 전(ours)이다.theirs가 추가한 의존성이 빠진 채 lockfile이 만들어진다. 머지가 끝나면package.json에는 그 의존성이 있는데 lockfile에는 없다 — 다음pnpm install --frozen-lockfile(CI 빌드)에서 정확히 이 불일치로 깨진다. 자동화하려던 바로 그 시나리오에서.✅ 권장 패턴 — lock은 '머지 완료 후'에 재생성
해법은 책임 분리다.
package.json정합성은 머지 드라이버가, lockfile 정합성은 '머지 완료 후 재생성'이 보장한다. lockfile은 머지 드라이버에 맡기지 않는다 — 머지가 끝나 워킹트리에 '병합된package.json'이 실제로 존재하는 시점으로 재생성을 미룬다.
CI에서는 의존성 자동 sync 잡이 자연스러운 자리다. base 브랜치를 향해 머지를 시도만 하고, 그 결과를 분류한다.
# 의존성 자동 sync (개념 골격 — 비밀값·레지스트리 등 환경 의존 부분은 생략) on: pull_request: paths: ['**/package.json', 'pnpm-lock.yaml'] concurrency: # 같은 PR 직렬화(레이스 방지) group: auto-sync-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: sync-deps: steps: - uses: actions/checkout@v4 with: { ref: ${{ github.event.pull_request.head.ref }}, fetch-depth: 0 } - run: scripts/setup-git-merge-drivers.sh # JSON 드라이버 등록(멱등) - name: 머지 시도 후 분류 run: | git fetch origin "$BASE" ORIG=$(git rev-parse HEAD) git merge "origin/$BASE" --no-commit --no-ff || true CONFLICTS=$(git diff --name-only --diff-filter=U) # package.json / lock 외 충돌이면 → 되돌리고 알림만 (코드는 절대 PR 에 안 섞음) if echo "$CONFLICTS" | grep -vqE '(^|/)package\.json$|^pnpm-lock\.yaml$'; then git reset --hard "$ORIG"; echo "코드 충돌 — sticky 코멘트로 안내"; exit 0 fi # 여기선 충돌이 package.json/lock 에만 있다. 머지가 끝났으니 # 워킹트리 package.json 은 '병합 완료' 상태 → 이때 재생성해야 정확하다. pnpm install --lockfile-only git add '**/package.json' pnpm-lock.yaml git commit -m "chore: deps sync" git push origin "HEAD:${{ github.event.pull_request.head.ref }}"로컬에서는
post-merge훅으로 머지 직후 같은 재생성을 돌리거나(생성된 lock은 개발자가 확인 후 커밋), 최소한 "머지 뒤pnpm install"을 팀 컨벤션으로 문서화한다. 어느 쪽이든 핵심은 동일하다 — 재생성을 머지 이후로 옮기면 드라이버 호출 순서 가정 자체가 필요 없어진다.가드레일도 빠뜨리지 말자. base의 코드는 PR로 끌어오지 않고
package.json·pnpm-lock.yaml두 종류만 되돌려 적용하면 릴리스 독립성이 보존된다. 봇이 만든 커밋에 또 워크플로가 반응하지 않도록 직전 커밋 작성자를 확인하는 루프 가드, 동일 PR 직렬화를 위한concurrency도 함께 둔다.🔍 실행 결과
- 측정 스니펫: 두 드라이버 모두
working-tree package.json = ours. 순서는 환경에 따라 달라질 수 있어도, "둘 다 병합 전package.json을 본다"는 사실은 변하지 않는다. - 권장 패턴: 머지가 끝난 시점의 워킹트리
package.json은ours+theirs가 합쳐진 '병합 완료' 상태다. 그때 재생성한 lockfile은 병합된 의존성 집합을 정확히 반영한다.
5. 장단점 및 고려사항
장점 단점·주의 ✓ 일상 PR의 lockfile 충돌을 사람 개입 없이 자동 흡수 ✗ 드라이버를 모두가 등록해야 함 → 로컬 prepare 훅 + CI action으로 멱등 등록 필수 ✓ package.json드라이버는 임시파일만 다뤄 순서·타이밍과 무관·멱등✗ 재생성은 의존성 range를 재해석 → 매칭되는 새 patch로 올라갈 수 있음(로컬 pnpm install과 동일한 동작)✓ 코드 불가침(두 파일만 push back)으로 릴리스 독립성 보존 ✗ CI가 PR 브랜치에 push하려면 write 권한 필요(내부 레포 전제) — fork PR은 제약 ✓ 머지 후 --frozen-lockfile로 정합성 검증 게이트를 둘 수 있음✗ 봇 커밋 무한 루프 가드, 동일 PR 직렬화 필요 도입 체크리스트:
- 멱등 등록: 로컬은 pnpm
prepare훅, CI는 composite action으로 같은 드라이버를 등록한다. - 워킹트리 의존 금지: 머지 드라이버는 임시파일만 만진다. 다른 파일의 병합 결과가 필요하면 그건 머지 이후 단계의 일이다.
- 가드레일: 코드 불가침(두 파일만), 봇 루프 가드,
concurrency직렬화. - 검증 게이트: 동기화 후
pnpm install --frozen-lockfile로 lock ↔package.json정합성을 한 번 더 확인한다.
6. 핵심 3줄 요약
- lockfile 충돌 자동화의 핵심은 "충돌을 푸는 것"이 아니라 "
package.json을 병합하고, lock은 그로부터 재생성"으로 문제를 재정의하는 것이다. - 단, 재생성을 머지 드라이버 안에 두지 말 것 — git 드라이버는 임시파일만 보고, 워킹트리는 머지 종료 후에야 갱신된다. 측정으로 직접 확인하라.
- 책임을 분리하면(드라이버는
package.json, 재생성은 머지 후) 호출 순서 가정이 통째로 사라진다.
위 측정 스니펫을 우리 git 버전에서 그대로 돌려 순서·타이밍을 확인하라. 그다음
package.jsonJSON 드라이버부터 도입하고, lock은 머지 후 재생성으로 붙인다.관련 글 제안
2026.06.19 - [Git] - squash merge가 release마다 cherry-pick이 충돌하는 이유
측정은 git 2.34.1(기본
ort전략) 기준이며, git 버전·병합 전략·실행 환경에 따라 드라이버 호출 순서는 달라질 수 있다. 다만 "드라이버는 임시파일만 입출력하고 워킹트리는 머지 종료 후 갱신된다"는 동작은ort전략의 구조적 특성이다. 도입 전 자신의 환경에서 재현 측정을 권한다. 도구 동작(특히 pnpm CLI 플래그)은 빠르게 바뀌므로 공식 문서로 현재 동작을 확인하라.반응형'Git' 카테고리의 다른 글
squash merge가 release마다 cherry-pick이 충돌하는 이유 (0) 2026.06.22 Trunk-based Development(TBD) 소개 (1) 2024.09.30 Git 기초 마스터하기 - 5편 Git을 활용한 최대 협업 효율성 활용하기 (0) 2024.01.22 Git 기초 마스터하기 - 4편 Git log로 프로젝트 히스토리 분석 (0) 2024.01.15 Git 기초 마스터하기 - 3편 Alias와 Hook으로 워크플로우 최적화하기 (1) 2024.01.08