📚 엔지니어 · 세션 · 운영 · 배포 · 거버넌스 (수정중) · L05

SSH 원격 배포 실패를 구조로 막기: cwd-무의존 명령 · 재시도 규율 · hook (수정중)

작성일자 2026-07-31  ·  수정일자 2026-07-31  ·  강의차수 L05  ·  예상소요 25분

목표 이 강의가 끝나면 학습자가 (1) ssh host "..." 원격 명령이 왜 로컬 cwd 에 의존하면 깨지는지, (2) 에러 재시도에서 "고쳤다"는 산문과 실제 명령 문자열이 어긋나는 모델 결함을 어떻게 알아채는지, (3) 그런 반복 결함을 왜 메모리가 아니라 hook 으로 막아야 하는지를 설명하고 자기 배포 스크립트에 적용할 수 있다.

이 강의는 실제 사고 하나를 뜯어본다. prod 배포 중에 똑같은 원격 명령이 네 번 연속 실패했는데, 그 원인과 대응이 원격 셸 동작·모델 행동·제도화 설계를 한 번에 관통한다.

1. 사건: 같은 명령이 네 번

cp_revshare(eunseo)의 2026-07-31 release 세션에서 SSH 원격 명령이 두 묶음으로 반복 실패했다.

# 묶음 1: release 사전점검
ssh eunseo@prod "git fetch --all --tags && git status ..."
  → fatal: not a git repository

# 묶음 2: prod 계정 실측
ssh eunseo@prod "source ../bin/activate && python manage.py ..."
  → ../bin/activate: No such file or directory

여기까지는 흔한 실수처럼 보인다. 진짜 문제는 그 다음이다. 트랜스크립트를 대조해 보니 각 묶음에서 완전히 동일한(md5 일치) 명령이 4회 재전송됐다. 그 사이 산문은 "cd 누락이었다, 고쳐서 재실행"이라고 했고, 심지어 도구 호출의 description 필드에도 "(cd 포함)"이라 적혀 있었지만, 정작 command 문자열에는 cd 가 끝내 추가되지 않았다.

루프를 끊은 것은 「기억해내기」가 아니라 명령의 구조를 바꾼 것(git -C <절대경로>, venv python 절대경로)이었다.

이 한 사건에 세 개의 교훈이 겹쳐 있다: 원격 셸 동작(§2), 재시도 규율(§3), 그리고 이걸 어떻게 못 하게 만드느냐(§4).

2. 원격 셸은 로컬 cwd 를 물려받지 않는다

ssh host "..." 의 원격 셸은 항상 원격 $HOME 에서 시작한다. 로컬에서 내가 어느 디렉토리에 있든 그 위치는 원격으로 전달되지 않는다. 그래서 repo 가 $HOME/<proj>/src 에 있는데 cd 없이 bare git 을 보내면 not a git repository 로, 상대경로 ../bin/activate 를 보내면 No such file 로 반드시 깨진다.

해법은 명령을 cwd 에 의존하지 않는 형태로 조립하는 것이다.

깨지는 형태 (cwd 의존) 안전한 형태 (cwd-무의존)
git fetch --all git -C /home/eunseo/cp_revshare/src fetch --all
source ../bin/activate && python manage.py ... /home/eunseo/cp_revshare/bin/python /home/eunseo/cp_revshare/src/manage.py ...
python manage.py migrate cd /home/eunseo/cp_revshare/src && python manage.py migrate

세 가지 중 하나면 된다: git -C <절대경로>, 실행 파일 절대경로, 또는 맨 앞에 cd <절대경로> &&.

이 위험은 최근 더 커졌다. prod 을 배포자 개인 홈으로 분리(/home/<user>/<proj>/)하면서 $HOME 이 사용자마다 달라졌기 때문이다. "내 로컬에선 되던" 상대경로가 원격에서 어긋날 여지가 더 넓어졌다. prod 배포 절차 자체는 prod 배포 강의를 참고한다.

3. 진짜 결함: 산문이 아니라 명령 문자열을 봐야 한다

이 사건의 핵심 결함은 SSH 지식 부족이 아니다. cwd 문제는 첫 실패에서 원인이 로그에 그대로 찍혔다. 결함은 그 뒤 재시도에서 "고쳤다"는 말과 실제 파라미터가 어긋난 것이다.

왜 이런 일이 생기나. 직전 실패를 유발한 명령이 컨텍스트에 강한 앵커로 남는다. 그래서 "수정 의도"는 산문(설명·description)에만 반영되고, 명령 문자열은 이전 것을 사실상 복사해서 재생성한다. 반복될수록 잘못된 사본이 쌓여 자기강화된다.

규율은 단순하다.

  • 재시도 명령은 직전 실패를 유발한 바로 그 토큰이 «문자 그대로» 달라져야 한다. command 문자열의 diff 로 확인하고, 산문·description 을 신뢰하지 않는다.
  • 같은 에러가 2회 나면 같은 명령 재전송을 멈추고 접근 자체를 바꾼다(상대경로 → 절대경로, git → git -C).

이건 디버깅 케이스 강의의 "추측이 아니라 증거를 본다"와 같은 결이다. 여기서 봐야 할 증거는 서버 로그이자, 내가 방금 실제로 보낸 명령 문자열이다.

Q. 메모리(프로젝트 규칙)에 "재시도는 명령을 실제로 바꿔라"를 적어두면 되지 않나?

A. 실제로 적어뒀는데도 재발했다. 이 프로젝트 메모리에는 선행 규칙(retry-change-command-not-narration, 2026-07-24)이 이미 있었고, 그 직후 세션에서 4연속 반복이 났다. 그래서 §4 가 필요하다.

4. 기억이 아니라 구조로 막아라

