Series 09 — Article 02 of 05

force push 하나로 팀을 멸망시키는 법

Jenkins 150개 레포 롤백, GitLab DB 삭제 유튜브 라이브 복구, 삼성 190GB 유출. Git 사고는 영화보다 극적이다 — 그리고 당신에게도 일어날 수 있다.

Part I

전설의 사고

Git은 안전한 도구다. 거의 모든 작업을 되돌릴 수 있고, 분산 구조 덕분에 단일 장애점이 없다. 하지만 그 안전장치를 무력화하는 명령어가 몇 개 있다. force push, reset --hard, 그리고 실수로 커밋된 비밀 키. 이 세 가지가 만들어낸 실제 사고들은, 개발자라면 한 번쯤 등줄기가 서늘해지는 이야기다.

Jenkins 2013

150개 레포지토리가 한 달 전으로 돌아간 날

2013년 11월, Jenkins 프로젝트의 GitHub 계정에서 150개 이상의 레포지토리가 동시에 약 한 달 전 상태로 롤백되었다. 원인은 Subversion에서 Git으로 마이그레이션하는 과정에서 실행된 스크립트의 force push였다. 자동화 도구가 역사를 다시 쓴 것이다.

프로젝트 리더 Kohsuke Kawaguchi는 사고 직후 메일링 리스트에 상황을 공지했다. 복구에는 수일이 걸렸고, 일부 레포지토리의 커밋 히스토리는 완전히 복원되지 못했다.

Jenkins 사고 타임라인
  • Day 0
    SVN-to-Git 마이그레이션 스크립트 실행. force push가 150+ 레포에 적용된다.
  • + 30min
    커뮤니티가 이상 징후를 감지. 최근 커밋들이 사라진 것을 발견한다.
  • + 2h
    Kohsuke Kawaguchi가 메일링 리스트에 긴급 공지. "Do not push anything" 경고.
  • + 1d
    로컬 클론을 보유한 기여자들의 히스토리를 수집하여 복구 시작.
  • + 5d
    대부분의 레포지토리 복구 완료. 일부 히스토리는 영구 소실.
GitLab.com 2017

300GB 데이터베이스, 4.7GB로 줄어들다

2017년 1월 31일, GitLab.com 운영 엔지니어가 데이터베이스 복제 지연 문제를 해결하던 중 프로덕션 데이터베이스를 삭제했다. 의도한 것은 스테이징 서버의 디렉토리였으나, 터미널 창을 잘못 선택했다. 6시간 전 백업 한 가지만 살아남았고, 300GB 데이터 중 292GB가 사라졌다.

GitLab은 전례 없는 결정을 내렸다. 복구 과정 전체를 유튜브 라이브로 중계한 것이다. 수천 명이 지켜보는 가운데 엔지니어들이 데이터를 복원하는 18시간짜리 마라톤이 벌어졌다. 사후 보고서에서 밝혀진 가장 충격적인 사실은 이것이었다.

"5가지 백업 방법 중 제대로 작동하는 것은 단 하나도 없었다"

GitLab Post-Mortem, 2017

pg_dump가 만드는 SQL 백업은 오류와 함께 실패하고 있었고, 아무도 그 사실을 모니터링하지 않았다. Azure 디스크 스냅샷은 비활성화되어 있었다. LVM 스냅샷과 S3 백업도 마찬가지였다. 유일하게 살아남은 것은 사고 6시간 전에 생성된 임시 스냅샷 하나였다.

AI Code Tool 2025–2026

AI가 실행한 git reset --hard

2025년부터 AI 코딩 에이전트가 터미널 명령을 직접 실행하는 시대가 열렸다. 그리고 예상된 사고가 현실이 되었다. AI 도구가 충돌을 해결하겠다며 git reset --hard를 실행하거나, 작업 디렉토리를 정리한다며 커밋되지 않은 변경사항을 날려버리는 사례가 보고되기 시작했다. 인간 개발자도 치지 않을 명령을, AI는 주저 없이 실행한다.

