블로그 문제 해결

SSH 원격 명령의 command not found, zsh .zshenv로 PATH 고치기

ssh 서버 "명령" 형태로 던질 때만 도구를 못 찾는 이유를 zsh 설정 파일 읽는 순서와 macOS path_helper로 설명합니다. 확인 명령 세 줄, .zshenv 설정 예시, bash 서버일 때와 스크립트에서 실패를 구분하는 방법까지 담았습니다.

2026년 8월 21일, 집에 둔 서버용 맥미니에 원격으로 컨테이너 개수를 물었더니 0이 돌아왔습니다. 실제로는 컨테이너 11개가 정상으로 돌고 있었습니다. 서버가 죽은 게 아니라 docker 명령을 못 찾은 것이었습니다. Homebrew 경로가 ~/.zprofile에만 있었는데, ssh 서버 "명령" 형태로 던지면 zsh는 ~/.zshenv만 읽습니다. 경로를 ~/.zshenv로 옮기고 나서 같은 명령이 11을 돌려줬습니다.

같은 날 같은 원인으로 폰에서 mosh 접속도 실패했습니다. 집에 있는 맥 3대를 확인해 보니 2대에서 이 문제가 있었고, 나머지 1대는 이미 .zshenv에 경로를 적어둔 상태였습니다.

ssh 접속하면 되는데 ssh 원격 명령은 command not found 가 나는 이유

처음 던진 명령은 이랬습니다.

ssh server "docker ps --format '{{.Names}}' | wc -l"

결과는 0이었습니다. 직접 접속해서 같은 명령을 치면 정상입니다.

ssh server
$ docker ps --format '{{.Names}}' | wc -l
11

앞의 경우가 에러처럼 보이지 않았다는 게 문제였습니다. docker: command not found는 표준 에러로 나가고, wc -l은 빈 입력을 받아 0을 찍습니다. 숫자가 나왔으니 정상 응답처럼 보이고, 모니터링 스크립트가 이 값을 읽고 있었다면 “컨테이너가 정말 0개”인 상황과 구분할 수 없습니다.

ssh로 접속했을 때와 ssh로 명령을 던졌을 때 zsh가 읽는 파일과 PATH가 달라 docker가 0개로 보이는 흐름

zsh 설정 파일 읽는 순서: .zshenv · .zprofile · .zshrc

zsh는 어떤 방식으로 실행됐는지에 따라 읽는 설정 파일이 다릅니다. zsh 공식 안내에 따르면 .zshenv는 모든 실행에서 읽고, .zshrc는 대화형 셸에서, .zprofile과 .zlogin은 로그인 셸에서 읽습니다.

파일읽는 때ssh 서버 "명령"
.zshenv모든 경우읽음
.zprofile로그인 셸안 읽음
.zshrc대화형 셸안 읽음
.zlogin로그인 셸 (마지막)안 읽음

ssh server로 접속해서 프롬프트를 받으면 로그인 셸이면서 대화형 셸이라 네 파일을 다 읽습니다.

ssh server "명령"은 다릅니다. 이 경우 sshd는 사용자 셸을 zsh -c "명령" 형태로 실행합니다. 로그인 셸도 대화형 셸도 아니라서 .zshenv만 읽습니다.

Homebrew를 설치하면 마지막에 보통 이런 안내가 나옵니다.

echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile

안내대로 하면 경로가 .zprofile에 들어갑니다. 그래서 터미널로 접속하면 잡히고, 명령을 던지면 안 잡힙니다.

macOS path_helper 가 /etc/zprofile 에서만 도는 문제

리눅스보다 macOS에서 이 문제가 더 크게 나타나는 이유가 있습니다. macOS는 /etc/paths와 /etc/paths.d/에 적힌 경로를 PATH에 넣어주는 path_helper를 쓰는데, 이걸 부르는 곳이 /etc/zprofile입니다.

# /etc/zprofile 안
if [ -x /usr/libexec/path_helper ]; then
    eval `/usr/libexec/path_helper -s`
fi

그래서 비대화형 셸에는 Homebrew 경로뿐 아니라 /usr/local/bin처럼 당연히 있을 것 같은 경로도 없을 수 있습니다. 컨테이너가 0개로 보였던 서버가 이 경우였고, 원격 명령의 PATH에 /usr/local/bin조차 없었습니다.

두 방식으로 PATH를 찍어보면 차이가 보입니다. 아래는 Apple 실리콘 맥을 예로 든 출력이고, 인텔 맥이라면 Homebrew 경로가 /usr/local/bin 쪽에 잡힙니다.

# 접속한 뒤 확인
ssh server
$ echo $PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

# 명령으로 던져서 확인
ssh server 'echo $PATH'
/usr/bin:/bin:/usr/sbin:/sbin

내 경우인지 확인하는 명령 세 줄

