- Published on
.gitignore가 작동하지 않을 때 — 원인 1위와 패턴 규칙 정독
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 무시하라고 썼는데 계속 올라옵니다
무시 목록에 한 줄을 적었고, 파일을 확인했고, 오타도 없습니다. 그런데 상태 확인에는 여전히 그 파일이 잡힙니다. 여기서 대부분은 규칙을 의심하며 별표를 하나 더 붙이거나 슬래시를 이리저리 옮겨 봅니다.
문제는 거의 언제나 규칙이 아닙니다. 무시 목록은 Git이 아직 추적하지 않는 경로에만 적용됩니다. 한 번이라도 커밋된 파일은 규칙을 아무리 정교하게 써도 계속 올라옵니다. 이 글은 그 원인부터 확인하고, 그다음으로 실제로 패턴이 잘못된 경우를 다룹니다.
원인 1위 — 이미 추적 중인 파일에는 적용되지 않는다
먼저 진단부터 합니다. 추적 중이면서 동시에 무시 규칙에 걸린 파일 목록을 뽑는 명령이 있습니다. 검색으로는 잘 나오지 않지만 이 문제의 정답에 가장 가까운 한 줄입니다.
$ git ls-files -i -c --exclude-standard
config/local.env
build/out.js
node_modules/.package-lock.json
여기에 이름이 나온다면 규칙은 정상이고, 그 파일이 인덱스에 들어 있는 것이 원인입니다. 인덱스에서만 빼면 됩니다.
$ git rm --cached config/local.env
rm 'config/local.env'
$ git status --short
D config/local.env
?? config/local.env
디스크의 파일은 그대로 있고 인덱스에서만 빠졌습니다. 그런데 상태 표시의 첫 줄을 잘 봐야 합니다. 이 작업은 삭제를 스테이징합니다. 커밋해서 올리면, 그 브랜치를 받아 오는 동료의 작업 디렉터리에서 해당 파일이 실제로 지워집니다. 무시 목록에 들어 있어도 마찬가지입니다.
설정 파일에 이 명령을 쓰는 경우가 특히 위험합니다. 동료의 로컬 접속 정보가 예고 없이 사라지고, 서비스가 뜨지 않고, 원인을 찾는 데 반나절이 갑니다. 실무에서는 두 가지를 같이 해야 합니다. 하나는 예시 파일을 따로 추적하는 것이고, 다른 하나는 커밋 메시지와 공지에 명시하는 것입니다.
$ cp config/local.env config/local.env.example # 값은 지우고 키만 남긴 채
$ git add config/local.env.example
$ git commit -m "chore: 로컬 환경 파일 추적 해제, 예시 파일 추가"
디렉터리 전체를 빼야 한다면 재귀 옵션을 씁니다.
$ git rm -r --cached node_modules
rm 'node_modules/.package-lock.json'
rm 'node_modules/.bin/tsc'
...
무시 규칙을 대대적으로 손봤다면 인덱스를 통째로 다시 만드는 방법도 있습니다. 다만 이 방법은 줄바꿈 설정이나 파일 모드 변경까지 한꺼번에 딸려 나오는 경우가 있어, 커밋 전에 반드시 변경 목록을 확인해야 합니다.
$ git rm -r --cached .
$ git add .
$ git status --short | head
여기서 흔히 보이는 잘못된 조언을 하나 짚겠습니다. 추적 중인 파일의 로컬 수정만 감추고 싶을 때 아래 명령이 자주 추천됩니다.
$ git update-index --assume-unchanged config/local.env
$ git update-index --skip-worktree config/local.env
앞의 것은 무시 기능이 아니라 성능 최적화입니다. 이 파일은 바뀌지 않을 테니 확인하지 말라고 Git에게 약속하는 것이고, 브랜치를 바꾸거나 받아 오는 과정에서 Git이 그 파일을 덮어써도 아무 경고가 없습니다. 뒤의 것은 조금 더 보수적이지만, 그 경로를 반드시 갱신해야 하는 작업을 만나면 알 수 없는 오류로 멈춥니다. 어느 쪽도 다른 사람에게 전달되지 않습니다. 추적을 유지한 채 무시하는 방법은 없습니다. 추적을 끊고 예시 파일을 두는 것이 유일하게 유지보수되는 답입니다.
어느 규칙이 범인인지 Git에게 직접 묻는다
패턴을 눈으로 읽고 추리할 필요가 없습니다. 어느 파일의 몇 번째 줄이 이 경로를 잡았는지 알려 주는 명령이 있습니다.
$ git check-ignore -v build/out.js
.gitignore:2:build/ build/out.js
파일 이름, 줄 번호, 규칙, 대상 경로 순입니다. 그런데 여기 함정이 있습니다.
$ git check-ignore -v config/local.env
$ echo $?
1
아무것도 출력하지 않습니다. 규칙이 잘못된 것처럼 보이지만 그렇지 않습니다. 이 명령은 기본적으로 인덱스를 참조하고, 추적 중인 경로는 무시 대상이 아니라고 판단해 조용히 넘어갑니다. 규칙 자체를 시험하려면 인덱스를 무시하라고 알려 줘야 합니다.
$ git check-ignore -v --no-index config/local.env
.gitignore:5:*.env config/local.env
규칙은 처음부터 정상이었습니다. 이 두 출력의 차이가 바로 원인 1위를 확인하는 가장 빠른 방법이기도 합니다. 인덱스를 무시했을 때만 규칙이 나온다면, 그 파일은 추적 중입니다.
전체 그림을 보고 싶을 때는 상태 확인에 옵션을 붙입니다.
$ git status --ignored --short
M src/app.ts
?? refund.ts
!! build/
!! .env
패턴 문법 정독 — 슬래시의 위치와 부정 패턴이 무력화되는 규칙
규칙이 정말 잘못된 경우로 넘어갑니다. 무시 패턴에서 대부분의 혼란은 슬래시가 어디에 있느냐에서 나옵니다.
| 패턴 | 의미 | 걸리는 예 | 걸리지 않는 예 |
|---|---|---|---|
logs | 이름이 logs인 파일과 디렉터리를 모든 깊이에서 | logs, src/logs | logs.txt |
logs/ | 디렉터리만, 모든 깊이에서 | logs/, src/logs/ | 같은 이름의 일반 파일 |
/logs | 이 규칙 파일과 같은 위치의 logs만 | logs | src/logs |
doc/*.txt | 슬래시가 있으므로 위치 고정, 한 단계만 | doc/note.txt | doc/api/note.txt |
build/* | build 안의 항목들, build 자체는 제외 대상 아님 | build/out.js | build |
!keep.log | 앞선 규칙을 취소 | 상위가 제외되지 않았을 때만 | 상위 디렉터리가 제외된 경우 |
표에서 세 번째 줄과 네 번째 줄이 핵심입니다. 패턴 안에 슬래시가 하나라도 있으면 그 패턴은 규칙 파일이 있는 위치를 기준으로 고정되고, 슬래시가 전혀 없으면 어느 깊이에서든 걸립니다. 마지막 줄의 별표는 슬래시를 넘지 못하므로 한 단계만 매칭합니다. 여러 단계를 건너뛰려면 이중 별표가 필요합니다.
# 규칙 파일 위치 기준으로 doc 아래 몇 단계든 걸립니다
doc/**/*.txt
# 모든 깊이의 build 디렉터리 (슬래시 없는 패턴과 같은 뜻)
**/build
# build 아래의 모든 것 (build 디렉터리 자체는 대상이 아님)
build/**
# 주석은 샵으로 시작합니다. 파일 이름이 샵으로 시작하면 역슬래시로 탈출합니다
\#important.txt
# 뒤에 붙은 공백은 무시됩니다. 살리려면 역슬래시를 붙입니다
trailing\
부정 패턴이 무력화되는 하나의 규칙
빈 디렉터리를 유지하려고 아래처럼 쓰는 경우가 아주 흔합니다.
$ cat .gitignore
build/
!build/keep/.gitkeep
$ git check-ignore -v build/keep/.gitkeep
.gitignore:1:build/ build/keep/.gitkeep
부정 패턴이 아래 줄에 있는데도 위의 규칙이 이겼습니다. 순서 문제가 아닙니다. Git은 제외된 디렉터리 안으로 아예 내려가지 않습니다. 디렉터리 단계에서 걸러 버리므로 그 안의 파일에 대한 규칙은 읽히지도 않습니다. 문서에도 상위 디렉터리가 제외된 파일은 다시 포함시킬 수 없다고 명시돼 있습니다.
해결은 디렉터리가 아니라 내용을 제외하고, 살릴 경로를 단계마다 열어 주는 것입니다.
$ cat .gitignore
build/*
!build/keep/
build/keep/*
!build/keep/.gitkeep
$ git check-ignore -v build/keep/.gitkeep
.gitignore:4:!build/keep/.gitkeep build/keep/.gitkeep
$ git status --short --ignored
?? build/keep/.gitkeep
!! build/out.js
같은 이유로 아래 형태도 자주 실패합니다. 나중 규칙이 이기는 것은 맞지만, 상위가 디렉터리째 막혀 있으면 그 규칙에 도달하지 못합니다.
$ cat .gitignore
logs/
!logs/app.log
$ git check-ignore -v logs/app.log
.gitignore:1:logs/ logs/app.log
무시 규칙의 우선순위 계층
규칙은 한 파일에만 있지 않습니다. Git은 여러 출처를 순서대로 확인하고, 앞에서 결정이 나면 뒤는 보지 않습니다. 높은 쪽부터 이렇습니다.
첫째는 명령줄에서 준 패턴입니다. 정리 명령이나 파일 목록 조회에 붙이는 제외 옵션이 여기에 해당합니다. 둘째는 대상 경로와 같은 디렉터리의 규칙 파일이고, 없으면 상위로 올라갑니다. 더 가까운 디렉터리의 규칙 파일이 항상 이깁니다. 셋째가 저장소 안에만 있고 공유되지 않는 로컬 제외 파일이며, 마지막이 사용자 전역 설정입니다.
$ git check-ignore -v logs/app.log
.git/info/exclude:1:logs/app.log logs/app.log
이 계층을 알면 무엇을 어디에 쓸지가 자동으로 정해집니다.
# 저장소 전원에게 공유할 규칙
$ cat .gitignore
# 나만 쓰는 임시 디렉터리처럼 커밋하면 안 되는 개인 규칙
$ cat .git/info/exclude
# 계정 전체에 적용할 편집기와 운영체제 부산물
$ git config --global core.excludesFile ~/.config/git/ignore
$ cat ~/.config/git/ignore
.DS_Store
.idea/
*.swp
실무 규칙 하나만 정한다면 이것입니다. 편집기와 운영체제가 만드는 파일은 프로젝트 규칙 파일에 넣지 않습니다. 내가 쓰는 편집기 이름이 남의 저장소에 커밋될 이유가 없고, 그 목록은 사람마다 다릅니다. 전역 설정에 두면 모든 프로젝트에서 한 번에 해결됩니다.
대소문자를 구분하지 않는 파일시스템이 만드는 유령 변경
macOS와 Windows의 기본 파일시스템은 이름의 대소문자를 구분하지 않습니다. Git은 저장소를 만들 때 이 사실을 감지해 설정을 자동으로 켭니다.
$ git config core.ignorecase
true
여기서 생기는 증상은 이렇습니다. 파일 이름을 대문자에서 소문자로 바꿨는데 상태 확인에 아무것도 잡히지 않습니다. 로컬에서는 잘 돌아가는데 리눅스 CI에서만 모듈을 찾지 못한다고 실패합니다. 저장소 안에는 여전히 예전 대소문자의 이름이 들어 있기 때문입니다.
이름 변경을 Git에 확실히 인식시키려면 두 단계로 나눕니다.
$ git mv Utils.ts utils-tmp.ts
$ git mv utils-tmp.ts utils.ts
$ git status --short
R Utils.ts -> utils.ts
강제 옵션 한 번으로 되는 경우도 많지만, 파일시스템에 따라 실패하므로 두 단계 방식이 확실합니다.
$ git mv --force Utils.ts utils.ts
무시 규칙에도 같은 문제가 따라옵니다. 확장자를 대문자로 쓴 파일과 소문자로 쓴 규칙이 어떤 머신에서는 걸리고 어떤 머신에서는 걸리지 않는 상황이 생깁니다. 규칙을 쓸 때 대소문자에 의존하지 않는 편이 안전합니다. 필요하면 두 가지를 모두 적어 두십시오.
비슷하게 사람을 괴롭히는 유령 변경이 하나 더 있습니다. 줄바꿈 문자입니다. 이쪽의 정답은 사람마다 설정을 맞추는 것이 아니라 저장소에 규칙을 커밋하는 것입니다.
$ cat .gitattributes
* text=auto eol=lf
*.png binary
이미 커밋된 시크릿은 무시 목록으로 지워지지 않는다
마지막이 가장 중요합니다. 접속 키를 실수로 커밋한 뒤 무시 목록에 추가하고 다음 커밋에서 파일을 지우는 대응을 자주 봅니다. 그 키는 여전히 이력 안에 그대로 있습니다. 무시 규칙은 앞으로 추가될 파일에 대한 규칙일 뿐, 과거를 바꾸지 않습니다.
$ git log --all --full-history --oneline -- config/local.env
5b21c4e chore: 로컬 환경 파일 추적 해제
9a03d17 feat: 결제 게이트웨이 연동
$ git log -S 'AKIA' --oneline --all
9a03d17 feat: 결제 게이트웨이 연동
순서를 정확히 지켜야 합니다. 첫 번째는 이력 정리가 아니라 키 폐기와 재발급입니다. 이력을 다시 쓰는 작업은 팀 전체의 조율이 필요해 빨라야 몇 시간인데, 공개 저장소를 훑는 자동 수집기는 몇 초 만에 키를 가져갑니다. 순서를 바꾸면 몇 시간 동안 유효한 키를 세상에 열어 두게 됩니다.
키를 폐기하고 나면 그다음이 이력 정리입니다.
$ git filter-repo --path config/local.env --invert-paths
Parsed 9134 commits
New history written in 11.42 seconds; now repacking/cleaning...
여기에도 한계가 분명합니다. 재작성은 내 저장소의 이력을 바꿀 뿐이고, 호스팅 서비스에는 PR 참조와 포크가 옛 커밋을 붙들고 있는 경우가 많아 별도의 정리 요청이 필요합니다. 이미 누군가 클론해 갔다면 회수는 불가능합니다. 이력 재작성은 유출의 취소가 아니라 확산 억제일 뿐입니다. 재작성이 치르는 대가는 저장소가 느려질 때 편 마지막 절에 자세히 정리했습니다.
예방은 훨씬 쌉니다. 값이 들어 있는 환경 파일은 처음부터 추적하지 않고 예시 파일만 커밋합니다. 커밋 훅에 시크릿 탐지기를 걸고, 호스팅 서비스의 푸시 차단 기능을 켭니다. 이 셋을 걸어 두는 데 걸리는 시간이 키 하나를 폐기하고 회전시키는 시간보다 짧습니다.
마치며 — 규칙을 의심하기 전에 상태를 확인하십시오
무시 목록이 듣지 않을 때의 순서는 언제나 같습니다. 먼저 추적 중인지 확인하고, 그다음 어느 규칙이 잡고 있는지 Git에게 묻고, 마지막에야 패턴을 고칩니다. 이 순서만 지켜도 검색에 쓰는 시간의 대부분이 사라집니다.
기억할 문장은 하나입니다. 무시 목록은 추적되지 않는 파일에 대한 규칙이고, 이미 인덱스에 있는 파일에는 아무 영향도 주지 않습니다. 그리고 시크릿이 걸렸다면 무시 목록은 애초에 대응 수단이 아닙니다. 그때 첫 번째 명령은 Git이 아니라 키 발급 콘솔에서 실행됩니다.