~/.zshrc에 node, npm, npx, corepack, nvm 다섯 이름의 지연 로딩 래퍼를 둔 셸에서, Claude Code의 Bash 도구가 실행한 명령은 command not found: _nvm_lazy를 낸 뒤 FUNCNEST 한도까지 재귀했다. 이 글은 그 재귀의 원인을 스냅샷 재현으로 확정하고, 공용 도우미를 없앤 자급자족 래퍼로 고친 과정을 적는다. 바꾼 범위는 nvm 블록 하나였다. zsh -c nvm은 처음부터 목표에서 뺐다. 아래 내용은 2026년 10월 13일 하네스 설정 실행에서 남긴 기록과 그 뒤의 라이브 점검을 따른다. 재현, 점검, 시간 측정은 모두 같은 실행 안에서 다시 돌린 결과다.

다섯 래퍼가 자기 자신을 다시 불렀다

구조는 단순했다. 다섯 이름은 얇은 래퍼 함수였고, 첫 호출 때 공용 도우미 _nvm_lazy가 래퍼를 지운 다음 nvm.sh를 읽고 같은 이름을 다시 불렀다. 문제는 Claude Code가 셸 시작 파일을 읽어 만든 스냅샷 위에서 드러났다. 실행 결과에는 두 가지 메시지가 찍혔다.

command not found: _nvm_lazy
maximum nested function level reached; increase FUNCNEST?

래퍼는 _nvm_lazy를 찾지 못해 첫 메시지를 내고, 자기 이름을 다시 불렀다. 그 이름은 지워지지 않았으므로 같은 래퍼가 다시 실행됐다. 이 과정이 반복되다가 한도에 닿아서야 멈췄다. zsh 매뉴얼은 FUNCNEST를 함수 중첩 깊이의 최대값으로 설명하고, 넘으면 함수가 호출되는 지점에서 오류가 난다고 적는다. 기본값은 보통 500이라고 한다.[zsh 매뉴얼] 실행 기록에는 실제 단계 수가 없다. 확인한 것은 한도에 닿았다는 사실뿐이다.

재귀 자체보다 이상했던 점은 따로 있었다. 함수 정의 다섯 개는 스냅샷에 남아 있었고, 빠진 것은 _nvm_lazy 하나뿐이었다. 그래서 “스냅샷이 함수를 버린다”고 바로 단정하지 않았다. 어떤 이름이 빠지는지부터 확인했다.

스냅샷 재현으로 원인을 좁혔다

계획에 적힌 표본 스냅샷 관찰에서 ^_[a-z]에 맞는 줄은 0줄이었다. 이름이 밑줄로 시작하는 함수가 하나도 없었다는 뜻이다. 이 관찰은 “밑줄로 시작하는 함수는 버려진다”는 가설을 세웠다. 다만 Claude Code 공식 문서는 시작 파일의 함수가 Bash 명령에 적용된다고 설명할 뿐, 어떤 이름을 거르는지는 적지 않는다.[Claude Code 문서] 그래서 이 가설은 재현으로 확인해야 했다.

재현은 세 단계였다. 먼저 대화형 zsh -i에서 함수 정의만 덤프했다. 다음으로 HOMEBREW_PREFIX를 지운 새 zsh -c가 그 덤프를 읽게 했다. 마지막으로 실제 스냅샷을 먼저 읽힌 뒤 덤프를 이어 읽게 했다. 기준선에서 이 재현은 command not found: _nvm_lazy와 FUNCNEST 안내 문구를 냈다. 재실행한 기준선 점검은 FUNCNEST 관련 줄을 18개 셌다.

기준선 스냅샷 재현에서 이름별 결과
이름기준선 결과이유
node, npm, npx, corepack_nvm_lazy를 찾지 못해 재귀래퍼는 남고 도우미만 없다
nvm같은 재귀같은 래퍼 구조
pnpm, bun통과래퍼를 거치지 않는다

이 표가 원인을 좁혔다. 재귀는 래퍼가 도우미를 부르는 구조에서 나오고, 그 도우미만 스냅샷에서 빠진다. 그러니 도우미를 부르지 않는 래퍼로 바꾸면 빠진 이름이 문제가 되지 않는다. 이것이 설계의 출발점이었다.

수용 기준을 먼저 적고 계획을 검증받았다

수정 전에 수용 기준 다섯 개를 계획에 적었다. 기준마다 무엇을 어떻게 확인할지도 함께 적었다.

