Git 실무 워크플로우 #4 충돌 해결 — 충돌의 구조와 mergetool·rerere

6 분 소요

충돌 마커를 읽고 해결하는 기본 절차는 Git 기초 #3 브랜치와 머지에서 다뤘습니다. 그런데 실무에서 만나는 충돌은 merge에서만 발생하지 않습니다. rebase 도중, cherry-pick 도중, stash를 다시 적용하는 도중에도 같은 형태로 나타납니다. 그리고 마커를 지우고 코드를 고르는 손기술만으로는 부족한 순간이 옵니다. 어느 쪽이 ours인지 헷갈리고, 원래 코드가 무엇이었는지 마커에 보이지 않고, 어제 푼 충돌을 오늘 또 풀고 있는 상황입니다. 이번 글은 충돌을 구조로 이해하고 도구로 다루는 방법을 정리합니다.

총 7편으로 구성됩니다.

  • #1 브랜치 전략 — GitHub Flow와 trunk-based
  • #2 rebase vs merge — 결정 기준과 금기
  • #3 interactive rebase — squash·fixup으로 커밋 정리
  • #4 충돌 해결 — 충돌의 구조와 mergetool·rerere ← 이번 글
  • #5 PR 운영 — 리뷰 단위·커밋 메시지·draft PR
  • #6 stash·cherry-pick·bisect — 일상 도구 셋
  • #7 모노레포와 Git — sparse-checkout·서브모듈·LFS

이번 글은 충돌의 세 재료인 base, ours, theirs부터 확인하고, rebase에서 방향이 뒤집혀 보이는 이유, 마커를 더 읽기 쉽게 만드는 zdiff3 설정, 에디터 통합과 rerere까지 진행합니다.

충돌의 구조 — base, ours, theirs #

Git 기초에서 본 3-way 머지를 떠올려 보겠습니다. Git은 충돌을 판정할 때 세 가지 버전을 재료로 씁니다.

  • base — 두 브랜치가 갈라지기 전의 공통 조상 버전입니다.
  • ours — 현재 내가 서 있는 쪽(HEAD)의 버전입니다.
  • theirs — 합쳐 오는 상대 쪽의 버전입니다.

base와 비교해서 한쪽만 바뀌었다면 Git은 바뀐 쪽을 자동으로 채택합니다. 양쪽 모두 base에서 다르게 바뀌었을 때만 사람에게 결정을 넘기는 것이 충돌입니다. 즉 충돌 해결이란 base에서 출발한 두 갈래 변경 중 무엇을 남길지 결정하는 일입니다. 이 관점이 서면 아래의 모든 도구가 같은 그림 위에 놓입니다.

rebase에서 ours와 theirs가 뒤집히는 이유 #

실무 혼란 1순위를 먼저 정리하겠습니다. merge 충돌에서 ours는 내 브랜치, theirs는 상대 브랜치입니다. 직관과 일치합니다. 그런데 rebase 도중 충돌에서는 반대로 보입니다.

rebase 중 충돌 — ours 가 main 쪽
$ git switch feature-retry
$ git rebase main
CONFLICT (content): Merge conflict in config.py
config.py 안의 마커
<<<<<<< HEAD (ours)
retry_count = 5        ← main 쪽 코드
=======
retry_count = 10       ← 내 브랜치의 코드
>>>>>>> 9f8e7d6 (theirs)

내 코드가 theirs 쪽에 있습니다. 이유는 rebase의 동작 방식에 있습니다. rebase는 main 위로 이동해서 내 커밋을 하나씩 다시 적용합니다. 재적용이 일어나는 동안 HEAD는 main 쪽 이력 위에 있으므로, ours는 main이 되고 지금 얹으려는 내 커밋이 theirs가 됩니다. 방향이 뒤집힌 것이 아니라 기준점이 이동한 것입니다. rebase 충돌에서 “내 것을 남기겠다"고 ours를 골랐다가 정반대 결과를 얻는 사고가 여기서 나옵니다. 마커의 라벨을 브랜치 이름으로 확인하는 습관이 가장 확실한 예방입니다.

zdiff3 — 마커에 base를 함께 표시하기 #

기본 충돌 마커의 약점은 원래 코드가 무엇이었는지를 보여 주지 않는다는 점입니다. 양쪽의 최종 상태만 보이므로, 각자가 base에서 무엇을 바꾼 것인지 추론해야 합니다. merge.conflictStyle을 zdiff3로 바꾸면 마커에 base 블록이 함께 표시됩니다.

zdiff3 설정
git config --global merge.conflictStyle zdiff3
설정 전 — 기본 마커
<<<<<<< HEAD
retry_count = 5
=======
retry_count = 10
>>>>>>> feature-retry
설정 후 — zdiff3 마커
<<<<<<< HEAD
retry_count = 5
||||||| 1a2b3c4 (base)
retry_count = 3
=======
retry_count = 10
>>>>>>> feature-retry