먼저 지금 상태를 봅니다. 홑따옴표로 감싸야 $PATH 같은 변수가 내 컴퓨터가 아니라 원격에서 풀립니다. 겹따옴표로 쓰면 내 컴퓨터의 PATH가 찍혀서 확인이 안 됩니다.

# 1. 비대화형 셸의 PATH
ssh server 'echo $PATH'

# 2. 문제의 명령이 잡히는지
ssh server 'command -v docker || echo "못 찾음"'

# 3. 어떤 셸로 실행되는지
ssh server 'echo $0; echo "대화형: ${-}"'

2번에서 “못 찾음”이 나오는데 접속해서 which docker를 치면 경로가 나온다면 이 문제입니다. 3번에서 zsh가 아니라 bash가 나오면 아래 bash 절을 보면 됩니다.

~/.zshenv 에 Homebrew PATH 넣는 법

~/.zshenv를 만들고 PATH만 넣습니다.

# ~/.zshenv — 모든 셸 호출에서 읽힌다
# Apple 실리콘
if [ -x /opt/homebrew/bin/brew ]; then
  eval "$(/opt/homebrew/bin/brew shellenv)"
fi

# 인텔 맥
if [ -x /usr/local/bin/brew ]; then
  eval "$(/usr/local/bin/brew shellenv)"
fi

# 도커 데스크톱·OrbStack 등이 심는 경로가 따로 있다면 함께
export PATH="$HOME/.local/bin:$PATH"

[ -x ... ]로 감싸 두면 Apple 실리콘과 인텔 맥에 같은 파일을 그대로 복사해 써도 됩니다. 적용됐는지는 위 확인 명령을 다시 돌려 보면 됩니다. 아래 출력은 Apple 실리콘 맥 기준입니다. ssh로 명령을 던질 때마다 셸이 새로 뜨기 때문에 다음 호출부터 바로 반영됩니다.

ssh server 'command -v docker'
/opt/homebrew/bin/docker

.zshenv 에 넣으면 안 되는 것

.zshenv는 모든 셸 호출마다 실행되기 때문에 두 가지를 조심해야 합니다.

첫째, 화면에 무언가 출력하면 안 됩니다. scp, rsync, git은 원격 셸의 표준 출력을 전송 데이터로 읽습니다. 환영 메시지나 echo 한 줄이 섞이면 전송이 깨집니다. zsh 공식 안내도 .zshenv에는 출력을 내는 명령이나 터미널이 붙어 있다고 가정하는 명령을 넣지 말라고 적고 있습니다.

둘째, 무거운 초기화를 넣으면 안 됩니다. nvm, rbenv, conda 초기화를 여기 넣으면 모든 원격 명령이 그만큼 느려집니다. 이런 건 .zshrc에 두고, 자동화에서 꼭 필요한 버전만 PATH에 직접 적는 편이 낫습니다.

원격 셸이 bash 라면 ~/.bashrc 위쪽에

bash는 읽는 파일이 다릅니다. bash 매뉴얼에 따르면 bash는 sshd가 실행한 비대화형 셸이라고 판단하면 ~/.bashrc를 읽습니다. 그런데 리눅스 배포판이 기본으로 넣어 두는 ~/.bashrc는 맨 앞에 “대화형이 아니면 여기서 끝”이라는 줄을 두는 경우가 많아서, PATH를 그 아래에 적으면 원격 명령에서는 반영되지 않습니다.

# ~/.bashrc 맨 위, 대화형 검사보다 앞에
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"

# 보통 이런 줄이 이미 있다
case $- in
    *i*) ;;
      *) return;;
esac

같은 매뉴얼에는 비대화형 bash가 BASH_ENV 변수가 가리키는 파일을 읽는다는 내용도 있습니다. SSH로 이 변수를 넘기려면 서버의 sshd_config에 PermitUserEnvironment yes가 필요해서 손이 더 갑니다.

# ~/.ssh/environment (sshd_config에 PermitUserEnvironment yes 필요)
BASH_ENV=/home/user/.bashenv

설정 파일을 건드리기 어렵다면 스크립트에서 절대 경로로 부르는 방법도 흔히 씁니다.

ssh server '/usr/bin/docker ps'

mosh-server not found 도 같은 원인

폰에서 mosh로 맥에 붙을 때 mosh-server not found가 나며 접속이 안 됐습니다. mosh는 접속할 때 원격에서 SSH로 mosh-server를 실행하는데, 이것도 비대화형 경로로 실행됩니다. Homebrew로 깐 mosh-server가 .zprofile의 경로에만 있어서 못 찾은 것이고, .zshenv로 옮긴 뒤 접속됐습니다.

.zshenv를 고쳤는데도 같은 메시지가 계속 나오면 접속하려는 맥에 mosh가 아예 설치되지 않은 경우입니다. 그 맥에서 brew install mosh부터 하면 됩니다.

