Git Branch Contamination & Recovery Case Study Report
Git 브랜치 오염 및 복구 사례 분석 보고서
주제:
잘못된 브랜치 머지 및 revert 이후, 오염된 히스토리를 제거하고 기능만 재적용하기까지의 전체 여정
1. 상황 (Situation)
PLACE-1058 기능을 개발하기 위해 진행한 feature/PLACE-1058 브랜치는 작업 과정에서 다음과 같은 복잡한 문제가 발생하였습니다.
기능 개발 중 실수로
dev브랜치를feat1(feature/PLACE-1058)브랜치에 잘못 가져오게 되었습니다.이후 별다른 인지 없이
feat1브랜치를master에 머지하였고, 이 과정에서 의도하지 않은 dev 커밋들까지 모두 master에 포함되었습니다.이를 발견한 후 master에서 revert를 수행해 dev 오염 커밋들을 되돌렸습니다.
하지만 revert 이후 다시
feat1브랜치로 PR을 생성하자, Git에서는 “변경된 내용이 없다”는 메시지가 출력되었고, 정상적인 PR 생성이 불가능한 상황이 되었습니다.당시 master에는 이미 다른 사람들의 새로운 커밋이 계속 쌓이고 있었고, 과거 시점으로 돌아가 브랜치를 다시 딸 수도 없는 구조였습니다.
기능 개발은 완료되어 있었지만, 히스토리가 오염된 상태에서 기능만 분리해 master에 반영하는 것이 매우 어려운 상황이었습니다.
즉, 기능 코드만 남기고 dev 오염 히스토리를 제거한 상태로 master에 적용하는 것이 핵심 목표였고, 이를 어떻게 해결할지 여러 시도와 실험을 거쳐야 했습니다.
2. 원인 분석 (Root Cause Analysis)
문제가 발생한 원인은 크게 두 가지 축으로 나뉩니다.
2.1 히스토리 오염의 직접 원인
feat1에dev를 잘못 merge/pull 하여 dev 커밋이 섞임원래 feat1은 master 기반으로만 만들어져야 했으나,
실수로 dev를 끌어오면서 완전히 다른 계열의 커밋들이 feat1 히스토리에 포함되었습니다.
오염된 feat1이 master로 merge됨
이 과정에서 master에도 dev 오염 커밋들이 모두 들어갔습니다.
master에서 revert 수행
revert는 "커밋을 없애는 것"이 아니라,
“기존 커밋을 반대 패치로 뒤집는 새로운 커밋”을 생성합니다.
따라서 master의 히스토리에는 여전히 'feat1 머지 커밋'이 남아 있게 됩니다.
2.2 이후 PR이 diff = 0이 된 구조적 원인
Git은 merge 기록을 기반으로 “이 브랜치의 커밋이 master에 포함된 적 있는가?”를 판단합니다.
feat1 → master merge (이력 남음)
master → revert (내용은 사라져도 “머지된 적 있다”는 기록은 남음)
이로 인해 Git의 판단은 다음과 같았습니다:
“feat1에서 들어온 커밋은 master에 이미 merge된 적 있으며, revert로 지워진 상태이므로 다시 merge할 diff는 없다.”
따라서 PR diff는 0이 되고, 정상적인 머지 PR을 만들 수 없게 되었습니다.
즉, 문제의 핵심은 **"내용은 revert로 사라졌지만, 이력은 남아 히스토리가 꼬였다는 점"**입니다.
3. 가설 (Hypothesis)
문제를 해결하기 위해 다음과 같은 가설을 세웠습니다.
가설 A — clean rebase로 해결 가능할 것이다
feat1을 master 기준으로 rebase -i 하면 dev 오염 커밋을 제거할 수 있을 것이라고 판단했습니다.
하지만 dev 오염 커밋이 많고, 커밋 간 의존 관계가 복잡하여 수동 정리가 사실상 불가능했습니다.
가설 B — squash merge로 단순히 파일 상태만 비교하면 해결될 것이다
merge --squash는 커밋 히스토리를 무시하고 “파일 최종 상태”만 비교하기 때문에 이게 가능해보였습니다.
그러나 master에 이미 feat1 merge → revert 이력이 남아 있기 때문에 squash merge는 “diff 없음”으로 판단해 적용되지 않았습니다.
가설 C — diff patch 방식이라면 히스토리를 무시하고 파일 상태만 강제 적용할 수 있다
master와 feat1-backup의 순수 파일 diff만 뽑아 master 기반 clean 브랜치에 적용한다면, 히스토리 꼬임 문제 없이 기능만 반영할 수 있을 것이라 예상했습니다.
dev 오염 코드도 patch 적용 중 충돌 해결을 통해 배제할 수 있습니다.
결론적으로 가장 현실적인 가설은 C는 반드시 작동한다였고, 실제로도 유효한 방법이었습니다.
4. 해결 방안들 분석 (Solution Options)
각 방식은 실제로 시도해보며 장단점을 세밀하게 비교하였습니다.
4.1 방법 1: Interactive Rebase로 오염 커밋 제거
✔ 장점
히스토리를 깔끔하게 유지할 수 있음
dev 오염 커밋만 골라서 drop 가능
❌ 단점
feat1 커밋이 50개 이상으로 너무 많았음
dev 오염 커밋을 정확히 판별할 수 없었음
개발 기간 및 위험도가 너무 높았음
→ 실무적으로 불가능에 가까움
4.2 방법 2: merge --squash 시도
✔ 기대했던 장점
커밋 히스토리를 무시하고 “변경된 파일 상태만” master에 반영 가능
실행에 매우 단순
❌ 실제 문제
master에 feat1 merge → revert 이력이 남아 있어서
Git이 “diff 없음”이라고 오판하여 squash 자체가 작동하지 않았음
→ revert가 존재하는 순간 squash merge는 신뢰할 수 없음
4.3 방법 3: Diff Patch 방식 (선택된 최종 방법)
✔ 방식
master 최신화
clean 브랜치 생성 (feature/PLACE-1058-clean)
backup 브랜치와 master의 diff를 patch로 추출
clean 브랜치에 patch를 적용
충돌에서 dev 오염 코드 제거
기능 코드만 남기는 방식
✔ 장점
커밋 히스토리를 완전히 무시
revert, dev 오염, merge 기록 등 Git 히스토리 꼬임을 모두 우회
실제로 존재하는 코드 변경만 정확하게 master에 재적용 가능
충돌도 한 번만 해결하면 끝
가장 안전하고 예측 가능
❌ 단점
patch 적용 중 충돌이 발생할 수 있으며, 직접 정리해야 함
커밋 히스토리는 feat1과 분리됨 (하지만 오히려 더 깔끔함)
→ 문제 구조상 사실상 유일한 현실적 해결책
4.4 방법 4: Cherry-pick 방식
✔ 방식
master에서 clean 브랜치 생성
feat1-backup 브랜치의 커밋 목록 확인
필요한 기능 관련 커밋만 하나씩 cherry-pick
충돌이 발생하면 개별 커밋 단위로 해결
모든 필요한 커밋을 적용한 뒤 clean 브랜치를 PR로 생성
✔ 장점
필요한 커밋만 선택적으로 가져올 수 있음
dev 오염 커밋을 배제하기 용이함
작은 규모의 기능 개발에는 매우 효과적임
❌ 단점
커밋이 많을 경우(수십 개 이상) 작업량이 폭발적으로 증가
각 커밋마다 충돌이 발생하면 충돌 해결을 반복해야 함
feat1 내에서 커밋 간 의존 관계가 복잡한 경우 cherry-pick 순서 관리가 어려움
5. 결론 (Conclusion)
위의 여러 방법 가운데, 현재 상황에서는 diff patch 방식이 절대적으로 최선의 선택이었습니다.
선정 이유는 다음과 같습니다.
revert 기록 때문에 squash/normal merge가 일절 불가능
feat1의 dev 오염 커밋이 너무 많아 rebase 전략이 비현실적
커밋 히스토리가 이미 회복 불가능하게 꼬인 상태
master 기준으로 완전히 새로 시작해야만 했음
diff patch 방식만이 “실제 코드 변경만”을 정확하게 재적용할 수 있었음
즉, 현실적·기술적·시간적 제약을 고려할 때, diff patch 전략이 거의 유일하게 성공 가능한 경로였습니다.
6. 결과 (Result)
diff patch 전략을 적용하여 다음과 같은 결과를 얻었습니다.
master에서 깨끗한 신규 브랜치(feature/PLACE-1058-clean)를 생성할 수 있었습니다.
오염된 커밋 히스토리는 완전히 제거하거나 독립시킬 수 있었습니다.
feat1-backup과 master의 실제 코드 차이만 clean 브랜치에 정확하게 반영되었습니다.
patch 적용 과정에서 dev에서 잘못 끌려온 코드도 자연스럽게 충돌 과정에서 제거되었습니다.
PR을 생성했을 때 diff가 매우 깔끔해졌으며, 기능 변경사항만 명확하게 표시되는 정상적인 PR이 완성되었습니다.
리뷰어 입장에서 훨씬 이해하기 쉬운 코드 구조가 되었고, master 기준으로도 안정적으로 merge 가능한 상태가 되었습니다.
결국, 오염된 브랜치를 더 이상 끌고 가지 않고, 기능 변경만 정확하게 선별하여 재적용하는 데 완전히 성공한 사례였습니다.
7. Diff Patch 방식의 상세 절차 (Step-by-Step Guide)
Diff Patch 방식은 오염된 브랜치의 히스토리를 완전히 배제하고, 실제 변경된 코드(diff)만을 추출하여 깨끗한(master 기반) 브랜치에 재적용하는 방법입니다. 아래 절차는 실제로 프로젝트에서 수행한 정확한 단계를 기반으로 작성하였습니다.
7.1 준비 단계 – 작업 공간 정리
로컬 저장소가 깨끗한지 확인합니다.
수정되었거나 stage되지 않은 파일들이 있다면, 임시 커밋 또는 stash를 수행합니다.
7.2 master 기반의 clean 브랜치 생성
master로 이동합니다.
최신 상태로 동기화합니다.
master를 기반으로 새로운 clean 브랜치를 생성합니다.
이 clean 브랜치는 오염이 없는 완전한 "새 작업 공간"입니다.
7.3 기존 기능 브랜치의 diff 추출
PLACE-1058 원본 작업 내용이 들어 있는 backup 브랜치로 이동합니다.
master와 feat1-backup 브랜치의 순수 파일 변화(diff) 를 하나의 patch 파일로 저장합니다.
이 patch 파일에는 커밋 히스토리가 아닌 순수 변경 내용만 담깁니다.
7.4 clean 브랜치에 diff patch 적용
clean 브랜치로 다시 이동합니다.
patch를 적용합니다.
충돌이 발생할 경우, 자동 적용 가능한 부분은 적용하고 나머지는
.rej파일로 떨어집니다.충돌이 발생한다면 다음 명령어를 사용합니다.
.rej파일이 생성된 경우, 해당 파일의 내용을 참조하여 dev에서 잘못 들어온 코드가 아닌 실제 기능 코드만 clean 브랜치에 수동 반영합니다.
7.5 변경 내용 검토 및 dev 오염 제거
master와의 최종 diff를 확인합니다.
변경 파일이 수십 개 존재하더라도, 이는 patch 적용의 정상적인 결과입니다.
clean 브랜치에서 직접 코드를 정리하고
git add합니다.
7.6 기능 변경 커밋 생성
정상적으로 diff 적용과 정리가 끝났다면 다음 명령으로 commit 합니다.
이 커밋 하나가 기존 feat1의 50개 이상의 커밋을 하나의 깔끔한 변경 집합으로 대체합니다.
7.7 원격 저장소에 푸시 후 PR 생성
clean 브랜치를 원격 저장소에 푸시합니다.
GitLab/GitHub에서 PR을 생성합니다.
Base: master
Compare: feature/PLACE-1058-clean
이 PR은 dev 오염 히스토리 없이 기능 코드만 명확하게 포함한 간결한 diff 로 표시됩니다.
Last updated