정의
설치한 CLI를 실행했는데 에러도 없고 출력도 없고 결과물도 없이 종료 코드 0으로 끝난 적이 있다면 이 개념이 필요하다.
main 가드(직접 실행 가드) 는 스크립트가 "지금 내가 직접 실행된 건가, 아니면 다른 모듈이 import한 건가"를 판별하는 관용구다. Node ESM에서는 import.meta.url === pathToFileURL(process.argv[1]).href, Python에서는 __name__ == "__main__" 계열이 그 역할을 한다. 문제는 이것이 경로 문자열 비교라는 점이다. 실행 경로가 심볼릭 링크면 두 값이 달라져 가드가 조용히 거짓이 되고, 본문이 한 줄도 실행되지 않은 채 정상 종료로 위장된다.
왜 필요한가
npm i -g로 설치한 CLI는 실제 파일이 아니라 ~/.nvm/versions/node/*/bin/<cmd> 같은 심볼릭 링크로 놓인다. 실행하면 process.argv[1]은 심링크 경로가 되고, import.meta.url은 링크가 가리키는 실제 파일 경로가 된다. 두 문자열이 다르므로 가드가 막아 버린다.
이 실패 모드가 특히 나쁜 이유는 실패 신호가 하나도 없다는 점이다. stderr도 비어 있고 종료 코드도 0이라 CI에서도 통과한다. 사용자는 도구 버그를 의심하지 못하고 자기 입력이 잘못됐다고 생각하며 시간을 태운다.
동작 원리
npm i -g mytool→bin/mytool.mjs를 가리키는 심링크가 PATH에 생긴다.- 사용자가
mytool log를 실행한다. - Node가 심링크를 따라 실제 파일을 로드한다 →
import.meta.url은 실경로. - 그러나
process.argv[1]은 사용자가 입력한 대로의 심링크 경로. import.meta.url === pathToFileURL(process.argv[1]).href가 false → 본문 미실행.- 모듈 최상위 코드만 돌고 정상 종료(exit 0).
| 값 | 심링크 실행 시 |
|---|---|
process.argv[1] | /Users/me/.nvm/.../bin/mytool |
import.meta.url | file:///Users/me/dev/mytool/bin/mytool.mjs |
| 비교 결과 | false → 조용한 no-op |
실무 적용
진단은 세 줄이면 끝난다.
ls -l "$(which mytool)" # 심링크인지 확인
node "$(readlink -f "$(which mytool)")" log # 실경로로 직접 실행
# 여기서 동작하면 원인은 main 가드 확정
수정은 양쪽을 실경로로 정규화한다.
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
const isDirectRun =
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
if (isDirectRun) await main(process.argv.slice(2));
더 단순한 대안은 가드를 없애는 것이다. bin/cli.mjs는 라이브러리를 import해 즉시 실행만 하고, 라이브러리 파일에는 실행 코드를 두지 않는다. 비교할 경로 자체가 사라진다.
트레이드오프
- realpath 정규화: 한 줄로 끝나지만
realpathSync가 존재하지 않는 경로에서 던진다.process.argv[1]이 없는 REPL 실행을 고려해 방어가 필요하다. - bin 엔트리 분리: 가장 견고하고 의도가 명확하다. 대신 파일이 하나 늘고 패키지
bin필드를 손봐야 한다. - 가드 유지 + 로그: 가드가 걸렸을 때 stderr로 한 줄 남기면 최소한 조용한 실패는 막는다. 근본 해결은 아니다.
사용하면 안 되는 경우
- 라이브러리로 import될 일이 전혀 없는 순수 실행 스크립트. 가드 자체가 불필요한 복잡도다.
- 실행 경로가 항상 고정된 컨테이너 엔트리포인트. 경로가 심링크를 타지 않으면 문제 자체가 없다.
흔한 실수
- exit 0을 성공으로 믿는다. 출력이 없고 결과물도 없으면 성공이 아니다. 실패는 반드시 보이게 만든다.
- 경로 문자열을 직접 비교한다. 심링크 외에도 macOS의
/private접두사, 대소문자 보존 파일시스템, Windows 단축 경로에서 어긋난다. - 셸 출력만 보고 판단한다. 래퍼·프록시·필터가 낀 셸에서는 출력이 가공돼 진단을 오도할 수 있다. 파일 내용은 별도 수단으로 재확인한다.
which가 준 경로를 실경로로 착각한다.readlink -f로 한 단계 더 풀어야 한다.
관련 개념
- shell-word-splitting-quoting — 셸 계층이 진단을 뒤집는 또 다른 사례
- serverless-stateless-execution — 실행 환경 가정이 깨지는 계열