원격 터미널에서는 빌드가 정상적으로 완료되지만 CI 작업으로 실행하면 command not found가 발생하거나 작업 디렉터리가 잘못될 수 있습니다. 같은 스크립트도 직접 실행할 때는 성공하지만 launchd로 시작하면 실패하기도 합니다. 이런 문제는 대개 도구 자체의 손상이 아니라 세 가지 실행 경로가 서로 다른 환경을 사용하기 때문에 발생합니다. 대화형 Shell은 사용자 설정을 읽고, 비대화형 Shell은 그중 일부만 읽으며, launchd는 훨씬 단순한 환경에서 프로세스를 시작합니다. 따라서 설정 파일에 PATH를 계속 추가하기보다 모든 자동화 작업이 명확하게 정의된 하나의 진입점을 거치도록 해야 합니다.
세 가지 실행 경로의 환경 정보부터 수집하기
설정을 바로 변경하지 마세요. 원격 터미널, CI 작업, launchd에서 동일한 정보를 수집해 차이가 PATH, 현재 디렉터리, 도구 선택 중 어디에서 발생하는지 확인합니다.
#!/bin/zsh
set -eu
printf 'user=%s
' "$(id -un)"
printf 'uid=%s
' "$(id -u)"
printf 'shell=%s
' "${SHELL:-unset}"
printf 'home=%s
' "${HOME:-unset}"
printf 'pwd=%s
' "$PWD"
printf 'path=%s
' "${PATH:-unset}"
for tool in zsh git xcodebuild ruby python3; do
printf '%s=' "$tool"
command -v "$tool" || printf 'missing
'
done
/usr/bin/xcode-select -p 2>/dev/null || true
/usr/bin/sw_vers
이 스크립트를 scripts/inspect-env.sh로 저장한 뒤 세 가지 경로에서 각각 실행하고 출력을 따로 보관합니다. 운영 작업에서는 전체 env 출력을 그대로 수집하지 마세요. 토큰이나 임시 자격 증명이 포함될 수 있습니다.
두 실행에서 확인된 도구의 절대 경로가 다르다면 현재 버전 번호가 같더라도 환경 차이로 간주해야 합니다. 이후 업그레이드 과정에서 두 경로의 도구가 서로 다른 결과를 낼 수 있습니다.
최소 환경에서 재현하기
env -i를 사용하면 사용자별 설정을 읽지 않는 작업 환경을 모의할 수 있습니다.
/usr/bin/env -i \
HOME="$HOME" \
USER="$USER" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
/bin/zsh ./scripts/inspect-env.sh
이 환경에서 문제가 안정적으로 재현된다면 도구를 반복해서 다시 설치할 필요가 없습니다. 실행 진입점과 PATH 정의를 점검해야 합니다.
단일 작업 진입점 스크립트 만들기
자동화 작업이 .zshrc에 의존해서는 안 됩니다. 이 파일은 대화형 작업을 위한 것으로, 프롬프트, 별칭, 터미널 감지, 로그인 세션에서만 유효한 로직이 포함되는 경우가 많습니다. 저장소 전용 진입점으로 scripts/run-ci.sh 같은 스크립트를 만드세요.
#!/bin/zsh
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
required_tools=(git xcodebuild)
for tool in "${required_tools[@]}"; do
if ! command -v "$tool" >/dev/null 2>&1; then
printf 'required tool missing: %s
' "$tool" >&2
exit 127
fi
done
exec ./scripts/build.sh
PATH 순서는 고정해야 하며 시스템 디렉터리도 유지해야 합니다. 작업에 추가 도구가 필요하다면 먼저 해당 장비에서 command -v로 실제 위치를 확인한 뒤 진입점 스크립트에 추가하세요. 사용자 설정 전체를 복사해서는 안 됩니다.
진입점 스크립트는 저장소 루트 디렉터리로 이동하는 역할도 맡습니다. 이렇게 하면 빌드 스크립트의 상대 경로가 CI 실행기의 임시 디렉터리나 launchd의 기본 디렉터리에 영향을 받지 않습니다.
launchd는 실행만 담당하도록 구성하기
launchd 설정은 단순하게 유지해야 합니다. 인터프리터, 진입점 스크립트, 작업 디렉터리, 로그 파일만 지정하고 plist 안에서 복잡한 명령을 조합하거나 민감한 값을 저장하지 마세요.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "file://localhost/System/Library/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ci-runner</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>/Users/runner/project/scripts/run-ci.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/runner/project</string>
<key>StandardOutPath</key>
<string>/Users/runner/Library/Logs/ci-runner.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/runner/Library/Logs/ci-runner.err.log</string>
</dict>
</plist>
로드하기 전에 구문부터 검사합니다.
plutil -lint ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl bootstrap "gui/$(id -u)" \
~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl kickstart -k \
"gui/$(id -u)/com.example.ci-runner"
plist를 수정했다면 먼저 bootout으로 기존 작업을 언로드한 뒤 다시 bootstrap해야 합니다. kickstart만 실행해서는 이미 로드된 이전 설정이 자동으로 갱신되지 않습니다.
도구 버전과 민감한 변수 관리하기
경로가 고정됐다고 해서 버전까지 고정되는 것은 아닙니다. 진입점 스크립트는 작업 시작 시 민감하지 않은 도구 버전, 확인된 경로, 작업 디렉터리를 출력해야 합니다. 그래야 실패한 작업을 비교하기 쉽습니다. 단, 전체 환경 변수를 출력해서는 안 됩니다.
| 점검 항목 | 권장 방법 | 실패 시 보관할 정보 |
|---|---|---|
| 도구 탐색 | command -v |
도구의 절대 경로 |
| 도구 버전 | 공식 버전 옵션 호출 | 버전 문자열 |
| 작업 디렉터리 | 저장소 루트로 명시적으로 cd |
pwd 출력 |
| 시스템 도구 체인 | 현재 개발자 디렉터리 확인 | 경로와 종료 코드 |
| 민감한 변수 | 존재 여부만 확인 | 값 없이 변수 이름만 기록 |
민감한 값은 통제된 자격 증명 절차를 통해 작업 실행 시점에 제공해야 합니다. 스크립트에서 ${TOKEN:?TOKEN is required}를 사용하면 값이 없을 때 즉시 실패하게 만들 수 있지만, set -x, env, 오류 출력으로 내용을 노출해서는 안 됩니다. 진단이 끝난 뒤에는 로그에 요청 헤더, 명령 인수, 임시 파일 경로가 실수로 기록되지 않았는지도 확인해야 합니다.
인수 테스트 매트릭스로 문제 마무리하기
수정 후에는 동일한 스크립트를 세 가지 실행 경로에서 모두 실행해야 합니다. 현재 터미널에서만 확인해서는 안 됩니다.
- 대화형 터미널에서
./scripts/run-ci.sh를 직접 실행합니다. env -i로 HOME, USER, 기본 PATH만 전달해 실행합니다.launchctl kickstart로 시작한 뒤 종료 상태와 두 로그 파일을 확인합니다.- 세 번의 실행에서 저장소 루트 디렉터리, 도구 절대 경로, 버전, 종료 코드를 비교합니다.
- 사용자 세션을 한 번 재시작한 뒤 다시 실행해 작업이 임시로 내보낸 변수에 의존하지 않는지 확인합니다.
흔한 실수는 수정 내용을 .zshrc에 넣은 뒤 직접 실행만 정상화되고 CI는 계속 실패하는 경우입니다. 또는 plist에서 별도의 PATH를 중복 관리하다가 몇 주 후 저장소 스크립트와 다시 달라지기도 합니다. 더 안정적인 경계는 스케줄러가 실행만 담당하고, 진입점 스크립트가 환경을 정의하며, 업무 스크립트가 빌드를 수행하도록 하는 것입니다. 세 계층의 책임을 분리하면 환경 차이를 기록하고 재현하며 검토할 수 있습니다.
OwnAMac의 원격 Mac에 이 방식을 적용할 때도 먼저 작업을 실제로 실행하는 사용자와 저장소 경로를 확인한 뒤 plist를 생성해야 합니다. 예시에 나온 사용자 이름을 그대로 복사하지 마세요. 최종 인수 기준은 단순히 “터미널에서 실행된다”가 아니라 최소 환경과 스케줄러 환경 모두에서 동일한 도구 집합을 확인하고 일관된 결과를 반환하는 것입니다.
자주 묻는 질문
터미널에서 실행되는 명령이 CI에서는 왜 command not found가 되나요?
대화형 Shell은 사용자 설정을 읽어 PATH를 확장할 수 있지만 비대화형 작업과 launchd는 같은 파일을 읽지 않습니다. 작업 진입 스크립트에서 PATH를 명시적으로 정의해야 합니다.
launchd plist에 접근 토큰을 저장해도 되나요?
권장하지 않습니다. plist가 백업이나 진단 자료에 포함될 수 있습니다. 민감한 값은 통제된 자격 증명 절차로 실행 시점에 주입하고 로그에는 출력하지 않아야 합니다.
수정 결과가 현재 터미널에만 적용된 것이 아닌지 어떻게 확인하나요?
같은 검증 스크립트를 대화형 터미널, env -i 최소 환경, launchd에서 각각 실행합니다. PATH, 작업 위치, 도구 절대 경로와 종료 코드를 비교하면 됩니다.
다음 빌드를 독점 Apple Silicon 물리 노드에서 실행하세요
작업 규모에 따라 세 가지 구성과 다섯 개 노드 중에서 선택할 수 있으며, 컴퓨팅과 스토리지는 다른 테넌트와 공유되지 않습니다. 모든 노드는 연중 365일 정상 운영되며, 실제 사용 가능 상태는 콘솔의 실시간 응답을 기준으로 합니다.