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의 버그 수정이 조용히 사라질 수 있습니다. 이런 충돌은 개수가 적어도 가장 위험한 부류입니다.
전략 수립: 단계적 병합
전략
- main을 리팩토링 브랜치로 먼저 머지 (역방향)
- 충돌을 카테고리별로 분류
- 자동 해결 가능한 충돌 먼저 처리
- 수동 해결 필요한 충돌은 하나씩
- 테스트 통과 확인
- 최종적으로 리팩토링 브랜치를 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+)를 쓰면 “자동 병합했을 때의 결과”와 “실제로 해결한 결과”의 차이만 볼 수 있어 충돌 해결 부분을 집중해서 리뷰할 수 있습니다.
교훈: 충돌 최소화 전략
핵심 교훈
- 자주 동기화: 장기 브랜치는 주기적으로 main을 머지
- 작은 단위로 분할: 가능하면 리팩토링을 여러 PR로 나누기
- 충돌 분류: 자동/수동 해결 구분하여 효율적으로 처리
- 테스트 필수: 충돌 해결 후 반드시 테스트
장기 브랜치 관리 전략
# 매주 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로 확인하는 습관은 유지해야 합니다.
충돌 최소화 팁
- 파일 이동과 수정 분리:
# 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
마무리
대규모 리팩토링 브랜치 병합은 어렵지만, 체계적인 전략으로 해결할 수 있습니다:
- 역방향 머지로 main을 안전하게 보호
- 충돌 분류로 효율적 처리
- 단계적 해결로 실수 최소화
- 테스트 필수로 회귀 방지 핵심: 한 번에 모든 충돌을 해결하려 하지 말고, 카테고리별로 나누어 처리하세요.
FAQ
Q1. rebase vs merge 중 뭘 써야 하나요? 장기 브랜치는 merge를 권장합니다. rebase는 히스토리를 다시 쓰므로, 이미 공유된 브랜치에서는 위험합니다.
Q2. 충돌이 너무 많으면 어떻게 하나요? 브랜치를 더 작은 단위로 나누는 것을 고려하세요. 예: 디렉토리 구조 변경 → 이름 변경 → 로직 변경을 각각 별도 PR로.
Q3. main에서 새로 추가된 파일은 어떻게 하나요? 리팩토링 브랜치에서 새 구조에 맞게 수정하여 추가하세요.
같이 보면 좋은 글
대규모 병합·충돌 해결 체크리스트
대규모 병합 체크리스트
- 백업 브랜치 생성
- 충돌 개수 파악
- 충돌 분류 (자동/수동)
- 자동 해결 가능한 충돌 먼저 처리
- 수동 충돌 하나씩 해결
- 컴파일 확인
- 테스트 실행
- 코드 리뷰
- 최종 병합
- 모니터링
충돌 해결 체크리스트
- 충돌 마커 완전히 제거
- 양쪽 변경사항 모두 고려
- 컴파일 확인
- 관련 테스트 실행
- git diff로 최종 확인