환경  Git 2.55 git-clean · gitignore · 2026-09-14 확인

배포는 늘 하던 대로 끝났습니다. 파이프라인은 초록불, 서버는 새 코드로 잘 돌고, 첫 화면도 멀쩡합니다. 그런데 며칠 지나 누군가 오래된 글을 열었더니 본문 이미지가 전부 깨져 있습니다. 서버에 들어가 보면 업로드 폴더가 통째로 없습니다. 삭제 로그도 없고, 누가 손댄 흔적도 없습니다. 지운 건 사람이 아니라 배포 스크립트에 몇 달째 박혀 있던 git clean -fd 한 줄이었습니다.

며칠에 걸쳐 일어난 일을 시간순으로 세우면

시작은 배포 스크립트를 정리하던 어느 날입니다. 빌드 산출물이 서버에 남아 다음 배포와 섞이는 게 거슬려서, 체크아웃 직후에 git clean -fd 를 넣습니다. 추적 안 하는 파일을 싹 치우고 깨끗한 상태에서 시작하자는 뜻이었고, 그날은 아무 일도 일어나지 않습니다. 업로드 폴더가 아직 비어 있었으니까요.

그 뒤로 사용자가 이미지를 올리기 시작합니다. 앱은 저장소 안의 public/uploads 아래에 파일을 씁니다. 이 폴더는 커밋한 적이 없고, .gitignore 에도 적어 두지 않았습니다. 처음 만들 때는 서버에서만 생기는 폴더라 굳이 신경 쓸 일이 없어 보였습니다.

다음 배포가 돌아갑니다. git clean -fd 는 추적 안 하는 파일을 지우는 명령이고, -d 가 붙었으니 추적 안 하는 디렉터리 안까지 들어갑니다. public/uploads 는 정확히 그 조건에 맞습니다. 폴더가 통째로 사라지고, 앱은 다음 업로드 때 폴더를 다시 만들어 새 파일을 씁니다. 그래서 겉으로는 업로드 기능이 계속 잘 되는 것처럼 보입니다.

발견이 늦어진 데는 앞단의 캐시가 한몫했습니다. 자주 열리는 글의 이미지는 캐시가 몇 시간씩 대신 내주고 있어서, 원본이 사라진 뒤에도 한동안 정상으로 보입니다. 캐시가 만료되고 나서야 깨진 이미지가 하나둘 드러나고, 그때는 이미 배포가 두세 번 더 지나간 뒤입니다.

# 배포 스크립트에 들어 있던 줄
git fetch origin && git checkout -f origin/master
git clean -fd

# 서버에서 확인해 보면
ls public/uploads
# ls: cannot access 'public/uploads': No such file or directory

체크아웃 다음 줄이 매번 업로드 폴더를 지우고 있었습니다

배포마다 지워졌는데도 업로드 기능은 계속 잘 됐습니다. 앱이 폴더를 다시 만들었기 때문입니다.

무엇을 잃었고, 왜 아무도 몰랐나

잃은 건 마지막 배포 이전에 올라온 파일 전부입니다. 배포 사이에 올라온 파일만 살아남고, 그 전 것은 배포 시점마다 한 묶음씩 사라졌습니다. 배포를 자주 할수록 살아남는 구간이 짧아지니, 부지런히 배포한 게 오히려 피해를 키운 셈입니다.

복구할 곳도 마땅치 않았습니다. 업로드 폴더는 저장소 밖이라 git 이력에 없고, 서버 백업은 통째 스냅샷이라 마지막 스냅샷 이후 올라온 파일은 어디에도 없습니다. 결국 사용자에게 다시 올려 달라고 부탁하는 것 말고는 길이 없는 파일이 생깁니다.

모니터링이 잡지 못한 건 당연했습니다. 헬스체크는 앱이 응답하는지만 보고, 배포 파이프라인은 명령이 0으로 끝났는지만 봅니다. git clean 은 시키는 대로 지우고 0으로 끝났으니 모든 지표가 초록불입니다. 사라진 파일의 개수를 세는 지표는 애초에 없었습니다.

캐시는 발견을 늦춘 데서 그치지 않았습니다. 사람이 서버를 확인할 때도 브라우저로 페이지를 열어 보는데, 그 페이지가 캐시된 이미지를 보여 주니 이상 없다고 판단하게 됩니다. 원본 디스크를 직접 보지 않는 한 문제를 확인할 방법이 없었던 겁니다.

