Git 머지 충돌 해결 실전 사례 | 대규모 리팩토링 브랜치 병합기

이 글의 핵심

한 번에 main으로 병합하려던 첫 시도는 삭제와 수정이 겹친 파일, import 순서 충돌이 뒤섞여 실패했습니다. 이 글은 충돌이 커진 이유를 돌아보고, 단순 충돌은 일괄 처리하고 양쪽 변경을 모두 살려야 하는 로직 충돌만 수동으로 푼 전략, 단계별 테스트와 PR 작성까지의 과정을 정리합니다.

들어가며

3개월간 진행된 대규모 리팩토링 브랜치를 main에 병합하려는데, 수백 개 파일에서 충돌이 발생하는 상황을 따라갑니다. 브랜치 이름, 파일 수, 테스트 수는 설명을 위해 구성한 예시지만, 흐름 자체는 장기 브랜치를 병합할 때 거의 그대로 반복되는 패턴입니다.

일상에 빗대면, 오래된 도면과 최신 현장 사진을 한 번에 겹쳐 붙이려는 작업과 비슷합니다. 한 장에 합치려면 어느 층·어느 구역부터 맞출지 나누지 않으면 금방 엉킵니다.

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


상황: 3개월 리팩토링 브랜치

왜 이렇게까지 충돌이 커졌나

배경은 단순합니다. 장기 브랜치 동안 main에는 기능·버그픽스가 계속 들어왔으며, 리팩토링 쪽에서는 디렉터리·클래스명까지 크게 움직였습니다. 같은 파일을 양쪽에서 다르게 진화시킨 결과, 병합 시점에 마커가 폭발한 것입니다.

리팩토링 내용

  • 브랜치: refactor/new-architecture
  • 기간: 2025년 12월 ~ 2026년 3월 (3개월)
  • 변경 범위:
    • 디렉토리 구조 변경 (src/ → app/)
    • 클래스 이름 변경 (UserManager → UserService)
    • 의존성 주입 패턴 도입
    • 테스트 코드 전면 재작성

main 브랜치 진행 상황

리팩토링 중에도 main 브랜치는 계속 진행:

  • 새로운 기능 20개 추가
  • 버그 수정 50개
  • 의존성 업데이트 10개

첫 병합 시도와 실패

순진한 시도

$ git checkout refactor/new-architecture
$ git merge main
Auto-merging src/user_manager.cpp
CONFLICT (content): Merge conflict in src/user_manager.cpp
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
...
Automatic merge failed; fix conflicts and then commit the result.

결과

$ git diff --name-only --diff-filter=U | wc -l
247  # 충돌 난(unmerged) 파일 247개

문제: 한 번에 247개 충돌을 해결하는 것은 사실상 불가능합니다. 충돌 목록을 위에서부터 하나씩 열어 고치기 시작하면, 수십 개쯤 지나서는 각 파일에서 “어느 쪽이 맞는지” 판단하는 기준이 흐려지고, 같은 종류의 충돌을 파일마다 다르게 해결하는 일관성 문제가 생깁니다. 중간에 멈추고 싶다면 git merge --abort로 머지 시작 전 상태로 되돌릴 수 있으므로, 무리하게 끝까지 밀고 나가기보다 일단 되돌리고 전략을 세우는 편이 낫습니다.

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


전략 수립: 단계적 병합

전략

  1. main을 리팩토링 브랜치로 먼저 머지 (역방향)
  2. 충돌을 카테고리별로 분류
  3. 자동 해결 가능한 충돌 먼저 처리
  4. 수동 해결 필요한 충돌은 하나씩
  5. 테스트 통과 확인
  6. 최종적으로 리팩토링 브랜치를 main에 머지

왜 역방향 머지인가?

# 방법 A: refactor 브랜치에서 main 머지 (권장)
$ git checkout refactor/new-architecture
$ git merge main
# 충돌 해결 후 refactor 브랜치에서 테스트
# 문제 없으면 main에 머지
# 방법 B: main에서 refactor 머지 (위험)
$ git checkout main
$ git merge refactor/new-architecture
# 충돌 해결 중 실수하면 main이 망가짐!

이유: 리팩토링 브랜치에서 충돌을 해결하면, main은 안전하게 유지됩니다. 충돌 해결 결과를 CI에서 먼저 검증할 수 있고, 문제가 있으면 리팩토링 브랜치만 되돌리면 됩니다. 마지막에 main으로 병합할 때는 이미 main의 모든 변경이 리팩토링 브랜치에 들어와 있으므로 충돌 없이 fast-forward에 가까운 병합이 됩니다. 시작 전에 git branch backup/refactor-before-merge처럼 백업 브랜치를 하나 만들어 두면, 해결 도중 되돌리고 싶을 때 reflog를 뒤질 필요가 없습니다.