문제의 핵심은 AI 에이전트가 명령의 파괴력을 이해하지 못한다는 것이다. "깨끗한 상태로 만들어줘"라는 프롬프트에 가장 효율적인 응답이 reset --hard일 수 있지만, 그것이 의미하는 데이터 손실은 AI의 고려 대상이 아니다.

이 세 가지 사고에는 공통점이 있다. 모두 단 한 줄의 명령어에서 시작되었다. 그리고 모두 되돌리기 어려웠다. 다음 섹션에서는 그 "되돌리기"의 구체적인 방법을 다룬다.

Part II

git reset --hard를 치고 3초 후

git reset은 세 가지 모드가 있다. 이 차이를 이해하지 못하면, 복구할 수 있었던 상황을 복구 불가능으로 만든다. 핵심은 Git의 세 가지 영역 — 커밋 히스토리, 스테이징 영역(Index), 작업 디렉토리(Working Directory) — 중 어디까지 되돌리느냐다.

--soft

커밋만 되돌린다

HEAD를 이전 커밋으로 이동시키지만, 스테이징 영역과 작업 디렉토리는 그대로 유지한다. 커밋 메시지를 수정하거나, 여러 커밋을 하나로 합칠 때 사용한다. 가장 안전한 옵션.

--mixed

커밋 + 스테이징을 되돌린다

기본값. HEAD와 Index를 이전 커밋으로 되돌리지만, 작업 디렉토리의 파일은 유지한다. 변경사항이 unstaged 상태로 남는다. 파일은 살아 있다.

--hard

전부 되돌린다

HEAD, Index, 작업 디렉토리 모두를 지정한 커밋으로 되돌린다. 커밋되지 않은 모든 변경사항이 사라진다. 복구 불가능 영역이 존재한다.

커밋하지 않은 작업은 reflog로도 복구 불가
  • git add를 하지 않은 새 파일은 Git이 추적하지 않으므로 완전히 사라진다
  • 스테이징했지만 커밋하지 않은 변경사항은 dangling blob으로 남을 수 있으나 보장되지 않는다
  • reset --hard 전에 반드시 git stash 또는 임시 커밋을 만들어라
  • IDE의 Local History 기능이 마지막 희망이 될 수 있다 (VS Code, IntelliJ)

reflog는 Git의 타임머신이다. HEAD가 가리켰던 모든 커밋의 로그를 90일간 보관한다. reset --hard로 커밋을 날렸더라도, 그 커밋이 한때 HEAD였다면 reflog에 기록이 남아 있다.

reflog로 복구하기 # 1. reflog에서 되돌리고 싶은 커밋 찾기 $ git reflog a1b2c3d HEAD@{0}: reset: moving to HEAD~3 f4e5d6c HEAD@{1}: commit: feat: 결제 모듈 완성 9g8h7i6 HEAD@{2}: commit: fix: 장바구니 버그 수정 j0k1l2m HEAD@{3}: commit: refactor: API 구조 개선 # 2. 원하는 시점으로 복구 $ git reset --hard HEAD@{1} HEAD is now at f4e5d6c feat: 결제 모듈 완성 # 또는 커밋 해시를 직접 지정 $ git reset --hard f4e5d6c

reflog에 커밋이 보이지 않는 극단적인 상황에서는, git fsck가 마지막 희망이다. Git 객체 데이터베이스에서 어떤 브랜치에도 연결되지 않은 "고아 객체(dangling object)"를 찾아낸다.

fsck로 고아 객체 찾기 # dangling commit 찾기 $ git fsck --lost-found dangling commit a1b2c3d4e5f6... dangling commit f7g8h9i0j1k2... dangling blob l3m4n5o6p7q8... # 내용 확인 $ git show a1b2c3d4e5f6 # 원하는 커밋이면 브랜치로 복구 $ git branch recovered a1b2c3d4e5f6

