pyenv rehash 락 파일 에러 해결: "couldn't acquire lock" 60초 멈춤 문제

pyenv rehash가 "couldn't acquire lock" 에러와 함께 60초 동안 멈추는 문제의 원인은 스테일 락 파일입니다. 소스 코드의 noclobber 메커니즘을 분석하고, 해결 방법과 --no-rehash를 활용한 예방법을 정리했습니다.

1. 문제 상황

터미널을 열 때마다 아래 에러가 나타나면서 60초 동안 멈추는 현상이 발생했습니다.

pyenv: cannot rehash: couldn't acquire lock /Users/kim/.pyenv/shims/.pyenv-shim for 60 seconds.
Last error message:
/opt/homebrew/Cellar/pyenv/2.6.23/libexec/pyenv-rehash: line 22:
/Users/kim/.pyenv/shims/.pyenv-shim: cannot overwrite existing file

증상 정리

항목 내용
에러 메시지 cannot rehash: couldn't acquire lock
대기 시간 60초 (기본 타임아웃)
발생 시점 새 터미널 창/탭 열 때마다
영향 범위 모든 셸 세션 (zsh, bash 모두)
pyenv 버전 2.6.23 (Homebrew)
OS macOS (Darwin 25.3.0)

매번 새 터미널을 열 때마다 60초를 기다려야 하니 개발 생산성에 심각한 영향을 미쳤습니다. 특히 iTerm2에서 여러 탭을 동시에 열면 각 탭마다 60초씩 멈추는 최악의 상황이었습니다.

2. 원인 분석

pyenv rehash란?

pyenv는 Python 버전 관리 도구입니다. 핵심 메커니즘은 shim 패턴으로, ~/.pyenv/shims/ 디렉토리에 경량 래퍼 스크립트(shim)를 두어 python, pip 같은 명령어를 가로채는 방식입니다.

# shim의 동작 원리
$ which python
/Users/kim/.pyenv/shims/python  # ← pyenv의 shim이 먼저 잡힘

# shim 내부 동작
$ cat ~/.pyenv/shims/python
#!/usr/bin/env bash
set -e
[ -n "$PYENV_DEBUG" ] && set -x
program="${0##*/}"        # ← "python" 추출
exec pyenv exec "$program" "$@"  # ← pyenv가 올바른 버전으로 라우팅

pyenv rehash는 이 shim 파일들을 재생성하는 명령어입니다. 새로운 Python 버전을 설치하거나 pip로 CLI 도구를 설치할 때 실행되어야 합니다.

셸 초기화와 rehash

문제의 시작점은 .zshrc(또는 .bashrc)에 있는 다음 설정입니다.

# ~/.zshrc
eval "$(pyenv init -)"

이 한 줄이 셸 시작 시 실행하는 작업을 풀어보면 이렇습니다.

# pyenv init - 가 실제로 생성하는 코드
export PATH="$PYENV_ROOT/shims:$PATH"   # 1. PATH에 shims 추가
export PYENV_SHELL=zsh                   # 2. 셸 변수 설정
source "$PYENV_ROOT/completions/pyenv.zsh"  # 3. 자동완성 로드
pyenv() { ... }                          # 4. pyenv 셸 함수 정의
command pyenv rehash                     # 5. ← 여기서 rehash 실행!

핵심: eval "$(pyenv init -)" 는 매 터미널 창을 열 때마다 pyenv rehash를 자동으로 호출합니다.

락 파일 메커니즘: 소스 코드 분석

pyenv rehash는 동시 실행을 방지하기 위해 **파일 기반 락(lock)**을 사용합니다. 실제 소스 코드를 살펴보겠습니다.

# /opt/homebrew/Cellar/pyenv/2.6.23/libexec/pyenv-rehash

SHIM_PATH="${PYENV_ROOT}/shims"
PROTOTYPE_SHIM_PATH="${SHIM_PATH}/.pyenv-shim"  # ← 이 파일이 락 + 프로토타입

