"CONFLICT (content): Merge conflict in..." 이 메시지를 보고 심장이 철렁 내려앉은 적, 다들 한 번쯤 있으시죠? 사실 Git 충돌은 저장소가 고장 났다는 뜻이 아니라 "이 부분은 사람이 직접 결정해 주세요"라는 지극히 정상적인 신호예요. 이 글에서는 충돌이 왜 생기는지부터 실전에서 자주 만나는 7가지 시나리오, 그리고 상황별로 바로 써먹을 수 있는 명령어까지 한 번에 정리해 드릴게요.
같은 지점을 서로 다르게 고친 두 브랜치가 만나면, Git은 판단을 멈추고 사람에게 물어봐요.
1. Git 충돌은 왜 생길까? 🤔
Git은 병합할 때 3-way merge(3방향 병합) 방식을 씁니다. 두 브랜치가 갈라지기 전의 공통 조상(base), 내 브랜치(HEAD), 그리고 합쳐 들어오는 브랜치(incoming) 이렇게 세 버전을 놓고 비교하는 거예요. 서로 다른 부분을 고쳤다면 Git이 알아서 합쳐주지만, 같은 줄을 서로 다르게 고쳤다면 Git은 "어느 쪽이 맞는지 저는 판단할 수 없어요"라며 사람에게 결정을 넘깁니다. 이게 바로 충돌이에요.
충돌은 git merge뿐 아니라 git pull(내부적으로 fetch + merge), git rebase, git cherry-pick 도중에도 똑같이 발생할 수 있어요. 대표적인 원인은 크게 세 가지로 정리됩니다.
- 같은 줄 동시 수정: 두 브랜치가 같은 줄을 서로 다르게 고친 경우. 가장 흔한 패턴이에요.
- 삭제 vs 수정: 한쪽에서는 파일을 지웠는데, 다른 쪽에서는 그 파일을 수정한 경우.
- 동시 생성(Add/Add): 두 브랜치가 각자 같은 이름의 새 파일을 만든 경우.
"충돌은 Git이 실패했다는 신호가 아니라, 두 사람의 의도가 겹쳤으니 검토해 달라는 요청이다." – 개발자 커뮤니티에서 흔히 하는 말
2. 충돌 마커 완전 해부
충돌이 나면 Git은 파일 안에 아래처럼 특수한 마커를 남겨둡니다. 처음엔 암호처럼 보이지만 규칙은 아주 단순해요.
<<<<<<< HEAD부터 =======까지는 현재 체크아웃 중인 내 브랜치의 내용이고, =======부터 >>>>>>> 브랜치명까지는 병합해 들어오는 쪽의 내용이에요. 다만 rebase 중에는 의미가 살짝 뒤바뀝니다. HEAD는 리베이스 대상(옮겨갈 브랜치)이고, 아래쪽은 지금 재적용되고 있는 내 커밋이거든요. 이 부분에서 많은 개발자들이 헷갈려 해요.
기본 마커만으로는 "원래 코드가 뭐였는지" 알 수 없어서 답답할 때가 많죠. 이럴 땐 diff3나 한 단계 더 똑똑한 zdiff3 스타일을 켜보세요. 공통 조상 내용까지 함께 보여줘서 누가 뭘 바꿨는지 훨씬 명확해집니다.
# 병합 충돌 표시를 zdiff3 스타일로 변경 (Git 2.35+)
git config --global merge.conflictStyle zdiff3
핵심 포인트: rerere로 반복 충돌 줄이기
같은 충돌을 리베이스할 때마다 계속 반복해서 풀고 있다면 git config --global rerere.enabled true로 "Reuse Recorded Resolution" 기능을 켜보세요. Git이 한 번 푼 충돌 패턴을 기억했다가, 다음에 똑같은 충돌이 나타나면 자동으로 같은 방식으로 풀어줍니다.
3. 실전 충돌 시나리오 & 해결법 🛠️
결국 대부분의 충돌은 아래 네 갈래 중 하나로 정리돼요. 전체 원복(지금 상황 자체를 취소), 원격지 원복(상대/원격 브랜치 채택), 로컬 원복(내 브랜치 채택), 직접 병합(둘을 합쳐서 새로 작성). 시나리오마다 이 네 가지를 실제 명령어와 예시로 하나씩 짚어볼게요.
| 해결 방법 | 언제 쓰나 | 대표 명령어 |
|---|---|---|
| 🔄 전체 원복 | 지금 상황 자체를 취소하고 다시 접근하고 싶을 때 | git merge --abort / git rebase --abort |
| 🌐 원격지 원복 | 상대(원격) 쪽 변경이 맞다고 확신할 때 | git checkout --theirs <file> |
| 💻 로컬 원복 | 내 브랜치 변경을 그대로 유지하고 싶을 때 | git checkout --ours <file> |
| ✍️ 직접 병합 | 두 변경사항을 모두 살려야 할 때 | 파일을 열어 수동으로 합치기 |
주의: --ours/--theirs의 의미는 merge와 rebase에서 정반대예요. merge 중에는 --ours=내 브랜치, --theirs=상대 브랜치지만, rebase 중에는 --ours=리베이스 대상(보통 원격 main), --theirs=지금 재적용 중인 내 커밋입니다. 헷갈릴 땐 명령 전에 git status로 한 번 더 확인하세요.
① 같은 줄을 동시에 수정 (Content Conflict)
가장 흔한 케이스예요. main 브랜치는 timeout을 5000으로, 내 feature 브랜치는 10000으로 바꿨다고 해볼게요. 앞서 본 마커가 그대로 나타납니다. 상황에 맞는 방법을 골라보세요.
🔄 전체 원복지금은 병합할 때가 아니다 싶으면 통째로 취소하고 나중에 다시 시도하세요.
git merge --abort # 병합 시작 이전 상태로 완전히 되돌아감
🌐 원격지 원복상대(원격) 브랜치의 10000이 맞다고 판단되면 그 값을 그대로 채택하세요.
git checkout --theirs src/config.js
git add src/config.js
git commit
💻 로컬 원복반대로 내 브랜치의 5000을 유지해야 한다면 이렇게 하면 됩니다.
git checkout --ours src/config.js
git add src/config.js
git commit
✍️ 직접 병합사실 이런 값 충돌은 둘 다 살리는 게 정답일 때가 많아요. 환경변수로 분리하는 식으로요. 마커를 지우고 나면 파일은 이렇게 정리됩니다.
git add src/config.js
git commit
② 파일 삭제 vs 수정 (Delete/Modify Conflict)
한쪽 브랜치에서는 파일을 지웠는데 다른 쪽에서는 그 파일을 수정했을 때 발생해요. git status에 "deleted by us" 혹은 "deleted by them"으로 표시됩니다. 이 상황에서는 '삭제 확정'과 '유지 확정' 중 하나를 고르는 셈이에요.
🔄 전체 원복
git merge --abort
삭제 쪽으로 확정할지, 수정된 내용을 살릴지는 파일 이름 하나로 결정됩니다.
# 삭제로 확정 (파일을 지우기로 결정)
git rm src/legacy.js
git commit
# 유지로 확정 (수정된 내용을 살리기로 결정)
git add src/legacy.js
git commit
③ 두 브랜치가 같은 파일을 새로 생성 (Add/Add Conflict)
서로 다른 브랜치에서 우연히 같은 이름의 파일을 새로 만들면 Git은 어느 내용이 맞는지 모르기 때문에 충돌로 표시해요. ①번과 마찬가지로 네 갈래 선택지가 그대로 적용됩니다.
git status # both added: 로 표시된 파일 확인
# 🔄 전체 원복
git merge --abort
# 🌐 원격지 원복 (상대가 만든 내용 채택)
git checkout --theirs src/utils/format.js && git add src/utils/format.js
# 💻 로컬 원복 (내가 만든 내용 채택)
git checkout --ours src/utils/format.js && git add src/utils/format.js
# ✍️ 직접 병합 (파일을 열어 두 내용을 함께 반영)
git add src/utils/format.js
git commit
④ Rebase 중 반복되는 충돌
Rebase는 커밋을 하나씩 순서대로 재적용하기 때문에, 같은 파일의 같은 부분이 여러 커밋에 걸쳐 반복해서 충돌로 나타날 수 있어요. 이럴 때는 앞서 소개한 rerere가 특히 빛을 발합니다. 이 시나리오는 위 경고 박스에서 설명한 --ours/--theirs 반전이 실제로 헷갈리는 대표 케이스예요.
git rebase main
# 충돌 발생 → 파일 수정 후
git add <충돌파일>
git rebase --continue # 다음 커밋으로 진행
# 🔄 전체 원복
git rebase --abort # 전체를 취소하고 리베이스 이전 상태로
# 이번 커밋만 건너뛰고 싶다면
git rebase --skip
# 리베이스 중 --ours = main(리베이스 대상), --theirs = 지금 재적용 중인 내 커밋
git checkout --ours src/config.js # main 쪽 내용으로 확정
git checkout --theirs src/config.js # 내 커밋 내용으로 확정
⑤ 파일 이름 변경 충돌 (Rename Conflict)
한쪽 브랜치는 파일 이름을 바꾸고, 다른 쪽은 그 파일의 내용을 수정했을 때 생겨요. Git이 이름 변경을 자동으로 감지하지 못하면 시각적 병합 도구를 쓰는 게 훨씬 편합니다.
git status # both modified: old-name.js -> new-name.js
# 🔄 전체 원복
git merge --abort
# 시각적으로 확인하며 정리 (권장)
git mergetool
# 또는 최종 파일명/내용을 수동으로 정리한 뒤
git add new-name.js
git commit
⑥ 바이너리 파일 충돌 (이미지·폰트 등)
이미지나 폰트 같은 바이너리 파일은 줄 단위로 합칠 수 없어요. '직접 병합'이 불가능하니 셋 중 하나만 고르면 됩니다.
# 🔄 전체 원복
git merge --abort
# 💻 로컬 원복 (내 브랜치 버전 유지)
git checkout --ours path/to/logo.png
git add path/to/logo.png
git commit
# 🌐 원격지 원복 (상대 브랜치 버전 유지)
git checkout --theirs path/to/logo.png
git add path/to/logo.png
git commit
# 최신 Git이라면 checkout 대신 restore 도 동일하게 동작해요
# git restore --ours/--theirs path/to/logo.png
⑦ 자동 생성 파일 충돌 (package-lock.json, yarn.lock 등)
package-lock.json이나 yarn.lock처럼 사람이 직접 손대지 않는 자동 생성 파일도 의외로 충돌이 자주 나요. 두 브랜치가 각자 다른 패키지를 설치하면서 파일 내용이 동시에 바뀌기 때문이죠. 이럴 땐 마커를 하나하나 손으로 고치려 하지 말고, 파일을 지운 뒤 명령어로 새로 생성하는 게 정석입니다.
git status # package-lock.json 충돌 확인
# 🔄 전체 원복
git merge --abort
# ✍️ 권장 해결법: 마커를 직접 고치지 말고 새로 생성
git checkout --ours package-lock.json # 일단 아무 쪽이나 선택해 충돌만 먼저 해소
npm install # package.json 기준으로 lock 파일을 새로 생성
git add package-lock.json
git commit
yarn.lock이나 pnpm-lock.yaml도 yarn install, pnpm install로 똑같이 처리하면 돼요. 참고로 package.json 자체에 충돌이 났다면 그건 사람이 관리하는 일반 텍스트 파일이니, 시나리오 ①처럼 직접 열어 병합하면 됩니다.
참고로 개발자 커뮤니티에서는 충돌을 소재로 한 유머가 끊이지 않아요. AI 도구 두 개가 각자 다른 방식으로 짠 코드가 충돌해서 어느 쪽 로직을 골라야 할지 난감했다는 경험담이나, 마커를 하나씩 이해하기보다 차라리 저장소를 새로 파겠다는 농담이 꾸준히 회자되죠. 그만큼 다들 한 번쯤 겪는 '국룰 스트레스'라는 뜻일 거예요 😅. 요즘은 VS Code의 Merge Editor(Accept Current/Incoming/Both 버튼), GitKraken의 GitLens(충돌 부분에 누가 언제 왜 고쳤는지 인라인으로 보여주는 기능), 그리고 문법 트리 단위로 코드를 이해해 서로 다른 부분을 건드린 변경은 사람이 보기도 전에 자동으로 합쳐주는 구조적 병합 도구 Mergiraf 같은 것들이 충돌 스트레스를 크게 줄여주고 있습니다.
4. 상황별 명령어 치트시트 📋
급할 때 바로 찾아볼 수 있도록, 지금까지 나온 명령어를 상황별로 표 하나에 정리했어요. 북마크해두고 필요할 때 꺼내 보세요.
| 상황 | 명령어 | 설명 |
|---|---|---|
| 충돌 파일 목록 확인 | git status | 어떤 파일이 충돌 상태인지 확인 |
| 내 쪽 변경 내용만 보기 | git diff --ours <file> | 병합 전 내 브랜치 변경 사항 확인 |
| 상대 쪽 변경 내용만 보기 | git diff --theirs <file> | 병합 전 상대 브랜치 변경 사항 확인 |
| 내 변경사항으로 확정 | git checkout --ours <file> | 파일 전체를 내 브랜치 버전으로 유지 |
| 상대 변경사항으로 확정 | git checkout --theirs <file> | 파일 전체를 상대 브랜치 버전으로 유지 |
| 해결 완료 표시 | git add <file> | 충돌을 수동으로 풀었다고 Git에 알림 |
| 병합 마무리 | git commit | 병합 커밋을 생성하며 병합 완료 |
| 병합 전체 취소 | git merge --abort | 병합 시작 이전 상태로 되돌리기 |
| 리베이스 계속 진행 | git rebase --continue | 현재 커밋 충돌 해결 후 다음 커밋으로 |
| 리베이스 커밋 건너뛰기 | git rebase --skip | 현재 커밋 자체를 건너뛰고 진행 |
| 리베이스 전체 취소 | git rebase --abort | 리베이스 시작 이전 상태로 되돌리기 |
| 시각적 병합 도구 실행 | git mergetool | VS Code, KDiff3 등 설정된 도구로 열기 |
| 반복 충돌 자동 재사용 | git config --global rerere.enabled true | 이전에 푼 충돌 패턴을 기억해 자동 재해결 |
| 충돌 가독성 향상 | git config --global merge.conflictStyle zdiff3 | 공통 조상 내용까지 함께 표시 |
5. 마무리: 충돌을 줄이는 습관과 요즘 도구들
충돌 자체를 없앨 수는 없지만, 빈도와 크기는 확실히 줄일 수 있어요. git pull --rebase로 최신 변경사항을 자주 받아오고, 커밋과 PR을 작은 단위로 자주 나누고, 같은 파일을 여러 사람이 오래 붙잡고 있지 않도록 팀원과 미리 소통하는 것만으로도 충돌은 눈에 띄게 줄어듭니다.
그래도 충돌이 났다면 이제는 당황하지 마세요. 마커를 읽는 법을 알고, 상황별 명령어를 손에 익혀두면 충돌은 그저 "잠깐 멈춰서 확인해 달라"는 Git의 정중한 요청일 뿐이니까요. 오늘 정리한 치트시트를 즐겨찾기 해두고, 다음 충돌부터는 여유 있게 풀어보세요 🙌