파이프라인의 초록불은 명령이 성공했다는 뜻이지, 지운 게 옳았다는 뜻이 아닙니다.

git clean 은 정확히 시킨 대로만 했다

git clean 은 버전 관리 아래에 있지 않은 파일을 작업 트리에서 지우는 명령입니다. 기본으로는 추적 안 하는 디렉터리 안으로 들어가지 않습니다. 너무 많이 지우는 걸 막으려는 안전장치인데, -d 를 붙이면 그 안전장치를 풀고 디렉터리째 지웁니다. 배포 스크립트는 그 -d 를 붙이고 있었습니다.

-f 도 안전장치를 하나 푸는 옵션입니다. clean.requireForce 설정이 false 가 아닌 한 git clean 은 -f 없이는 아무것도 지우지 않습니다. 기본값이 true 라 사람이 손으로 칠 때는 대개 한 번 멈춰 서게 되는데, 스크립트에 -f 를 박아 두면 그 멈춤이 없습니다.

여기서 제일 중요한 구분이 하나 있습니다. git clean 은 평소에 무시 규칙을 지킵니다. .gitignore 에 적힌 파일은 추적을 안 해도 지우지 않습니다. 무시된 파일까지 지우려면 -x 를 따로 붙여야 하고, 그 옵션은 빌드 산출물까지 싹 밀어 깨끗한 트리를 만들 때 쓰라고 있는 겁니다. 뒤집어 말하면, 업로드 폴더를 .gitignore 에 한 줄만 적어 뒀어도 이 사고는 없었습니다.

그러니 잘못은 명령에 있지 않았습니다. 추적도 안 하고 무시 목록에도 없는 폴더는 git 입장에서 그냥 낯선 파일입니다. 낯선 파일을 치우라고 -fd 로 시켰으니, 시킨 대로 치운 겁니다.

# 지우기 전에 무엇이 지워질지 먼저 본다 (-n 은 requireForce 를 무시하고 아무것도 안 지운다)
git clean -nd
# Would remove public/uploads/

# 무시 규칙에 걸려 있는지 확인한다
git check-ignore -v public/uploads/a.jpg
# (아무것도 안 나오면 무시 규칙에 없다는 뜻)

git status --ignored --short | grep uploads
# ?? public/uploads/   ← '!!' 가 아니라 '??' 면 추적도 무시도 안 된 상태

?? 는 낯선 파일, !! 는 무시된 파일입니다. git clean -fd 는 앞쪽만 지웁니다

git clean -fd 는 무시된 파일은 두고 갑니다. 지워진 폴더는 무시 목록에 없었던 겁니다.

그래서 무엇을 바꿨나

첫 조치는 한 줄입니다. .gitignore 에 public/uploads/ 를 적었습니다. 끝에 슬래시를 붙이면 디렉터리에만 맞는 패턴이 됩니다. 이 한 줄로 git clean -fd 는 그 폴더를 건너뛰고, git status 에서도 ?? 로 떠서 눈에 밟히던 게 사라집니다. 저장소를 같이 쓰는 사람 모두에게 적용되니 팀 규칙으로도 굳습니다.

혼자만 쓰는 규칙이라면 .git/info/exclude 에 적어도 됩니다. 저장소 하나에만 해당하고 남과 나눌 필요가 없는 패턴이 갈 자리로, .gitignore 와 같은 문법을 쓰되 커밋되지 않습니다. 다만 배포 서버에서 폴더를 새로 clone 하면 이 파일은 따라오지 않으니, 서버가 여럿이거나 자주 다시 받는 환경이면 .gitignore 쪽이 안전합니다.

두 번째는 배포 스크립트 쪽입니다. 무시 규칙에 기대는 대신 -e 로 제외 패턴을 명령에 직접 붙였습니다. -e 는 표준 무시 규칙에 더해 그 패턴을 추가로 지키게 하는 옵션이라, 누가 나중에 .gitignore 를 정리하다 그 줄을 지워도 배포 스크립트만은 업로드 폴더를 건드리지 않습니다. 이중으로 걸어 둔 셈입니다.

세 번째는 폴더의 위치 자체입니다. 사용자가 올린 파일은 코드와 수명이 다릅니다. 코드는 배포마다 갈아 끼우는 것이고 업로드는 쌓이는 것인데, 둘을 한 트리에 두니 코드용 도구가 업로드를 코드처럼 다뤘습니다. 저장소 밖 경로에 두고 심볼릭 링크로 연결해 두면 git clean 이 볼 일 자체가 없습니다. -x 를 누가 붙이더라도 마찬가지입니다.