이 밖에도 안 될 때 볼 곳이 몇 군데 있습니다.

  • .zshenv를 만들었는데 안 바뀐다면 3번 확인 명령으로 원격 셸이 정말 zsh인지 봅니다
  • 도구가 Homebrew가 아닌 다른 위치(앱이 자기 폴더에 까는 경우)에 있다면 접속한 상태에서 command -v 도구이름으로 경로를 확인해 .zshenv에 추가합니다
  • cron으로 도는 작업은 SSH와 또 다른 최소 PATH로 실행됩니다. 스크립트 안에서 PATH를 직접 지정하거나 절대 경로를 쓰는 편이 안전합니다

스크립트에서 명령 실패와 0개를 구분하는 법

경로를 고치는 것과 별개로, 값이 이상할 때 알아챌 수 있게 해두는 편이 낫습니다. 처음 예시라면 “명령을 못 찾음”과 “정말 0개”를 다른 결과로 만들면 됩니다.

#!/usr/bin/env bash
set -euo pipefail

rc=0
out=$(ssh server 'command -v docker >/dev/null || exit 127; docker ps --format "{{.Names}}"') || rc=$?

if [ "$rc" -eq 127 ]; then
  echo "docker 명령을 찾지 못했습니다 (PATH 문제 의심)" >&2
  exit 1
elif [ "$rc" -ne 0 ]; then
  echo "원격 명령 실패 (종료 코드 ${rc})" >&2
  exit 1
fi

count=$(printf '%s' "$out" | grep -c . || true)
echo "컨테이너 ${count}개"

set -e를 켠 상태에서는 out=$(...)가 실패하는 순간 스크립트가 바로 끝나서 다음 줄의 $?를 볼 기회가 없습니다. 그래서 || rc=$?를 붙여 종료 코드를 따로 받아 둡니다. 원격에서 exit 127로 끝내면 ssh도 같은 코드를 돌려주기 때문에, 127이면 명령을 못 찾은 경우로 보면 됩니다.

set -u를 쓸 때 주의할 점도 하나 있습니다. 한글이 바로 붙는 문자열에서 $count개라고 쓰면 셸이 count개 전체를 변수 이름으로 읽습니다. ${count}개처럼 중괄호로 감싸야 합니다. 이걸 놓쳐서 스크립트가 unbound variable로 죽은 적이 한 번 있습니다.

명령은 실행됐는데 결과가 안 나오는 다른 사례는 에러 없이 멈춘 크론 백업과 while read 루프를 로그로 잡는 법에, 자동화가 멈춘 걸 산출물로 확인하는 방법은 만료된 토큰 때문에 자동 발행이 닷새 멈췄던 기록에 적어 두었습니다.

새 컴퓨터에 도구를 깔 때 같이 볼 것

이번 일 뒤로는 새 맥에 Homebrew로 도구를 깔면 .zprofile만 보지 않고 .zshenv에도 경로가 있는지 같이 봅니다. 확인은 다른 컴퓨터에서 한 줄이면 됩니다.

ssh 새컴퓨터 'command -v docker mosh-server git'

경로가 다 찍히면 자동화에서도 같은 도구를 찾습니다. 빠진 게 있으면 그 도구의 경로를 ~/.zshenv에 추가하면 됩니다.

자주 묻는 질문

ssh로 접속하면 docker가 되는데 ssh 서버 "docker ps" 는 왜 실패하나요?

두 경우가 읽는 설정 파일이 다르기 때문입니다. 접속해서 프롬프트를 받으면 로그인 셸이라 .zprofile과 .zshrc를 읽습니다. 명령을 인자로 던지면 비대화형 셸이라 zsh는 .zshenv만 읽습니다. PATH 설정이 .zprofile에만 있으면 후자에서는 명령을 찾지 못합니다.

zsh는 어떤 상황에 어떤 파일을 읽나요?

.zshenv는 모든 경우에 읽습니다. .zprofile은 로그인 셸에서만, .zshrc는 대화형 셸에서만, .zlogin은 로그인 셸의 마지막에 읽습니다. SSH로 명령을 던지는 경우는 로그인도 대화형도 아니므로 .zshenv만 해당합니다.

macOS에서 Homebrew 경로가 로그인할 때만 잡히는 이유는 무엇인가요?

Homebrew 설치 안내가 shellenv 호출을 .zprofile에 넣도록 안내하기 때문입니다. 또한 macOS는 path_helper로 /etc/paths의 기본 경로를 채우는데 이 역시 /etc/zprofile에서 호출되므로, 비대화형 셸에는 /usr/local/bin조차 없을 수 있습니다.

.zshenv에 아무거나 넣어도 되나요?

아닙니다. .zshenv는 모든 셸 호출마다 실행되므로 무거운 초기화나 출력이 있으면 scp·rsync·git 같은 도구가 깨집니다. 환경 변수와 PATH 설정만 넣고, 화면에 무언가 출력하는 명령은 넣지 않아야 합니다.

#SSH#zsh#macOS#자동화#트러블슈팅