상황
feat/new-feature 브랜치에서 작업을 마치고 PR을 올렸는데 GitHub에서 Merge Conflict가 발생했다.
그런데 이상한 점이 하나 있었다.
이 브랜치는 새 기능(B)을 개발하기 위한 브랜치였는데, 충돌이 발생한 파일들은 전부 다른 기능(A) 과 관련된 파일들이었다.
src/__init__.py
src/a.py
src/b.py
src/c.py
tests/d.py
정작 내가 수정한 적도 없는 파일들인데, 왜 충돌이 발생한 걸까?
원인 파악
1. 브랜치 커밋 구조 확인
먼저 브랜치에 어떤 커밋이 있는지 확인했다.
git log --oneline origin/main..feat/new-feature
결과는 다음과 같았다.
abcdefg feat(feature-b): implement new feature ← 이번 PR의 실제 작업
abcdefh feat(feature-a): improve processing ← ??? 왜 이게 들어있지?
이번 작업과 관련된 커밋 외에도 기능 A와 관련된 커밋이 하나 더 포함되어 있었다.
2. main 브랜치 이력 확인
브랜치가 오래전에 분기된 상태였기 때문에, 그동안 main에는 여러 PR이 머지되어 있었다. 관련 파일의 히스토리를 확인해 보니 다음과 같았다.
git log --oneline <merge-base>..origin/main -- src/services/feature_a/
abcdefg feat(feature-a): additional improvements (PR #2)
abcdefh feat(feature-a): improve processing (PR #1)
여기서 눈에 띄는 점은 PR #1의 커밋 메시지가 내 브랜치의 커밋과 동일했다.
3. 결정적 단서 - patch-id 비교
혹시 같은 작업인지 확인하기 위해 patch-id를 비교했다.
git show abcdefg | git patch-id
git show abcdefh | git patch-id
두 결과는 동일했다.
patch-id는 변경 내용 자체를 기반으로 생성되는 식별자다. 즉, 내 브랜치의 abcdefg는 이미 main에 머지된 abcdefh와 내용은 완전히 동일한 중복 커밋이었다. 커밋 해시만 달랐을 뿐이다.
왜 충돌이 발생했을까?
상황을 정리하면 다음과 같다.
브랜치 기능 A 상태
| main | PR #1 + PR #2 |
| 내 브랜치 | PR #1과 동일한 변경(다른 커밋 해시) |
Git은 커밋 해시를 기준으로 이력을 관리한다. 따라서 내용은 같더라도 해시가 다르면 서로 다른 커밋으로 인식한다.
그 결과 Git은 중복 커밋까지 포함해서 3-way merge를 시도했고, main에 추가된 후속 변경(PR #2)과 충돌이 발생한 것이다.즉, 이미 main에 반영된 변경이 내 브랜치에도 중복으로 남아 있었고, 이후 main에서 같은 파일을 추가 수정하면서 merge conflict가 발생했다.
해결
핵심은 중복 커밋만 제거하는 것이었다. 이럴 때는 git rebase가 가장 깔끔하다.
git rebase origin/main
rebase 과정에서 Git은 patch-id를 비교해 이미 main에 존재하는 동일한 변경은 자동으로 건너뛴다.
이번 경우에도
- 중복 커밋은 자동으로 제외되고
- 실제 작업인 Feature B 커밋만 main 위에 다시 적용되었다.
실제 reflog에서도 중복 커밋은 적용되지 않고, 필요한 커밋만 재적용된 것을 확인할 수 있었다.
rebase (start): checkout origin/main
rebase (pick): feat(feature-b): implement new feature
rebase (finish): returning to feat/new-feature
결과적으로 브랜치는 다음처럼 정리되었다.
git log --oneline origin/main..HEAD
bbcdefj feat(feature-b): implement new feature
마지막으로 rebase는 커밋 히스토리를 다시 작성하기 때문에 force push가 필요하다.
git push --force-with-lease
--force-with-lease는 원격 브랜치가 예상과 다르게 변경된 경우 덮어쓰기를 막아주는 안전한 force push 옵션이다.
이후 PR의 merge conflict는 사라졌고, 변경 사항도 이번 작업과 관련된 내용만 깔끔하게 남았다.
배운 점
- 충돌이 발생한 파일이 내 작업과 전혀 관련 없다면, 브랜치에 중복 커밋이 포함되어 있는지 확인해 보자.
- git patch-id는 "내용은 같지만 해시만 다른 커밋"을 찾을 때 매우 유용하다.
- 중복 커밋이 원인이라면 git rebase가 가장 깔끔한 해결 방법이 될 수 있다.
- 장기간 유지되는 브랜치는 주기적으로 main을 rebase(또는 merge)해 최신 상태를 유지하면 큰 충돌을 예방할 수 있다.