1단계: main을 리팩토링 브랜치로 머지

머지 시작

$ git checkout refactor/new-architecture
$ git merge main --no-commit --no-ff
# 충돌 상황 저장
$ git status > conflicts.txt

충돌 파일 분류

$ grep "both modified" conflicts.txt | wc -l
189  # 양쪽 모두 수정
$ grep "deleted by us" conflicts.txt | wc -l
34   # 리팩토링에서 삭제, main에서 수정
$ grep "added by them" conflicts.txt | wc -l
24   # main에서 새로 추가

git status의 “us”와 “them”은 현재 체크아웃한 브랜치 기준입니다. 지금은 refactor/new-architecture에서 main을 머지하므로 us = 리팩토링, them = main입니다. 반대로 rebase 중에는 us가 “위에 쌓아 올리는 기준 브랜치”가 되어 의미가 뒤집히므로, 스크립트에서 --ours/--theirs를 쓸 때 가장 많이 헷갈리는 부분입니다. 스크립트로 처리하려면 사람이 읽는 git status 문구보다 git status --porcelain의 두 글자 코드(UU 양쪽 수정, DU 우리 쪽 삭제, UD 상대 쪽 삭제, AU/UA 한쪽 추가)를 쓰는 편이 안정적입니다.


2단계: 충돌 분류 및 우선순위

충돌 분류

# classify_conflicts.py
import subprocess
conflicts = subprocess.check_output(['git', 'diff', '--name-only', '--diff-filter=U']).decode().splitlines()
categories = {
    'rename': [],      # 파일명만 변경
    'trivial': [],     # 자동 해결 가능 (import 순서 등)
    'logic': [],       # 로직 변경 (수동 해결 필요)
    'delete': [],      # 삭제 vs 수정
}
for file in conflicts:
    if 'test' in file:
        categories['trivial'].append(file)
    elif file.endswith('.h') or file.endswith('.hpp'):
        categories['rename'].append(file)
    else:
        categories['logic'].append(file)
for cat, files in categories.items():
    print(f"{cat}: {len(files)}개")

이 스크립트의 분류 기준(경로에 test가 들어가면 trivial, 헤더면 rename)은 이 프로젝트의 사정에 맞춘 휴리스틱일 뿐입니다. delete 카테고리도 파일 이름만으로는 알 수 없으므로 위 git status --porcelain 코드로 따로 채워야 합니다. 요점은 분류를 자동화하는 것 자체보다, 분류 결과를 보고 “trivial로 분류된 파일 중 실제로 로직 변경이 있는 것은 없는가”를 샘플로 확인하는 과정입니다. 분류가 틀리면 다음 단계의 일괄 처리가 main의 변경을 통째로 지워 버립니다.

결과

rename: 45개
trivial: 78개
logic: 66개
delete: 34개

3단계: 자동 해결 가능한 충돌

Import 순서 충돌

<<<<<<< HEAD (refactor/new-architecture)
#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 checkout --ours <파일>이 충돌 부분만이 아니라 파일 전체를 리팩토링 쪽 버전으로 바꾼다는 것입니다. 같은 파일에서 충돌 없이 자동 병합된 main의 변경까지 함께 버려집니다. 아래 “패턴 3”처럼 main이 include를 하나 추가한 경우, --ours로 처리하면 그 include가 사라지고 빌드가 깨집니다. 파일 단위로 한쪽을 택하는 방식은 “이 파일에서 main의 변경은 모두 버려도 된다”고 확신할 때만 써야 하고, 충돌 부분만 한쪽을 택하고 싶다면 git merge-file --ours나 머지 도구를 씁니다.

테스트 파일 충돌

테스트 파일은 리팩토링에서 전면 재작성했으므로:

$ git checkout --ours tests/*.cpp

일괄 처리

# trivial 카테고리 일괄 해결
$ for file in $(cat trivial_conflicts.txt); do
    git checkout --ours "$file"
    git add "$file"
done

일괄 처리 전후로 git diff main -- <파일>을 몇 개 뽑아 보고 main 쪽 변경이 의도대로 버려졌는지 확인하는 것이 좋습니다. 이 단계에서 가장 흔한 사고는 main에서 들어온 보안 패치나 버그 수정이 “테스트 파일이니까”, “import만 바뀐 파일이니까”라는 분류 속에 섞여 사라지는 것입니다. $(cat ...) 루프는 파일 이름에 공백이 있으면 깨지므로, 그런 가능성이 있다면 while IFS= read -r file; do ... done < trivial_conflicts.txt 형태를 씁니다.


4단계: 수동 해결이 필요한 충돌

로직 충돌 예시

<<<<<<< HEAD (refactor/new-architecture)
// 리팩토링: UserService로 변경
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
};

해결: 두 변경 모두 반영

// 병합 결과: 리팩토링 + 캐싱
class UserService {
    std::shared_ptr<Database> db_;
    std::unordered_map<int, User> cache_; // main의 캐싱 기능 추가
    
public:
    User getUser(int id) {
        // main의 캐싱 로직 유지
        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;
    }
};

로직 충돌은 두 쪽의 의도를 합치는 작업입니다. 리팩토링의 의도는 “UserManager를 UserService로 바꾸고 DB를 주입받는다”이고, main의 의도는 “조회 결과를 캐시한다”입니다. 어느 한쪽 코드를 고르는 게 아니라, 리팩토링된 구조 위에 main의 기능을 다시 구현해야 합니다. 이때 main에서 이 기능을 추가한 커밋을 찾아(git log main --oneline -- src/user_manager.cpp) 커밋 메시지와 관련 테스트를 함께 보면, 충돌 마커만 볼 때 놓치는 조건(예: 캐시 무효화가 다른 파일에 있었는지)을 알 수 있습니다.

합친 결과에도 따져 볼 점이 남습니다. 리팩토링 쪽에서 UserService를 여러 스레드가 공유하도록 바꿨다면, main의 cache_는 원래 단일 스레드 가정이라 락 없이 쓰면 데이터 레이스가 됩니다. 충돌 해결은 “컴파일되게 만들기”가 아니라 “두 변경이 함께 있을 때도 맞게 만들기”라는 점이 이런 데서 드러납니다.


5단계: 테스트 및 검증

단계별 테스트

# 1. 컴파일 확인
$ cmake --build build
# 성공
# 2. 단위 테스트
$ cd build && ctest
# 1234 tests passed, 5 tests failed
# 3. 실패한 테스트 수정
$ gdb --args ./test_user_service
# 캐싱 로직 테스트 추가 필요
# 4. 통합 테스트
$ ./integration_tests.sh
# 성공
# 5. 성능 회귀 테스트
$ ./benchmark.sh
# 성능 저하 없음 확인

충돌 해결 직후 실패하는 테스트는 대개 두 종류입니다. 하나는 해결 과정에서 한쪽 변경이 빠진 경우(위의 --ours 사고)이고, 다른 하나는 양쪽 변경이 각각은 맞지만 함께 있을 때 깨지는 경우입니다. 후자는 main에서 추가된 기능의 테스트가 리팩토링 브랜치에서 재작성되면서 사라졌을 때 발견되지 않고 지나가기 쉬우므로, main에서 추가된 테스트 파일 목록(git diff --name-only --diff-filter=A <merge-base> main -- tests/)을 확인해 리팩토링 쪽 테스트에 대응하는 것이 있는지 점검해야 합니다.

머지 커밋 생성

$ git add .
$ git commit -m "Merge branch 'main' into refactor/new-architecture
Resolved 247 conflicts:
- Renamed files: UserManager → UserService
- Preserved new features from main (caching, new APIs)
- Updated tests for new architecture
All tests passing."

6단계: main으로 최종 병합

PR 생성

$ git push origin refactor/new-architecture
$ gh pr create \
  --title "Refactor: New architecture with dependency injection" \
  --body "$(cat <<'EOF'
## Summary

3개월간 진행한 아키텍처 리팩토링을 main에 병합합니다.
### 주요 변경사항
- 디렉토리 구조 변경 (src/ → app/)
- 의존성 주입 패턴 도입
- UserManager → UserService 등 이름 변경
- 테스트 커버리지 85% → 92%
### 머지 충돌 해결
- 247개 충돌을 단계적으로 해결
- main의 새 기능 (캐싱, 새 API) 모두 반영
- 모든 테스트 통과 확인
### 테스트 결과
- Unit tests: 1234/1234 passed
- Integration tests: 45/45 passed
- Performance: 회귀 없음
EOF
)"

코드 리뷰

팀원들이 주요 충돌 해결 부분을 리뷰:

# 충돌 해결 부분만 보기
$ git diff main...refactor/new-architecture -- src/user_service.cpp

최종 병합

$ git checkout main
$ git merge refactor/new-architecture --no-ff
$ git push origin main

git diff main...refactor/new-architecture의 점 세 개는 “두 브랜치의 공통 조상부터 refactor 끝까지”의 변경을 보여 줍니다. main을 이미 역방향 머지했으므로, 이 diff에는 리팩토링의 변경과 충돌 해결 결과가 함께 보입니다. 리뷰어에게는 수천 줄 전체보다 머지 커밋이 한 일을 보여 주는 것이 효과적인데, git show <머지 커밋> --remerge-diff(Git 2.36+)를 쓰면 “자동 병합했을 때의 결과”와 “실제로 해결한 결과”의 차이만 볼 수 있어 충돌 해결 부분을 집중해서 리뷰할 수 있습니다.


교훈: 충돌 최소화 전략

핵심 교훈

  1. 자주 동기화: 장기 브랜치는 주기적으로 main을 머지
  2. 작은 단위로 분할: 가능하면 리팩토링을 여러 PR로 나누기
  3. 충돌 분류: 자동/수동 해결 구분하여 효율적으로 처리
  4. 테스트 필수: 충돌 해결 후 반드시 테스트

장기 브랜치 관리 전략

# 매주 main을 리팩토링 브랜치로 머지
$ git checkout refactor/new-architecture
$ git merge main
# 충돌 해결 (작은 단위로)
# 또는 rebase (히스토리를 깔끔하게)
$ git rebase main
# 단, 이미 공유된 브랜치면 rebase 주의

장기 브랜치에서 main을 자주 머지하면 같은 종류의 충돌을 반복해서 해결하게 되는데, git config --global rerere.enabled true로 rerere(reuse recorded resolution)를 켜 두면 한 번 해결한 충돌 패턴을 Git이 기록했다가 다음에 똑같은 충돌이 나오면 자동으로 적용해 줍니다. 특히 긴 브랜치를 rebase할 때 커밋마다 같은 충돌이 반복되는 상황에서 효과가 큽니다. 다만 자동 적용된 결과도 검토 없이 커밋하면 안 되므로, 적용 후 git diff로 확인하는 습관은 유지해야 합니다.

충돌 최소화 팁

  1. 파일 이동과 수정 분리:
   # Commit 1: 파일만 이동
   $ git mv src/user_manager.cpp app/user_service.cpp
   $ git commit -m "Rename: UserManager → UserService"
   
   # Commit 2: 내용 수정
   $ vim app/user_service.cpp
   $ git commit -m "Refactor: Apply DI pattern"

Git은 이동을 따로 기록하지 않고, 병합할 때 삭제된 파일과 추가된 파일의 내용이 얼마나 비슷한지(기본 50% 이상)를 보고 이름 변경을 추정합니다. 이동과 대규모 수정을 한 커밋에 섞으면 유사도가 낮아져 이름 변경으로 인식되지 않고, main의 수정이 옛 경로로 가서 modify/delete 충돌이 됩니다. 이동만 한 커밋을 따로 두면 Git이 이름 변경을 따라가 main의 수정을 새 경로의 파일에 자동으로 적용해 줄 가능성이 커집니다. 이미 섞여 버렸다면 git merge -X find-renames=30%처럼 임계값을 낮춰 볼 수 있습니다. 2. .gitattributes로 머지 전략 설정:

# .gitattributes
*.generated.* merge=ours  # 생성 파일은 충돌 시 현재 브랜치 것 유지 (병합 후 재생성)
# merge=ours 드라이버는 직접 정의해야 동작함
$ git config merge.ours.driver true

merge=ours는 Git에 내장된 드라이버가 아니라서 위처럼 merge.ours.driver를 설정해야 동작하고, merge=theirs에 해당하는 내장 드라이버는 없습니다. 이 설정은 각 개발자의 로컬 설정이라 저장소에 커밋되지 않는다는 점도 주의해야 합니다. package-lock.json 같은 락 파일은 한쪽을 택하기보다, 충돌이 나면 한쪽을 받아 들인 뒤 패키지 매니저로 다시 생성(npm install)하는 것이 안전합니다. 3. 충돌 마커 검색:

# 충돌이 남아있는지 확인
$ git diff --check
$ grep -r "<<<<<<< HEAD" .

실전 충돌 해결 패턴

패턴 1: 같은 함수 다른 수정

<<<<<<< HEAD
// 리팩토링: 함수명 변경
void UserService::createUser(const UserData& data) {
    db_->insert(data);
}
=======
// main: 유효성 검사 추가
void UserManager::createUser(const UserData& data) {
    if (!data.isValid()) {
        throw std::invalid_argument("Invalid user data");
    }
    db_->insert(data);
}
>>>>>>> main

해결: 두 변경 모두 반영

void UserService::createUser(const UserData& data) {
    if (!data.isValid()) {
        throw std::invalid_argument("Invalid user data");
    }
    db_->insert(data);
}

패턴 2: 파일 이동 + 수정

# 리팩토링: src/user_manager.cpp → app/user_service.cpp
# main: src/user_manager.cpp에 새 메서드 추가
# (이름 변경이 감지되지 않은 경우)
$ git status
Unmerged paths:
        deleted by us:   src/user_manager.cpp

해결:

# main의 변경사항 확인
$ git show main:src/user_manager.cpp > /tmp/main_version.cpp
$ diff app/user_service.cpp /tmp/main_version.cpp
# 새 메서드를 app/user_service.cpp에 수동 추가
$ vim app/user_service.cpp
# 충돌 해결
$ git rm src/user_manager.cpp
$ git add app/user_service.cpp

패턴 3: Import 경로 충돌

<<<<<<< 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

해결: 새 경로로 통일 + 새 기능 추가

#include "app/services/user_service.h"
#include "app/utils/logger.h"
#include "app/features/new_feature.h"  // 경로 변환

도구 활용

VS Code 머지 도구

// settings.json
{
  "merge-conflict.autoNavigateNextConflict.enabled": true,
  "git.mergeEditor": true
}

Git 설정

# 3-way diff 도구 설정
$ git config --global merge.tool vimdiff
$ git config --global merge.conflictstyle diff3   # Git 2.35+라면 zdiff3 권장
# 충돌 해결 도구 실행
$ git mergetool

diff3 스타일은 충돌 마커에 공통 조상의 원래 코드를 함께 보여 줍니다. 기본 스타일은 양쪽 결과만 보여 주므로 “둘 중 누가 무엇을 바꿨는지”를 추측해야 하지만, 원래 코드가 있으면 “리팩토링은 이름을 바꿨고, main은 조건문을 추가했다”가 한눈에 보여 두 변경을 합치기 쉬워집니다. 대규모 병합을 한 번이라도 해 보면 이 설정 하나가 체감 난이도를 가장 크게 바꾼다는 것을 알게 됩니다.

충돌 마커 이해

<<<<<<< HEAD (현재 브랜치)
// 리팩토링 브랜치의 코드
||||||| merged common ancestors (공통 조상)
// 원래 코드
=======
// main 브랜치의 코드
>>>>>>> main

마무리

대규모 리팩토링 브랜치 병합은 어렵지만, 체계적인 전략으로 해결할 수 있습니다:

  1. 역방향 머지로 main을 안전하게 보호
  2. 충돌 분류로 효율적 처리
  3. 단계적 해결로 실수 최소화
  4. 테스트 필수로 회귀 방지 핵심: 한 번에 모든 충돌을 해결하려 하지 말고, 카테고리별로 나누어 처리하세요.

FAQ

Q1. rebase vs merge 중 뭘 써야 하나요? 장기 브랜치는 merge를 권장합니다. rebase는 히스토리를 다시 쓰므로, 이미 공유된 브랜치에서는 위험합니다.

Q2. 충돌이 너무 많으면 어떻게 하나요? 브랜치를 더 작은 단위로 나누는 것을 고려하세요. 예: 디렉토리 구조 변경 → 이름 변경 → 로직 변경을 각각 별도 PR로.

Q3. main에서 새로 추가된 파일은 어떻게 하나요? 리팩토링 브랜치에서 새 구조에 맞게 수정하여 추가하세요.


같이 보면 좋은 글


대규모 병합·충돌 해결 체크리스트

대규모 병합 체크리스트

  • 백업 브랜치 생성
  • 충돌 개수 파악
  • 충돌 분류 (자동/수동)
  • 자동 해결 가능한 충돌 먼저 처리
  • 수동 충돌 하나씩 해결
  • 컴파일 확인
  • 테스트 실행
  • 코드 리뷰
  • 최종 병합
  • 모니터링

충돌 해결 체크리스트

  • 충돌 마커 완전히 제거
  • 양쪽 변경사항 모두 고려
  • 컴파일 확인
  • 관련 테스트 실행
  • git diff로 최종 확인