launchd가 단순히 tmux new-session -d만 실행하는 스크립트를 가리키게 하고 KeepAlive를 켜면, 오래 살아 있는 백그라운드 에이전트가 아니라 몇 초마다 다시 시작되는 작업이 됩니다. 이 불일치는 겉으로는 잘 드러나지 않지만, 핵심은 launchd가 실제로 어떤 프로세스를 감시하느냐에 있습니다.
tmux가 분리될 때 launchd가 보는 것

재시작 루프가 생기는 이유는 launchd가 잘못된 PID를 감독하고 있기 때문입니다. tmux new-session -d는 의도적으로 분리된 tmux 서버와 세션을 띄운 뒤, 그 명령을 실행한 tmux 클라이언트는 즉시 상태 코드 0으로 종료합니다 . launchd가 추적한 것은 오래 살아 있는 서버가 아니라 그 클라이언트이므로, launchd 입장에서는 자신이 시작한 프로그램이 방금 정상 종료된 것입니다.
KeepAlive=true에서는 추적 중인 PID가 어떤 식으로든 종료되면 즉시 다시 실행됩니다. 그래서 흐름은 이렇게 됩니다. launchd가 래퍼를 시작하고, tmux 클라이언트가 세션을 분리한 뒤 0으로 종료하고, launchd가 종료를 감지해 다시 실행하고, 클라이언트가 또 종료합니다. 끝없는 반복입니다. launchd가 자체 지수 백오프 제한을 적용하기 시작하면 재시작 속도만 느려질 뿐, 근본적인 설계 오류는 해결되지 않습니다 .
Apple의 launchd 문서는 tmux 런처가 어기는 제약을 명확히 설명합니다. launchd가 관리하는 프로세스는 데몬화하거나, fork 후 종료하거나, setsid()를 호출하거나, 내부에서 작업 디렉터리를 바꾸거나, stdio를 직접 리다이렉트해서는 안 됩니다. 이런 동작이 있으면 launchd는 프로세스가 죽었다고 판단하고 다시 실행합니다 . 분리 세션 런처는 바로 이 금지된 일을 합니다. 실제 작업을 백그라운드로 fork한 뒤 종료합니다.
구조적으로 보면 KeepAlive는 오래 살아 있는 포그라운드 프로세스가 죽었을 때 다시 시작하기 위해 만들어졌습니다. 정상 종료는 작업이 더 이상 일을 하지 않는다는 뜻이라고 가정합니다. 하지만 분리형 런처는 충돌해서가 아니라 원래 그렇게 하도록 만들어졌기 때문에 종료됩니다. 그래서 KeepAlive는 정상 동작을 실패로 읽습니다. 다음 섹션에서 다룰 해결책은 launchd에 런처를 계속 살려 달라고 하지 않고, 로드 시 한 번만 실행하게 하거나 실제 포그라운드 프로세스를 직접 감독하게 하는 것입니다.
멱등 plist: KeepAlive 없이 RunAtLoad

