3-way merge, 충돌 없어도 코드는 깨진다

3-way merge 는 병합할 두 브랜치와 그 공통 조상(merge base), 딱 세 스냅샷의 줄만 비교해 충돌 여부를 판정한다. 그 사이에 쌓인 중간 커밋들은 판정에 들어가지 않아, 충돌이 안 떴다고 병합이 옳다는 뜻은 아니다.

충돌 없음, 그런데 문서가 스스로 부정한다

충돌 없음 배너와 병합 버튼 위에 손 커서가 놓인 코드 리뷰 화면(왼쪽), 그리고 두 브랜치 곡선의 끝 노드만 밝게 빛나고 중간 커밋 점들은 흐린 회색으로 남은 채 아래에 공통 조상 기준선이 깔린 브랜치 그림(오른쪽)

PR(풀 리퀘스트) 화면에 "충돌 없음 — 자동으로 병합할 수 있습니다"라는 초록 문구가 뜨면 대개 그대로 버튼을 누른다. 도구가 문제없다고 했으니 문제가 없다고 읽는 것이다. 그런데 git 공식 문서에는 그 안심을 정면으로 뒤집는 사례가 적혀 있다. 두 브랜치가 같은 줄을 똑같이 고쳤고, 그중 한쪽이 나중에 그 변경을 되돌렸다고 하자. 병합하면 되돌림은 결과에 남지 않는다. 충돌도 뜨지 않는다.

일부러 꾸며낸 상황도 아니다. 급한 수정을 두 사람이 각자 넣었다가 한쪽이 부작용을 발견해 되돌리는 일은 어느 팀에서나 생긴다. 문서는 왜 이런 일이 벌어지는지도 같은 문단에서 밝혀 둔다.

It occurs because only the heads and the merge base are considered when performing a merge, not the individual commits.

병합을 수행할 때는 두 브랜치의 끝(heads)과 공통 조상(merge base)만 고려되고 개별 커밋들은 고려되지 않기 때문에, 이런 일이 생긴다.

git 공식 문서 `merge-strategies`

"충돌 없음"은 병합이 옳다는 판정이 아니다. 도구가 들여다본 세 장의 스냅샷에서 줄이 서로 부딪히지 않았다는, 그보다 훨씬 좁은 보고다. 이 글에서는 그 판정축이 무엇을 보고 무엇을 안 보는지, 그 밖에서 실제로 무엇이 깨지는지, 그리고 널리 인용되는 "33%"라는 숫자의 분모가 어디에 붙어 있는지를 차례로 확인한다.

3-way merge 가 실제로 보는 세 장

판정에 들어가는 스냅샷은 셋이다. 두 브랜치의 마지막 상태(ours·theirs), 그리고 둘이 갈라지기 직전의 공통 조상(merge base)이다. 이름의 "3-way"가 가리키는 것이 바로 이 셋이다. 조상은 기준선 노릇을 한다 — 어느 쪽이 무엇을 바꿨는지는 조상과 견줘 봐야 알 수 있기 때문이다.

규칙 자체는 줄 하나 단위로 아주 단순하다. 한쪽만 조상과 달라졌으면 달라진 쪽을 채택한다. 조상과 ours 가 같다면 그 줄을 고친 것은 theirs 뿐이니, theirs 의 값을 그대로 쓰면 된다는 판단이다. 양쪽이 똑같이 달라졌으면 같은 변경을 두 번 넣지 않고 한 번만 반영한다. 양쪽이 서로 다르게 달라졌을 때만 충돌 마커를 세우고 사람을 부른다. 그림 1에 조합별 결과를 한 판에 모아 두었다.

값을 넣어 보면 더 분명하다. 조상 파일의 어떤 줄이 timeout = 30 이라고 하자. ours 에서 그 줄이 timeout = 5 로 바뀌었고 theirs 는 그대로라면, 병합 결과는 timeout = 5 다. 반대로 theirs 만 timeout = 10 으로 바꿨다면 결과는 timeout = 10 이다.

