Git 머지 충돌 해결 예제 | 장기 리팩토링 브랜치를 main에 병합하는 순서

이 글의 핵심

장기 브랜치를 한 번에 main으로 병합하려 하면 삭제와 수정이 겹친 파일, 경로만 바뀐 충돌, 양쪽 로직을 합쳐야 하는 충돌이 한 목록에 뒤섞입니다. 이 글은 가상의 리팩토링 브랜치를 예로 충돌이 커지는 이유를 짚고, 충돌을 종류별로 나눠 기계적인 것은 일괄 처리하되 main의 변경이 사라지지 않게 확인하는 방법, 로직 충돌을 두 쪽의 의도로 합치는 방법, 병합 결과를 검증하고 리뷰하는 방법을 정리합니다.

들어가며

이 글은 실제 프로젝트의 병합 기록이 아니라, 장기 브랜치를 병합할 때 거의 그대로 반복되는 상황을 가상의 예제로 구성해 따라가는 연습입니다. 브랜치 이름과 파일 경로는 설명을 위한 것이고, 명령과 그 의미는 실제 Git 동작 그대로입니다.

장기 브랜치 병합이 어려운 이유는 충돌의 개수보다 충돌의 종류가 섞여 있다는 데 있습니다. 경로만 바뀐 충돌, 한쪽을 그대로 택하면 되는 충돌, 양쪽 로직을 합쳐야 하는 충돌이 한 목록에 뒤섞여 있으면, 쉬운 것 수백 개를 처리하느라 지쳐 정작 주의가 필요한 로직 충돌에서 실수하게 됩니다. 그래서 충돌을 먼저 분류하고, 기계적으로 풀 수 있는 것과 사람이 판단해야 하는 것을 나누는 것이 이 글의 중심입니다.


예제 상황

refactor/new-architecture 브랜치에서 몇 달 동안 다음과 같은 리팩토링을 진행했다고 가정해 봅시다.

  • 디렉터리 구조를 src/에서 app/으로 옮겼습니다.
  • UserManager 클래스를 UserService로 이름을 바꾸고 의존성 주입 방식으로 바꿨습니다.
  • 테스트 코드를 새 구조에 맞춰 다시 작성했습니다.

그동안 main에는 기능 추가, 버그 수정, 의존성 업데이트가 계속 들어왔습니다. 같은 파일을 양쪽에서 다른 방향으로 바꿨으니, 병합 시점에 충돌이 몰리는 것은 자연스러운 결과입니다.


첫 병합 시도

같은 줄을 수정한 두 브랜치를 병합했을 때의 CONFLICT 메시지와 충돌 표시

그림: 실제 실행 결과 — 두 브랜치가 같은 줄을 고치면 Git이 자동으로 고르지 못하고 충돌 표시를 남긴다. <<<<<<< 위가 현재 브랜치(main), >>>>>>> 아래가 합치려는 브랜치다. (환경: Ubuntu 24.04)

git switch refactor/new-architecture
git merge main

이런 브랜치라면 출력은 대략 다음과 같은 형태가 됩니다(파일 이름은 예시입니다).

Auto-merging src/auth/login.cpp
CONFLICT (content): Merge conflict in src/auth/login.cpp
CONFLICT (modify/delete): src/old_module.cpp deleted in HEAD and modified in main.  Version main of src/old_module.cpp left in tree.
...
Automatic merge failed; fix conflicts and then commit the result.

충돌 난 파일의 개수는 git diff --name-only --diff-filter=U | wc -l로 셀 수 있습니다. 이 수가 수십, 수백 개라면 목록을 위에서부터 하나씩 열어 고치는 방식은 금방 무너집니다. 수십 개쯤 지나면 각 파일에서 어느 쪽이 맞는지 판단하는 기준이 흐려지고, 같은 종류의 충돌을 파일마다 다르게 해결하게 됩니다. 이럴 때는 git merge --abort로 머지 시작 전 상태로 되돌리고 전략부터 세우는 편이 낫습니다.

modify/delete 충돌은 특히 눈여겨봐야 합니다. 리팩토링 쪽에서 파일을 옮기거나 지웠는데 main에서는 그 파일을 수정했다는 뜻이라, 어느 한쪽을 택하면 main의 버그 수정이 조용히 사라질 수 있습니다. 이런 충돌은 개수가 적어도 가장 위험한 부류입니다.


전략: main을 리팩토링 브랜치로 먼저 머지