fsck가 찾아내는 dangling object는 Git의 가비지 컬렉션(git gc)이 실행되기 전까지 남아 있다. 기본 설정에서 약 2주의 유예 기간이 있지만, gc.auto 설정에 따라 더 일찍 삭제될 수 있다. 시간이 곧 복구 가능성이다.

Part III

잘못된 브랜치에 커밋했다

Stack Overflow의 2023년 설문에 따르면, 개발자의 약 60%가 잘못된 브랜치에 커밋한 경험이 있다고 답했다. 가장 흔한 실수는 feature 브랜치 대신 main에 직접 커밋하는 것이다. 아직 push하지 않았다면 비교적 간단하게 해결할 수 있다.

cherry-pick으로 커밋 옮기기 # 1. 잘못 커밋한 해시 확인 $ git log --oneline -3 a1b2c3d (HEAD -> main) feat: 새 기능 추가 # 이 커밋을 feature로 옮기고 싶다 f4e5d6c fix: 버그 수정 9g8h7i6 docs: README 업데이트 # 2. 올바른 브랜치로 이동 후 cherry-pick $ git checkout feature-branch $ git cherry-pick a1b2c3d # 3. main에서 잘못된 커밋 제거 (아직 push 전이라면) $ git checkout main $ git reset --hard HEAD~1

문제는 이미 push한 후다. 원격 저장소에 올라간 커밋을 reset으로 되돌리면, 다른 팀원의 히스토리와 충돌이 발생한다. 이때는 revert를 사용해야 한다.

reset

히스토리를 다시 쓴다

커밋 자체를 히스토리에서 제거한다. 로컬에서만 사용하거나, force push가 필요하다. 다른 사람이 이미 pull했다면 모든 팀원의 로컬을 오염시킨다. push 전에만 안전하게 사용할 수 있다.

revert

되돌리는 커밋을 추가한다

기존 커밋을 취소하는 새 커밋을 생성한다. 히스토리가 보존되므로 force push가 필요 없다. push 후에도 안전하게 사용할 수 있다. 머지 커밋을 되돌릴 때는 -m 1 옵션이 필요하다.

push 후 머지 되돌리기 # 잘못된 머지 커밋 되돌리기 # -m 1: 첫 번째 부모(main)를 기준으로 되돌린다 $ git revert -m 1 <merge-commit-hash> # 일반 커밋 되돌리기 $ git revert <commit-hash> # 여러 커밋을 한 번에 되돌리기 $ git revert --no-commit HEAD~3..HEAD $ git commit -m "revert: 최근 3개 커밋 되돌리기"

규칙은 단순하다. push 전에는 reset, push 후에는 revert. 이 한 줄을 기억하면 잘못된 브랜치 커밋의 90%를 해결할 수 있다.

Part IV

2,380만 개의 비밀이 새어나가는

코드를 잃는 것보다 더 위험한 사고가 있다. 비밀 키를 커밋하는 것이다. AWS 키, 데이터베이스 비밀번호, API 토큰이 한 번이라도 Git 히스토리에 기록되면, 커밋을 삭제해도 히스토리에 남는다. 그리고 누군가는 반드시 그것을 찾는다.

23,800,000
2024년 한 해 동안 GitHub에서 유출된 시크릿 수
GitGuardian State of Secrets Sprawl 2025 Report

연간 2,380만 건. 하루 평균 65,000건 이상의 비밀이 공개 저장소에 노출되고 있다. 그리고 이것은 GitHub 한 곳의 수치일 뿐이다. GitLab, Bitbucket, 그리고 사내 저장소까지 합치면 실제 규모는 이보다 훨씬 크다.

Samsung 2022

190GB 소스코드, 6,600개 비밀 키