KeepAlive를 RunAtLoad로 바꾸세요. RunAtLoad=true이고 KeepAlive 키가 없는 plist는 로그인 또는 부팅 시 명령을 정확히 한 번 실행하고, 프로세스가 종료되어도 다시 실행하지 않습니다 . 요점은 이것입니다. 재시작 의미론은 launchd가 아니라 그것을 책임져야 하는 계층으로 옮깁니다. 클라이언트가 분리된 뒤에도 세션을 유지하는 tmux 서버 , 또는 Claude Code 자체 supervisor 데몬이 그 역할을 맡습니다. launchd는 돌보미가 아니라 부트스트래퍼가 됩니다.
ProgramArguments의 첫 단계에 tmux has-session 가드를 넣어 런처를 멱등하게 만드세요. plist를 수정한 뒤 흔히 하는 launchctl unload/load 사이클은 그렇지 않으면 명령을 다시 실행해 중복 세션을 띄웁니다. 이 가드는 재실행을 no-op으로 만듭니다.
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>tmux has-session -t claude 2>/dev/null || tmux new-session -d -s claude 'claude'</string>
</array>plist는 최소한으로 유지하세요. 여섯 개 키면 작업 전체를 담을 수 있고, stdio는 ProgramArguments 안에 묻힌 셸 리다이렉트가 아니라 StandardOutPath/StandardErrorPath를 통해 launchd가 소유합니다. 셸 리다이렉트는 launchd가 제재하는 데몬화 동작 중 하나입니다 .
| 키 | 값 | 역할 |
|---|---|---|
Label | com.you.claude | 고유한 작업 식별자 |
ProgramArguments | has-session 가드 + new-session | 멱등 런처 |
RunAtLoad | true | 로드 시 한 번 실행, 재시작 없음 |
WorkingDirectory | 프로젝트 루트 | 프로세스 내부의 cd가 아니라 launchd가 설정 |
StandardOutPath | 로그 파일 경로 | launchd가 stdout을 소유 |
StandardErrorPath | 로그 파일 경로 | launchd가 stderr를 소유 |
또 하나의 요구사항은 plist가 아니라 세션 내부에 있습니다. Claude Code를 무인 실행하려면 도구를 미리 승인해야 합니다. 그렇지 않으면 아무도 응답할 수 없는 대화형 권한 프롬프트에서 무기한 멈춥니다 . claude --allowedTools "Bash,Read,Edit"처럼 upfront로 선언하거나 명시적인 권한 모드를 설정하세요. 이것을 빼먹으면 멈춘 것처럼 보이는 조용한 실패가 됩니다. tmux 세션은 살아 있고, plist도 정상 로드되었지만, 에이전트는 단지 키 입력을 기다리고 있을 뿐입니다.
결국 논리는 launchd가 어떤 프로세스를 감시하느냐로 정리됩니다. 아래 예시 스니펫은 실제 데몬으로 실행하는 코드는 아니고 제어 흐름을 보여 주는 모델입니다. keep-alive supervisor 아래에서 분리형 런처가 세 번 재시작되는 경우와, supervisor가 실제 포그라운드 프로세스를 추적할 때 재시작이 0번인 경우를 비교합니다.
#!/usr/bin/env python3
"""launchd KeepAlive + `tmux new -d ...` exits fast, so launchd respawns it.
Fix: make the launchd Program stay in the foreground (or let launchd manage the
real daemon directly), instead of starting a detached tmux session and exiting.
"""
def launchd(program, keepalive=True, limit=4):
starts = 0
while True:
starts += 1
code = program()
print(f"start #{starts}: exit={code}")
if not keepalive or code == "still-running" or starts >= limit:
return starts
def tmux_detach():
print(" tmux new-session -d 'worker' # parent exits immediately")
return 0
def foreground_worker():
print(" exec worker # foreground process stays alive")
return "still-running"
print("bad: KeepAlive watches a detached tmux parent")
bad = launchd(tmux_detach)
print("\nfix: launchd watches the real foreground process")
good = launchd(foreground_worker)
print(f"\nrespawns: bad={bad - 1}, fixed={good - 1}")Daemonize, setsid, stdio: launchd는 각각을 어떻게 해석하나
전통적인 Unix 데몬에서 프로그램이 “얌전하게” 동작한다고 여겨지는 POSIX 습관 세 가지가 launchd 아래에서는 그대로 문제를 일으킨다. Apple의 지침은 명확하다. launchd가 관리하는 프로세스는 daemonize하거나, fork 후 부모가 종료하거나, setsid를 호출하거나, 내부에서 작업 디렉터리를 바꾸거나, 자체적으로 stdio를 리디렉션하면 안 된다. 그렇게 하면 launchd가 작업이 죽었다고 판단해 다시 실행할 수 있다 . launchd가 실제로 각각을 어떻게 보는지는 다음과 같다.
- Daemonize / fork-and-exit: launchd가 실행한 PID가 “진짜” 워커를 fork한 직후 바로 종료된다. launchd는 의도적인 분리와 크래시를 구분할 수 없으므로 빠른 종료를 실패로 처리하고,
KeepAlive가 설정되어 있으면 다시 시작한다. 이는tmux new-session -d와 같은 함정이다. 부모는 0을 반환하지만 작업은 다른 곳에서 계속된다. setsid(): 새 프로세스 세션을 만들어 자식 프로세스를 launchd의 프로세스 그룹에서 떼어낸다. launchd는 그 자식 트리를 추적하거나, 시그널을 보내거나, 리소스 사용량을 집계할 수 없게 된다. 종료나 재시작도 실제로 관리하고 싶은 워커에 더 이상 닿지 않는다.- stdio 자체 리디렉션:
ProgramArguments가 내부적으로 출력을 파이프하면StandardOutPath와StandardErrorPath뒤의 파일 핸들은 아무것도 가리키지 않게 되고 로그 캡처가 조용히 깨진다. 로그는 항상 plist 키로 라우팅하고, 프로그램은 평소처럼 stdout/stderr에 쓰게 둔다.
이들을 하나로 묶는 규칙은 간단하다. launchd가 시작한 프로세스는 포그라운드에 유지하고, 분리, 세션 경계, 로그 라우팅은 프로그램이 아니라 plist가 맡게 한다.
슈퍼바이저 데몬: plist 이후의 지속성

