들어가며
Git으로 프로젝트를 관리하다 보면,
올리지 말았어야 할 파일이 이미 커밋에 포함되어 있는 상황 을 마주할 때가 있습니다.
저의 경우에는 Obsidian으로 관리하는 블로그 글 중블로그/게시완료 폴더가 Git 히스토리에 남아 있는 게 문제였습니다.
이미 여러 커밋에 걸쳐 추적되고 있었기 때문에,
단순히 삭제하는 것만으로는 히스토리에서 완전히 지워지지 않습니다.
이번 글에서는 git filter-branch를 사용해
커밋 히스토리에서 특정 폴더를 완전히 제거한 과정과,
그 과정에서 만난 에러들을 정리합니다.
git rm만으로는 부족한 이유
"그냥 git rm으로 지우면 되는 거 아닌가?" 라고 생각할 수 있습니다.
git rm -r --cached "블로그/게시완료"
git commit -m "블로그/게시완료 폴더 제거"
이렇게 하면 현재 커밋 이후로는 해당 폴더가 추적되지 않습니다.
하지만 과거 커밋에는 여전히 파일이 남아 있습니다.
누군가 이전 커밋을 체크아웃하거나, 히스토리를 탐색하면
해당 파일을 그대로 볼 수 있습니다.
| 방법 | 현재 이후 제거 | 과거 히스토리 제거 |
|---|---|---|
git rm |
O | X |
git filter-branch |
O | O |
민감한 데이터나 더 이상 추적하고 싶지 않은 파일이라면,
히스토리 자체를 재작성해야 합니다.
이것이 git filter-branch가 필요한 이유입니다.
git filter-branch란?
Git에는 커밋 히스토리를 재작성(rewrite)하는 기능이 내장되어 있습니다.
그 중 하나가 git filter-branch입니다.
git filter-branch는 저장소의
모든 커밋을 처음부터 끝까지 순회하면서, 각 커밋에 지정한 필터(명령어)를 적용하는 도구입니다.
쉽게 말해, 과거의 모든 커밋을 하나씩 열어서 특정 파일을 빼거나,
커밋 정보를 수정한 뒤, 새로운 히스토리로 다시 쌓는 작업입니다.
이 과정에서 각 커밋의 내용이 바뀌기 때문에,
커밋 해시도 전부 새로 생성됩니다.
결과적으로 "처음부터 그 파일이 없었던 것처럼" 히스토리가 깔끔하게 재작성됩니다.
주로 다음과 같은 상황에서 사용합니다:
- 민감한 데이터 제거: API 키, 비밀번호, 개인정보가 담긴 파일이 실수로 커밋된 경우
- 대용량 파일 제거: 바이너리 파일이나 빌드 산출물이 히스토리에 쌓여 저장소 크기가 비대해진 경우
- 특정 폴더/파일의 완전 삭제: 더 이상 추적하고 싶지 않은 경로를 히스토리에서 완전히 지우고 싶은 경우
저의 경우는 세 번째에 해당했습니다.
블로그/게시완료 폴더를 히스토리에서 완전히 지우기 위해 이 기능을 사용했습니다.
git filter-branch 명령어 분석
제가 사용한 명령어는 다음과 같습니다.
git filter-branch --force --index-filter \
'git rm -rf --cached --ignore-unmatch "블로그/게시완료"' \
--prune-empty --tag-name-filter cat -- --all
옵션 하나하나가 각각의 역할을 가지고 있으니,
표로 정리해 보겠습니다.
| 옵션 | 역할 |
|---|---|
--force |
이전에 filter-branch를 실행한 백업이 남아 있어도 강제로 다시 실행 |
--index-filter |
각 커밋의 인덱스(스테이징 영역) 에 대해 명령어를 실행. 워킹 디렉토리를 건드리지 않아 --tree-filter보다 빠름 |
git rm -rf --cached |
인덱스에서 해당 경로를 재귀적(-r)으로 강제(-f) 삭제 |
--ignore-unmatch |
해당 파일이 존재하지 않는 커밋에서도 에러 없이 넘어감 |
--prune-empty |
파일 제거 후 변경사항이 없어진 빈 커밋을 자동으로 제거 |
--tag-name-filter cat |
태그도 새로운 커밋 해시에 맞게 갱신 |
-- --all |
모든 브랜치와 참조에 대해 실행 |
--index-filter는 모든 커밋을 순회하면서
각 커밋의 인덱스에서 해당 파일을 제거하고,
새로운 커밋 해시로 히스토리를 재작성합니다.
커밋이 많을수록 시간이 걸리지만, --tree-filter보다는 훨씬 빠릅니다.
실행 과정에서 만난 에러와 해결
에러 1: Cannot rewrite branches — unstaged changes
명령어를 실행하자마자 다음 에러가 발생했습니다.
Cannot rewrite branches: You have unstaged changes.
filter-branch는 히스토리를 재작성하는 작업이기 때문에,
워킹 디렉토리가 깨끗한 상태여야 합니다.
수정 중이던 파일이 있으면 실행을 거부합니다.
해결 방법: git stash로 현재 변경사항을 임시 저장한 뒤 진행했습니다.
# 1. 현재 변경사항을 임시 저장
git stash
# 2. filter-branch 실행
git filter-branch --force --index-filter \
'git rm -rf --cached --ignore-unmatch "블로그/게시완료"' \
--prune-empty --tag-name-filter cat -- --all
# 3. 변경사항 복원 (이 단계에서 문제가 발생합니다)
git stash pop
git stash 후 filter-branch는 정상적으로 완료되었습니다.
여기까지는 순조로웠습니다.
에러 2: stash pop 실패 — 참조 깨짐
filter-branch가 성공적으로 끝난 후,git stash pop으로 임시 저장한 변경사항을 복원하려 했습니다.
fatal: 'refs/stash@{0}' is not a stash-like commit
이 에러는 filter-branch가 모든 커밋 해시를 재작성하면서 발생한 문제입니다.
git stash는 내부적으로 현재 커밋을 기반으로 임시 커밋을 만듭니다.
그런데 filter-branch가 히스토리를 전부 재작성하면서,
stash가 참조하던 원본 커밋이 더 이상 존재하지 않게 됩니다.
참조가 깨졌기 때문에 stash를 복원하는 것은 불가능합니다.
stash에 넣었던 변경사항은 복구할 수 없습니다.
filter-branch전에 stash 대신 별도의 브랜치에 커밋하거나,
변경사항을 다른 곳에 백업해 두는 편이 안전합니다.
다행히 제 경우에는 stash에 넣었던 파일이 크리티컬하지 않아서
수동으로 다시 수정하는 것으로 마무리할 수 있었습니다.
실행 후 정리 작업
filter-branch가 끝난 뒤에도,
Git 내부에는 원본 커밋의 참조가 reflog에 남아 있고,
실제 객체도 바로 삭제되지 않습니다.
디스크 공간을 확보하고 민감한 데이터를 완전히 제거하려면 정리 작업이 필요합니다.
# reflog 만료 — 원본 커밋에 대한 참조 제거
git reflog expire --expire=now --all
# 가비지 컬렉션 — 참조가 없는 객체를 실제로 삭제
git gc --prune=now --aggressive
이 작업까지 마쳐야 비로소
로컬 저장소에서 해당 파일이 완전히 제거됩니다.
그리고 원격 저장소에도 반영하려면 강제 푸시가 필요합니다.
git push --force --all
GitHub 공식 권장 도구 — git filter-repo
사실 GitHub 공식 문서에서는git filter-branch 대신 git filter-repo를 권장하고 있습니다.git filter-branch는 이미 deprecated(사용 중단 권고) 상태입니다.
# 설치
pip install git-filter-repo
# 실행
git filter-repo --invert-paths --path "블로그/게시완료"
두 도구의 차이를 정리하면 다음과 같습니다.
git filter-repo |
git filter-branch |
|
|---|---|---|
| 상태 | 현재 공식 권장 도구 | deprecated |
| 속도 | 빠름 | 느림 (커밋마다 쉘 명령 실행) |
| 안전성 | 백업/정리 자동 처리 | reflog/gc 수동 처리 필요 |
| 설치 | pip install git-filter-repo |
Git 기본 포함 |
| 민감 데이터 전용 옵션 | --sensitive-data-removal (v2.47+) |
해당 없음 |
가능하다면 git filter-repo를 사용하는 것을 추천합니다.
저는 환경 문제로 filter-branch를 사용했지만,filter-repo가 모든 면에서 더 나은 선택입니다.
주의사항 정리
filter-branch든 filter-repo든,
히스토리를 재작성하는 작업은 여러 가지 부작용이 따릅니다.
- 강제 푸시 필수:
히스토리가 바뀌었으므로git push --force가 필요합니다.
협업 중인 저장소라면 팀원에게 반드시 공유해야 합니다. - 모든 커밋 해시가 변경됨:
히스토리를 재작성하면 해당 커밋 이후의 모든 커밋 해시가 바뀝니다.
기존에 특정 커밋을 참조하고 있던 PR, 이슈 링크 등이 깨질 수 있습니다. - stash가 손실될 수 있음:
위에서 겪었듯이,filter-branch전에git stash를 사용하면 이후 복원이 불가능합니다.
별도 브랜치에 커밋하거나 파일을 복사해 두세요. - .gitignore 등록 필수:
히스토리에서 제거한 뒤에는.gitignore에 해당 경로를 추가해서,
이후 다시 커밋되는 것을 방지해야 합니다.
# .gitignore
블로그/게시완료/
돌아보며
단순히 파일을 삭제하는 것과,
히스토리에서 완전히 제거하는 것은 전혀 다른 작업이라는 걸 체감한 경험이었습니다.
특히 git stash로 임시 저장한 변경사항이filter-branch 이후에 복구가 안 되는 상황은,
직접 겪어보지 않으면 예상하기 어려운 부분이었습니다.
"히스토리 재작성은 모든 참조를 바꾼다"는 사실을 머리로는 알고 있었지만,
stash까지 영향을 받을 거라고는 생각하지 못했습니다.
앞으로 히스토리 재작성 작업을 할 때는,
작업 전에 변경사항을 별도 브랜치에 커밋해 두거나 파일을 외부에 백업해 두는 습관을 들여야겠다고 느꼈습니다.
그리고 가능하다면 git filter-repo를 사용하는 것이 훨씬 안전하고 편리합니다.
비슷한 상황을 겪고 계신 분들께 조금이나마 도움이 되었으면 합니다.
ⓒ 굿햄 2026. daryeou@gmail.com all rights reserved.
'개발환경' 카테고리의 다른 글
| [Docker-compose] Mac에서 command not found: docker-compose 가 발생할 때 (0) | 2024.10.22 |
|---|---|
| [Windows] 매번 ssh키 비밀번호를 묻는다면? SSH Agent 자동실행 방법 (0) | 2024.03.19 |
댓글