2022년 3월, 해킹 그룹 Lapsus$가 삼성전자에서 190GB에 달하는 내부 소스코드를 유출했다. 유출된 코드에는 갤럭시 기기의 신뢰 실행 환경(TrustZone), 생체 인증 잠금 해제 알고리즘, 퀄컴 관련 기밀 소스코드가 포함되어 있었다.

보안 연구원들이 유출된 코드를 분석한 결과, 6,695개의 하드코딩된 시크릿 키가 발견되었다. API 키, 인증서, 내부 서비스 토큰이 소스코드에 평문으로 박혀 있었다. Git 히스토리를 관리하지 않은 대가는 상상 이상이었다.

Uber 2016

GitHub 한 줄이 만든 1,900억 원 합의

2016년, 공격자가 Uber 개발자의 비공개 GitHub 레포지토리에서 AWS 자격 증명을 발견했다. 이 한 줄의 키로 5,700만 명의 사용자 데이터와 60만 명의 운전자 면허 정보에 접근했다. Uber는 이 사실을 1년간 은폐하다 적발되어, 2018년 1억 4,800만 달러(약 1,900억 원)의 합의금을 지불했다.

Mercedes-Benz 2024

공개 레포의 토큰 하나, 전체 소스코드 접근

2024년 1월, 보안 연구팀 RedHunt Labs가 메르세데스-벤츠의 공개 GitHub 레포지토리에서 내부 GitHub Enterprise Server의 인증 토큰을 발견했다. 이 토큰으로 API 키, 클라우드 설정, 설계 문서 등 전체 소스코드에 무제한 접근이 가능했다. 3개월간 아무도 눈치채지 못했다.

비밀 키가 한 번이라도 Git 히스토리에 들어갔다면, 단순히 파일을 삭제하고 새 커밋을 만드는 것으로는 부족하다. 히스토리에 남은 이전 커밋에서 여전히 조회할 수 있기 때문이다. 히스토리 자체를 다시 써야 한다.

git filter-repo로 히스토리에서 비밀 제거 # git filter-repo 설치 (pip) $ pip install git-filter-repo # 특정 파일을 히스토리 전체에서 제거 $ git filter-repo --path config/secrets.yml --invert-paths # 특정 문자열을 히스토리 전체에서 치환 $ git filter-repo --replace-text expressions.txt # expressions.txt 내용: literal:AKIAIOSFODNN7EXAMPLE==>***REMOVED***
BFG Repo-Cleaner (대용량 레포에 더 빠름) # 특정 파일 히스토리에서 제거 $ java -jar bfg.jar --delete-files secrets.yml # 특정 문자열 치환 $ java -jar bfg.jar --replace-text passwords.txt # 정리 후 강제 푸시 $ git reflog expire --expire=now --all $ git gc --prune=now --aggressive $ git push --force

하지만 이미 원격에 push된 비밀은 히스토리를 다시 써도 안전하지 않다. 누군가가 이미 fork하거나 clone했을 수 있기 때문이다. 유출된 키는 반드시 즉시 폐기(revoke)하고 새 키를 발급해야 한다. 히스토리 재작성은 최선이 아니라, 추가 피해를 줄이는 차선이다.

유출 방지 도구
gitleaks

오픈소스 시크릿 스캐너

커밋 전에 pre-commit hook으로 시크릿을 탐지한다. CI/CD 파이프라인에 통합 가능하며, 기존 히스토리 전체를 스캔할 수 있다. GitHub에서 58,000+ 스타를 보유한 업계 표준 도구.

TruffleHog

딥 스캐닝 엔진

600개 이상의 시크릿 패턴을 탐지하며, 발견된 키가 실제로 유효한지 자동 검증한다. Git 히스토리뿐 아니라 S3, Slack, Confluence까지 스캔 범위를 확장할 수 있다.

GitHub Push Protection

플랫폼 레벨 차단