# 1) 무시 규칙에 올린다 — 끝 슬래시는 디렉터리에만 맞는다
echo 'public/uploads/' >> .gitignore

# 2) 배포 스크립트는 규칙과 별개로 한 번 더 제외한다
git clean -fd -e public/uploads/

# 3) 아예 트리 밖으로 뺀다
mkdir -p /srv/app-data/uploads
mv public/uploads/* /srv/app-data/uploads/ 2>/dev/null
rmdir public/uploads && ln -s /srv/app-data/uploads public/uploads

# 바꾼 뒤 확인 — 아무것도 안 나와야 한다
git clean -nd | grep uploads

셋 중 하나만 해도 막히지만, 규칙과 스크립트 양쪽에 걸어 두면 한쪽이 무너져도 버팁니다

무시 규칙은 사람이 지울 수 있습니다. 배포 스크립트의 -e 는 그 사람이 잊어도 남습니다.

이번에 남은 것

가장 먼저 남은 건 -fd 를 손으로 치는 습관과 스크립트에 박는 일의 차이입니다. 손으로 칠 때는 무엇이 지워지는지 한 번쯤 생각하고, 찝찝하면 -n 으로 먼저 봅니다. 스크립트에 들어가는 순간 그 생각이 없어지고, 매 배포마다 그때 그 판단이 반복됩니다. 배포 스크립트에 지우는 명령을 넣을 때는 -n 결과를 로그에 남기는 줄을 함께 넣는 편이 낫습니다.

두 번째는 추적 안 하는 파일과 무시하는 파일을 같은 것으로 여겼던 점입니다. git 은 이 둘을 분명히 가릅니다. 커밋 안 하는 폴더라고 끝난 게 아니라, 어느 쪽에 둘지까지 정해야 합니다. git status 에 ?? 가 오래 떠 있는 폴더가 있다면 그게 바로 다음 사고 후보입니다.

세 번째는 캐시 뒤에 있는 원본을 직접 보는 습관입니다. 브라우저로 페이지가 열린다는 것과 디스크에 파일이 있다는 것은 다른 이야기였습니다. 배포 뒤 확인 절차에 업로드 폴더의 파일 수를 세어 이전 값과 비교하는 줄을 하나 넣어 두면, 줄어드는 순간 바로 알 수 있습니다.

정리하면 볼 곳은 세 군데입니다. 배포 스크립트 안의 지우는 명령, .gitignore 에 빠진 폴더, 그리고 캐시 뒤의 원본. 이 셋을 한 번 훑어 두면 같은 종류의 사고는 배포 전에 걸립니다.

조치 후 확인할 것

  • 배포 스크립트에서 git clean 이 들어간 줄을 찾아 -x 가 없는지, -e 로 데이터 폴더를 제외했는지 봅니다. 같은 인자로 git clean -nd 를 돌려 지워질 목록에 업로드·데이터 폴더가 없는지 확인합니다.
  • 서버의 저장소 안에서 git status --ignored --short 를 돌려 ?? 로 뜨는 디렉터리를 전부 살핍니다. 앱이 쓰는 폴더가 거기 있으면 .gitignore 에 끝 슬래시를 붙여 올립니다.
  • git check-ignore -v 로 업로드 폴더 안의 파일 하나를 넣어 봅니다. 어느 파일 몇째 줄 규칙에 걸리는지 나와야 하고, 아무것도 안 나오면 아직 보호되지 않은 겁니다.
  • 배포 직후 업로드 폴더의 파일 개수를 배포 전 값과 비교하는 줄을 파이프라인에 넣습니다. 줄어들면 실패로 끝내야 캐시가 가리기 전에 잡힙니다.
  • 가능하면 사용자 파일은 저장소 트리 밖으로 옮기고 심볼릭 링크로 연결합니다. 그러면 -x 를 붙여도 git clean 이 볼 일이 없습니다.

배포 스크립트의 한 줄은 사람이 매번 내리던 판단을 한 번 굳혀 놓은 것입니다. 그 판단이 옳았는지는 폴더가 하나 늘어날 때마다 다시 물어야 하는데, 스크립트는 묻지 않습니다. 그래서 무시 규칙과 -e 를 함께 걸어 두고, 배포 뒤에는 캐시가 아니라 디스크를 봅니다.