애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • git 머지 드라이버는 '파일명 순서'로 협력하지 않는다 — pnpm-lock 충돌 자동화 심화기
    Git 2026. 6. 29. 19:23
    728x90
    반응형

    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

     

    두 가지가 가정과 다르다.

    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.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 플래그)은 빠르게 바뀌므로 공식 문서로 현재 동작을 확인하라.

    반응형

    댓글

Designed by Tistory.