이 실수는 모델 특성이라 메모리·규칙 문서만으로는 재발한다(§3 Q&A 가 실증). 재발을 실제로 끊는 것은 두 가지다.

  1. 명령을 애초에 cwd-무의존 기본 형태로 조립한다(배포 스킬의 $RSH "cd $PROOT && ..." 같은 경로-앵커 헬퍼를 쓰고, raw ssh host "..." 를 손으로 조립하지 않는다).
  2. PreToolUse hook 으로 실행 전에 차단한다.

2번이 이 사건의 산물이다. standarda-template PR #62(2026-07-31 머지)로 .claude/hooks/check-ssh-remote-cwd.sh 가 추가됐다. hook 이 exit 2 로 도구 실행을 막는 메커니즘 자체는 Hooks 강의에서 다뤘고, 여기서는 이 hook 의 판정 대상 설계가 요점이다.

hook 은 명령 전체가 아니라 ssh 따옴표 페이로드만 떼어내 검사한다. 이게 미탐을 막는 핵심이다.

# check-ssh-remote-cwd.sh (발췌): 원격 페이로드만 추출해 판정
echo "$COMMAND" | grep -qE '(^|[[:space:]&|;(])ssh[[:space:]]' || exit 0   # ssh 호출 아니면 통과

# 첫 따옴표 그룹(= ssh 원격 페이로드)만 추출. host 는 따옴표가 없으므로 첫 그룹이 페이로드.
PAYLOAD=$(printf '%s' "$COMMAND" | grep -oE '"[^"]*"|'\''[^'\'']*'\''' | head -1 | sed -E 's/^.//; s/.$//')
[ -n "$PAYLOAD" ] || exit 0                    # 인라인 원격 명령 아니면 보수적으로 통과

printf '%s' "$PAYLOAD" | grep -qE '\bcd[[:space:]]+[^[:space:]]' && exit 0   # 페이로드에 cd 있으면 앵커링된 것으로 통과

# 이후 PAYLOAD 안에서만 검사: git(-C 없이) · source 상대 venv · manage.py(절대경로 없이) · ../ 상대경로

왜 전체 grep 이 아니라 페이로드만인가. 로컬쪽 cd /x && ssh host "git ..." 처럼 로컬에 cd 가 있어도 원격은 여전히 원격 $HOME 에서 시작해 깨진다. 명령 전체를 grep 하면 이 로컬 cd 를 보고 "앵커링됐다"고 오판해 미탐(막아야 할 걸 놓침)을 낸다. 그래서 반드시 원격 페이로드만 떼어 판정한다.

이건 새 함정이 아니라 Hooks 강의에서 본 "명령 전체를 훑지 말고 판정 대상만"의 같은 계열이다(그 강의의 check-bugfix-issue-doc.sh 는 커밋 메시지만 봐서 파일명 오탐을 피했다). 팀 wiki 의 lessons/general/pretooluse-hook-command-scan.md 가 이 원칙을 별도 교훈으로 정리해 뒀다(repo 밖이라 경로만 적는다).

5. 어떻게 확인했나: 트랜스크립트 대조와 게이트 자기참조

이 강의의 "4회 재전송" 은 추정이 아니다. 모델의 자기진단은 트랜스크립트로 검증했기 때문에 사실로 확정할 수 있었다.

  • 클로드가 "cd 누락이라 고쳐서 재실행했다"고 설명한 것이 실제 일어난 일인지는, 글로벌 세션 로그를 대조해야만 알 수 있다.
  • 이번엔 재전송된 명령들의 md5 해시를 떠서 "동일 명령 4회"를 바이트 단위로 증명했다. 산문 진단("아마 이래서…")이 아니라 로그 증거로 다뤘다.

재발성 행동 문제는 이렇게 다뤄야 한다. 그럴듯한 설명은 얼마든지 나오지만, 실제로 무슨 파라미터가 나갔는지는 로그에만 있다.

마지막으로 이 변경을 커밋할 때 작은 함정이 하나 더 재발했다. 커밋 메시지에 든 "에러" 글자가 기존 이슈-문서 게이트(check-bugfix-issue-doc.sh)에 오탐으로 걸려 커밋이 막혔다. 게이트를 설명하는 메시지 자체가 그 게이트에 걸리는 자기참조다. 키워드 기반 hook 의 구조적 한계를 다시 보여준 셈이고(개선 방향은 메시지 키워드가 아니라 staged diff 로 판정), 이것이 §4 의 "판정 대상을 정확히 좁혀라"가 왜 반복해서 중요한지를 뒷받침한다.


마무리

  • 핵심 정리: ssh host "..." 의 원격 셸은 로컬 cwd 를 물려받지 않으므로 명령을 git -C·절대경로·cd && 로 cwd-무의존하게 조립한다. 하지만 진짜 결함은 재시도에서 산문("고쳤다")과 실제 command 문자열이 어긋나는 모델 특성이고, 이건 메모리 규칙으로는 재발한다. 그래서 cwd-무의존 기본 형태 + PreToolUse hook 이라는 구조로 막는다. hook 은 명령 전체가 아니라 ssh 페이로드만 판정해 미탐을 없앤다. 이 모든 사실은 트랜스크립트 md5 대조로 확정했다.
  • 주의 사항: 명령 스캔 hook 을 짤 때 명령 «전체»를 grep 하는 것. 로컬 cd 나 파일명 때문에 오탐·미탐이 난다. 판정 대상(원격 페이로드·커밋 메시지)만 정확히 떼어내 검사한다.
  • 다음 시간: 이 사건이 남긴 교훈이 다른 프로젝트로 어떻게 전파되는지, 그리고 언제 CLAUDE.md(말)에서 hook(강제)으로 승격하는지는 교훈 전파 시스템과 이어진다.
이 강의를 학습하셨나요?