Git에 비밀키를 커밋했을 때 — 키 회전과 filter-repo 히스토리 정리
.env 파일을 통째로 커밋했거나, 코드에 하드코딩된 AWS 액세스 키와 API 토큰이 커밋에 섞여 들어갔고, 이미 push까지 끝난 상황입니다. 이 사고에서 가장 흔한 첫 반응은 “빨리 히스토리에서 지우자"입니다. 그런데 대응 순서가 반대입니다. 1순위는 히스토리 정리가 아니라 키 무효화입니다. 이 글은 그 이유부터 확인하고, 상황별 정리 절차와 예방 장치까지 순서대로 정리합니다.
1순위는 키 무효화 #
push된 비밀키는 지워질 수 있는 정보가 아니라 이미 유출된 정보로 간주해야 합니다.
- 공개 저장소라면 봇이 분 단위로 수집합니다. GitHub의 공개 이벤트를 실시간으로 스캔하며 자격 증명을 수집하는 자동화 도구들이 상시로 돌고 있습니다. push하고 몇 분 안에 남의 손에 들어갔다고 가정하는 것이 안전합니다.
- 사본은 원본 정리로 지워지지 않습니다. 포크, 동료의 클론, CI가 받아 간 체크아웃과 로그에는 이미 사본이 존재합니다. 원본 저장소의 히스토리를 다시 써도 이 사본들에는 아무 영향이 없습니다.
그래서 어떤 정리 작업보다 먼저, 발급처에서 해당 자격 증명을 폐기하고 새로 발급합니다. AWS 액세스 키는 IAM에서 비활성화 후 삭제하고, GitHub 토큰과 서드파티 API 키는 각 서비스에서 revoke하고, DB 비밀번호라면 비밀번호 자체를 변경합니다. 키가 무효화된 순간부터 히스토리에 남은 문자열은 더 이상 위험하지 않은 죽은 값이 됩니다.
상황 판단 — push 전인가 후인가 #
키 무효화 다음은 히스토리 정리입니다. 절차는 커밋이 원격에 올라갔는지에 따라 완전히 달라집니다.
- 아직 push 전 — 내 컴퓨터에만 있는 커밋이므로 로컬에서 다시 쓰면 끝납니다.
- push 후 — 원격과 모든 사본에 퍼진 상태이므로, 전용 도구로 히스토리를 다시 쓰고 팀 전체의 협조가 필요합니다.
push 전이라면 — 로컬에서 정리 #
방금 만든 마지막 커밋에 비밀키가 들어갔다면 두 명령으로 정리됩니다.
git rm --cached .env # 추적만 해제, 파일은 작업 디렉터리에 남음
echo ".env" >> .gitignore
git add .gitignore
git commit --amend --no-edit # 마지막 커밋을 새로 교체git rm --cached는 파일을 지우지 않고 Git의 추적에서만 내립니다. 이어서 --amend로 커밋을 교체하면 비밀키가 없는 커밋이 마지막 커밋을 대신합니다.
비밀키가 몇 커밋 전에 들어갔다면 interactive rebase로 해당 커밋을 고칩니다.
git rebase -i <비밀키 커밋의 직전 커밋>
# todo 리스트에서 해당 커밋을 edit 로 변경
git rm --cached .env
git commit --amend --no-edit
git rebase --continue이 경우 키 회전은 판단의 영역입니다. 커밋이 내 컴퓨터 밖으로 나간 적이 없다면 유출은 없었던 것이지만, 확신할 수 없다면(예를 들어 개인 원격에라도 한 번 올렸다면) 회전하는 쪽이 안전합니다.
push 후라면 — filter-repo로 히스토리 정리 #
원격에 올라간 커밋은 히스토리 전체를 다시 쓰는 도구가 필요합니다. 표준 도구는 git filter-repo입니다. 과거에 쓰이던 git filter-branch는 느리고 함정이 많아 Git 공식 문서도 filter-repo를 권장합니다.
pip install git-filter-repo
# 또는 macOS: brew install git-filter-repofilter-repo는 실수로 작업 저장소를 망치는 것을 막기 위해 새로 clone한 깨끗한 저장소에서 실행하는 것을 전제로 합니다. 정리 전용 클론을 하나 새로 받습니다.
git clone https://github.com/example/my-service.git cleanup
cd cleanup
git filter-repo --invert-paths --path .env--path로 지정한 파일을 남기는 것이 기본 동작이므로, --invert-paths를 붙여 반대로 그 파일만 전체 히스토리에서 제거합니다. 파일은 남기되 코드 안의 키 문자열만 바꿔야 한다면 치환 규칙 파일을 씁니다.
AKIAIOSFODNN7EXAMPLE==>***REMOVED***
regex:AKIA[0-9A-Z]{16}==>***REMOVED***git filter-repo --replace-text expressions.txt정리가 끝난 히스토리는 모든 커밋의 해시가 바뀐 완전히 새로운 히스토리입니다. filter-repo가 안전을 위해 원격 설정을 제거해 두므로, 원격을 다시 연결하고 강제로 올립니다.
git remote add origin https://github.com/example/my-service.git
git push --force --all
git push --force --tags마지막이 가장 중요합니다. 팀 전원에게 기존 클론을 버리고 새로 clone하라고 공지합니다. 옛 히스토리를 가진 클론에서 pull이나 push를 하면 지웠던 커밋이 원격으로 되살아나는 경로가 됩니다. 히스토리 재작성은 도구가 아니라 이 협조 절차까지 끝나야 완료됩니다.
지워도 남는 곳 #
원본 저장소를 완벽히 정리해도 GitHub 플랫폼에는 흔적이 남을 수 있습니다.
- 이미 만들어진 포크의 히스토리는 원본 정리와 무관하게 유지됩니다.
- PR의 diff 화면과 커밋 해시로 직접 접근하는 캐시된 뷰는 저장소 히스토리에서 커밋이 사라진 뒤에도 한동안 열립니다.
이 영역은 저장소 주인이 직접 지울 수 없고, GitHub Support에 캐시 제거와 포크 처리를 요청해야 정리됩니다. 뒤집어 말하면 히스토리 정리를 아무리 잘해도 완전한 삭제는 보장되지 않으며, 이것이 키 회전이 1순위인 두 번째 이유입니다.
예방 장치 #
같은 사고를 반복하지 않는 장치는 커밋 전 단계부터 겹겹이 둘 수 있습니다.
- .gitignore 선등록 —
.env,*.pem같은 파일은 만들기 전에 등록하는 순서를 습관으로 만듭니다. Git 기초 #2에서 다룬 원칙 그대로입니다. - GitHub push protection — 저장소의 secret scanning 설정에서 push protection을 켜 두면, 알려진 형식의 자격 증명이 포함된 push를 GitHub가 수신 단계에서 차단합니다.
- pre-commit 훅 — gitleaks 같은 스캐너를 pre-commit 훅으로 걸어 두면 커밋이 만들어지기 전에 로컬에서 걸러집니다.
- 키를 코드 밖으로 — 애초에 코드와 저장소에 키가 존재하지 않게 환경변수와 시크릿 매니저(AWS Secrets Manager 등)로 주입하고, 저장소에는 값이 빈
.env.example만 둡니다.
마무리 #
사고 대응 순서를 다시 정리합니다.
- 회전 — 발급처에서 즉시 폐기하고 재발급합니다. push된 키는 이미 유출된 것으로 간주합니다.
- 정리 — push 전이면 amend나 rebase로, push 후면 fresh clone에서 filter-repo로 히스토리를 다시 씁니다.
- 공지 — force push 후 팀 전원이 re-clone하게 안내합니다. 포크와 캐시는 GitHub Support 요청으로 처리합니다.
- 예방 — .gitignore 선등록, push protection, pre-commit 스캐너, 시크릿 매니저로 재발을 막습니다.
히스토리 정리는 눈에 보이는 흔적을 지우는 마무리 작업이고, 사고의 실제 봉합은 키 무효화가 담당합니다. 순서만 지켜도 이 사고는 수습 가능한 해프닝으로 끝납니다.