Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로
git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요.
브랜치 두 개를 합치는 일은 겉보기엔 단순해요. "내 쪽 변경"과 "상대 쪽 변경"을 모아서 하나로 만들면 되니까요. 그런데 막상 같은 파일의 같은 줄을 양쪽이 다르게 고쳤다면, Git은 누구 손을 들어줘야 할까요?
이 질문에 답하려면 Git은 한 가지 정보를 더 알아야 해요. 두 갈래가 갈라지기 직전, 공통의 출발점이 무엇이었는지요. 이 출발점을 찾아서 비교의 기준으로 삼는 것이 merge의 핵심이에요. 그 과정을 한 겹씩 벗겨볼게요.
merge가 실제로 하는 일
git merge는 상황에 따라 전혀 다른 두 가지 동작을 해요.
Fast-forward — 합칠 게 없는 경우
현재 브랜치가 가리키는 커밋이 합치려는 브랜치의 조상이라면, Git은 새 커밋을 만들지 않아요. 그냥 브랜치 포인터를 앞으로 옮기기만 해요. 이걸 fast-forward라고 해요.
before: A───B (main)
\
C───D (feature)
after: A───B───C───D (main, feature)
main이 B에 있고 feature가 D에 있는데, B는 D의 조상이에요. 그러니 main에서 새로 만든 변경이 없어요. Git은 합칠 것 자체가 없으니 main을 그냥 D로 옮겨요. 충돌도, merge 커밋도 없어요.
True merge — 양쪽 다 변경이 있는 경우
문제는 양쪽 다 새 커밋이 쌓였을 때예요. 이럴 땐 한쪽 포인터만 옮겨서는 다른 쪽 변경이 사라져요. 그래서 Git은 두 부모를 가진 merge 커밋을 새로 만들어요.
flowchart LR
A --> B --> C
B --> D --> E
C --> M
E --> M
A["A (base)"]
M["M (merge commit)"]
C와 E를 합쳐 M을 만들려면, Git은 C의 트리와 E의 트리를 비교해서 하나로 섞어야 해요. 그런데 두 트리만 놓고 비교하면 곤란한 상황이 생겨요. 다음 절에서 그 이유를 볼게요.
왜 2-way가 아니라 3-way인가
파일 하나를 예로 들어볼게요. 어떤 줄이 한쪽에는 foo, 다른 쪽에는 bar로 되어 있어요. 두 버전만 보면 Git은 이렇게밖에 추론할 수 없어요. "둘이 다르다. 누가 맞는지는 모르겠다."
여기에 공통 출발점(base) 을 한 줄 더 얹으면 그림이 완전히 달라져요.
| 경우 | base | ours | theirs | 결론 |
|---|---|---|---|---|
| 1 | foo |
foo |
bar |
theirs가 바꿈 → bar 채택 |
| 2 | foo |
bar |
foo |
ours가 바꿈 → bar 채택 |
| 3 | foo |
bar |
baz |
양쪽 다 다르게 바꿈 → 충돌 |
base와 비교하면 "누가 무엇을 바꿨는가"를 알 수 있어요. 한쪽만 바꿨으면 그 변경을 그대로 받아들이고, 양쪽 다 같은 위치를 다르게 바꿨을 때만 충돌로 남겨요. 이게 3-way merge예요. 비교 대상이 ours, theirs, 그리고 base까지 셋이라서 3-way라고 불러요.
그러니 merge의 진짜 출발점은 이 base를 찾는 일이에요. Git은 이 base를 merge base라고 불러요.
merge base — 최선의 공통 조상
merge base는 두 커밋의 공통 조상 중 가장 좋은 것이에요. Git 공식 문서는 "좋다"의 기준을 이렇게 정의해요.
One common ancestor is better than another common ancestor if the latter is an ancestor of the former. A common ancestor that does not have any better common ancestor is a best common ancestor, i.e. a merge base.
풀어 쓰면 이래요. 공통 조상이 여러 개 있을 때, 어떤 조상 X가 다른 조상 Y의 후손이라면 X가 Y보다 "더 좋은" 조상이에요. 더 가까우니까요. 그리고 자기보다 더 좋은 공통 조상이 없는 지점이 바로 merge base예요.
직접 확인할 수도 있어요.
# 두 브랜치의 merge base 커밋 해시를 출력
git merge-base feature main
# base와 양쪽 끝을 한눈에 비교
git diff $(git merge-base feature main) feature
git diff $(git merge-base feature main) main
merge가 안에서 하는 일을 거칠게 옮기면 이런 모양이에요.
BASE=$(git merge-base feature main)
git diff $BASE main # ours가 무엇을 바꿨나
git diff $BASE feature # theirs가 무엇을 바꿨나
# 두 diff를 합친다. 같은 위치를 양쪽이 다르게 건드렸으면 충돌.
index의 세 칸 — 충돌은 어떻게 표현되나
true merge 도중 충돌이 나면, Git은 충돌 파일을 index에 세 칸(stage) 으로 나눠 담아요. 공식 문서가 정의하는 stage 번호는 이래요.
- stage 1 — 공통 조상(base) 버전
- stage 2 — HEAD, 즉 현재 브랜치(ours) 버전
- stage 3 — 합쳐 들어오는 쪽(theirs) 버전
git ls-files -u 로 이 세 칸을 직접 볼 수 있어요.
git ls-files -u
# 100644 <base-blob> 1 app.js
# 100644 <ours-blob> 2 app.js
# 100644 <theirs-blob> 3 app.js
작업 트리의 파일에는 충돌 마커가 들어가요. 이 마커의 모양은 merge.conflictStyle 설정으로 바뀌어요.
기본값인 merge 스타일은 ours와 theirs만 보여줘요.
<<<<<<< HEAD
const port = 8080;
=======
const port = 3000;
>>>>>>> feature
diff3 스타일은 가운데에 base 버전까지 끼워 넣어요. "원래 뭐였는지"를 같이 보여주니 어느 쪽이 무엇을 바꿨는지 추적하기 쉬워요.
<<<<<<< HEAD
const port = 8080;
||||||| merged common ancestors
const port = 80;
=======
const port = 3000;
>>>>>>> feature
Git 2.35(2022년 1월)에 추가된 zdiff3 스타일은 diff3에서 한 발 더 나가요. 충돌 영역의 맨 앞과 맨 뒤에서 양쪽이 똑같이 가진 줄을 충돌 바깥으로 밀어내요. 그래서 실제로 손봐야 하는 충돌 덩어리가 더 작아져요.
git config --global merge.conflictStyle zdiff3
여기까지가 공통 조상이 하나일 때의 이야기예요. 그런데 공통 조상이 여러 개라면요?
criss-cross merge — merge base가 둘 이상일 때
브랜치를 서로 주거니 받거니 merge하다 보면, 두 커밋의 "최선의 공통 조상"이 하나로 정해지지 않는 일이 생겨요. 공식 문서는 이걸 criss-cross merge라고 불러요.
flowchart LR
O --> P1
O --> P2
P1 --> A
P2 --> A
P1 --> B
P2 --> B
O["O"]
P1["1"]
P2["2"]
A["A (main)"]
B["B (feature)"]
1과 2가 서로 한 번씩 섞이면서 A와 B가 만들어졌어요. 이때 1도 A와 B의 공통 조상이고, 2도 그래요. 둘 중 누구도 상대의 후손이 아니라서, 둘 다 똑같이 최선의 merge base예요. 공식 문서의 표현을 그대로 옮기면 이래요.
When the history involves criss-cross merges, there can be more than one best common ancestor for two commits ... both 1 and 2 are merge bases of A and B. Neither one is better than the other (both are best merge bases).
이런 경우를 눈으로 확인하려면 --all 옵션을 써요.
git merge-base --all feature main
# <commit-1>
# <commit-2>
base가 둘이면 3-way merge가 곤란해져요. 기준이 둘이니까요. 한쪽 base만 골라 쓰면, 그 base에는 안 들어 있지만 다른 base에는 들어 있던 변경이 "새 변경"으로 잘못 잡혀서 엉뚱한 충돌이 나거나, 반대로 충돌로 잡혔어야 할 변경이 조용히 묻혀버려요.
recursive — 가상 merge base를 만든다
이 문제를 푸는 발상이 recursive 전략이에요. base가 여러 개면, 그 base들끼리 먼저 merge해서 가상의 공통 조상 하나를 만들어요. 그리고 그 가상 트리를 기준 삼아 진짜 3-way merge를 해요. 공식 문서가 recursive와 ort를 똑같이 설명하는 문장이 이거예요.
When there is more than one common ancestor that can be used for 3-way merge, it creates a merged tree of the common ancestors and uses that as the reference tree for the 3-way merge.
base가 또 여러 개면? base들을 merge하는 과정에서 그 base들의 base를 다시 찾고, 그것도 여러 개면 또 재귀적으로 합쳐요. 이 "조상을 합치려고 다시 자기 자신을 부른다"는 점에서 이름이 recursive예요.
flowchart LR
M1["merge base 1"] --> V["virtual merge base"]
M2["merge base 2"] --> V
V --> R["3-way merge: V vs ours vs theirs"]
Git 문서는 이 방식이 실제로 충돌을 줄였다고 적어요. Linux 2.6 커널 개발 히스토리의 실제 merge 커밋들로 검증했더니, 잘못 합쳐지는 일 없이 충돌이 더 적었다고 해요.
recursive는 오랫동안 git merge와 git pull의 기본 전략이었어요. Git 2.33까지 그랬어요.
recursive에서 ort로 — 왜 다시 썼나
recursive는 잘 동작했지만 약점이 있었어요. rename 감지가 느리고, 동작 과정에서 작업 트리와 index를 거쳐 가야 해서 오버헤드가 컸어요. 미묘한 정확성 문제도 몇 가지 알려져 있었고요.
그래서 Git 메인테이너 Elijah Newren이 merge 엔진을 처음부터 다시 썼어요. 이름이 ort, Ostensibly Recursive's Twin(겉보기엔 recursive의 쌍둥이)의 약자예요. recursive와 똑같은 개념(가상 merge base, 3-way merge, rename 감지)을 그대로 흉내 내되, 성능과 정확성 문제를 걷어낸 거예요. Git 2.34(2021년 11월)부터 ort가 기본 전략이에요.
핵심 설계 변화는 in-memory 라는 점이에요. recursive가 중간 결과를 작업 트리와 index에 풀어 놓으며 진행했다면, ort는 결과 트리가 완성될 때까지 작업 트리와 index를 전혀 건드리지 않고 메모리 안에서만 계산해요. 이 한 가지 결정이 성능과 응용 범위를 동시에 바꿨어요.
GitHub 엔지니어링 블로그가 공개한 수치예요.
- rename이 많이 섞인 merge에서 ort는 recursive보다 약 500배 빨라요.
- rebase처럼 비슷한 merge가 연속으로 일어나는 경우엔 9000배 이상 빨라요. 앞선 merge의 계산 결과를 캐시해서 다시 쓸 수 있기 때문이에요.
연속 merge에서 캐시가 통하는 건 in-memory 설계 덕분이에요. 작업 트리에 결과를 쓰지 않으니, 한 번 계산한 rename 정보나 부분 결과를 다음 merge에서 그대로 재사용할 수 있어요.
| 항목 | recursive | ort |
|---|---|---|
| 기본 전략 시기 | ~ Git 2.33 | Git 2.34 ~ |
| 이름 의미 | 조상 병합 시 재귀 호출 | Ostensibly Recursive's Twin |
| 동작 위치 | 작업 트리/index 경유 | in-memory |
| rename 많은 merge | 기준 | 약 500배 빠름 |
| 연속 merge(rebase) | 매번 다시 계산 | 결과 재사용으로 9000배 이상 |
겉으로 보이는 동작은 같아요. 그래서 "쌍둥이"라는 이름이 붙었어요. 대부분의 사용자는 기본 전략이 바뀐 줄도 모르고 넘어갔어요.
rename을 따라가는 merge
ort가 recursive보다 압도적으로 빨라지는 지점이 바로 rename이에요. 왜 rename이 merge에서 까다로운지부터 짚어볼게요.
Git은 파일을 이름으로 추적하지 않아요. 내용으로 추적해요. 커밋에는 "이 파일은 저 파일을 rename한 것"이라는 기록이 따로 없어요. Git은 한쪽에서 사라진 파일과 새로 생긴 파일을 짝지어 보고, 내용이 충분히 비슷하면 rename으로 간주해요. 기본 유사도 임계값은 50%이고, 전략 옵션으로 조정할 수 있어요.
# 유사도 80% 이상일 때만 rename으로 인정
git merge -X find-renames=80% feature
rename이 merge를 어렵게 만드는 시나리오는 이래요. 한쪽 브랜치는 user.js를 account.js로 옮기기만 했고, 다른 쪽 브랜치는 그대로 user.js에 버그 수정을 했어요.
flowchart LR
Base["base: user.js"] --> Ours["ours: rename to account.js"]
Base --> Theirs["theirs: edit user.js"]
Ours --> Result["result: account.js with the edit applied"]
Theirs --> Result
순진하게 파일 이름만 보면, ours에는 user.js가 없으니 "theirs가 만든 user.js를 그대로 추가"하고 account.js도 따로 남겨버려요. 파일이 둘로 쪼개지는 거예요. 제대로 된 merge라면 theirs의 버그 수정을 rename된 account.js에 적용해야 해요. 이게 rename을 따라가는 merge예요.
문제는 rename 감지가 비싸다는 거예요. 사라진 파일과 생긴 파일을 모두 짝지어 내용 유사도를 재야 하니, 변경 파일이 많을수록 비교 횟수가 빠르게 늘어요. recursive는 이 작업을 매 merge마다 처음부터 다시 했고, 큰 저장소에서 눈에 띄게 느렸어요.
ort는 이 부분을 다시 설계했어요. rename 감지를 더 빠르게 만들고, in-memory라서 한 번 계산한 rename 결과를 같은 흐름의 다음 merge에서 재사용할 수 있어요. 그래서 공식 수치가 rename 많은 merge에서 약 500배, rebase처럼 비슷한 merge가 연속될 때 9000배 이상이라고 나오는 거예요. 두 숫자 모두 rename 처리 개선과 결과 재사용이 핵심이에요.
in-memory 설계가 연 문 — 서버 사이드 merge
ort가 작업 트리를 건드리지 않는다는 점은 단순한 성능 최적화에 그치지 않았어요. 작업 트리 없이도 merge를 할 수 있다는 뜻이거든요.
이걸 그대로 노출한 게 git merge-tree --write-tree예요. 이 모드는 ort 엔진을 써서 진짜 merge를 수행해요. 3-way 내용 병합, 여러 조상의 재귀적 통합, rename 감지, 디렉터리/파일 충돌 처리까지 다 하고, 그 결과를 최상위 트리 객체 하나로 기록해요. 작업 트리도 index도 건드리지 않아요. 이 modern 모드는 Git 2.38부터 기본이고 Git 2.38 이상이 필요해요.
# 작업 트리 체크아웃 없이 두 브랜치를 merge한 결과 트리를 계산
git merge-tree --write-tree feature main
# 충돌이 없으면 결과 트리의 해시를 출력하고 exit 0
# 충돌이 있으면 exit 1, 충돌 정보를 함께 출력
merge base 탐색도 자동으로 해주고, 충돌 여부를 종료 코드(성공 0, 충돌 1)로 알려줘요. 그래서 bare 저장소나 체크아웃하지 않은 브랜치도 merge할 수 있어요. Git 호스팅 서비스가 서버에서 merge를 미리 시도해 볼 수 있게 된 거예요. 실제로 GitHub은 2022년 9월부터 서버 사이드 merge 커밋 생성을 merge-ort 기반으로 전환했어요. 웹에서 "Merge pull request" 버튼을 눌렀을 때 도는 게 바로 이 엔진이에요.
전략을 손으로 고르기
기본은 ort지만, 상황에 따라 다른 전략이나 옵션을 줄 수 있어요.
# 전략 옵션: 충돌 시 한쪽을 자동 채택 (충돌 없는 변경은 양쪽 다 반영)
git merge -X ours feature
git merge -X theirs feature
# rename 감지 임계값 조정
git merge -X find-renames=80% feature
# 줄 끝(개행) 정규화 후 merge
git merge -X renormalize feature
# 서로 다른 octopus가 아닌, 명시적으로 ort 지정
git merge -s ort feature
-X ours와 흔히 헷갈리는 게 -s ours 전략이에요. -X ours는 충돌 난 부분만 ours로 푸는 거지만, -s ours는 상대 브랜치 내용을 통째로 버리고 merge 커밋만 만들어요. 히스토리 그래프상으로만 합친 척하는 거라, 의미가 완전히 다르니 주의해요.
octopus 전략은 셋 이상의 브랜치를 한 번에 merge할 때 쓰는데, 충돌이 나면 그냥 멈춰요. 복잡한 충돌 해소는 두 브랜치씩 차근차근 하라는 뜻이에요.
직접 따라 해보기
작은 저장소를 만들어서 criss-cross까지 눈으로 확인해볼 수 있어요.
mkdir merge-lab && cd merge-lab && git init
printf 'port=80\n' > config
git add config && git commit -m "base"
git switch -c feature
printf 'port=3000\n' > config
git commit -am "feature: port 3000"
git switch -
printf 'port=8080\n' > config
git commit -am "main: port 8080"
# 같은 줄을 양쪽이 다르게 바꿨으니 충돌
git merge feature
git ls-files -u # stage 1/2/3 확인
git merge-base --all main feature # 공통 조상 확인
git merge --abort # 실험 원상복구
merge.conflictStyle을 merge → diff3 → zdiff3로 바꿔가며 같은 충돌을 다시 내보면, 마커 안에 base가 어떻게 들어왔다 빠지는지 체감할 수 있어요.
마무리
git merge 한 줄을 풀어보면 이런 흐름이었어요.
- merge는 fast-forward(포인터만 이동)와 true merge(merge 커밋 생성)로 갈려요.
- true merge의 핵심은 merge base 예요. base와 비교해야 "누가 무엇을 바꿨는지" 알 수 있고, 그래서 2-way가 아니라 3-way merge예요.
- 충돌은 index의 stage 1/2/3(base/ours/theirs)으로 표현되고, 마커 모양은
merge.conflictStyle로 바뀌어요. - criss-cross로 merge base가 여러 개면, 그 base들을 먼저 합쳐 가상 merge base 를 만들어요. 이게 recursive라는 이름의 유래예요.
- Git 2.34부터 기본 전략은 ort예요. recursive를 처음부터 다시 쓴 in-memory 엔진으로, rename 많은 merge에서 약 500배, 연속 merge에서 9000배 이상 빨라요.
- in-memory 설계 덕에 작업 트리 없이 merge하는
git merge-tree --write-tree(Git 2.38 기본)가 가능해졌고, GitHub의 서버 사이드 merge가 여기서 돌아요.
merge가 "양쪽을 그냥 섞는다"가 아니라 "공통 출발점과 비교해서 누가 무엇을 바꿨는지 추론한다"는 걸 알고 나면, 충돌 마커를 마주했을 때 무엇을 보고 무엇을 결정해야 하는지가 한결 또렷해져요.
참고 자료
- git-merge-base Documentation — merge base / best common ancestor 정의와
--all, criss-cross 예시 (공식 문서) - merge-strategies Documentation — ort와 recursive 전략, 가상 merge base, 전략 옵션 (공식 문서)
- git-merge Documentation — true merge의 index stage와
merge.conflictStyle(공식 문서) - git-merge-tree Documentation —
--write-tree모드와 Git 2.38 기본화 (공식 문서) - Highlights from Git 2.34 — The GitHub Blog — ort 기본 전략 전환과 500x/9000x 성능 수치
- Highlights from Git 2.35 — The GitHub Blog — zdiff3 충돌 스타일 도입
- Merge commits now created using the merge-ort strategy — GitHub Changelog — GitHub 서버 사이드 merge의 ort 전환