순서는 다음과 같습니다. main을 리팩토링 브랜치로 먼저 머지(역방향 머지)하고, 충돌을 종류별로 분류해 기계적인 충돌부터 처리한 뒤, 로직 충돌을 하나씩 풀고, 테스트를 통과시킨 다음, 마지막에 리팩토링 브랜치를 main에 병합합니다.

# 권장: 리팩토링 브랜치 위에서 main을 머지
git switch refactor/new-architecture
git branch backup/refactor-before-merge   # 되돌릴 지점 확보
git merge main

# 비권장: main 위에서 바로 리팩토링 브랜치를 머지
git switch main
git merge refactor/new-architecture

리팩토링 브랜치에서 충돌을 해결하면 main은 해결이 끝날 때까지 손대지 않은 상태로 남습니다. 충돌 해결 결과를 CI에서 먼저 검증할 수 있고, 문제가 있으면 리팩토링 브랜치만 되돌리면 됩니다. 그 사이 main이 더 움직이지 않았다면, 마지막에 main으로 병합할 때는 main의 변경이 이미 모두 들어와 있으므로 새 충돌이 생기지 않습니다. 시작 전에 백업 브랜치를 만들어 두면 해결 도중 되돌리고 싶을 때 reflog를 뒤질 필요가 없습니다.


1단계: 충돌을 종류별로 나누기

git status의 사람용 문구 대신 --porcelain의 두 글자 코드를 쓰면 스크립트로 분류하기 쉽습니다.

코드의미 (지금은 us = 리팩토링, them = main)
UU양쪽 모두 수정
DU우리 쪽(리팩토링)에서 삭제, 상대 쪽(main)에서 수정
UD우리 쪽에서 수정, 상대 쪽에서 삭제
AA양쪽에서 같은 경로에 새 파일 추가
AU / UA한쪽에서만 추가 (주로 이름 변경 충돌에서 나타남)
DD양쪽 모두 삭제 (주로 이름 변경 충돌에서 나타남)

“us”와 “them”은 현재 체크아웃한 브랜치 기준입니다. 지금은 refactor/new-architecture에서 main을 머지하므로 us가 리팩토링, them이 main입니다. rebase 중에는 us가 “위에 쌓아 올리는 기준 브랜치”가 되어 의미가 뒤집히므로, --ours/--theirs를 쓸 때 가장 많이 헷갈리는 부분입니다.

# classify_conflicts.py
import subprocess
from collections import defaultdict

out = subprocess.run(
    ["git", "status", "--porcelain=v1", "-z"],
    check=True, capture_output=True,
).stdout.decode()

categories = defaultdict(list)
for entry in filter(None, out.split("\0")):
    code, path = entry[:2], entry[3:]
    if code == "UU":
        # 경로 규칙은 프로젝트마다 다른 휴리스틱입니다
        categories["test" if path.startswith("tests/") else "content"].append(path)
    elif code in ("DU", "UD", "DD", "AU", "UA", "AA"):
        categories[f"structure:{code}"].append(path)

for name, files in sorted(categories.items()):
    print(f"{name}: {len(files)}")
    with open(f"conflicts_{name.replace(':', '_')}.txt", "w") as f:
        f.write("\n".join(files) + "\n")

-z를 쓰면 경로에 공백이나 비 ASCII 문자가 있어도 따옴표 처리 없이 그대로 받을 수 있습니다. 경로에 tests/가 들어가면 테스트로 보는 식의 규칙은 이 예제 프로젝트에 맞춘 휴리스틱일 뿐입니다. 중요한 것은 분류 자체보다, 분류 결과를 보고 “기계적으로 처리할 묶음에 실제로 로직 변경이 섞여 있지 않은가”를 샘플로 확인하는 과정입니다. 분류가 틀리면 다음 단계의 일괄 처리가 main의 변경을 통째로 지워 버립니다.


2단계: 기계적인 충돌 처리

include 경로 충돌

<<<<<<< HEAD
#include "app/services/user_service.h"
#include "app/utils/logger.h"
=======
#include "src/user_manager.h"
#include "src/logger.h"
>>>>>>> main

경로만 바뀐 충돌이라면 리팩토링 쪽을 택하면 됩니다. 이때 두 가지 방법의 차이를 정확히 알아야 합니다.

# 파일 전체를 리팩토링 쪽 버전으로 교체
git checkout --ours -- src/some_file.cpp
git add src/some_file.cpp