2023년부터 모든 공개 레포에 기본 활성화. push 시점에 시크릿 패턴을 감지하면 push 자체를 차단한다. 200개 이상의 서비스 패턴을 지원하며, bypass 시 감사 로그가 남는다.

Part V

--force-with-lease, 또는 팀을 지키는 방법

force push가 위험한 이유는 명확하다. 원격 브랜치의 히스토리를 로컬 히스토리로 무조건 덮어쓰기 때문이다. 다른 팀원이 그 사이에 push한 커밋이 있어도, 경고 없이 삭제된다. --force-with-lease는 이 문제를 해결하는 안전장치다.

--force vs --force-with-lease # 위험: 원격 브랜치를 무조건 덮어쓴다 $ git push --force origin feature # -> 다른 사람의 커밋이 있어도 삭제됨 # 안전: 원격 브랜치가 예상한 상태일 때만 push $ git push --force-with-lease origin feature # -> 다른 사람이 push한 커밋이 있으면 거부됨 # 더 안전: 구체적인 예상 상태를 명시 $ git push --force-with-lease=feature:<expected-hash> origin feature

--force-with-lease는 push 전에 원격 브랜치의 상태를 확인한다. 마지막으로 fetch한 시점의 원격 ref와 현재 원격 ref가 다르면 — 즉, 누군가 그 사이에 push했다면 — push를 거부한다. "lease(임대)"라는 이름은 이 메커니즘에서 온 것이다. 당신이 알고 있는 상태를 "임대"하고, 임대 기간이 유효할 때만 갱신을 허용한다.

하지만 --force-with-lease에도 맹점이 있다. git fetch를 실행한 직후라면, 원격 ref가 최신 상태로 업데이트되어 다른 사람의 커밋을 알고 있는 것으로 간주된다. 이 경우에도 force push가 성공한다. 근본적인 해결책은 Protected Branch다.

Protected Branch 설정 (GitHub CLI) # main 브랜치에 force push 차단 + PR 필수 설정 $ gh api repos/{owner}/{repo}/branches/main/protection \ --method PUT \ -f required_pull_request_reviews='{"required_approving_review_count":1}' \ -F allow_force_pushes=false \ -F allow_deletions=false
Git 사고 예방 체크리스트
  • git push --force 대신 --force-with-lease를 사용한다. 셸 alias로 등록하면 습관이 된다: alias gpf='git push --force-with-lease'
  • main/master 브랜치에 Protected Branch를 설정한다. force push 차단, PR 필수, 최소 1명 리뷰 승인 필수
  • pre-commit hook에 gitleaks를 등록한다. 시크릿이 커밋되기 전에 차단하는 것이 유출 후 정리하는 것보다 100배 쉽다
  • git reset --hard 전에 반드시 임시 브랜치를 만든다. git branch backup-$(date +%s) 한 줄이면 보험이 된다
  • .env, credentials, private key 파일은 .gitignore에 등록한다. 첫 커밋 전에 .gitignore를 설정하는 것이 가장 확실한 방어선이다
  • AI 코딩 도구에 파괴적 Git 명령을 허용하지 않는다. reset --hard, clean -f, push --force는 인간의 확인을 거쳐야 한다
  • 백업은 테스트하지 않으면 백업이 아니다. GitLab 사고의 교훈 — 정기적으로 복구 훈련을 실행한다

완벽한 예방은 불가능하다. 인간은 실수하고, 자동화 도구는 예상치 못한 동작을 하며, 공격자는 항상 빈틈을 노린다. 하지만 실수의 피해 범위를 줄이는 것은 가능하다. --force-with-lease, Protected Branch, pre-commit hook. 이 세 가지만으로도 대부분의 참사를 예방할 수 있다.

모든 실수에는
되돌아갈 길이 있다

Git은 거의 모든 것을 기억한다. 문제는 당신이 그 기억에 접근하는 방법을 아느냐다. reflog, fsck, filter-repo. 도구는 이미 있다 — 알고 있기만 하면 된다.