콘텐츠로 건너뛰기
Codex

launchd 아래에서 환경변수를 디버깅하는 방법

TECH NOTE · AUTOMATION OPS

launchd에서만 환경변수가 달라져 스크립트가 깨질 때, 터미널과 무엇이 다른지 확인하고 PATH를 추적하는 방법을 정리했습니다.

터미널과 launchd의 환경은 따로 본다

launchd 아래에서 환경변수를 디버깅할 때는 “왜 터미널에서는 되는데 여기서는 안 되지?”를 먼저 나눠야 한다. 터미널에서 보이는 PATH, OPENAI_API_KEY, HOME 같은 값이 launchd 작업에도 그대로 들어간다고 보면 거의 항상 헷갈린다. 내 경우에는 .zshrc에 넣어둔 값이 당연히 먹을 줄 알았는데, launchd는 그 파일을 읽고 시작하는 게 아니었다.

터미널은 내가 로그인한 셸이고, launchd는 macOS가 백그라운드 작업을 띄우는 관리자에 가깝다. 그래서 봐야 할 곳도 세 군데로 나뉜다.

확인할 것 보는 이유
터미널 환경변수 내가 평소에 쓰는 셸에서는 값이 있는지 본다
launchd 사용자 도메인 환경 launchd가 새 작업에 물려줄 값이 있는지 본다
실제 작업 안의 환경 plist로 뜬 프로세스가 최종적으로 뭘 받았는지 본다

처음엔 첫 번째만 보고 있었다. 터미널에서 env를 치면 값이 보이니까 “환경변수는 있는데 왜 안 되지?”가 된다. 그런데 그건 터미널 얘기고, launchd 얘기는 아직 시작도 안 한 상태다.

터미널 값부터 확인한다

터미널에서 먼저 보는 건 간단하다. 지금 셸이 어떤 환경변수를 들고 있는지 확인한다.

env | sort

여기서 값이 보이면 “내 터미널에는 있다”까지만 확인한 것이다. launchd에도 있다는 뜻은 아니다. 특히 .zshrc, .zprofile, .bash_profile 같은 파일에 넣어둔 값은 터미널을 열 때만 들어오는 경우가 많다.

PATH 문제도 여기서 자주 걸린다. 터미널에서는 node, python, brew, codex 같은 명령이 잘 잡히는데 launchd에서는 못 찾는다. 터미널이 알아서 만들어준 PATH를 launchd 작업이 그대로 받지 않기 때문이다.

그래서 launchd plist에서는 가능하면 명령 이름만 쓰지 말고 절대경로를 쓰는 쪽이 덜 헷갈린다. “터미널에서는 되는데 launchd에서는 안 된다”의 절반쯤은 결국 이 문제였다.

launchd가 들고 있는 값은 따로 본다

launchd 쪽 환경은 launchctl로 본다. 먼저 특정 변수 하나가 launchd 사용자 도메인에 잡혀 있는지 확인한다.

launchctl getenv PATH

내 환경에서는 이 명령이 빈 줄로 나오는 경우가 있었다. 그게 이상한 게 아니다. launchd의 사용자 도메인에 PATH가 따로 설정되어 있지 않다는 뜻에 가깝다.

전체 환경 블록을 보고 싶으면 이렇게 본다.

launchctl print gui/$(id -u) | sed -n '/environment = {/,/}/p'

여기서 보이는 값이 launchd가 새로 띄우는 사용자 작업에 물려줄 수 있는 값이다. 터미널의 env 결과보다 훨씬 작게 나올 수 있다. 처음엔 이 차이가 좀 당황스럽다. 터미널은 별별 값을 다 들고 있는데, launchd 쪽은 정말 필요한 것만 있는 느낌으로 나온다.

launchctl setenv로 값을 넣을 수도 있다. 다만 이건 이미 떠 있는 작업을 마법처럼 고쳐주지는 않는다. 새로 시작되는 작업부터 영향을 받는다고 보는 편이 맞다. 그리고 재부팅까지 생각하면 plist 안에 명시하는 쪽이 더 덜 흔들린다.

실제 작업 안에서 env를 찍어본다

가장 확실한 방법은 launchd가 띄운 프로세스 안에서 env를 찍는 것이다. 내 경우에는 이걸 보기 전까지 계속 터미널 상태만 보고 삽질했다.

