백업 압축 파일이나 API 키가 든 설정 파일을 깃에 같이 올렸다는 걸 뒤늦게 알면, 보통 git rm으로 지우고 커밋합니다. 그런데 git 커밋 파일 삭제는 그렇게 끝나지 않습니다. 이 노트북(윈도우 11, git 2.55)에서 50MB 파일과 가짜 비밀키를 커밋했다가 git rm으로 지우는 상황을 그대로 재현해 봤더니, 파일은 목록에서 사라졌지만 .git 폴더는 여전히 50.0MB였고, 원격에서 새로 clone한 사람도 옛 커밋에서 키를 그대로 읽을 수 있었습니다. 이 글에서는 아직 push 전인 경우와 이미 올라간 경우를 나눠서, 실제로 돌려 본 명령과 출력, 그리고 그 과정에서 만난 오류 두 가지를 정리합니다.
먼저 확인: git rm으로는 히스토리에서 안 지워진다
실험용 저장소를 TEMP 폴더에 만들고 네 번 커밋했습니다. 두 번째 커밋에서 50MB짜리 backup.zip(압축이 안 되는 무작위 데이터)과 API_KEY = "sk-demo-..."가 든 config.py를 함께 올렸고, 마지막 커밋에서 git rm backup.zip을 하고 키를 환경변수에서 읽도록 바꿨습니다. 비밀키는 실제 키가 아닌 가짜 문자열입니다.
상태를 확인하려고 저장소 진단 스크립트를 따로 만들었습니다. .git 폴더 용량, 히스토리 전체에서 1MB가 넘는 파일, 특정 문자열이 등장하는 커밋을 찾아 줍니다. 표준 라이브러리와 git만 씁니다.
# 저장소 진단: .git 폴더 크기, 큰 파일이 남은 커밋, 비밀키가 남은 커밋을 출력
import os
import subprocess
import sys
def git(*args, cwd):
return subprocess.run(['git', *args], cwd=cwd, capture_output=True,
text=True, encoding='utf-8').stdout.strip()
def dir_size(path):
total = 0
for root, _, files in os.walk(path):
for name in files:
total += os.path.getsize(os.path.join(root, name))
return total
repo = sys.argv[1]
secret = sys.argv[2] if len(sys.argv) > 2 else 'sk-demo-1234567890abcdef'
size = dir_size(os.path.join(repo, '.git'))
print(f'.git 폴더: {size / 1024 / 1024:.1f}MB ({size:,}바이트)')
print('현재 파일:', ' '.join(git('ls-files', cwd=repo).split()))
# 히스토리 전체에서 1MB 넘는 파일 찾기
big = []
for line in git('rev-list', '--objects', '--all', cwd=repo).splitlines():
parts = line.split(' ', 1)
if len(parts) == 2:
size = int(git('cat-file', '-s', parts[0], cwd=repo))
if size > 1024 * 1024:
big.append((size, parts[1]))
for size, name in big:
print(f'히스토리 속 큰 파일: {name} {size / 1024 / 1024:.1f}MB')
if not big:
print('히스토리 속 큰 파일: 없음')
# 비밀키 문자열이 들어간 커밋 찾기 (git log -S)
hits = git('log', '--all', '--oneline', '-S', secret, cwd=repo)
print('비밀키가 등장하는 커밋:', hits.replace('\n', ' | ') if hits else '없음')
$ git log --oneline
7cdc506 remove backup, read key from env
04eb7ba app v2
abd0104 add backup and config
152d444 first commit
$ python check_repo.py work
.git 폴더: 50.0MB
현재 파일: app.py config.py
히스토리 속 큰 파일: backup.zip 50.0MB
비밀키가 등장하는 커밋: 7cdc506 remove backup, read key from env | abd0104 add backup and config
현재 파일 목록에는 app.py와 config.py만 있지만, 히스토리에는 50MB 파일이 그대로 있습니다. 깃은 커밋마다 그 시점의 파일 전체를 보관하기 때문에, 나중 커밋에서 지워도 이전 커밋 abd0104 안의 파일은 남습니다. 원격을 만들고 다른 사람이 새로 clone한 상황도 확인했습니다.
$ git clone remote.git teammate (원격에서 새로 받은 동료)
$ python check_repo.py teammate
.git 폴더: 50.0MB
히스토리 속 큰 파일: backup.zip 50.0MB
$ git show abd0104:config.py
API_URL = "https://api.example.com"
API_KEY = "sk-demo-1234567890abcdef"
새로 받은 사람의 저장소도 50.0MB였고, 커밋 해시 하나로 키가 그대로 출력됐습니다. 공개 저장소라면 누구나 이렇게 볼 수 있다는 뜻입니다.
아직 push 전, 방금 한 커밋이라면: –cached + –amend
가장 흔하고 쉬운 경우입니다. git add .로 .env까지 같이 커밋했고 아직 push하지 않았다면, 그 커밋만 고쳐 쓰면 됩니다.
$ git show --stat --format="%h %s" HEAD
b8962b6 read key from env
.env | 1 +
app.py | 3 ++-
$ git rm --cached .env
rm '.env'
(.gitignore 파일에 .env 한 줄 추가)
$ git add .gitignore
$ git commit --amend --no-edit
$ git show --stat --format="%h %s" HEAD
6e002c0 read key from env
.gitignore | 1 +
app.py | 3 ++-
$ git status --short --ignored
!! .env
git rm --cached는 저장소에서만 빼고 내 PC의 파일은 남깁니다(.env는 그대로 있고 !!, 즉 무시된 파일로 표시됨). --cached 없이 git rm을 하면 실제 파일까지 지워지니 주의하세요. --amend 후 커밋 해시가 b8962b6에서 6e002c0으로 바뀌었고, 커밋 내용에서 .env가 빠졌습니다.
다만 한 가지가 마음에 걸려 더 확인해 봤습니다.
$ git log --all --oneline -S sk-demo-1234567890abcdef
(출력 없음)
$ git reflog --oneline
6e002c0 HEAD@{0}: commit (amend): read key from env
b8962b6 HEAD@{1}: commit: read key from env
1cf9ade HEAD@{2}: commit (initial): first commit
$ git show b8962b6:.env
API_KEY=sk-demo-1234567890abcdef
git log --all -S로 찾으면 아무것도 안 나오지만, 고치기 전 커밋 b8962b6은 reflog(HEAD 이동 기록)에 남아 있어서 해시를 알면 여전히 내용을 볼 수 있었습니다. reflog는 내 PC에만 있고 push되지 않으니 push 전이라면 큰 문제는 아니지만, PC 자체를 공유하거나 폴더를 통째로 압축해 넘길 계획이라면 아래에 나오는 reflog expire와 gc까지 해 두는 게 안전합니다.
이미 지난 커밋에 들어갔다면: git-filter-repo
여러 커밋 전에 들어간 파일은 –amend로 못 지웁니다. 히스토리 전체를 다시 써야 하는데, 깃 공식 문서도 오래된 git filter-branch 대신 권하는 도구가 git-filter-repo입니다. 파이썬 스크립트 하나라 pip로 설치됩니다. 설치 후 바로 원래 작업하던 폴더에서 실행했더니 첫 오류가 났습니다.
$ python -m pip install git-filter-repo
$ git filter-repo --version
a40bce548d2c
$ git filter-repo --invert-paths --path backup.zip (원래 작업하던 폴더에서)
Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.
(expected at most one entry in the reflog for HEAD)
Note: when cloning local repositories, you need to pass
--no-local to git clone to avoid this issue.
Please operate on a fresh clone instead. If you want to proceed
anyway, use --force.
“새로 clone한 저장소가 아니라서 히스토리를 덮어쓰지 않겠다”는 안전장치입니다. 되돌릴 수 없는 작업이라, 작업 중이던 저장소 대신 새 복제본에서 하라는 뜻입니다. --force로 밀어붙이는 대신 안내대로 원격에서 새로 clone했습니다. 원격이 로컬 폴더 경로일 때는 --no-local을 붙여야 한다는 안내도 그대로 따랐습니다. 참고로 git filter-repo --version을 처음 실행할 때만 16.04초가 걸렸고, 다시 실행하니 0.22초였습니다.
비밀키는 파일째 지우는 대신 값만 바꾸기로 했습니다. replacements.txt에 sk-demo-1234567890abcdef==>***REMOVED*** 한 줄을 적고, 큰 파일 제거와 함께 실행했습니다.
$ git clone --no-local remote.git clean
$ git -C clean filter-repo --invert-paths --path backup.zip --replace-text replacements.txt
NOTICE: Removing 'origin' remote; see 'Why is my origin removed?'
in the manual if you want to push back there.
Parsed 4 commits
HEAD is now at fbcc2de remove backup, read key from env
New history written in 0.78 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
Completely finished after 1.20 seconds.
$ git -C clean log --oneline
fbcc2de remove backup, read key from env
cdae915 app v2
e9e8234 add backup and config
152d444 first commit
$ git -C clean show HEAD~2:config.py
API_URL = "https://api.example.com"
API_KEY = "***REMOVED***"
$ python check_repo.py clean
.git 폴더: 0.0MB (32,475바이트)
현재 파일: app.py config.py
히스토리 속 큰 파일: 없음
비밀키가 등장하는 커밋: 없음
커밋 4개를 다시 쓰는 데 0.78초, 정리까지 1.20초 걸렸습니다. .git 폴더는 50MB에서 32,475바이트로 줄었고, 옛 커밋의 config.py에는 키 대신 ***REMOVED***가 들어갔습니다. 확인할 점이 세 가지 있습니다.
- 커밋 해시가 바뀝니다. 첫 커밋
152d444는 그대로지만, 파일이 바뀐 커밋부터 뒤는abd0104→e9e8234처럼 전부 새 해시가 됩니다. - origin이 삭제됩니다. 실수로 바로 push하지 않도록 filter-repo가 원격 설정을 지웁니다(
git remote -v출력이 비어 있었음). 다시 올리려면 직접 추가해야 합니다. - 원본은 안전합니다. 새 복제본에서 작업했기 때문에 결과가 마음에 안 들면 그 폴더만 지우면 됩니다.
원격에 다시 올리기: 강제 푸시와 남은 흔적
$ git -C clean remote add origin ../remote.git
$ git -C clean push origin main
! [rejected] main -> main (fetch first)
error: failed to push some refs to '../remote.git'
$ git -C clean push --force origin main
+ 7cdc506...fbcc2de main -> main (forced update)
$ git -C remote.git cat-file -t abd0104 (강제 푸시 직후, 옛 커밋 해시로 조회)
commit
히스토리가 바뀌었으니 일반 push는 거절되고, --force가 필요합니다. 그런데 강제 푸시가 끝난 직후에도 원격 저장소에서 옛 커밋 abd0104를 조회하니 여전히 있었습니다. 브랜치는 새 히스토리를 가리키지만, 객체 자체는 원격에서 가비지 컬렉션이 돌기 전까지 남기 때문입니다. 실험에서는 원격도 내 PC의 폴더라 git gc --prune=now를 직접 실행할 수 있었고, 그 뒤에야 fatal: Not a valid object name abd0104가 나오며 사라졌습니다.
GitHub 같은 호스팅 서비스에서는 서버 쪽 gc를 직접 실행할 수 없습니다. 그래서 비밀키라면 히스토리 정리보다 키 폐기·재발급이 먼저입니다. 한 번 공개 저장소에 올라간 키는 정리하기 전에 이미 누군가 복사해 갔을 수 있다고 보고, 발급한 서비스에서 즉시 폐기하세요. 히스토리 정리는 그다음에 하는 뒷정리입니다.
가장 놓치기 쉬운 함정: 동료의 git pull
이번 실험에서 가장 의외였던 부분입니다. 정리 전에 clone해 둔 동료 저장소에서 평소처럼 git pull을 해 봤습니다.
$ git pull --no-rebase (옛 히스토리를 가진 동료 PC)
Merge made by the 'ort' strategy.
+ 7cdc506...fbcc2de main -> origin/main (forced update)
$ git log --oneline --graph
* 653a026 Merge branch 'main' of .../remote
|\
| * fbcc2de remove backup, read key from env
| * cdae915 app v2
| * e9e8234 add backup and config
* | 7cdc506 remove backup, read key from env
* | 04eb7ba app v2
* | abd0104 add backup and config
|/
* 152d444 first commit
$ python check_repo.py teammate
.git 폴더: 50.0MB (52,476,089바이트)
히스토리 속 큰 파일: backup.zip 50.0MB
오류도 충돌도 없이 “Merge made”로 끝났습니다. 깃 입장에서는 옛 히스토리와 새 히스토리가 첫 커밋에서 갈라진 두 갈래일 뿐이라 그냥 합쳐 버린 겁니다. 현재 파일 목록(git ls-files)은 app.py, config.py로 정상이라 겉으로는 아무 일도 없어 보이는데, 히스토리에는 50MB 파일과 키가 다시 들어왔습니다. 이 상태로 동료가 push하면 애써 지운 것이 원격에 그대로 돌아갑니다.
동료 쪽에서는 pull 대신 원격 상태로 맞추고, 남은 객체를 정리해야 합니다. 가장 간단한 방법은 새로 clone하는 것이고, 기존 폴더를 살려야 한다면 다음과 같이 했습니다.
$ git reset --hard origin/main
HEAD is now at fbcc2de remove backup, read key from env
$ python check_repo.py teammate
.git 폴더: 50.0MB (52,476,395바이트)
히스토리 속 큰 파일: 없음
$ git reflog expire --expire=now --all
$ git gc --prune=now
$ python check_repo.py teammate
.git 폴더: 0.0MB (32,221바이트)
reset --hard만으로는 히스토리에서 큰 파일이 안 보여도 용량이 50.0MB 그대로였습니다. reflog가 옛 커밋을 붙잡고 있기 때문이고, reflog expire와 gc --prune=now까지 해야 32,221바이트로 줄었습니다. 단, reset --hard는 커밋하지 않은 변경을 지우니 동료가 작업 중인 내용이 있다면 먼저 따로 저장해 두게 하세요.
상황별 정리와 작업 순서
| 상황 | 방법 | 실측에서 확인한 점 |
|---|---|---|
| 방금 커밋, push 전 | git rm –cached + .gitignore + commit –amend | 해시 바뀜, 옛 커밋은 reflog에 남음 |
| 지난 커밋, push 전 | git-filter-repo (새 clone에서) | 4커밋 1.20초, .git 50MB → 32KB |
| 이미 push함 | filter-repo + push –force + 동료 재clone | 원격에 옛 객체가 gc 전까지 남음 |
| 비밀키 노출 | 키 폐기·재발급 먼저, 그다음 히스토리 정리 | 해시만 알면 옛 커밋에서 키가 읽힘 |
이미 push한 경우의 순서를 정리하면 다음과 같습니다.
- 비밀키라면 발급처에서 먼저 폐기하고 새 키를 받습니다.
- 함께 작업하는 사람들에게 작업 중인 내용을 push하고 잠시 멈춰 달라고 알립니다.
- 원격에서 새로 clone한 폴더에서
git filter-repo를 실행합니다. - 위의 진단 스크립트로 큰 파일과 키가 남지 않았는지 확인합니다.
git remote add origin ...후git push --force로 올립니다(브랜치·태그가 여러 개면 모두).- 동료들은
git pull대신 새로 clone합니다. - 같은 실수를 막도록
.gitignore에.env,*.zip같은 패턴을 넣어 둡니다.
git rm은 “앞으로” 그 파일을 빼는 것이고, 지난 히스토리는 그대로 둡니다. 히스토리에서 지우려면 히스토리를 다시 써야 하고, 그 결과는 모든 복제본이 따라와야 완성됩니다.
마무리
가장 작은 첫걸음은 위의 check_repo.py를 지금 쓰는 저장소에 한 번 돌려 보는 것입니다. python check_repo.py 저장소경로 찾을문자열로 실행하면 히스토리 속 1MB 넘는 파일과, 지정한 문자열(예: 키 앞부분)이 들어간 커밋이 나옵니다. 아무것도 바꾸지 않는 읽기 전용 점검이라 부담 없이 확인할 수 있습니다.
다음 글에서는 블로그 이미지 용량을 WebP 변환으로 일괄로 줄이는 방법을 다루겠습니다.