# 락 획득 함수
acquire_lock() {
  local ret
  set -o noclobber   # ← 핵심: 기존 파일 덮어쓰기 금지
  last_acquire_error="$( { ( echo -n > "$PROTOTYPE_SHIM_PATH"; ) 2>&1 1>&3 3>&1-; } 3>&1)" || ret=1
  set +o noclobber
  [ -z "${ret}" ]
}

여기서 핵심은 bash의 noclobber 옵션입니다.

set -o noclobber

이 옵션이 켜지면 > 리다이렉션으로 이미 존재하는 파일을 덮어쓸 수 없습니다. POSIX open(2) 시스템 콜의 O_EXCL | O_CREAT 플래그와 동일한 효과로, 원자적(atomic) 파일 생성을 보장합니다.

# noclobber 동작 예시
$ set -o noclobber
$ echo "test" > /tmp/lockfile    # 파일이 없으면 → 성공 (생성)
$ echo "test" > /tmp/lockfile    # 파일이 있으면 → 실패!
# bash: /tmp/lockfile: cannot overwrite existing file

이것이 바로 에러 메시지의 정체입니다.

/opt/homebrew/Cellar/pyenv/2.6.23/libexec/pyenv-rehash: line 22:
/Users/kim/.pyenv/shims/.pyenv-shim: cannot overwrite existing file

"cannot overwrite existing file"은 bash의 noclobber 경고 메시지입니다. pyenv가 출력한 것이 아니라 bash 자체가 출력한 것이죠.

60초 타임아웃 루프

락을 한 번에 못 잡으면 pyenv는 60초 동안 재시도합니다.

# 타임아웃 루프
declare start=$SECONDS
PYENV_REHASH_TIMEOUT=${PYENV_REHASH_TIMEOUT:-60}  # ← 기본 60초

while (( SECONDS <= start + PYENV_REHASH_TIMEOUT )); do
  if acquire_lock; then
    acquired=1
    trap release_lock EXIT  # ← 정상 종료 시 락 해제
    break
  else
    sleep 0.1 2>/dev/null || sleep 1  # ← 100ms마다 재시도
  fi
done

# 60초 초과 → 에러 출력 후 종료
if [ -z "${acquired}" ]; then
  echo "pyenv: cannot rehash: couldn't acquire lock \
$PROTOTYPE_SHIM_PATH for $PYENV_REHASH_TIMEOUT seconds." >&2
  echo "$last_acquire_error" >&2
  exit 1
fi

100ms마다 재시도하며 총 60초를 대기합니다. 그 동안 터미널은 응답 없이 멈춰 있게 됩니다.

락 해제 메커니즘

정상적인 경우, 락은 이렇게 해제됩니다.

remove_prototype_shim() {
  rm -f "$PROTOTYPE_SHIM_PATH"   # ← .pyenv-shim 파일 삭제
}

release_lock() {
  remove_prototype_shim
}

# EXIT 트랩으로 자동 정리 보장
trap release_lock EXIT

trap ... EXIT은 프로세스가 종료될 때 자동으로 실행되는 정리 핸들러입니다. SIGTERM, SIGINT(Ctrl+C), 정상 종료 등 대부분의 경우를 커버합니다.

그렇다면 왜 스테일 락 파일이 남았는가?

trap ... EXIT동작하지 못하는 경우가 있습니다.

┌─────────────────────────────────────────────────────────────┐
│          trap ... EXIT가 동작하지 않는 경우들               │
├─────────────────┬───────────────────────────────────────────┤
│ SIGKILL (kill -9)│ 잡을 수 없는 시그널. 즉시 종료.          │
│ 시스템 크래시     │ OS가 프로세스를 즉시 정리                │
│ 터미널 강제 종료  │ Force Quit으로 터미널 앱 종료 시          │
│ 전원 차단        │ 물리적 전원 off                          │
│ 디스크 가득 참    │ rm 자체가 실패할 수 있음                  │
└─────────────────┴───────────────────────────────────────────┘

제 경우, 이전에 터미널을 비정상적으로 종료하면서(혹은 시스템 업데이트 후 재부팅 등) pyenv rehash 프로세스가 락 해제 없이 죽은 것으로 보입니다. .pyenv-shim 파일이 디스크에 남아있고, 이후 모든 rehash 시도가 이 "유령 락"에 막혀 60초씩 대기하게 된 것입니다.