디버깅용 LaunchAgent는 이런 식으로 잠깐 만든다. 실제 서비스에 바로 넣기보다, 문제를 좁히는 용도로만 둔다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>local.env-dump</string>

    <key>ProgramArguments</key>
    <array>
      <string>/bin/zsh</string>
      <string>-lc</string>
      <string>/usr/bin/env | /usr/bin/sort > /tmp/launchd-env.txt 2>&1</string>
    </array>

    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>

여기서 일부러 /usr/bin/env, /usr/bin/sort처럼 절대경로를 썼다. 디버깅하는 중에 PATH 때문에 또 실패하면 원인이 하나 더 늘어난다. zsh -lc를 넣은 것도 명령 파이프를 편하게 쓰려는 용도이지, 평소 터미널 환경이 그대로 들어온다는 뜻은 아니다.

이 파일을 ~/Library/LaunchAgents/local.env-dump.plist에 둔 뒤 로드하면 /tmp/launchd-env.txt에 실제 환경이 찍힌다. 여기서 원하는 값이 없으면, 그 작업은 애초에 그 환경변수를 받은 적이 없는 것이다.

plist에 직접 넣을 값과 셸에 둘 값을 구분한다

launchd 작업에 꼭 필요한 값이면 plist의 EnvironmentVariables에 직접 넣는 편이 제일 읽기 쉽다.

<key>EnvironmentVariables</key>
<dict>
  <key>NODE_ENV</key>
  <string>production</string>
  <key>PATH</key>
  <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>

다만 비밀값은 여기서 조심해야 한다. plist 파일은 결국 파일로 남는다. 토큰이나 키처럼 노출되면 곤란한 값은 별도 파일 권한, Keychain, 서비스 자체의 설정 방식을 따로 봐야 한다. launchd 환경변수에 다 밀어 넣는 게 항상 좋은 답은 아니었다.

반대로 NODE_ENV, PATH, LANG처럼 실행 환경을 고정하는 값은 plist에 명시해두는 게 나중에 봐도 덜 헷갈린다. 터미널 초기화 파일에 숨어 있으면 몇 달 뒤에 다시 보고 “이 값 어디서 들어왔지?”가 된다.

로그보다 먼저 환경을 확인한다

launchd 작업이 실패하면 처음엔 로그부터 뒤지게 된다. 그런데 환경변수 문제는 로그가 친절하게 말해주지 않는 경우가 많다. 명령을 못 찾거나, 인증값이 없거나, 홈 경로가 예상과 다를 뿐이다.

그래서 순서는 이렇게 잡는 게 편했다.

  1. 터미널에서 값이 있는지 본다.
  2. launchd 사용자 도메인에 값이 있는지 본다.
  3. 실제 LaunchAgent 안에서 env를 파일로 찍는다.
  4. 그다음 plist의 ProgramArguments, EnvironmentVariables, 절대경로를 고친다.
  5. 이미 떠 있던 작업은 다시 시작해서 새 환경을 받게 한다.

여기서 핵심은 “터미널에서 된다”를 증거로 너무 크게 쓰지 않는 것이다. 그건 그냥 한쪽 환경이 정상이라는 뜻이다. launchd에서 깨지는 문제는 launchd 안에서 찍은 값으로 봐야 한다.

같이 나오는 질문

`.zshrc`에 넣으면 launchd도 읽나?

보통 그렇게 기대하면 안 된다. .zshrc는 대화형 셸 쪽에 가깝다. launchd 작업은 터미널을 열고 명령을 치는 흐름이 아니라서, 거기에 넣은 환경변수를 자동으로 가져간다고 보면 삽질하기 쉽다.

`launchctl setenv`로 고치면 끝인가?

잠깐 확인할 때는 쓸 수 있다. 다만 이미 실행 중인 작업에는 바로 반영되지 않는다고 보고, 재시작까지 같이 생각해야 한다. 오래 둘 설정이면 plist나 서비스 설정 쪽에 명시하는 편이 낫다.

`PATH`만 맞추면 되나?

많은 경우에는 PATH가 문제였지만, 전부는 아니다. HOME, LANG, 인증 관련 변수, 작업 디렉터리도 같이 본다. 특히 launchd 작업은 “내가 터미널에서 그 폴더에 들어가 실행했다”는 상황이 아니기 때문에, 경로는 가능하면 절대경로로 확인하는 게 덜 헷갈린다.

다음에 실제 작업 plist를 볼 때는 ProgramArguments에 명령 이름만 들어가 있는지, EnvironmentVariables가 비어 있는지, 그리고 launchd 안에서 찍은 /tmp/launchd-env.txt가 터미널의 env와 어떻게 다른지부터 보면 된다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다