둘 다 손댔는데 값이 5와 10으로 갈릴 때, 그때 비로소 충돌이 뜬다. 도구가 어느 쪽이 옳은지 고를 근거를 갖고 있지 않기 때문이다. 한쪽만 바뀌었을 때는 근거가 있다 — 나머지 한쪽이 조상과 같으니 그 줄을 건드리지 않았다고 읽으면 된다. 판정에 필요한 것은 이 세 값이 전부다.

조상·ours·theirs 세 값의 조합별 병합 판정 결과를 색으로 구분한 규칙 도표
그림 1. 3-way merge 의 줄 단위 판정 규칙 — 조합에 따라 채택·한 번만 반영·충돌로 갈린다.

여기서 빠진 것이 이 글의 주제다. 두 브랜치가 갈라진 뒤 각자 쌓은 중간 커밋들은 판정 입력이 아니다. 스무 개의 커밋을 거쳐 도착한 상태든 한 번에 도착한 상태든, 도구가 읽는 것은 도착점 하나뿐이다. 그 사이에 무엇을 넣었다 뺐는지, 무엇을 고쳤다 되돌렸는지는 판정에 흔적을 남기지 않는다. 커밋을 잘게 나눠 쌓든 한 덩어리로 쌓든 병합의 판정은 달라지지 않는다.

되돌림이 사라지는 규칙을 코드로 재현

문서의 한 문단을 그냥 믿고 넘어갈 수도 있다. 그런데 앞 절의 규칙이 워낙 단순해서 직접 돌려 보는 편이 빠르다. 세 값을 놓고 갈래를 넷으로 나누는 판정은 그대로 열 줄 남짓한 파이썬 함수가 된다. 아래 코드는 git 을 실행하지 않는다.

판정 규칙만 그대로 옮겨 놓고, 입력으로는 문서가 든 되돌림 사례를 넣었다. 두 브랜치가 접속 타임아웃을 30에서 5로 똑같이 줄였고, theirs 쪽만 나중에 30으로 되돌린 상황이다. 출력에서 확인할 것은 충돌 수와 병합 결과, 두 줄이다.

BASE   = ["def connect(host):", "    timeout = 30", "    return open(host, timeout)"]
OURS   = ["def connect(host):", "    timeout = 5",  "    return open(host, timeout)"]
THEIRS = ["def connect(host):", "    timeout = 30", "    return open(host, timeout)"]

def merge3(base, ours, theirs):
    merged, conflicts = [], 0
    for b, o, t in zip(base, ours, theirs):
        if o == t:            # 양쪽이 같다
            merged.append(o)
        elif b == o:          # theirs 만 조상과 다르다
            merged.append(t)
        elif b == t:          # ours 만 조상과 다르다
            merged.append(o)
        else:                 # 양쪽이 서로 다르게 바뀌었다
            merged.append("<<<<<<< 충돌 >>>>>>>")
            conflicts += 1
    return merged, conflicts

merged, conflicts = merge3(BASE, OURS, THEIRS)
print("theirs 커밋 이력 :", " -> ".join(["timeout = 30", "timeout = 5", "timeout = 30"]))
print("판정 입력 세 장  :", BASE[1].strip(), "/", OURS[1].strip(), "/", THEIRS[1].strip())
print("충돌 수          :", conflicts)
print("병합 결과        :", merged[1].strip())
print("theirs 최종 상태 :", THEIRS[1].strip())
theirs 커밋 이력 : timeout = 30 -> timeout = 5 -> timeout = 30
판정 입력 세 장  : timeout = 30 / timeout = 5 / timeout = 30
충돌 수          : 0
병합 결과        : timeout = 5
theirs 최종 상태 : timeout = 30

theirs 브랜치는 세 커밋을 거쳐 30으로 돌아왔지만, 판정에 들어간 것은 도착점 하나였다. 조상도 30이고 theirs 도 30이니 규칙은 "theirs 는 이 줄을 건드리지 않았다"고 읽는다. 그러면 남는 것은 ours 의 5뿐이고, 충돌은 0이다. 문서가 적어 둔 문장은 비유가 아니라 이 규칙에서 곧바로 나오는 결과다.