pyenv 락 파일의 한계: PID 미저장

흥미로운 점은 .pyenv-shim 파일에 프로세스 ID(PID)가 저장되지 않는다는 것입니다.

# 일반적인 락 파일 패턴 (PID 저장)
echo $$ > /var/run/myapp.pid  # PID를 기록
# → 나중에 해당 PID가 살아있는지 확인 가능

# pyenv의 락 파일 패턴 (PID 없음)
echo -n > .pyenv-shim  # 빈 파일만 생성
# → 파일이 있으면 무조건 "락 잡힘"으로 판단

이 때문에 pyenv는 "진짜 다른 프로세스가 작업 중인 것"과 "스테일 락 파일이 남은 것"을 구분할 수 없습니다. 이것은 pyenv 커뮤니티에서도 인지하고 있는 설계상 한계입니다.

3. 해결 방법

즉각 해결: 스테일 락 파일 제거

# 1. 스테일 락 파일 확인
ls -la ~/.pyenv/shims/.pyenv-shim
# -rwxr-xr-x@ 1 kim  staff  295 Mar  1 22:01 /Users/kim/.pyenv/shims/.pyenv-shim

# 2. 삭제
rm ~/.pyenv/shims/.pyenv-shim

# 3. rehash 재실행
pyenv rehash
# (정상 완료, 에러 없음)

단 3줄이면 됩니다. .pyenv-shim은 rehash 과정의 임시 파일이므로 삭제해도 전혀 문제가 없습니다.

Before/After 비교

# Before: 터미널 열 때마다 60초 대기
$ time zsh -l -c 'exit'
# pyenv: cannot rehash: couldn't acquire lock ... for 60 seconds.
# real    1m0.234s  ← 60초!

# After: 정상 속도 복구
$ rm ~/.pyenv/shims/.pyenv-shim && pyenv rehash
$ time zsh -l -c 'exit'
# real    0m0.412s  ← 0.4초

원라이너 해결

급한 상황에서 복사-붙여넣기용 원라이너입니다.

rm -f ~/.pyenv/shims/.pyenv-shim && pyenv rehash && echo "Fixed!"

4. 핵심 개념 정리

pyenv rehash 동작 흐름

pyenv rehash 실행
    │
    ▼
┌──────────────────┐
│ 1. 락 획득 시도   │ ← noclobber로 .pyenv-shim 파일 생성
│    (echo -n > )   │
└────────┬─────────┘
         │
    성공? ──── No ──→ 100ms 대기 후 재시도 (최대 60초)
         │                              │
        Yes                        타임아웃 → 에러 출력 + 종료
         │
         ▼
┌──────────────────┐
│ 2. 프로토타입     │ ← .pyenv-shim에 shim 스크립트 내용 작성
│    shim 생성      │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ 3. 구버전 shim    │ ← 프로토타입과 다른 shim 삭제
│    제거           │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ 4. 새 shim 생성   │ ← ln (하드링크)로 각 실행 파일의 shim 생성
│    (하드링크)      │    python, pip, black 등
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ 5. 락 해제        │ ← rm -f .pyenv-shim
│    (trap EXIT)    │
└──────────────────┘

주요 개념 비교

개념 설명 pyenv에서의 역할
shim 명령어를 가로채는 래퍼 스크립트 ~/.pyenv/shims/python
rehash shim 파일을 재생성하는 과정 새 패키지 설치 후 실행 필요
noclobber bash의 파일 덮어쓰기 방지 옵션 원자적 락 메커니즘으로 사용
prototype shim 모든 shim의 원본이 되는 템플릿 .pyenv-shim 파일, 락 역할 겸함
trap EXIT 프로세스 종료 시 실행되는 핸들러 락 파일 자동 삭제에 사용
hard link 같은 inode를 공유하는 파일 링크 shim을 효율적으로 복제

noclobber vs flock vs 기타 락 메커니즘

