Git 브랜치·병합·충돌, 직접 일으켜서 해결해 보기
브랜치 만들기와 전환, fast-forward와 병합 커밋의 차이, 실제 CONFLICT를 한 줄씩 해결하는 순서까지. Git이 출력한 오류와 힌트는 모두 실제 출력이고, 해결 순서를 하나씩 확인합니다.
시리즈: Git 강좌 2/6 (초급). 커밋은 할 줄 알지만(1강) 브랜치는 무서운 분을 위한 글입니다. 브랜치를 만들고 옮겨 다니고, fast-forward와 병합 커밋의 차이를 이해하고, 실제 충돌을 침착하게 해결할 수 있게 됩니다.
"브랜치 따서 작업하고 PR 올려 주세요"라는 말을 듣고 일을 시작한 신입이, 처음으로 동료와 같은 줄을 고쳤을 때 Git이 CONFLICT를 띄우며 멈추는 순간. 이 글은 그 순간을 위한 것입니다. 충돌은 Git의 고장도, 내 실수도 아닙니다. Git이 혼자서는 답할 수 없는 질문을 던진 것뿐입니다. 여기서는 일부러 충돌을 만들고 그 질문에 답해 봅니다.
모든 명령은 Git 2.54.0(Apple Git-157), Apple Silicon Mac(M1 Pro)의 임시 저장소에서 데모 신원으로 실행했습니다. 예제 프로그램은 macOS 기본 Python 3.9.6으로 돌렸습니다.
브랜치의 정체
브랜치는 커밋 하나를 가리키는 "움직이는 이름표"입니다. 그게 전부이고, 파일 사본이 아닙니다. 직접 들여다보면 바로 보입니다.
git init -b main
# ... price.py 커밋(아래 코드) ...
git switch -c feature/tax
cat .git/HEAD
cat .git/refs/heads/main
Switched to a new branch 'feature/tax'
ref: refs/heads/feature/tax
53ed290a238d27dc408f15b64fd12bbf3bda8b04
HEAD는 "지금 내가 있는 곳"을 뜻하는 Git 용어입니다. 여기서는 feature/tax 브랜치를 가리킵니다. 그리고 브랜치의 실체는 커밋 ID(16진수 40자 + 줄바꿈)를 적은 41바이트짜리 파일입니다. 커밋하면 Git은 지금 있는 브랜치의 이름표를 새 커밋으로 옮깁니다.
예제는 주문 합계를 계산하는 짧은 프로그램입니다.
def total(items):
return sum(p * q for p, q in items)
print(total([(1200, 3)]))
switch·branch·checkout 대응표
| 하고 싶은 일 | 새 명령(Git 2.23 이상) | 예전 방식 |
|---|---|---|
| 브랜치 목록 | git branch |
같음 |
| 만들면서 이동 | git switch -c 이름 |
git checkout -b 이름 |
| 있는 브랜치로 이동 | git switch 이름 |
git checkout 이름 |
| 병합된 브랜치 삭제 | git branch -d 이름 |
같음 |
| 파일 수정 취소 | git restore 파일 |
git checkout -- 파일 |
git checkout 하나가 "브랜치 이동"과 "파일 덮어쓰기"를 모두 맡고 있어서 헷갈렸기 때문에 switch와 restore로 나뉘었습니다. 이 시리즈는 새 명령을 씁니다.
초보자가 자주 보는 오류 두 개를 출력 그대로 옮깁니다.
$ git switch feture/x
fatal: invalid reference: feture/x
$ git switch -c main
fatal: a branch named 'main' already exists
둘 다 종료 코드 128이고 아무것도 바뀌지 않습니다.
fast-forward: 커밋을 만들지 않는 병합
feature/tax에서 부가세 10%를 붙여 커밋하고, main으로 돌아와 병합합니다.
sed -i '' 's/ return sum(p \* q for p, q in items)/ return round(sum(p * q for p, q in items) * 1.10)/' price.py
git commit -qam "Add 10% tax"
git switch main
git merge feature/tax
git log --oneline --graph --all
Switched to branch 'main'
Updating 53ed290..bc19c90
Fast-forward
price.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
* bc19c90 Add 10% tax
* 53ed290 Add price.py
(sed -i ''는 macOS 문법입니다. GNU/Linux에서는 빈 문자열 없이 sed -i이고, 여기서는 실행하지 않았습니다. 편집기로 직접 고쳐도 됩니다.)
feature/tax가 갈라진 뒤 main에는 새 커밋이 없었습니다. 그래서 Git은 main 이름표를 bc19c90까지 옮기기만 했습니다. 병합 커밋은 생기지 않고 히스토리는 일직선 그대로입니다. 이어서 git branch -d feature/tax는 Deleted branch feature/tax (was bc19c90).을 출력했습니다.
병합 커밋: 양쪽이 모두 움직였을 때
이번에는 양쪽을 다 진행시킵니다. feature/readme에서 README를 추가하고, 그사이 main에서는 price.py에 두 번째 예시를 넣었습니다. 서로 다른 파일이라 충돌은 없습니다.
git merge feature/readme -m "Merge branch 'feature/readme'"
git log --oneline --graph --decorate --all
Merge made by the 'ort' strategy.
README.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 README.md
* 3caba54 (HEAD -> main) Merge branch 'feature/readme'
|\
| * 6c782a6 (feature/readme) Add README
* | 87b0099 Second example
|/
* bc19c90 Add 10% tax
* 53ed290 Add price.py
3caba54가 병합 커밋, 즉 부모가 둘인 커밋입니다. ort는 Git 2.34부터 기본이 된 병합 전략의 이름입니다.
팀 규칙이 "히스토리는 일직선만"이라면 git merge --ff-only가 fast-forward할 수 없을 때 거부해 줍니다.
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: git merge --no-ff
hint:
hint: or:
hint:
hint: git rebase
hint:
hint: Disable this message with "git config set advice.diverging false"
fatal: Not possible to fast-forward, aborting.
반대로 git merge --no-ff는 항상 병합 커밋을 남기므로, "이 기능 브랜치가 여기서 합쳐졌다"는 사실이 히스토리에 드러납니다.
| 상황 | 명령 | 결과 |
|---|---|---|
| 내 브랜치에 새 커밋이 없음 | git merge feature |
fast-forward, 커밋 없음 |
| "여기서 기능을 합쳤다" 표시를 남기고 싶음 | git merge --no-ff feature |
FF가 가능해도 병합 커밋 |
| 병합 커밋을 만들 바엔 실패하길 원함 | git merge --ff-only feature |
FF 또는 fatal:(종료 코드 128) |
| 양쪽 모두 새 커밋이 있음 | git merge feature |
병합 커밋, 경우에 따라 충돌 |
진짜 CONFLICT를 한 단계씩 해결하기
일부러 충돌을 만듭니다. feature/discount는 세금 줄에 10% 할인을 넣고, 그사이 main은 같은 줄을 8% 세율로 바꿨습니다.
git switch -c feature/discount
sed -i '' 's/\* 1.10)/* 1.10 * 0.9)/' price.py; git commit -qam "10% discount"
git switch main
sed -i '' 's/\* 1.10)/* 1.08)/' price.py; git commit -qam "Use 8% reduced tax"
git merge feature/discount; echo "exit=$?"
Auto-merging price.py
CONFLICT (content): Merge conflict in price.py
Automatic merge failed; fix conflicts and then commit the result.
exit=1
1단계: git status에게 묻는다.
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: price.py
2단계: 표시를 읽는다. Git은 두 버전을 모두 파일에 적어 놓았습니다.
def total(items):
<<<<<<< HEAD
return round(sum(p * q for p, q in items) * 1.08)
=======
return round(sum(p * q for p, q in items) * 1.10 * 0.9)
>>>>>>> feature/discount
<<<<<<< HEAD와 ======= 사이가 내 쪽(main), =======와 >>>>>>> feature/discount 사이가 들어오는 쪽입니다. 지금 파일은 일부러 깨진 상태입니다.
$ python3 price.py
File "~/demo/br/price.py", line 2
<<<<<<< HEAD
^
IndentationError: expected an indented block
설정해 두면 좋은 것이 merge.conflictStyle=zdiff3(Git 2.35 이상)입니다. 원래 줄까지 보여 주기 때문에 양쪽이 각각 무엇을 바꿨는지 바로 보입니다.
<<<<<<< HEAD
return round(sum(p * q for p, q in items) * 1.08)
||||||| 3caba54
return round(sum(p * q for p, q in items) * 1.10)
=======
return round(sum(p * q for p, q in items) * 1.10 * 0.9)
>>>>>>> feature/discount
이제 분명해졌습니다. 한쪽은 세율을 바꿨고, 다른 쪽은 할인을 붙였습니다. 정답은 둘 다 살리는 것입니다.
3단계: 파일을 최종 내용으로 고치고 표시 줄을 모두 지웁니다.
def total(items):
return round(sum(p * q for p, q in items) * 1.08 * 0.9)
4단계: 확인하고, 스테이징하고, 커밋한다.
git diff --check # 출력이 없으면 표시가 안 남은 것
python3 price.py
git add price.py
git commit --no-edit
3499
972
[main a231e1a] Merge branch 'feature/discount'
충돌 중 흔한 실수
표시를 남긴 채 커밋하기. 빠뜨린 표시는 git diff --check가 잡아 줍니다. 표시가 남은 파일에서는 다음과 같이 출력되고 종료 코드는 2였습니다.
price.py:2: leftover conflict marker
price.py:4: leftover conflict marker
price.py:6: leftover conflict marker
git add가 곧 "해결했다"고 Git에 알리는 동작이므로, 그 전에 실행하세요.
commit이나 switch로 도망치기. 충돌 중에는 이렇게 됩니다.
$ git commit -m x
error: Committing is not possible because you have unmerged files.
hint: Fix them up in the work tree, and then use 'git add/rm <file>'
hint: as appropriate to mark resolution and make a commit.
fatal: Exiting because of an unresolved conflict.
U price.py
$ git switch feature/discount
fatal: cannot switch branch while merging
Consider "git merge --quit" or "git worktree add".
진짜 비상구는 git merge --abort입니다. 병합 전 상태로 돌아갔고, git status --short는 아무것도 출력하지 않았습니다.
커밋 안 한 수정을 들고 switch하기. 충돌은 아니지만 똑같이 당황하는 장면입니다.
error: Your local changes to the following files would be overwritten by checkout:
price.py
Please commit your changes or stash them before you switch branches.
Aborting
커밋하거나 git stash(5강)로 잠시 치워 둔 뒤 옮기면 됩니다.
병합 안 된 브랜치 지우기. Git이 막아 줍니다.
error: the branch 'feature/coupon' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/coupon'
커밋 메시지에 남은 뜻밖의 내용. git commit --no-edit로 만든 병합 커밋을 git log -1 --format=%B로 보니 Merge branch 'feature/discount' 뒤에 # Conflicts:와 # price.py가 그대로 남아 있었습니다. 편집기를 열지 않으면 # 줄이 지워지지 않았습니다. 깔끔한 메시지를 원하면 git commit(편집기가 열림)이나 -m을 쓰세요.
실측: 브랜치는 얼마나 가벼울까
브랜치는 작은 파일 하나일 뿐이라 만드는 비용이 거의 없습니다. git branch b<n>을 1,000번 반복하는 것을 세 번(총 3,000개), Apple Silicon Mac(M1 Pro), Git 2.54.0, APFS에서 쟀습니다.
| 측정 | 결과 |
|---|---|
git branch × 1,000 (1회/2회/3회) |
15.33초 / 18.19초 / 15.90초, 개당 약 16ms |
| 브랜치 하나의 파일 크기 | 41바이트 |
3,000개 생성 전 → 후 .git 디스크 사용량 |
284 KB → 24,284 KB |
git pack-refs --all 후 |
12,448 KB (packed-refs 176,907바이트) |
git for-each-ref refs/heads(3,003개), 낱개 → 묶음 |
0.188초 → 0.135초 |
솔직한 설명 두 가지. 16ms의 대부분은 git 프로세스를 띄우는 시간이지 브랜치 자체의 비용이 아닙니다. 그리고 디스크 사용량이 커 보이는 건 41바이트 ref 파일과 그 reflog 파일이 각각 4KiB 블록을 차지하기 때문입니다. pack-refs는 ref를 파일 하나로 묶지만 reflog는 남습니다. 실무 결론은 브랜치는 공짜이니 작업마다 하나씩 만들고, 병합된 것은 지워서 git branch 목록을 깔끔하게 유지하라는 것입니다.
솔직한 한계와 버전 메모
- Git 2.54.0에서만 확인했습니다.
git switch와git restore는 Git 2.23,merge.conflictStyle=zdiff3는 Git 2.35에 추가됐고,ort는 Git 2.34부터 기본 병합 전략입니다.advice힌트 문구(git config set …)는 이 버전 기준입니다. --no-edit에서 주석 줄이 남는 동작은 편집기를 설정하지 않은 2.54.0에서 관찰한 것입니다.sed -i ''는 BSD/macOS 문법입니다. GNU 형식은 실행하지 않았습니다.- 브랜치 생성 시간은 프로세스 시작 비용이 대부분이라 기계와 파일 시스템에 따라 달라집니다.
연습 문제
main에 있고,feature/x가 갈라진 뒤main에는 새 커밋이 없습니다.git merge feature/x를 하면 새 커밋이 생기나요?- 충돌 중에 파일을 고쳤는데
git status에 아직both modified가 나옵니다. 무엇을 빠뜨렸을까요? - 파일에
<<<<<<<줄이 있는데 어느 쪽이 맞는지 판단이 안 됩니다. 병합 전으로 돌아가려면?
정답
- 생기지 않습니다. fast-forward라서
main이feature/x끝으로 옮겨질 뿐입니다. 그래도 병합 커밋을 원하면--no-ff를 붙입니다. git add <파일>. 파일을 고치기만 해서는 해결로 처리되지 않고, 스테이징해야 해결된 것으로 봅니다. 그다음git commit.git merge --abort.
이전 강좌: Git 강좌 1: init·add·commit · 다음 강좌: Git 강좌 3: 원격 저장소와 push·pull·fetch · 이후: rebase와 cherry-pick · 신입 개발자 학습 로드맵으로 돌아가기.
자주 묻는 질문
fast-forward와 병합 커밋은 무엇이 다른가요?
상대 브랜치가 갈라진 뒤 내 브랜치에 새 커밋이 없으면 Git은 브랜치 위치만 앞으로 옮기고(fast-forward) 새 커밋을 만들지 않습니다. 양쪽 모두 새 커밋이 있으면 부모가 둘인 병합 커밋을 만듭니다. git merge --no-ff는 fast-forward가 가능해도 병합 커밋을 만들고, git merge --ff-only는 그 밖의 병합을 거부합니다.
충돌 도중에 아무것도 잃지 않고 빠져나오려면?
git merge --abort를 실행하면 git merge 전 상태로 돌아갑니다. 실험에서 그 뒤 git status --short는 비어 있었습니다. 충돌 중에는 git commit(Committing is not possible because you have unmerged files)과 git switch(cannot switch branch while merging)가 모두 거부됩니다.
충돌 표시를 다 지웠는지 어떻게 확인하나요?
git add 전에 git diff --check를 실행하세요. 표시가 남은 파일에서는 줄마다 leftover conflict marker가 출력되고 종료 코드는 2였습니다. 프로그램이나 테스트도 돌려 보세요. 표시가 남은 파일은 파이썬이 실행을 거부했습니다.