되돌린 사람 쪽에서 보면 사정이 나쁘다. 자기 브랜치에서 분명히 되돌렸고, 병합할 때 아무 경고도 받지 못했는데, 결과 파일에는 되돌리기 전 값이 앉아 있다. 도구는 거짓말을 하지 않았다. 다만 그 되돌림이라는 사건이 애초에 도구의 시야에 들어간 적이 없었을 뿐이다.

충돌 없이도 깨진다 — 구문 기준 실측

그러면 이런 일은 얼마나 잦을까. On the Correctness of Software Merge(2026)는 자바 오픈소스 76개 프로젝트의 병합 시나리오 43,774건을 도구별로 다시 돌렸다.

git 의 기본 병합은 그중 31,533건을 충돌 없이 통과시켰다. 그 무충돌 결과 가운데 40건, 곧 31,533건의 0.13%가 "양쪽 편집을 모두, 그리고 그것만 반영해야 한다"는 기준을 어겼다. 논문의 Fig. 12 가 그 사례다 — 한쪽이 끼운 코드와 다른 쪽이 고친 코드가 같아지자, 도구는 충돌 대신 같은 코드를 두 벌 복사해 넣었다.

0.13%는 구문 기준으로도 오답인 병합의 비율이다. 코드 구조만 보고 판정하니, 문법은 멀쩡한데 의미상 서로를 망가뜨리는 편집은 세지 않는다. 40건은 상한이 아니라 하한이다.

"33%가 깨져 있다"의 분모를 다시 센다

가장 자주 인용되는 숫자는 따로 있다. "깨끗하게 병합된 것의 33%가 실은 깨져 있다"는 문장이다. 출처는 Brun 외의 2011년 논문이고, 근거인 Figure 4 를 다시 세면 분모가 어긋난다.

Figure 4 는 9개 프로젝트 3,562건을 담는다. 그중 빌드·테스트까지 되돌려 본 것은 세 프로젝트 1,694건이다. 텍스트 충돌 266건, 빌드 실패 24건, 테스트 실패 109건이고, 나머지 1,295건은 문제없이 통과했다(그림 2). 논문이 깨끗한 병합 399건이라 부른 그 399는 266+24+109, 곧 전체 충돌의 수다.

1,694건의 병합을 텍스트 충돌·빌드 실패·테스트 실패·전부 통과로 분해한 폭포 그림
그림 2. Brun 외 Figure 4 재계산(Git·Perl5·Voldemort) — 1,694 = 266 + 24 + 109 + 1,295, 깨끗 판정 1,428건 중 깨진 것은 133건(9.3%).

그래서 33%(133/399)는 그 399 중 텍스트로 드러나지 않은 133건의 몫이다. 그 문장이 재려던 값은 다른 쪽이다 — 깨끗하다고 통과시킨 병합은 1,694에서 266을 뺀 1,428건, 그중 깨진 133건은 9.3%(133/1,428)다. 겁주는 숫자는 3분의 1 아래로 내려가도, 조용히 깨진 133건은 그대로다.

잘못 붙은 분모는 다음 논문으로 옮겨 갔다

분모를 잘못 붙인 문장은 그 논문에서 끝나지 않았다. Kasi & Sarma 의 2013년 논문이 관련 연구를 정리하며 그 문장을 분모째 다시 인용했다. 원표인 Figure 4 가 Brun 외의 논문에 실려 있는데도 그랬다.

같은 논문 Table I 은 프로젝트별 값도 싣는다. 텍스트 충돌 7.6~19.3%, 빌드 실패 2.1~14.7%, 테스트 실패 5.6~35%로 갈린다고 보고했다(그림 3). Brun 외의 9개 프로젝트에서도 텍스트 충돌은 7~42%였다.

프로젝트 네 곳의 텍스트 충돌 비율을 값 순으로 세운 막대 그림
그림 3. Kasi & Sarma Table I — 텍스트 충돌 비율은 프로젝트마다 7.6%에서 19.3%까지 갈린다.