수용 기준 Z1부터 Z5
기준내용확인 방법
Z1node, npm, npx, corepack이 zsh -i -c, zsh -c, 스냅샷 재현에서 버전을 낸다. nvm은 대화형 셸과 스냅샷 재현에서만 같은 성공 대상이다‘command not found’나 FUNCNEST 없이 버전 출력
Z2도우미를 지운 상태에서도 재귀가 없다도우미 미정의 점검
Z3zsh -i -c exit 10회 평균이 기준선 0.162초에 0.02초를 더한 값 안이다시간 측정
Z4변경이 원본 84–104줄 안에 머물고 백업, 해시, 원자 교체, 변경 기록이 있다diff 게이트와 적용 기록
Z5pnpm과 bun이 같은 환경에서 동작한다버전 출력

계획은 다른 벤더의 모델이 한 번에 검증했다. 점수는 coverage 9, feasibility 9, dependencies 8, verifiability 8, risks 9였고 blockers와 unreviewed는 비어 있었다. 검증자가 든 이유는 단일 슬라이스가 다섯 기준을 모두 덮고, 래퍼가 자신을 먼저 지운 뒤 다시 부르는 방식이 관찰된 재귀를 직접 겨냥한다는 것이었다. 점검 행렬은 뒤 단계의 산출물이므로 지금 없다고 계획을 막을 이유가 아니라고 했다.

처음 계획은 zsh -c nvm을 목표 밖으로 기록해 메인 세션에 올렸고, 메인 세션이 Z1을 고쳐 예상된 비목표로 명시했다. 이 판단은 뒤에서 다시 적는다.

공용 도우미를 없애고 래퍼를 자급자족하게 바꿨다

고친 래퍼는 한 번 호출될 때 아래 순서로 움직인다.

  1. 다섯 이름을 모두 unset -f로 지운다. zsh 매뉴얼은 unset -f가 unfunction과 같다고 적는다.[zsh 매뉴얼]
  2. HOMEBREW_PREFIX가 비어 있고 기본 설치 경로의 Homebrew nvm.sh가 있으면 그것을 읽는다. 없으면 NVM_DIR 아래 nvm.sh를 읽는다. 자동 완성은 대화형 셸일 때만 읽는다.
  3. 같은 이름을 리터럴로 다시 부른다. 예를 들어 node 래퍼는 node "$@"로 끝난다.
이 흐름도는 고친 래퍼가 한 번 호출될 때 거치는 다섯 단계를 보여 준다. 먼저 다섯 이름을 unset -f로 지운다. 다음으로 HOMEBREW_PREFIX가 비어 있고 기본 설치 경로에 Homebrew nvm.sh가 있으면 그것을 읽고, 없으면 NVM_DIR 아래의 nvm.sh를 읽는다. 그다음 대화형 셸일 때만 자동 완성을 읽는다. 마지막으로 node "$@"처럼 같은 이름을 다시 부른다. 첫 단계가 강조되어 있다.
래퍼가 nvm을 읽기 전에 자기 정의부터 지우므로, nvm.sh를 찾지 못해도 다시 부른 이름은 재귀하지 않는다.

핵심은 첫 단계에 있다. 래퍼가 자기 자신을 먼저 지우므로 nvm.sh를 못 찾더라도 같은 이름을 다시 불러도 재귀하지 않는다. 함수 본문에는 밑줄로 시작하는 도우미 호출이 하나도 없다. 다섯 함수는 작은따옴표로 묶은 본문 변수 하나를 eval로 펼쳐 만들었고, 블록을 빠져나올 때 임시 변수를 unset한다.