git checkout --ours <파일>(또는 git restore --ours <파일>)은 충돌 부분만이 아니라 파일 전체를 리팩토링 쪽 버전으로 바꿉니다. 같은 파일에서 충돌 없이 자동 병합된 main의 변경까지 함께 버려집니다. 아래 “include가 추가된 충돌”처럼 main이 include를 하나 추가했거나 같은 파일의 다른 함수에서 버그를 고쳤다면, 그 변경이 사라지고 빌드가 깨지거나 버그가 되살아납니다. 파일 단위로 한쪽을 택하는 방식은 “이 파일에서 main의 변경은 모두 버려도 된다”고 확신할 때만 씁니다.

충돌한 부분만 한쪽을 택하고 자동 병합된 나머지는 살리고 싶다면, 머지 자체를 전략 옵션과 함께 다시 실행하는 방법이 있습니다.

git merge --abort          # 진행 중인 머지를 먼저 취소
git merge -X ours main     # 충돌한 덩어리만 우리 쪽을 택하고, 나머지 병합 결과는 유지

-X ours는 충돌이 난 덩어리(hunk)에 대해서만 우리 쪽을 택하고, 겹치지 않는 main의 변경은 그대로 병합합니다. 전략 -s ours(상대 브랜치의 변경을 통째로 무시)와는 완전히 다른 것이니 혼동하면 안 됩니다. 다만 이 옵션은 모든 파일에 일괄 적용되므로, 로직 충돌까지 조용히 한쪽으로 결정해 버린다는 점에서 저장소 전체에 쓰기는 위험합니다. 파일 하나만 이렇게 처리하고 싶다면 인덱스의 단계(stage)를 꺼내 git merge-file을 쓸 수 있습니다.

f=src/some_file.cpp
git show :1:"$f" > /tmp/base   # 공통 조상
git show :2:"$f" > /tmp/ours   # 우리 쪽 (리팩토링)
git show :3:"$f" > /tmp/theirs # 상대 쪽 (main)
git merge-file --ours /tmp/ours /tmp/base /tmp/theirs   # 결과가 /tmp/ours에 기록됨
cp /tmp/ours "$f" && git add "$f"

테스트 파일 충돌

테스트를 새 구조에 맞춰 전면 재작성했다면, 충돌 난 테스트 파일은 리팩토링 쪽을 택하는 것이 맞을 수 있습니다. 하지만 main에서 버그를 고치면서 추가한 회귀 테스트가 그 안에 있었다면 함께 사라지므로, 일괄 처리하기 전에 main 쪽에서 테스트에 무엇이 바뀌었는지부터 봅니다.

base=$(git merge-base HEAD main)
git diff --stat "$base" main -- tests/                     # main이 테스트에서 바꾼 것
git diff --name-only --diff-filter=A "$base" main -- tests/  # main이 새로 추가한 테스트 파일

확인이 끝난 묶음은 목록 파일로 일괄 처리합니다.

while IFS= read -r file; do
  git checkout --ours -- "$file" && git add -- "$file"
done < conflicts_test.txt

for file in $(cat ...) 형태는 파일 이름에 공백이 있으면 깨지므로 위처럼 while read를 씁니다. 일괄 처리 뒤에는 git diff main -- <파일>을 몇 개 뽑아 main 쪽 변경이 의도대로만 버려졌는지 확인합니다. 이 단계에서 가장 흔한 사고는 main에서 들어온 보안 패치나 버그 수정이 “테스트 파일이니까”, “include만 바뀐 파일이니까”라는 분류 속에 섞여 사라지는 것입니다.


3단계: 로직 충돌 해결