이런 범위는 신뢰구간이 아니다. 프로젝트 넷의 값을 최솟값과 최댓값으로 묶은 스프레드일 뿐이다. 여기 없는 다섯 번째가 이 범위에 들어온다는 보장은 없다.

구조 병합이라는 대안, 공짜는 아니다

숫자 이야기는 여기까지다. 어긋남이 줄을 판정축으로 삼아 생긴다면 축을 바꾸면 된다 — 추상 구문 트리(AST)를 비교하는 구조 병합이 그 길이다. 다만 대가가 처리 시간이다 — 같은 43,774건 실험에서 spork 의 누적 시간은 git 의 약 2,118배였다.

정확도도 한 방향으로만 좋아지지 않는다. 덜 시끄러운 도구가 더 안전한 도구는 아니다.

기준선 자체가 깨져 있을 때 — 가상 조상과 rerere

공통 조상이 하나라는 보장도 없다. 두 브랜치가 서로를 몇 번씩 주고받으면 조상 후보가 둘 이상 남는다(criss-cross). 기본 전략 ort 는 그 조상들을 먼저 병합한 가상 조상을 기준선으로 쓴다. 소스 주석은 예비 병합이 실패할 때를 이렇게 적어 둔다 — "When the merge fails, the result contains files with conflict markers. The cleanness flag is ignored (unless indicating an error) …" 에러를 알릴 때가 아니면 깨끗함 여부는 무시한다는 뜻이다(그림 4).

criss-cross 병합 경로 공통 조상 후보 둘 criss-cross 이력 조상끼리 재귀 병합 충돌해도 멈추지 않음 가상 조상 마커가 남을 수 있다 본 병합 가상 조상을 기준선으로
그림 4. criss-cross 에서 ort 는 조상들을 먼저 병합해 가상 조상을 만들고, 그 결과가 깨끗하지 않아도 본 병합을 진행한다.

즉 기준선에 <<<<<<< 가 박힌 채 본 병합이 시작될 수 있다. 한 번 푼 충돌을 기억하는 rerere 도 여기서는 소용이 없다. 마커가 실제로 뜬 충돌만 기억하기 때문이다.

"충돌 없음"을 다시 읽는 법

이제 그 초록 문구를 번역할 수 있다. "이 병합은 옳다"가 아니라 "세 스냅샷의 줄이 서로 부딪히지 않았다"이다. 판정 입력은 양 끝과 공통 조상 셋이고, 판정 단위는 줄이며, 그 축 밖의 일은 보고 대상이 아니다. 되돌림이 사라지는 것도, 같은 코드가 두 벌 복사되는 것도, 기준선 안에 충돌 마커가 남는 것도 전부 그 축 밖에서 일어난다.

그렇다고 병합마다 겁을 먹으라는 말은 아니다. 무충돌 병합의 대부분은 실제로 멀쩡하고, 숫자도 그렇게 나왔다. 달라지는 것은 무엇을 어디에 기대느냐다. 초록 문구는 줄 단위 판정까지만 보증하니, 그 밖의 보증은 빌드와 테스트에서 따로 받아내야 한다. 되돌린 변경이 결과에 남았는지 같은 것은 도구가 아니라 사람이 확인할 항목으로 남는다.

숫자를 읽는 법도 같이 남는다. 33%와 9.3%는 분자가 133으로 같고 분모만 다른 두 문장이었다. 전자는 전체 충돌 399건 기준이고 후자는 깨끗 판정 1,428건 기준이다. 덧셈 세 번이면 되는 일이 논문 사이를 건너뛰며 몇 해를 살아남기도 한다.

남는 것은 판정축을 세어 보는 습관 하나다. git 의 판정축은 세 스냅샷과 그 안의 줄이고, 인용된 백분율의 판정축은 그 값이 매달린 분모다. 둘 다 눈으로 셀 수 있다. 세 스냅샷과 줄, 분모까지 세어 보면 초록 문구도 33%도 제 크기로 읽힌다.

더 읽기