nvm README는 export NVM_DIR="$HOME/.nvm" 뒤에 [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"를 두어, 파일이 있을 때만 nvm.sh를 읽도록 권한다.[nvm README] 고친 래퍼도 기록된 동작으로 nvm.sh가 있을 때만 읽는다. 두 설명은 같은 조건을 말하지만, 래퍼 설계가 README를 따랐다는 근거는 실행 기록에 없다.

nvm.sh를 찾을 수 없는 환경도 따로 만들어 확인했다. 점검 행렬에서 node는 상속된 PATH의 실행 파일로 폴백해 rc=0으로 끝났다. 다만 이 경로는 node 하나에만 돌렸다. nvm은 실행 파일이 없는 셸 함수이므로 command not found: nvm(rc=127)으로 끝났다. 재귀는 없었고, 이 결과는 예상된 것으로 기록했다.

블록은 원본 21줄에서 33줄이 됐다. 늘어난 12줄 가운데 네 줄은 설명 주석이다. 기본 node bin을 PATH 앞에 두는 기존 블록은 그대로 두었으므로, #!/usr/bin/env node 스크립트도 계속 돌아간다.

적용과 되돌리기

적용은 홈 디렉터리에 임시 파일을 만든 뒤 이름을 바꾸는 원자 교체로 했다. 바꾸기 전에 ~/.zshrc를 쓰기로 연 프로세스가 있는지 lsof로 확인했고, 찾지 못했다. 바꾼 뒤에는 해시를 확인했다. 되돌리기는 백업을 같은 방식의 원자 교체로 복원하는 것이다. 복원 전에는 현재 해시가 적용 후 값과 같은지 먼저 본다. 이 순서를 적어 두면 되돌릴 때 엉뚱한 파일을 덮어쓰지 않는다.

점검 행렬과 시작 시간

수정 뒤 같은 행렬을 라이브 ~/.zshrc로 다시 돌렸다. 행렬은 대화형, zsh -c, 스냅샷 재현, 도우미 미정의, nvm.sh 없음, 두 번째 호출, 구문 검사로 나뉜다. 점검 스크립트는 ~/.zshrc를 읽기만 하고 고치지 않는다.

수정 뒤 라이브 점검 결과
구간대상결과
대화형 zsh -inode, npm, npx, corepack, nvm, pnpm, bun7개 모두 rc=0
zsh -cnode, npm, npx, corepack, pnpm, bunrc=0, 상속된 PATH로 판단
zsh -cnvmrc=127, 비목표
스냅샷 재현래퍼 다섯 개rc=0
실제 스냅샷에 덤프를 더한 점검래퍼 다섯 개rc=0
도우미 미정의래퍼 다섯 개rc=0
nvm.sh 없음node, nvmnode rc=0, nvm rc=127(예상)
두 번째 호출2개rc=0
zsh -n~/.zshrc 구문rc=0
FUNCNEST 관련 줄라이브 전체0줄(기준선 18줄)
점검 행렬 여섯 칸을 보여 준다. 대화형 zsh -i에서는 7개 명령이 모두 rc=0이고, zsh -c에서는 도구 명령이 상속된 PATH로 rc=0이다. zsh -c nvm은 rc=127이지만 비목표다. 강조된 스냅샷 재현 칸에서는 래퍼 다섯 개가 모두 rc=0이다. nvm.sh가 없을 때 nvm은 예상대로 rc=127이고, FUNCNEST 관련 줄은 기준선 18줄에서 0줄로 줄었다.
수정 뒤 점검에서 재귀가 났던 스냅샷 재현 경로는 래퍼 다섯 개가 모두 rc=0이었고, rc=127 두 건은 예상된 실패다.

rc=0인 명령은 32개였다. rc=127 두 건은 모두 예상된 실패다. 하나는 zsh -c nvm이고, 다른 하나는 nvm.sh를 찾을 수 없게 했을 때의 nvm이다. FUNCNEST 관련 줄은 FUNCNEST 또는 nested function 문자열을 세어 셌다. 라이브 덤프 85줄에서 _nvm_lazy를 가리키는 줄은 0줄이었다.

시작 시간도 쟀다. zsh -i -c exit를 10회 돌려 평균을 냈다. 같은 세션의 기준선은 0.162초, 단계 후보는 0.158초, 라이브는 0.156초였다. 라이브 중앙값은 0.160초였다. Z3의 한도는 0.182초였으므로 안에 들어온다. 다만 차이는 몇 밀리초이고 측정 간격이 0.01초 단위다. 그래서 빨라졌다고 읽지 않고, 같은 수준이라고 읽었다.

zsh -c nvm은 목표가 아니다

zsh -c nvm은 수정 전에도 command not found: nvm으로 실패했다. zsh -c는 ~/.zshrc를 읽지 않는다. nvm은 실행 파일이 없는 셸 함수이므로 바이너리처럼 해석되지도 않는다. 그래서 Z1에서는 zsh -c의 node, npm, npx, corepack만 상속된 PATH로 판단하고, nvm은 비목표로 명시했다. 이 실패는 이번 변경이 만든 것이 아니다.

점검 쪽에서 실수한 세 가지

아래 세 가지는 모두 제품 파일이 아니라 점검 쪽에서 생긴 문제다. 그대로 남긴다.

  1. 인자 오류. 기준선 행렬의 첫 구간 zsh -i -c <명령> 줄이 모두 bad option string: '-i -c'가 됐고, 종료 코드도 잡히지 않았다. 두 옵션을 한 문자열로 넘긴 점검 쪽 인자 오류로 보인다. 같은 파일의 뒤쪽 줄에서 인자를 바로잡아 다시 돌렸다. 정확한 원인 설명은 기록에 없다.
  2. 빈 측정 파일. 첫 기준선 점검과 첫 단계 점검의 시간 측정 파일과 요약 파일이 비어 있었다. 같은 스크립트를 다시 돌리자 10회 측정이 모두 기록됐다. 첫 실행이 빈 이유는 기록에 없다. 이 글의 수치는 모두 재실행분이다.
  3. 증거 공백. 검증자는 pnpm과 bun이 스냅샷에서도 동작한다는 결과가 없다고 지적했다. 스냅샷 구간이 다섯 래퍼만 돌렸고, pnpm과 bun은 일반 실행 구간에만 있었기 때문이다. 게이트는 이 항목 때문에 실패했다. 제품 줄은 하나도 바꾸지 않고, 두 도구를 스냅샷 재현과 덤프 점검에 넣어 실행했다. 네 건 모두 rc=0이었고 버전은 pnpm 12.6.0, bun 1.4.3이었다. 정책상 보정은 한 번만 했고, 재리뷰는 하지 않았다.

후속: 제어 tty가 없을 때 생기는 gitstatus 경고

nvm 수정 뒤에도 대화형 실행마다 stderr에 can't change option: monitor와 gitstatus failed to initialize가 붙었다. 명령의 종료 코드와 출력은 정상이었다. 이 경고는 nvm 수정 이전의 기준선에도 있었으므로 nvm 변경의 결함이 아니다. FUNCNEST 점검 패턴에도 걸리지 않았다.

원인은 powerlevel10k가 쓰는 gitstatus 플러그인이 monitor 옵션을 켜려 할 때, 제어 tty가 없는 셸에서는 zsh가 그 옵션을 바꾸지 못한다는 점이었다. /dev/tty를 열 수 없을 때만 생겼고, 실제 pty 아래의 셸에서는 생기지 않았다. 해결은 ZSH_THEME 줄 뒤에 아래 한 줄을 넣는 것이었다. 이유를 적은 주석 한 줄도 함께 넣었다.

{ : </dev/tty } 2>/dev/null || ZSH_THEME=""

/dev/tty를 열 수 있으면 아무것도 하지 않고, 열 수 없으면 테마를 비워 powerlevel10k를 읽지 않는다. 그러면 gitstatus가 시작되지 않아 경고도 생기지 않는다. 활성화 뒤 제어 tty가 없는 시작 시간의 첫 라이브 측정은 0.141초로 한도를 넘었다. 당시 부하 평균이 약 6이었고, 같은 조건의 재측정은 0.104초와 0.105초였다. 잡음으로 판단한 뒤 메인 세션이 변경 전후를 번갈아 15번씩 잰 결과는 0.1124초에서 0.1095초였다. 임시 ZDOTDIR 시험(시작 시간 0.107초에서 0.091초)은 적용 전 후보의 결과이고, 그때 pty의 대화형 셸은 powerlevel10k를 그대로 유지했다.

남은 한계

이 재현은 이미 있던 스냅샷 위에 고친 함수를 덮어 쓰는 방식이었다. 고친 ~/.zshrc가 처음부터 만든 새 스냅샷은 이 실행에서 직접 관찰하지 않았다.

그 뒤 메인 세션을 통해 새 세션에서 node --version이 v24.21.0을 냈다는 확인이 전달됐다. 이 확인은 node 하나에 대한 것이다. 나머지 래퍼는 같은 방식으로 확인하지 않았다.

밑줄로 시작하는 함수를 스냅샷이 거른다는 규칙도 문서로 확인된 것이 아니다. 한 표본의 관찰과 재현에서 나온 추론이다. 문서로 확인된 것은 시작 파일의 함수가 Bash 명령에 적용된다는 점까지다. 보정 뒤에는 재리뷰가 없었다. 시작 시간 차이는 측정 해상도 안이다. 이 세 가지는 독자가 그대로 알고 읽어야 할 한계다.

자주 묻는 질문

셸 함수 이름이 _로 시작하면 Claude Code가 항상 버리나?

이 글에서 확인한 것은 두 가지다. 표본 스냅샷에서 해당 줄이 0줄이었고, 도우미를 빼고 재현하면 래퍼가 재귀했다. 공식 문서는 이 규칙을 설명하지 않으므로 모든 환경에서 항상 버린다고 단정하지 않는다.

nvm.sh를 찾지 못하면 node는 어떻게 되나?

점검 행렬에서 node는 상속된 PATH의 실행 파일로 폴백해 rc=0으로 끝났다. nvm은 실행 파일이 없는 셸 함수라 command not found: nvm(rc=127)이 예상된 결과다. 두 경우 모두 재귀는 없다.

zsh -c nvm도 고쳐야 하나?

고치지 않는다. zsh -c는 ~/.zshrc를 읽지 않고, nvm은 실행 파일이 없는 함수라 바이너리처럼 해석되지 않는다. 이 실패는 수정 전에도 있었다.

관련 글과 출처