가운데 ||||||| 블록이 공통 조상의 코드입니다. 원래 3이었고, 한쪽은 5로, 다른 쪽은 10으로 바꿨다는 사실이 마커 안에서 바로 읽힙니다. 두 변경의 의도를 비교해서 결정할 수 있으므로 판단의 질이 달라집니다. 설정 한 줄의 비용으로 얻는 효과 중 가장 큰 편에 속합니다.

에디터로 풀기 — VS Code merge editor #

마커를 직접 편집하는 대신 3-way 뷰를 제공하는 도구를 쓸 수도 있습니다. VS Code는 충돌 파일을 열면 Resolve in Merge Editor 버튼을 제공하고, ours, theirs, base, 결과를 네 패널로 보여 줍니다. 터미널 중심으로 작업한다면 git mergetool이 충돌 파일을 순서대로 설정된 도구에 띄워 줍니다.

VS Code 를 mergetool 로 설정
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'

충돌 전체가 아니라 파일 단위로 한쪽을 통째로 채택하는 경우도 잦습니다. 자동 생성 파일이나 lock 파일이 대표적입니다. 이때는 파일을 열 필요 없이 한 줄로 끝냅니다.

파일 단위로 한쪽 채택
git checkout --ours package-lock.json     # 내 쪽 채택
git checkout --theirs package-lock.json   # 상대 쪽 채택
git add package-lock.json

브랜치 전환이 switch로 넘어간 뒤에도 이 용도의 checkout --ours/--theirs는 여전히 관용적으로 쓰입니다. rebase 중이라면 앞 절에서 정리한 대로 ours와 theirs의 방향이 뒤집혀 있다는 점만 다시 확인하기 바랍니다.

rerere — 같은 충돌을 두 번 풀지 않기 #

오래 유지되는 브랜치를 주기적으로 rebase하거나, 같은 변경을 여러 브랜치에 옮기다 보면 완전히 같은 충돌을 반복해서 만나게 됩니다. rerere(reuse recorded resolution)를 켜 두면 Git이 충돌 해결 내용을 기록해 두었다가, 같은 충돌을 다시 만났을 때 이전 해결을 자동으로 적용합니다.

rerere 켜기
git config --global rerere.enabled true

이후의 동작은 자동입니다. 충돌을 해결하고 커밋하면 기록이 남고, 다음에 같은 충돌이 나면 출력에 안내가 나타납니다.

같은 충돌을 다시 만났을 때
CONFLICT (content): Merge conflict in config.py
Resolved 'config.py' using previous resolution.

파일은 이미 이전 해결대로 채워져 있으므로 내용을 확인하고 git add만 하면 됩니다.

노트
rerere는 기록된 해결을 그대로 재적용하므로, 잘못 푼 해결도 그대로 반복됩니다. 이전 기록이 의심될 때는 git rerere forget <파일>로 해당 파일의 기록을 지우고 다시 해결하면 됩니다.

잘못 들어갔을 때 — 중단 명령 정리 #

충돌 해결 도중에 상황이 꼬였다면, 억지로 마무리하는 것보다 시작 전 상태로 돌아가서 다시 시도하는 편이 빠릅니다. 세 작업 모두 중단 명령이 있습니다.

상황중단 명령돌아가는 지점
merge 충돌 중git merge --abortmerge 시작 전
rebase 충돌 중git rebase --abortrebase 시작 전
cherry-pick 충돌 중git cherry-pick --abortcherry-pick 시작 전

중단은 실패가 아닙니다. zdiff3로 마커를 다시 읽고, 필요하면 대상을 더 잘게 쪼개서 다시 시도하는 것이 충돌을 크게 만드는 것보다 항상 낫습니다.

마무리 #

이번 글의 핵심은 네 가지입니다.

  • 충돌은 base에서 갈라진 두 변경 중 무엇을 남길지 결정하는 일이고, 재료는 base, ours, theirs 세 가지입니다.
  • rebase 충돌에서는 기준점이 이동해 ours가 main 쪽이 됩니다. 마커의 라벨을 확인하는 습관이 사고를 막습니다.
  • merge.conflictStyle zdiff3는 마커에 base를 함께 보여 줘 판단의 질을 높이고, 반복되는 충돌은 rerere가 대신 풉니다.
  • 꼬였을 때는 --abort로 시작 전으로 돌아가 다시 시도합니다.

충돌까지 다뤘으니 이제 브랜치를 합류시키는 관문인 PR로 넘어갑니다. 다음 글인 “Git 실무 워크플로우 #5 PR 운영 — 리뷰 단위·커밋 메시지·draft PR"에서는 리뷰받기 좋은 PR의 크기, 커밋 메시지 컨벤션, draft PR을 활용한 진행 공유까지 팀에서 PR을 운영하는 기준을 정리하겠습니다.

X