메인 콘텐츠로 건너뛰기
main에서는 인사말을 hello git으로, 작업 브랜치에서는 hello world로 바꿨어요. 두 작업을 합치려는데 CONFLICT가 나와요. 파일을 열어보니 코드 사이에 <<<<<<< 같은 표시까지 생겼어요. 어느 내용을 남겨야 할까요? 앞편에서는 서로 다른 파일의 변경을 자동으로 합쳤어요. 이번에는 Git이 자동으로 합치지 못한 부분을 직접 결정하고 병합을 마무리해봐요. 충돌이 났다고 기존 커밋이 사라진 것은 아니에요.

같은 출발점에서 같은 줄을 다르게 고쳤어요

이번 실습은 공통 조상이 있는 두 로컬 브랜치의 텍스트 병합이에요. 세 시점의 index.js를 비교해봐요. 양쪽이 같은 줄에 서로 다른 결과를 기록했어요. Git은 어느 인사말이 요구사항에 맞는지 판단하지 못해요. 병합을 멈추고 사람이 결과를 정하도록 남겨둬요. 같은 파일을 수정했다고 항상 충돌하는 것은 아니에요. 겹치지 않는 부분은 자동으로 합칠 수 있어요. Pro Git의 병합 충돌 설명을 참고해요. 여기서는 인사말을 한 번 출력하되 두 단어를 모두 남겨 hello git world로 만든다고 정할게요. 충돌 해결은 한쪽을 통째로 고르는 일만 뜻하지 않아요. 양쪽 의도를 반영한 새로운 내용으로 편집할 수도 있어요.

별도 연습 저장소를 준비해요

명령은 Bash 기준이에요. 준비 명령의 git init -b main에는 Git 2.28 이상이 필요해요. 설치와 작성자 설정은 첫 커밋 실습을 참고해요. Node.js와 원격 저장소는 필요하지 않아요. 앞편의 저장소는 이미 병합을 마쳤으므로 그대로 두고 새 저장소를 만들어요. 기존 Git 저장소 밖에서 실행해요. 같은 이름의 폴더가 있어 mkdir가 실패하면 멈추고 새 이름을 골라요.
아래 >는 파일 전체를 덮어써요. 방금 만든 빈 연습 폴더에서만 실행해요. 작성자와 충돌 표시 설정은 이 저장소에만 적용해요. 별도 hooks, 서명, merge driver, 자동 충돌 해결 재사용 설정이 없는 환경을 기준으로 해요. merge.conflictStyle을 merge로 지정해 아래 표시와 맞춰요.
준비가 끝나면 아래 상태예요. practice/abort는 뒤에서 중단을 실습하려고 병합 전 main에 붙인 이름이에요. 지금 전환하지는 않아요. 커밋 ID는 실행마다 달라질 수 있어요. 로그가 별도 화면으로 열리면 q로 나와요.

충돌이 나면 무엇이 멈출까요?

현재 브랜치가 main이고 status가 clean인지 확인한 뒤 실행해요. --ff를 명시해 fast-forward만 허용하는 개인 설정과 구분해요.
이 명령은 의도적으로 실패해요. 검증 환경에서는 아래 출력이 나왔어요. 안내 문구와 번역은 환경에 따라 다를 수 있어요.
실패를 확인한 뒤 상태와 파일을 읽어요.
status에는 Unmerged paths와 both modified: index.js가 보여요. 짧은 상태 출력은 아래와 같아요. 충돌 중인 두 칸은 평소 스테이징 전후 표시와 구분해서 읽어야 해요. UU는 양쪽에서 수정한 파일이 아직 해결되지 않았다는 뜻이에요. git status의 충돌 상태 표를 기준으로 해요.
--diff-filter=U는 아직 병합되지 않은 파일을 골라요. 여기서는 index.js 하나예요. 마지막 커밋은 여전히 Change greeting to hello git이고 총 커밋 수도 3이에요. 파일은 병합 도중의 상태로 바뀌었지만, 병합 커밋은 아직 없어요.
index.js
<<<<<<< HEAD 아래부터 ======= 위까지는 현재 main 쪽 내용이에요. 그 아래부터 >>>>>>> feature/world 위까지는 합치려는 브랜치 내용이에요. 이 표시는 작업 파일에 들어 있어요. 구분선만 지우고 두 줄을 모두 남기면 인사말을 두 번 출력하는 코드가 돼요.
여기서 현재 쪽(ours)은 main, 상대 쪽(theirs)은 feature/world예요. 항상 나와 동료를 가리키는 이름은 아니에요. 특히 rebase에서는 해석이 달라질 수 있으므로 이 병합의 방향을 다른 작업에 그대로 적용하지 않아요.

