git 머지 드라이버는 '파일명 순서'로 협력하지 않는다 — pnpm-lock 충돌 자동화 심화기
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 LOG
git 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.json JSON 드라이버부터 도입하고, lock은 머지 후 재생성으로 붙인다.
관련 글 제안
2026.06.19 - [Git] - squash merge가 release마다 cherry-pick이 충돌하는 이유
측정은 git 2.34.1(기본 ort 전략) 기준이며, git 버전·병합 전략·실행 환경에 따라 드라이버 호출 순서는 달라질 수 있다. 다만 "드라이버는 임시파일만 입출력하고 워킹트리는 머지 종료 후 갱신된다"는 동작은 ort 전략의 구조적 특성이다. 도입 전 자신의 환경에서 재현 측정을 권한다. 도구 동작(특히 pnpm CLI 플래그)은 빠르게 바뀌므로 공식 문서로 현재 동작을 확인하라.