0. 무슨 일이 있었나
finance-tracker 작업 중에 client/node_modules 심링크가 커밋에 섞여 통합 브랜치까지 올라갔습니다.
파일 하나입니다. 59바이트입니다. 내용은 절대경로 한 줄입니다.
$ git cat-file -p c761ff48723614f4c176c967db84cea8ec7c7cb4
/Users/vinyl/vinylstudio/finace-tracker/client/node_modules
$ git cat-file -s c761ff48723614f4c176c967db84cea8ec7c7cb4
59이게 왜 문제냐면, 이걸 받은 사람의 기계에는 저 경로가 없기 때문입니다. client/node_modules 자리에 깨진 링크가 놓이고, yarn build가 실행 파일을 못 찾고 죽습니다.
발견까지 23분 걸렸습니다.
08:55:25 심링크가 포함된 커밋
09:13:25 그 커밋이 통합 브랜치로 머지됨
09:18:24 문제를 인지하고 수정 커밋
09:19:24 같은 심링크가 다른 브랜치에서 또 발견됨마지막 줄이 이 글을 쓰는 이유입니다. 한 번이 아니라 두 번이었습니다.
1. 왜 심링크를 쓰고 있었나
여러 세션이 같은 저장소를 동시에 작업하고 있었습니다. 각 세션은 git worktree로 별도 트리를 씁니다.
git worktree add -b feat/foo ../project-foo-wt origin/develop
git worktree add -b feat/bar ../project-bar-wt origin/develop워크트리마다 yarn install을 새로 돌리면 시간도 걸리고 디스크도 먹습니다. 그래서 원본 체크아웃의 node_modules를 가리키는 심링크를 겁니다.
ln -s /Users/vinyl/vinylstudio/finace-tracker/client/node_modules client/node_modules이 구성 자체는 문제가 아닙니다. 문제는 이게 git이 추적할 수 있는 대상이라는 점입니다. 디렉터리는 git이 직접 추적하지 않지만, 심링크는 blob으로 저장됩니다. 모드 120000이 그 표시입니다.
$ git ls-files -s client/node_modules
120000 c761ff4872... 0 client/node_modules2. 첫 번째 오해, gitignore가 막아줄 것이다
사고 당시 .gitignore는 이랬습니다.
node_modules/
data/
dist/
.env
.DS_Store
*.log
ref/
client/node_modules/
client/dist/client/node_modules/가 명시적으로 있습니다. 이미 이 경로를 의식하고 있었다는 뜻입니다. 그런데도 뚫렸습니다.
트레일링 슬래시 때문입니다. foo/ 패턴은 디렉터리만 매칭합니다. 같은 이름의 심링크는 그대로 통과합니다.
스크래치 저장소에서 직접 확인했습니다.
$ echo "node_modules/" > .gitignore
$ ln -s /tmp/real/node_modules client/node_modules
$ git add client/node_modules
$ git ls-files -s client/node_modules
120000 e62d6f97b3... 0 client/node_modules # 무시되지 않고 스테이징됨슬래시를 떼면 막힙니다.
$ echo "node_modules" > .gitignore
$ git add client/node_modules
The following paths are ignored by one of your .gitignore files:
client/node_modules이건 gitignore 문서에 적혀 있는 동작입니다. "패턴이 슬래시로 끝나면 디렉터리에만 매칭된다"는 문장이 그대로 있습니다. 읽은 적은 있는데 심링크와 연결해서 생각한 적은 없었습니다.
3. 두 번째 오해, gitignore에 넣으면 이제 안전하다
슬래시를 떼는 것만으로는 부족합니다. 이미 추적되기 시작한 파일에는 gitignore가 아예 적용되지 않습니다.
같은 저장소에서 확인했습니다.
$ cat .gitignore
node_modules
$ git ls-files -s client/node_modules
120000 e62d6f97b3... 0 client/node_modules # 이미 추적 중
$ rm -f client/node_modules && ln -s /tmp/real2/node_modules client/node_modules
$ git status --porcelain -- client/node_modules
M client/node_modules # gitignore에 있는데도 잡힘두 구멍은 독립적입니다. 하나를 고쳐도 다른 하나가 남습니다.
| 구멍 | 조건 | 해결 |
|---|---|---|
| 트레일링 슬래시 | 패턴이 /로 끝남 |
슬래시 제거 |
| 추적 시작 이후 | 이미 인덱스에 있음 | git rm --cached |
수정 커밋은 둘 다 처리했습니다.
-node_modules/
+# node_modules 에는 트레일링 슬래시를 붙이지 않는다. `foo/` 는 **디렉터리만**
+# 매칭해서 심링크는 그대로 통과한다.
+node_modules
+client/node_modules
-client/node_modules/주석을 길게 단 이유는, 다음에 이 파일을 보는 사람이 "슬래시 붙이는 게 관례 아닌가" 하고 되돌릴 것 같았기 때문입니다.
4. 진짜 원인, git add에 디렉터리를 넘겼다
여기까지는 "왜 막히지 않았나"입니다. "왜 들어갔나"는 따로입니다.
저장소에는 이미 규칙이 있었습니다. git add -A를 쓰지 않는다. 경로를 명시한다.
그리고 그 규칙을 지켰습니다. git add -A를 쓰지 않았습니다. 대신 이렇게 했습니다.
git add client디렉터리 하나만 지정했으니 "경로를 명시한" 것으로 읽었습니다. 그런데 git add <디렉터리>는 그 아래를 재귀적으로 훑습니다. 심링크가 딸려 들어갑니다.
스크래치 저장소에서 그대로 재현됩니다.
$ ln -s /tmp/real/node_modules client/node_modules
$ echo "console.log(1)" > client/app.js
$ git add client
$ git ls-files -s -- client
100644 e14c4f2a95... 0 client/app.js
120000 e62d6f97b3... 0 client/node_modules # 딸려 들어옴규칙이 없어서 난 사고가 아니라, 규칙을 좁게 읽어서 난 사고입니다. "경로를 명시한다"를 "-A만 안 쓰면 된다"로 읽으면 디렉터리 추가는 규칙을 지킨 것처럼 보입니다.
그래서 규칙 문장을 고쳤습니다. 금지 대상을 열거하는 형태로 바꿨습니다.
git add src/lib/foo.js src/lib/bar.js # O
git add src # X — 아래 심링크까지 딸려온다
git add -A # X5. 가장 아팠던 것, 확인은 했는데 안 보였다
커밋 전에 스테이징 확인을 했습니다. 안 한 게 아닙니다. 이렇게 했습니다.
git status -s | grep -v node_modulesnode_modules 관련 노이즈가 많아서 걸러낸 것입니다. 평소에는 편했습니다.
그런데 이번에는 확인하려던 바로 그 줄이 지워졌습니다.
재현하면 이렇습니다.
$ git status -s
A client/extra.js
M client/node_modules
?? .gitignore
$ git status -s | grep -v node_modules
A client/extra.js
?? .gitignore심링크 줄이 사라졌습니다. 화면에는 정상적인 스테이징 상태만 남습니다. 확인했고, 문제없어 보였고, 커밋했습니다.
이게 이 사고에서 제일 중요한 지점이라고 생각합니다. 앞의 규칙을 다 어겼어도 이 필터만 없었으면 커밋 전에 잡혔습니다.
노이즈를 줄이는 방향을 반대로 잡아야 했습니다. 제외하는 게 아니라 볼 것을 지정하는 쪽입니다.
git status -s | grep -v <이름> # X — 그 이름이 문제일 때 못 본다
git status -s -- <경로> <경로> # O — 볼 것을 지정한다5.1 확인했다와 보였다는 다르다
사고 보고를 쓰면서 처음에 "스테이징을 확인했으나 놓쳤다"라고 적었습니다. 그러면 다음 사람은 주의력 문제로 읽습니다.
실제 원인은 주의력이 아니라 출력이 가려진 것입니다. 이 둘은 대응이 완전히 다릅니다. 전자는 "더 꼼꼼히 보자"로 끝나고, 후자는 "확인 명령을 고치자"가 됩니다.
그래서 문장을 고쳐 적었습니다. 확인 명령을 실행한 사실이 아니라, 출력에 실제로 무엇이 보였는지를 근거로 써야 합니다.
6. 얼마나 퍼져 있었나
한 번 일어난 일은 대개 여러 번 일어나 있습니다. 전체 이력을 훑었습니다.
git rev-list --all | while read c; do
out=$(git ls-tree -r "$c" 2>/dev/null | awk '$1=="120000"{print $4}')
[ -n "$out" ] && echo "$c $out"
done세 커밋이 나왔습니다.
| 커밋 | 시각 | 위치 |
|---|---|---|
87865e4 |
08:55:25 | 기능 브랜치 |
8aff25d |
09:13:25 | 통합 브랜치로 머지된 스쿼시 커밋 |
bb130cd |
09:19:24 | 다른 기능 브랜치 |
세 번째가 문제였습니다. 첫 사고를 인지한 뒤에 만들어진 커밋입니다. 다른 브랜치에서 같은 실수를 반복하고 있었습니다.
같은 방식으로 스테이징하고 있었으니 당연한 결과입니다. 사람이 조심하는 것으로는 안 됩니다.
현재 통합 브랜치는 깨끗합니다.
$ git ls-tree -r origin/develop | awk '$1=="120000"'
$ 7. pre-commit 훅
스테이징 시점에 막는 장치를 넣었습니다. 두 가지를 봅니다.
- 심링크 (모드
120000) - 비정상적으로 큰 파일 (기본 2MB)
핵심은 무엇을 검사 대상으로 삼느냐입니다.
7.1 작업 트리를 훑으면 안 된다
처음 떠오른 방법은 이겁니다.
if find . -type l; then
echo "심링크 발견"
exit 1
fi두 군데가 틀렸습니다.
첫째, 작업 트리에는 심링크가 정상적으로 존재합니다. 그게 이 구성의 전제입니다. 그걸 잡으면 아무것도 스테이징하지 않은 커밋도 막힙니다.
둘째, find는 결과가 없어도 종료 코드 0을 반환합니다. if find ...; then은 조건이 항상 참입니다. 모든 커밋이 막힙니다.
두 번째는 코드 리뷰에서 잡힐 수도 있지만, 첫 번째는 훅을 실제로 돌려보기 전에는 안 보입니다.
7.2 인덱스만 검사한다
실제로 넣은 코드입니다.
SYMLINKS="$(git diff --cached --name-only --diff-filter=AM -z \
| xargs -0 -I{} sh -c 'git ls-files -s "{}" 2>/dev/null' \
| awk '$1=="120000"{ $1=""; $2=""; $3=""; sub(/^[ \t]+/,""); print }')"git diff --cached가 인덱스만 봅니다. 작업 트리의 무시 대상 심링크는 걸리지 않습니다.
에러 메시지에 해제 방법과 올바른 스테이징 방법을 같이 넣었습니다.
[staging-guard] ⛔ 심링크가 스테이징됐습니다.
client/node_modules
해제:
git restore --staged client/node_modules
스테이징은 디렉터리가 아니라 파일 경로를 명시하세요.
git add client/src/lib/foo.js (O)
git add client (X — 심링크까지 딸려옵니다)막기만 하고 무엇을 하라는 말이 없으면, 사람은 훅을 고치는 대신 지웁니다.
8. 훅은 세 가지 경우로 검증한다
여기서 한 번 더 배웠습니다. 훅을 넣고 "심링크를 막는가"만 확인하고 넘어갈 뻔했습니다.
실제로 검증한 세 경우입니다.
8.1 막아야 할 것을 막는가
$ git add client
$ git ls-files -s -- client
100644 e14c4f2a95... 0 client/app.js
120000 e62d6f97b3... 0 client/node_modules
$ git commit -m c1
[staging-guard] ⛔ 심링크가 스테이징됐습니다.
client/node_modules차단됨. 통과입니다.
8.2 막지 말아야 할 것을 통과시키는가
$ git restore --staged client/node_modules
$ git ls-files -s -- client
100644 e14c4f2a95... 0 client/app.js
$ git commit -m c2
$ git log --oneline -1
c1eb21b c2통과됨. 오탐 없음.
이쪽이 더 중요합니다. 훅을 죽이는 것은 대개 오탐입니다. 통과해야 할 커밋이 막히면 사람은 훅을 고치지 않고 없앱니다. 7.1의 find 버전이었다면 여기서 걸렸을 것입니다.
8.3 우회 수단이 실제로 동작하는가
$ git commit --no-verify -m bypass
$ git log --oneline -1
4687701 bypass동작합니다.
우회 수단을 숨기지 않고 문서에 적었습니다. 숨기면 급할 때 훅 파일 자체를 지웁니다. 지워진 훅은 돌아오지 않지만, 한 번 건너뛴 커밋은 기록에 남습니다.
8.4 큰 파일도 같이
$ dd if=/dev/zero of=big.bin bs=1024 count=3000
$ git add big.bin
$ git commit -m c3
[staging-guard] ⛔ 큰 파일이 스테이징됐습니다 (기준 1953KB).
big.bin (3000KB)기준값은 환경변수로 넘길 수 있게 뒀습니다. 의도적으로 큰 파일을 넣어야 하는 경우가 있기 때문입니다.
9. 정리 순서에도 함정이 있다
인덱스에서 빼는 것과 실체를 지우는 것은 다릅니다.
git rm --cached client/node_modules # O — 인덱스에서만 뺀다
git rm client/node_modules # X — 실체까지 지운다두 번째를 하면 의존성이 사라져서 빌드가 죽습니다. 다시 ln -s로 걸어야 합니다.
관련해서 실제로 한 번 더 겪은 것이 있습니다. git stash push -u가 심링크를 일반 파일로 바꿔 놓습니다. 경로 문자열 59바이트가 들어 있는 텍스트 파일이 됩니다. 그 상태에서 빌드하면 이렇게 됩니다.
sh: vite: command not found브랜치를 오가느라 stash를 썼다면 복구해야 합니다.
rm -f client/node_modules
ln -s /path/to/original/client/node_modules client/node_modules10. 남은 것
정리하면 이렇습니다.
| 층 | 규칙 | 이 사고에서 |
|---|---|---|
| 스테이징 | 디렉터리가 아니라 파일 경로 | git add client가 원인 |
| 확인 | 제외 필터를 걸지 않는다 | grep -v가 증거를 지움 |
| 무시 규칙 | 방어 수단으로 세지 않는다 | 두 구멍 다 뚫림 |
| 훅 | 마지막 방어선 | 사후 도입 |
순서가 중요합니다. 훅은 마지막 방어선이지 1차가 아닙니다. 훅이 있다는 이유로 스테이징 규칙을 느슨하게 하면, 훅이 빠진 환경에서 그대로 사고가 납니다. 새 클론에서 core.hooksPath 설정이 안 되어 있으면 훅은 없는 것과 같습니다.
가장 값이 컸던 교훈은 4번과 5번입니다. 규칙은 있었고 확인도 했습니다. 규칙을 좁게 읽었고, 확인 명령이 증거를 지웠습니다. 둘 다 더 조심하자로는 안 고쳐지는 종류입니다.
이 사고의 일반화된 규칙은 git 공식 문서의 gitignore 패턴 설명과 githooks 문서를 근거로 정리해 공통 프로세스 저장소에 별도로 올렸습니다. 프로젝트 이름과 사건 기록을 뺀 규칙만 남긴 형태입니다.
측정 가능한 결과는 하나입니다. 훅 도입 후 같은 유형의 커밋은 0건입니다. 다만 도입한 지 하루도 안 됐으니 이 숫자는 아직 아무 의미가 없습니다. 한 달 뒤에 다시 세어 볼 생각입니다.