Git

git 머지 드라이버는 '파일명 순서'로 협력하지 않는다 — pnpm-lock 충돌 자동화 심화기

Kir93 2026. 6. 29. 19:23
728x90
반응형

1. 도입부 (Why This Matters)

pnpm-lock.yamlpackage.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.jsonpnpm-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

 

두 가지가 가정과 다르다.

  1. 호출 순서가 거꾸로다. package.json이 아니라 pnpm-lock 드라이버가 먼저 호출됐다(5회 반복·recursive 전략에서도 동일). 파일명 알파벳 순서는 드라이버 호출 순서를 보장하지 않는다.
  2. 더 결정적인 건 타이밍이다. 두 드라이버 모두 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.jsonours+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 플래그)은 빠르게 바뀌므로 공식 문서로 현재 동작을 확인하라.

반응형