plist가 분리를 맡고 나면, Claude Code 자체 슈퍼바이저가 에이전트 생명주기를 맡을 수 있다. 그러면 tmux를 프로세스 소유자 자리에서 완전히 빼낼 수 있다. claude --bg는 에이전트를 분리된 세션으로 시작한 뒤 즉시 반환하며, claude agents, claude attach <id>, claude logs <id>, claude stop <id>로 멀티플렉서가 아무것도 감독하지 않아도 전체 생명주기를 제어할 수 있다 . 별도의 슈퍼바이저 프로세스가 이 세션들을 호스팅하고, 자동 업데이트와 슈퍼바이저 재시작 중에도 디스크에 상태를 유지하며, Mac이 잠자기 상태에 들어가도 세션을 보존하고 깨어날 때 다시 연결한다. 문서화된 재연결 동작에는 v2.1.200 이상이 필요하다 .
슈퍼바이저가 견디지 못하는 것은 완전한 종료다. 실행 중인 세션은 중단되고 복구가 필요할 수 있다. 바로 이 지점에서 RunAtLoad plist의 역할이 계속 남는다. KeepAlive 재실행기가 아니라, 다음 로그인 시 슈퍼바이저를 한 번 다시 실행하거나 다시 붙인 뒤 물러나는 로그인/부팅 부트스트래퍼로서다.
그 위에 대시보드를 얹고 싶다면 Codeman v1.3.2(MIT, 2026년 7월 13일 릴리스, GitHub 스타 약 461개)는 최대 20개의 tmux 세션을 감독하며, Respawn Controller가 유휴 감지를 수행하고 임계값에서 /clear와 /init로 컨텍스트를 순환한다 . 핵심은 이것이다. plist는 한 번 실행만 맡기고, 슈퍼바이저는 상태 지속성을 맡기며, tmux는 점검용으로만 둔다. 재실행 로직 자체는 절대 tmux에 맡기지 않는다.
자주 묻는 질문
왜 KeepAlive=true가 tmux new-session -d와 함께 재실행 루프를 만들까?
launchd는 자신이 실행한 프로세스의 PID를 추적하는데, -d 플래그는 tmux 클라이언트가 분리된 세션을 만든 직후 코드 0으로 바로 종료되게 만들기 때문이다. KeepAlive=true가 있으면 launchd는 어떤 종료든 실패한 작업으로 보고 즉시 다시 실행한다. 그래서 실제 워커가 아니라 계속 종료되는 런처만 감독하게 된다. 결과적으로 launchd의 지수 백오프 스로틀링이 개입할 때까지 빠른 재실행 루프가 돈다. Apple 지침도 launchd 관리 프로세스가 fork-and-exit하거나 분리되면 안 된다고 경고한다. 그렇지 않으면 launchd가 프로세스가 죽었다고 판단해 다시 실행할 수 있다 .
터미널 멀티플렉서와 KeepAlive=true를 함께 써도 안전한 경우가 있을까?
있다. ProgramArguments가 fork-and-exit하지 않는, 하나의 오래 실행되는 포그라운드 프로세스를 실행할 때는 KeepAlive=true가 맞다. 이 경우 launchd는 실제 워커를 보고 있으므로 진짜 크래시 재시작 동작을 제공한다. 재실행 문제는 tmux new-session -d처럼 세션이 만들어지자마자 부모가 반환하는 detach-and-exit 런처에 한정된다. 포그라운드 exec 래퍼, 예를 들어 에이전트를 직접 exec하거나 포그라운드에 남아 있는 스크립트는 launchd의 PID 가정을 유지하므로 KeepAlive와 함께 써도 안전하다 .
무인 Claude Code 운영에는 어떤 권한 모드가 맞을까?
범위가 좁게 정해진 작업이라면 명시적 허용 목록과 함께 headless 모드를 실행한다. 예를 들어 --allowedTools "Bash,Read,Edit"를 지정하면 실행 중 대화형 승인 프롬프트에서 멈추지 않는다 . 더 넓은 자율성이 필요하다면 auto 또는 plan 모드에 PreToolUse 훅을 결합해 rm -rf 같은 고위험 명령을 실행 전에 차단한다 . Auto 모드는 별도 분류기를 사용하며, 프로덕션 배포, 데이터 유출, 강제 푸시 같은 범주는 여전히 차단한다. 연속 3회 또는 총 20회 차단되면 폴백한다 . bypassPermissions는 격리된 컨테이너와 VM용으로만 문서화되어 있다. 주 사용 머신에서는 쓰지 말아야 한다.
claude --bg를 쓰면 장기 실행 에이전트 운영에서 tmux가 불필요해질까?
생명주기 관점, 즉 자동 업데이트 중 지속성, 깨어날 때 재연결(v2.1.200+ 필요), 재실행에서는 네이티브 슈퍼바이저 데몬이 tmux를 상당 부분 대체한다 . claude --bg는 세션을 백그라운드 에이전트로 시작한 뒤 즉시 반환하며, claude attach <id>, claude logs <id>, claude stop <id>, claude respawn <id>은 그 슈퍼바이저를 통해 세션을 관리한다 . 그래도 tmux는 점검 레이어로는 쓸모가 있다. 붙어서 실시간 출력을 보고, 로그를 tail하고, claude attach <id> 옆에서 git 상태를 확인할 수 있다. 다만 재실행 로직은 tmux에 넘기지 않는다.
이 글이 도움이 되셨다면, 새 글이 올라올 때마다 이메일로 받아보세요.