API 키를 커밋해서 푸시까지 했을 때, 키부터 바꾸고 git filter-repo 로 히스토리에서 지우는 순서
환경 git-filter-repo 2.47 · Git 2.54 · GitHub · 2026-10-11 확인
급하게 배포를 고치다 git add . 로 .env 까지 같이 커밋했고, 그대로 원격에 푸시했다. 몇 분 뒤 알아채고 파일을 지운 커밋을 하나 더 올렸다. 그런데 GitHub 에서 이전 커밋을 열면 키가 그대로 보인다. 지우는 커밋은 지금 상태만 바꿀 뿐, 키가 들어간 커밋은 히스토리에 남아 있기 때문이다.
목표는 키를 무력화하고, 그다음에 흔적을 지우는 것
이 작업의 목표는 두 가지다. 하나는 새어 나간 키를 아무도 쓸 수 없게 만드는 것이고, 다른 하나는 저장소 히스토리에서 그 키가 든 내용을 없애는 것이다. 순서가 중요하다. 키를 먼저 폐기하거나 교체해야 한다.
공개 저장소라면 푸시한 순간 이미 누군가 받아 갔다고 보는 게 맞다. 히스토리를 아무리 깨끗하게 다시 써도 그 전에 클론해 간 사본까지 지울 수는 없다. 그래서 키 교체가 본 조치고, 히스토리 정리는 뒷수습이다.
실제로 키를 바꾸고 나면 히스토리 재작성까지 갈 필요가 없는 경우도 많다. 죽은 키는 남아 있어도 아무것도 열지 못한다. 히스토리를 다시 쓰면 커밋 해시가 바뀌고 동료의 클론, 열린 PR, 서명까지 줄줄이 영향을 받는다. 비밀번호 파일처럼 키 교체로 해결이 안 되거나, 내용 자체를 남기면 안 되는 경우에 이 순서를 끝까지 따라간다.
키 교체가 본 조치다. 히스토리를 지우는 건 교체를 마친 뒤의 뒷수습이다.
사전 조건: 새 도구, 새 클론, 멈춘 작업
도구는 git filter-repo 를 쓴다. Git 에 기본으로 들어 있지 않아서 따로 설치해야 한다. 민감 정보 제거용 플래그 --sensitive-data-removal 이 2.47 버전부터 들어왔으니 그보다 오래된 것은 업데이트한다. 예전 방식인 git filter-branch 는 쓰지 않는다.
작업은 늘 새로 받은 클론에서 한다. filter-repo 는 방금 클론한 저장소처럼 보이지 않으면 실행을 거부한다. 푸시 안 한 로컬 작업까지 덮어써서 날리는 사고를 막으려는 안전장치다. 평소 쓰던 작업 폴더에서 억지로 --force 를 붙여 돌리지 말고, 옆 폴더에 새로 받는다.
같이 일하는 사람이 있다면 정리하는 동안 해당 저장소에 푸시하지 말아 달라고 미리 알린다. 열린 PR 도 가능하면 머지하거나 닫아 둔다. 히스토리를 바꾸면 그 PR 들의 diff 가 달라지고 리뷰 코멘트가 엉뚱한 줄에 붙는다. 브랜치 보호 규칙이 강제 푸시를 막고 있다면 잠깐 풀어야 한다는 것도 이때 확인한다.
brew install git-filter-repo # 또는 pip install git-filter-repo
git filter-repo --version # 2.47 이상인지 확인
git clone https://github.com/OWNER/REPO.git REPO-clean
cd REPO-clean평소 작업 폴더가 아니라 새 클론에서 시작한다
키가 어디에 들어갔는지부터 찾는다
지우기 전에 범위를 잡는다. 파일 하나가 통째로 문제인지, 소스 파일 안에 키 문자열 하나가 박힌 건지에 따라 쓰는 옵션이 달라진다.
파일이 통째로 문제라면 그 파일이 어느 경로로 존재했는지 전부 확인한다. 중간에 이름을 바꾸거나 폴더를 옮긴 적이 있으면 옛 경로에도 내용이 남아 있다. 문자열이 문제라면 git log -S 로 그 문자열을 더하거나 뺀 커밋을 모든 브랜치에서 찾는다.
# 파일이 통째로 문제일 때: 그 파일이 거쳐 간 커밋과 경로
git log --all --name-status -- .env config/.env
# 키 문자열이 소스에 박혔을 때: 그 문자열이 들어가고 빠진 커밋
git log --all -S'sk_live_51Hx' --oneline옛 경로까지 빠짐없이 적어 둔다
파일이 통째로 문제라면 --invert-paths 로 걷어 낸다
--path 로 파일을 지정하고 --invert-paths 를 붙이면, 그 경로만 빼고 나머지 히스토리를 다시 쓴다. 모든 브랜치와 태그에서 해당 파일이 처음부터 없었던 것처럼 된다.
경로는 파일 이름만 쓰면 안 되고 저장소 루트 기준 전체 경로를 써야 한다. 앞 단계에서 옛 경로를 찾았다면 --path 를 여러 번 붙인다. 하나라도 빠지면 그 경로의 내용은 그대로 남는다.
--sensitive-data-removal 은 꼭 붙인다. 이 플래그가 있으면 원격의 모든 참조를 먼저 받아 와서 함께 다시 쓰고, 마지막에 처음 바뀐 커밋 해시를 알려 준다. 이 해시는 뒤에서 남은 사본을 확인할 때 쓴다. 이 플래그를 붙이면 origin 원격도 그대로 남는다. 빼고 돌리면 filter-repo 가 안전장치로 origin 을 지워 버려서 푸시하기 전에 다시 등록해야 한다.
git filter-repo --sensitive-data-removal --invert-paths \
--path .env \
--path config/.env옮기기 전 경로까지 --path 로 함께 넘긴다
문자열만 문제라면 --replace-text 로 바꿔 끼운다
설정 파일이나 소스 안에 키 한 줄이 들어간 경우라면 파일을 통째로 지울 이유가 없다. --replace-text 에 바꿀 문자열을 적은 파일을 넘기면, 히스토리의 모든 텍스트 파일에서 그 문자열만 다른 값으로 바꾼다.
목록 파일은 한 줄에 하나씩 쓴다. 그냥 적으면 문자열 그대로 찾아서 ***REMOVED*** 로 바꾼다. 줄 끝에 ==> 를 붙이면 바꿀 값을 직접 정할 수 있고, regex: 로 시작하면 정규식으로 찾는다. 목록 파일은 저장소 밖에 둔다. 저장소 안에 두면 키가 든 파일을 또 하나 만드는 셈이다.
바이너리 파일 안의 문자열은 이 방법으로 바뀌지 않는다. 키가 이미지나 압축 파일 속에 들어갔다면 그 파일을 --invert-paths 로 통째로 걷어 내야 한다.
cat > ../secrets.txt <<'EOF'
sk_live_51HxAbCdEf
regex:AKIA[0-9A-Z]{16}==>AKIA_REMOVED
EOF
git filter-repo --sensitive-data-removal --replace-text ../secrets.txt목록 파일은 저장소 바깥(../)에 둔다
지워졌는지 확인하고 강제 푸시한다
푸시하기 전에 처음 찾을 때 쓴 명령을 그대로 다시 돌린다. 이번에는 아무것도 나오지 않아야 한다. 하나라도 걸리면 빠뜨린 경로나 문자열이 있다는 뜻이니 filter-repo 를 한 번 더 돌린다. 여기까지는 이 클론을 버리고 처음부터 다시 해도 아무 문제가 없다.
GitHub 라면 이 단계에서 영향받는 PR 수도 세어 둔다. filter-repo 가 바뀐 참조 목록을 .git/filter-repo/changed-refs 에 남기는데, 여기서 refs/pull/ 로 시작하는 줄이 영향받는 PR 이다. 숫자가 생각보다 크면 지금 멈추고 다시 판단한다.
확인이 끝나면 --mirror 로 모든 브랜치와 태그를 한 번에 덮어쓴다. 현재 브랜치만 푸시하면 다른 브랜치에 든 옛 히스토리가 그대로 남는다. 이 푸시가 끝나면 되돌릴 수 없다. refs/pull/ 로 시작하는 참조는 GitHub 가 읽기 전용으로 막아 둬서 푸시가 실패하는데, 이건 정상이다. 그 외의 참조가 실패하면 브랜치 보호 때문이니 잠시 풀고 다시 푸시한다.
git log --all --name-status -- .env config/.env # 아무것도 안 나와야 한다
git log --all -S'sk_live_51Hx' --oneline # 마찬가지
grep -c '^refs/pull/.*/head$' .git/filter-repo/changed-refs # 영향받는 PR 수
git push --force --mirror origin--mirror 는 --force 를 포함하지만, 덮어쓴다는 걸 눈에 보이게 하려고 함께 적는다
흔한 실패: 동료의 pull 한 번에 키가 되돌아온다
가장 흔한 실패는 다시 오염되는 것이다. 정리하기 전에 클론해 둔 동료가 아무 생각 없이 git pull 하고 git push 하면 옛 히스토리가 머지되면서 키가 든 커밋이 그대로 돌아온다. 커밋 수도 두 배가 된다. 동료들에게는 기존 클론을 버리고 새로 받으라고 하거나, 작업 중인 브랜치를 머지하지 말고 새 히스토리 위로 rebase 하라고 분명히 알린다.
강제 푸시만으로 GitHub 에서 완전히 사라지지도 않는다. 옛 커밋은 해시를 알면 캐시된 화면으로 여전히 열리고, 그 커밋을 참조하는 PR 도 남아 있다. 완전히 지우려면 GitHub 지원 센터에 요청해야 하고, 그때 영향받는 PR 수와 filter-repo 가 출력한 First Changed Commit 해시를 함께 낸다. 포크에 남은 사본은 포크 주인이 직접 지워야 한다.
끝으로 같은 일을 막아 둔다. .env 같은 파일은 .gitignore 에 넣고 그 변경을 커밋해서 모두에게 퍼뜨린다. git add . 대신 파일을 하나씩 올리고, 커밋 전에 git diff --cached 로 올라갈 내용을 본다. GitHub 의 푸시 보호를 켜 두면 키 모양의 문자열이 든 푸시를 서버에서 먼저 막아 준다.
정리 전 클론에서 pull 과 push 를 한 번만 해도 지운 키가 다시 올라간다. 동료에게 새 클론을 받게 하는 데까지가 정리다.
조치 후 확인할 것
- 새어 나간 키를 폐기하거나 교체해서, 히스토리에 남아 있어도 쓸 수 없는 상태인지
- git log --all 로 옛 경로와 키 문자열을 다시 찾았을 때 아무것도 나오지 않는지
- git push --force --mirror 에서 refs/pull/ 말고 실패한 참조가 없는지
- 동료들이 기존 클론을 버리고 새로 받았거나, 브랜치를 rebase 로 옮겼는지
- .gitignore 와 푸시 보호로 같은 파일이 다시 올라가지 않게 막아 두었는지
키가 새어 나갔을 때 손이 먼저 가는 건 히스토리 정리지만, 실제로 위험을 끝내는 건 키 교체다. 교체를 끝내고 나서도 내용을 남기면 안 될 때만 새 클론에서 filter-repo 를 돌리고, 확인하고, 미러로 덮어쓰고, 남은 사본을 챙긴다. 마지막 단계를 빼먹으면 앞의 수고가 동료의 pull 한 번에 사라진다.