<<<<<<< HEAD
// 리팩토링: UserService로 변경, DB를 주입받음
class UserService {
    std::shared_ptr<Database> db_;

public:
    User getUser(int id) {
        return db_->query("SELECT * FROM users WHERE id = ?", id);
    }
=======
// main: 조회 결과 캐싱 추가
class UserManager {
    std::unordered_map<int, User> cache_;

public:
    User getUser(int id) {
        if (auto it = cache_.find(id); it != cache_.end()) {
            return it->second;
        }
        auto user = db_->query("SELECT * FROM users WHERE id = ?", id);
        cache_[id] = user;
        return user;
    }
>>>>>>> main
};

로직 충돌은 두 쪽의 의도를 합치는 작업입니다. 리팩토링의 의도는 “UserManager를 UserService로 바꾸고 DB를 주입받는다”이고, main의 의도는 “조회 결과를 캐시한다”입니다. 어느 한쪽 코드를 고르는 것이 아니라, 리팩토링된 구조 위에 main의 기능을 다시 구현해야 합니다.

class UserService {
    std::shared_ptr<Database> db_;
    std::unordered_map<int, User> cache_;  // main에서 추가된 캐시
    std::mutex cacheMutex_;                // 리팩토링 후 여러 스레드가 공유한다면 필요

public:
    User getUser(int id) {
        {
            std::lock_guard lock(cacheMutex_);
            if (auto it = cache_.find(id); it != cache_.end()) {
                return it->second;
            }
        }
        auto user = db_->query("SELECT * FROM users WHERE id = ?", id);
        std::lock_guard lock(cacheMutex_);
        cache_[id] = user;
        return user;
    }
};

main에서 이 기능을 추가한 커밋을 찾아(git log main --oneline -- src/user_manager.cpp) 커밋 메시지와 관련 테스트를 함께 보면, 충돌 마커만 볼 때 놓치는 조건을 알 수 있습니다. 예를 들어 캐시 무효화 코드가 사용자 수정 함수 쪽, 즉 충돌이 나지 않은 다른 파일에 들어 있었다면 그것도 새 구조로 옮겨야 합니다. 위 코드의 뮤텍스도 같은 맥락입니다. main의 캐시는 단일 스레드 가정으로 작성됐을 수 있는데, 리팩토링 후 UserService를 여러 스레드가 공유한다면 락 없이 쓰는 순간 데이터 레이스가 됩니다. 충돌 해결은 “컴파일되게 만들기”가 아니라 “두 변경이 함께 있을 때도 맞게 만들기”입니다.

파일 이동과 수정이 겹친 충돌

리팩토링에서 src/user_manager.cpp를 app/user_service.cpp로 옮기고 크게 고쳤는데 main은 옛 경로의 파일에 메서드를 추가했다면, Git이 이름 변경을 감지하지 못해 DU(deleted by us) 충돌이 납니다.

# main이 옛 파일에서 바꾼 내용만 확인
git diff "$(git merge-base HEAD main)" main -- src/user_manager.cpp

# 그 변경을 새 파일에 직접 옮긴 뒤
git rm src/user_manager.cpp
git add app/user_service.cpp

main 버전 전체를 새 파일과 비교하면 리팩토링으로 바뀐 부분까지 전부 차이로 나와 읽기 어렵습니다. 공통 조상과 main을 비교하면 main이 실제로 바꾼 부분만 보이므로, 그것만 새 구조에 옮기면 됩니다.

include가 추가된 충돌

<<<<<<< HEAD
#include "app/services/user_service.h"
#include "app/utils/logger.h"
=======
#include "src/user_manager.h"
#include "src/logger.h"
#include "src/new_feature.h"  // main에서 추가
>>>>>>> main

겉보기에는 경로만 바뀐 충돌과 같지만, main이 새 헤더를 추가했습니다. 해결은 새 경로로 통일하면서 추가된 include를 새 구조의 경로로 옮기는 것입니다.

#include "app/services/user_service.h"
#include "app/utils/logger.h"
#include "app/features/new_feature.h"

이 경우를 --ours로 처리하면 new_feature.h include가 사라지는데, 이것이 1단계에서 묶음마다 샘플을 확인해야 하는 이유입니다.


4단계: 테스트와 머지 커밋

git diff --check                 # 남은 충돌 마커와 공백 오류 검사
git grep -n -E '^(<<<<<<<|>>>>>>>) ' # 혹시 남은 마커 확인
cmake --build build
ctest --test-dir build --output-on-failure

충돌 해결 직후 실패하는 테스트는 대개 두 종류입니다. 하나는 해결 과정에서 한쪽 변경이 빠진 경우(--ours 사고)이고, 다른 하나는 양쪽 변경이 각각은 맞지만 함께 있을 때 깨지는 경우입니다. 후자는 main에서 추가된 기능의 테스트가 리팩토링 브랜치에서 재작성되면서 사라졌을 때 발견되지 않고 지나가기 쉬우므로, 2단계에서 뽑은 “main이 추가한 테스트 파일” 목록에 대응하는 테스트가 리팩토링 쪽에도 있는지 점검합니다.

모든 충돌 파일을 git add로 표시했다면 git commit으로 머지 커밋을 만듭니다. 메시지에는 충돌 개수보다 “어떤 종류의 충돌을 어떤 기준으로 풀었는지”를 남겨 두세요. 나중에 같은 파일에서 다시 충돌이 났을 때 그 기록이 근거가 됩니다.

Merge branch 'main' into refactor/new-architecture

- 경로만 바뀐 충돌: 리팩토링 쪽 경로로 통일
- 테스트 충돌: 새 테스트를 유지하고 main의 회귀 테스트를 새 구조로 이식
- UserManager 캐싱(main)을 UserService에 재구현하고 락 추가

5단계: main으로 병합과 리뷰

리팩토링 브랜치를 푸시하고 PR을 연 뒤, 리뷰어에게는 수천 줄짜리 전체 diff보다 머지 커밋이 한 일을 보여 주는 것이 효과적입니다.

git show --remerge-diff <머지 커밋>    # Git 2.36+

--remerge-diff는 “자동 병합을 다시 했을 때의 결과(충돌 마커 포함)“와 “실제로 커밋한 결과”의 차이만 보여 주므로, 사람이 충돌을 어떻게 풀었는지 집중해서 리뷰할 수 있습니다. 전체 변경은 git diff main...refactor/new-architecture로 보는데, 점 세 개는 두 브랜치의 공통 조상부터 refactor 끝까지의 변경이라는 뜻입니다.

git switch main
git merge --no-ff refactor/new-architecture

--no-ff를 주면 리팩토링 전체가 하나의 머지 커밋으로 묶여, 문제가 생겼을 때 git revert -m 1 <머지 커밋>으로 한 번에 되돌리기 쉬워집니다.


다음에 충돌을 줄이는 방법

장기 브랜치는 main을 자주 머지할수록 충돌이 한 번에 몰리지 않습니다. 반복해서 비슷한 충돌을 풀게 된다면 rerere(reuse recorded resolution)를 켜 둡니다.

git config --global rerere.enabled true

rerere는 한 번 해결한 충돌을 기록했다가 똑같은 충돌이 다시 나오면 같은 해결을 적용해 줍니다. 긴 브랜치를 rebase할 때 커밋마다 같은 충돌이 반복되는 상황에서 특히 효과가 큽니다. 다만 자동 적용된 결과도 검토 없이 커밋하면 안 되므로, 적용 후 git diff로 확인하는 습관은 유지해야 합니다.

파일 이동과 내용 수정은 커밋을 나누는 것이 좋습니다.

git mv src/user_manager.cpp app/user_service.cpp
git commit -m "Move user_manager.cpp to app/user_service.cpp"
# 그다음 커밋에서 내용 수정

Git은 이동을 따로 기록하지 않고, 병합할 때 삭제된 파일과 추가된 파일의 내용이 얼마나 비슷한지(기본 50% 이상)를 보고 이름 변경을 추정합니다. 다만 병합은 커밋 하나하나가 아니라 공통 조상과 브랜치 끝을 비교하므로, 이동 커밋을 따로 두는 것만으로 병합 시 감지가 보장되지는 않습니다. 이동 후 수정이 클수록 끝 상태의 유사도가 낮아지기 때문입니다. 이동을 먼저 main에 따로 병합해 두면 이후 병합의 기준점이 새 경로가 되어 가장 확실하고, 이미 섞여 버렸다면 git merge -X find-renames=30% main처럼 임계값을 낮춰 볼 수 있습니다.

생성 파일처럼 충돌 시 항상 현재 브랜치 것을 유지할 파일은 .gitattributes로 지정할 수 있습니다.

# .gitattributes
*.generated.cpp merge=ours
git config merge.ours.driver true

merge=ours는 Git에 내장된 드라이버가 아니라서 merge.ours.driver를 설정해야 동작하고, 이 설정은 각자의 로컬 설정이라 저장소에 커밋되지 않습니다. 또 이 드라이버는 양쪽이 모두 파일을 바꿨을 때만 호출됩니다. package-lock.json 같은 락 파일은 한쪽을 택하기보다, 충돌이 나면 한쪽을 받아들인 뒤 패키지 매니저로 다시 생성하는 것이 안전합니다.

마지막으로 충돌 마커에 공통 조상을 함께 표시하도록 설정해 둡니다.

git config --global merge.conflictStyle zdiff3   # Git 2.35+, 이전 버전은 diff3
<<<<<<< HEAD
// 리팩토링 브랜치의 코드
||||||| 공통 조상 커밋
// 원래 코드
=======
// main 브랜치의 코드
>>>>>>> main

기본 스타일은 양쪽 결과만 보여 주므로 누가 무엇을 바꿨는지 추측해야 하지만, 원래 코드가 함께 있으면 “리팩토링은 이름을 바꿨고, main은 조건문을 추가했다”가 한눈에 보여 두 변경을 합치기 쉬워집니다. VS Code를 쓴다면 "git.mergeEditor": true로 3-way 머지 에디터를 켜 같은 정보를 화면 나눔으로 볼 수 있습니다.