# 1. noclobber (pyenv 방식)
set -o noclobber
echo -n > lockfile  # 원자적 파일 생성. PID 미저장.
# 장점: 외부 도구 불필요, POSIX 호환
# 단점: PID 미저장, 스테일 락 감지 불가

# 2. flock (Linux 표준)
flock -n /tmp/lockfile command  # 커널 레벨 파일 락
# 장점: 프로세스 종료 시 자동 해제
# 단점: macOS에서 기본 미지원

# 3. mkdir 기반 락
mkdir /tmp/lockdir 2>/dev/null  # mkdir은 원자적
# 장점: 간단, 크로스 플랫폼
# 단점: noclobber와 동일한 스테일 문제

# 4. PID 기반 락
echo $$ > lockfile  # PID 기록
kill -0 $(cat lockfile) 2>/dev/null  # PID 생존 확인
# 장점: 스테일 락 감지 가능
# 단점: PID 재사용 가능성 (레이스 컨디션)

5. 베스트 프랙티스

재발 방지 체크리스트

A. --no-rehash 옵션 사용 (권장)

셸 시작 시 자동 rehash를 비활성화하여 락 경합을 원천 차단합니다.

# ~/.zprofile (로그인 셸당 1회 실행)
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init --path)"     # ← PATH만 설정, rehash 안 함

# ~/.zshrc (인터랙티브 셸마다 실행)
eval "$(pyenv init - --no-rehash)"  # ← 셸 함수만 등록, rehash 안 함

이렇게 하면 pyenv install이나 pip install로 새 실행 파일을 추가한 후에만 수동으로 pyenv rehash를 실행하면 됩니다.

# 새 Python 버전 설치 후
pyenv install 3.12.0
pyenv rehash   # ← 이때만 수동 실행

# 새 CLI 도구 설치 후
pip install black
pyenv rehash   # ← 이때만 수동 실행

B. 가드 조건 추가

.pyenv-shim 파일이 이미 있으면 rehash를 건너뛰는 방어 로직입니다.

# ~/.zshrc에 추가
if [[ ! -f "$PYENV_ROOT/shims/.pyenv-shim" ]]; then
  eval "$(pyenv init -)"               # 정상: rehash 포함
else
  eval "$(pyenv init - --no-rehash)"   # 락 있으면: rehash 건너뜀
fi

C. 이중 초기화 금지

.zprofile.zshrc 양쪽 모두eval "$(pyenv init -)"를 넣지 마세요.

# ❌ 잘못된 설정: 이중 rehash
# ~/.zprofile
eval "$(pyenv init -)"          # rehash 1회

# ~/.zshrc
eval "$(pyenv init -)"          # rehash 2회 → 락 경합 2배

# ✅ 올바른 설정: 역할 분리
# ~/.zprofile
eval "$(pyenv init --path)"     # PATH만 설정

# ~/.zshrc
eval "$(pyenv init -)"          # 셸 함수 + rehash (1회만)

D. iTerm2 세션 복원 설정 확인

macOS에서 iTerm2를 사용한다면, 시스템 부팅 시 이전 세션을 복원할 때 수십 개의 탭이 동시에 열리면서 rehash가 동시에 실행됩니다.

iTerm2 > Settings > General > Startup
  → "Only Restore Hotkey Window" 선택
  또는
  → "Use System Window Restoration Setting" 해제

이렇게 하면 부팅 후 한꺼번에 여러 탭이 열리는 것을 방지할 수 있습니다.

E. 타임아웃 커스터마이징

기본 60초가 너무 길다고 느껴지면 환경 변수로 조절할 수 있습니다.

# ~/.zshrc에 추가
export PYENV_REHASH_TIMEOUT=10   # 10초로 단축 (빠른 실패)

다만 이것은 근본 해결이 아닌 임시 방편입니다.

문제 발생 시 진단 스크립트

반복적으로 문제가 발생한다면 다음 스크립트로 진단할 수 있습니다.

#!/bin/bash
# pyenv-diagnose.sh - pyenv rehash 문제 진단