파일 편집, add, 커밋은 서로 다른 단계예요

1

최종 파일 내용을 결정하기

편집기로 index.js를 아래 한 줄로 바꾸고 모든 충돌 표시를 지워요. 이 작은 연습 파일에서는 다음 명령으로 전체 내용을 교체해도 같아요. 실제 파일에는 충돌 밖의 코드도 있으므로 파일 전체를 이 예제로 덮어쓰지 않아요.
cat으로 인사말이 한 줄인지 확인해요. diff --check에 출력이 없어도 아직 상태는 UU index.js예요. 파일을 편집한 것만으로 Git의 미해결 상태가 해제되지는 않아요.diff --check는 변경에 들어간 충돌 표시나 공백 오류를 검사해요. 프로그램의 문법이나 요구사항까지 검사하지는 않아요. 오류가 나오면 고친 뒤 다시 확인해요. git diff의 검사 옵션을 참고해요.
2

해결한 내용을 스테이징하기

파일 내용이 맞으면 이 파일만 스테이징해요.
status에는 All conflicts fixed but you are still merging.이 보여요. 짧은 출력은 M index.js이고 미해결 파일 목록은 비어 있어요. diff --cached에는 hello git에서 hello git world로 바뀐 내용이 보여요. 마지막 diff는 비어 있어야 해요.add는 지금 파일 내용을 인덱스에 담고 이 경로를 해결한 것으로 표시해요. 내용이 올바른지는 사람이 확인해야 해요. 이후 다시 편집했다면 그 변경을 확인하고 다시 add해야 커밋에 들어가요. git add 공식 문서를 참고해요.
3

병합 커밋으로 마무리하기

미해결 파일이 없고 스테이징한 내용이 맞으면 병합을 완료해요. 이번에는 메시지 편집기를 열지 않도록 commit -m을 써요.
총 커밋은 4개이고 새 커밋에는 부모가 둘 있어요. rev-parse의 첫째·둘째 ID, 셋째·넷째 ID가 각각 같아요. 첫 부모는 병합 전 main, 둘째 부모는 feature/world예요. main만 병합 커밋으로 이동하고 두 연습 브랜치는 원래 커밋에 남아요.git merge --continue도 충돌 해결 후 병합을 완료하는 방법이에요. 이 실습에서는 이미 commit으로 끝냈으므로 이어 실행하지 않아요. 두 방식의 관계는 git merge의 충돌 해결 절차를 참고해요.
현재 브랜치는 main이고 상태는 clean이에요. 커밋에 담긴 인사말은 hello git world 한 줄이며 병합 커밋의 부모가 둘이에요. 실제 프로젝트에서는 파일과 diff 확인에 더해 해당 코드의 테스트도 실행해요.

해결을 그만두고 병합 전으로 돌아가려면

이번에는 준비 단계에서 남겨둔 practice/abort로 전환해 별도 병합을 시도해요. 방금 완성한 main의 커밋은 그대로 남아요.
전환 후 파일은 hello git이고 병합 전 상태는 clean이에요. 마지막 명령은 앞과 같은 충돌로 실패해요. 이번에는 해결 커밋을 만들지 않고 중단할 거예요.
git merge --abort는 진행 중인 병합을 중단해요. 병합 도중 편집한 해결 내용도 버리므로 필요한 내용은 먼저 별도로 복사해요. 병합 전에 미커밋 변경이 있었다면 항상 복원할 수 있다고 가정하지 않아요. 완료된 병합 커밋을 되돌리는 명령도 아니에요. git merge의 중단 조건을 참고해요.
중단이 편집도 되돌리는지 확인하려고 연습 파일을 한 번 고친 뒤 중단해요.
파일은 hello git으로 돌아오고 상태는 clean이에요. HEAD는 병합 시도 전에 출력한 ID와 같아요. 두 diff는 비어 있고 커밋 수는 4 그대로예요. temporary resolution은 커밋하지 않았으며 중단하면서 작업 파일에서도 없어졌어요. 마지막으로 완료한 병합이 있는 main으로 돌아가요.
최종 상태는 main, clean, 인사말 hello git world예요. README는 처음 내용 그대로이고 원격은 없어요. feature/world와 practice/abort는 남아 있어요.

상태를 보고 다음 행동을 골라요

충돌 해결의 기준은 구분선이 사라졌는지가 아니라 의도한 파일을 검토하고 그 결과로 병합을 완료했는지예요. 스테이징한 내용이 헷갈리면 세 상태 비교를, 부모 관계가 헷갈리면 병합과 커밋 그래프를 다시 읽어봐요.