echo "=== pyenv 기본 정보 ==="
echo "pyenv version: $(pyenv --version)"
echo "PYENV_ROOT: $PYENV_ROOT"
echo "PYENV_SHELL: $PYENV_SHELL"

echo ""
echo "=== 락 파일 상태 ==="
LOCK_FILE="$PYENV_ROOT/shims/.pyenv-shim"
if [[ -f "$LOCK_FILE" ]]; then
  echo "WARNING: 락 파일 존재!"
  ls -la "$LOCK_FILE"
  echo "파일 내용:"
  cat "$LOCK_FILE"
  echo ""
  echo "생성 시간: $(stat -f '%Sm' "$LOCK_FILE" 2>/dev/null || stat -c '%y' "$LOCK_FILE" 2>/dev/null)"
else
  echo "OK: 락 파일 없음"
fi

echo ""
echo "=== shims 디렉토리 ==="
echo "shim 개수: $(ls "$PYENV_ROOT/shims/" 2>/dev/null | wc -l | tr -d ' ')"
echo "디렉토리 권한: $(ls -ld "$PYENV_ROOT/shims/")"

echo ""
echo "=== 셸 설정 확인 ==="
echo "--- .zshrc에서 pyenv 관련 라인 ---"
grep -n "pyenv" ~/.zshrc 2>/dev/null || echo "(없음)"
echo "--- .zprofile에서 pyenv 관련 라인 ---"
grep -n "pyenv" ~/.zprofile 2>/dev/null || echo "(없음)"

echo ""
echo "=== rehash 실행 진행 중인 프로세스 ==="
ps aux | grep "[p]yenv-rehash" || echo "없음"
# 실행
chmod +x pyenv-diagnose.sh
./pyenv-diagnose.sh

6. FAQ

Q: .pyenv-shim 파일을 삭제해도 안전한가요?

A: 네, 완전히 안전합니다. .pyenv-shimpyenv rehash 과정에서만 사용되는 임시 파일로, 락(lock)과 프로토타입 shim의 이중 역할을 합니다. rehash가 완료되면 자동으로 삭제되는 파일이므로, 잔존하는 것 자체가 비정상 상태입니다.

Q: "cannot overwrite existing file"이 pyenv 에러인가요?

A: 아닙니다. 이 메시지는 bash 자체의 noclobber 경고입니다. pyenv는 set -o noclobber 후 파일 생성을 시도하는데, 파일이 이미 존재하면 bash가 이 메시지를 출력합니다. pyenv는 이 메시지를 캡처해서 마지막에 보여주는 것뿐입니다.

Q: 여러 터미널 탭을 동시에 열면 왜 문제가 되나요?

A: macOS에서는 새 터미널 창마다 로그인 셸로 실행됩니다. 각 셸이 .zshrceval "$(pyenv init -)"pyenv rehash를 동시에 호출하면 락 경합이 발생합니다. 하나의 프로세스만 락을 획득하고, 나머지는 60초 동안 대기합니다. 이것은 레이스 컨디션(race condition)의 전형적인 사례입니다.

Q: PYENV_REHASH_TIMEOUT을 0으로 설정하면 어떻게 되나요?

A: 재시도 없이 즉시 실패합니다. 빠른 실패를 원한다면 유용하지만, 정상적인 동시 실행 상황에서도 실패하게 되므로 최소 5-10초는 권장합니다.

# 즉시 실패 (권장하지 않음)
export PYENV_REHASH_TIMEOUT=0

# 적절한 타협점
export PYENV_REHASH_TIMEOUT=10

Q: pyenv 대신 다른 Python 버전 관리 도구를 쓰는 것이 나을까요?

A: pyenv는 가장 널리 사용되는 Python 버전 관리 도구이고, 이 락 문제는 비교적 드물게 발생합니다. 다만 최근에는 shim을 사용하지 않는 대안도 있습니다.

도구 shim 사용 특징
pyenv O 가장 성숙, 플러그인 풍부
mise (rtx) X Rust 기반, 빠름, 다중 언어 지원
uv X Rust 기반, pip/venv 통합
asdf O 다중 언어 지원, pyenv보다 느림

7. 참고 자료