본문 바로가기
Tech Notes

tmux와 launchd로 원격 개발 환경 상시화하기

by miracle-tech 2026. 7. 15.
728x90
반응형

SSH를 끊으면 왜 프로세스가 죽을까 — tmux와 launchd로 원격 개발 환경 상시화하기

집에 있는 Mac Mini를 회사에서 브라우저로 쓰고 싶었다. Tailscale로 길은 뚫었고, SSH로 붙어서 서버를 띄웠다. 잘 됐다. 그런데 터미널을 닫는 순간 전부 끊겼다.

이 글은 그 하루의 기록이다. tmux를 왜 쓰는지, 그리고 왜 결국 tmux를 안 쓰게 됐는지에 대한.


1. 문제: SSH를 끊으면 왜 프로세스가 죽나

원격 서버에 SSH로 붙어서 이렇게 띄웠다고 하자.

ssh miracle@100.101.102.103
orca serve --pairing-address 100.101.102.103

서버가 뜬다. 브라우저로 접속도 된다. 그런데 SSH 세션을 닫으면 서버도 같이 죽는다.

이유는 프로세스 트리에 있다.

SSH에서 직접 실행 SIGHUP이 그대로 내려간다 → 죽음 tmux 세션 안에서 실행 tmux 서버에서 사슬이 끊긴다 → 생존 SSH 세션 터미널 창을 닫음 ✕ SIGHUP 로그인 셸 (zsh) 종료 SIGHUP orca serve 함께 죽는다 서비스 중단 SSH 세션 터미널 창을 닫음 ✕ SIGHUP 여기서 끊긴다 tmux 서버 원격에서 독립 실행 orca serve 계속 돈다 서비스 유지

SSH로 접속하면 원격에 로그인 셸이 뜨고, 거기서 실행한 프로세스는 그 셸의 자식이 된다. 세션이 끊기면 커널이 해당 터미널에 붙은 프로세스 그룹에 SIGHUP(hangup)을 보낸다. 대부분의 프로그램은 이 시그널의 기본 동작대로 종료된다.

이름부터가 유물이다. 전화선(모뎀)으로 접속하던 시절, 수화기를 내려놓으면(hang up) 붙어 있던 프로세스를 정리하라는 신호였다. 지금은 물리적 전화선이 없지만 메커니즘은 그대로 남아 있다.

터미널 창은 단순한 창이 아니라 프로세스의 생명줄이다. 창을 닫으면 그 안의 프로세스도 딸려 나간다.


2. tmux: 세션을 SSH에서 떼어내기

tmux(terminal multiplexer)는 이 사슬에 한 겹을 끼워 넣는다.

tmux 세션 안에서 프로그램을 돌리면, 그 프로세스의 부모는 SSH 셸이 아니라 tmux 서버가 된다. tmux 서버는 원격 머신에서 독립적으로 살아 있다. 그래서 SSH가 끊겨도 SIGHUP이 tmux 서버까지 도달하지 않고, 안의 프로세스는 계속 돈다. (위 그림 오른쪽)

창문에 비유하면 이렇다. SSH는 방 안을 들여다보는 창문이고, tmux는 방 안에서 계속 돌아가는 컴퓨터다. 창문을 닫아도 컴퓨터는 꺼지지 않는다.

알아야 할 명령어는 이게 전부다

명령 하는 일
brew install tmux설치 (macOS)
tmux new -s orcaorca라는 이름의 세션 생성 + 진입
Ctrl-B → Ddetach — 밖으로 나오지만 안은 계속 돎
tmux attach -t orca세션에 다시 붙기
tmux ls살아 있는 세션 목록
tmux kill-session -t orca세션 종료

Ctrl-B가 tmux의 지휘 키(prefix)다. Ctrl-B를 누르고 손을 뗀 다음 D를 눌러야 한다. 동시에 누르는 게 아니다.

tmux new -s orca
orca serve --pairing-address 100.101.102.103
# Ctrl-B 누르고 뗀 다음 D

detach가 핵심이다. 이 순간 터미널로 돌아오지만 tmux 세션은 원격에 그대로 살아 있다. SSH를 끊고 노트북을 닫고 퇴근해도 계속 돈다.

제대로 됐는지 확인하는 법

detach만 하고 안심하면 안 된다. 실제로 SSH를 완전히 끊어보고 서비스가 살아 있는지 확인해야 한다.

exit                          # SSH 세션 종료
ssh miracle@100.101.102.103    # 다시 접속
tmux ls                       # 세션이 남아 있나?
# orca: 1 windows (created ...)  ← 성공

3. tmux의 한계: 재부팅

여기까지 하면 SSH 문제는 해결된다. 그런데 하나가 남는다.

생존 시나리오 비교 같은 프로세스가 각 상황에서 살아남는가 tmux launchd 그냥 실행 SSH 세션 끊김 터미널 창을 닫음 프로세스 크래시 에러로 죽음 / kill KeepAlive 머신 재부팅 정전 / OS 업데이트 RunAtLoad tmux는 SSH 끊김만 막는다. 서비스로 만들려면 launchd(systemd)가 필요하다.

재부팅하면 tmux 세션도 사라진다.

정전이 나거나, macOS 업데이트가 강제 재시작을 걸거나, 그냥 껐다 켜면 tmux 서버도 같이 죽는다. 그러면 집에 돌아가서 다시 SSH로 붙어 tmux new -s orca부터 다시 쳐야 한다.

"어디서든 일한다"가 목표인데 "단, 재부팅되면 집에 가서 손으로 살려야 함"이라는 각주가 붙는다면 그건 완성이 아니다.

게다가 매번 이걸 치는 것 자체가 이상하다.

tmux new -s orca
orca serve --pairing-address 100.101.102.103

서버 프로세스를 손으로 띄우고 있다는 건, 그게 아직 서비스가 아니라는 뜻이다.


4. launchd: 진짜 답

macOS에서 부팅 시 자동 실행 + 죽으면 자동 부활을 담당하는 건 launchd다. (리눅스의 systemd에 해당한다.)

~/Library/LaunchAgents/에 plist 하나를 만들면 된다.

cat > ~/Library/LaunchAgents/dev.orca.serve.plist << 'EOF'
<?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>dev.orca.serve</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/orca</string>
        <string>serve</string>
        <string>--pairing-address</string>
        <string>100.101.102.103</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/miracle/orca-serve.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/miracle/orca-serve.err.log</string>
</dict>
</plist>
EOF

launchctl load ~/Library/LaunchAgents/dev.orca.serve.plist

plist 키 정리

역할 빠뜨리면
Labeljob 고유 식별자로드 실패
ProgramArguments실행할 명령 + 인자 (배열)
RunAtLoad부팅/로그인 시 자동 시작재부팅 후 안 뜸
KeepAlive죽으면 자동 재시작크래시 시 그대로 멈춤
StandardOutPathstdout 로그 경로출력이 어디에도 안 남음
StandardErrorPathstderr 로그 경로에러 원인 추적 불가

ProgramArguments에는 절대 경로를 써야 한다. launchd는 로그인 셸의 PATH를 상속하지 않는다. which orca로 확인한 실제 경로를 그대로 박는다.

등록 확인

launchctl list | grep orca
# 90389   0       dev.orca.serve   ← PID가 붙어 있으면 실행 중

ps aux | grep "orca serve" | grep -v grep

KeepAlive는 반드시 테스트하라

등록했다고 끝이 아니다. 실제로 죽여보고 되살아나는지 봐야 한다.

pkill -f "orca serve"
sleep 5
launchctl list | grep dev.orca.serve

PID가 다른 번호로 바뀌어 있으면 성공이다.

이게 왜 중요하냐면 — 나는 이 메커니즘의 위력을 반대 방향에서 먼저 배웠다. 며칠 전 안 쓰는 에이전트 프로세스를 정리하는데, kill을 할 때마다 새 PID로 계속 부활했다. 두더지 잡기였다.

60979 60978 ...   # kill
60984 60983 ...   # 새 PID로 부활

프로세스를 아무리 죽여도 소용없었다. 부모를 타고 올라가니 이렇게 나왔다.

ps -ww -o pid,ppid,command -p 60973
#   PID  PPID COMMAND
# 60973     1 /bin/zsh /Users/miracle/.hermes/scripts/start-hermes-jihun.sh

PPID가 1(launchd). 답은 launchctl unload였다.

교훈: 아래에서부터 죽이지 말고, 누가 살려내는지부터 찾아라.

ps -o pid,ppid,command -p <PID>로 부모를 타고 올라가면 나온다. PPID가 1이면 launchd다.

그때 나를 괴롭히던 그 메커니즘을, 이번엔 내가 원하는 곳에 쓴 셈이다.


5. 언제 tmux, 언제 launchd

둘은 대체재가 아니라 용도가 다르다.

tmux launchd / systemd
SSH 끊김에 살아남나OO
재부팅에 살아남나XO (RunAtLoad)
죽으면 자동 부활XO (KeepAlive)
화면을 다시 볼 수 있나O (attach)X (로그로만)
스크롤백 유지OX
설정 비용명령 한 줄plist 작성
적합한 대상대화형 작업서비스 / 데몬

tmux가 맞는 경우 — 긴 빌드, DB 마이그레이션, 디버깅 세션처럼 "화면을 다시 열어서 진행 상황을 보고 싶은" 것. attach로 스크롤백까지 그대로 돌아올 수 있다는 게 강점이다.

launchd가 맞는 경우 — 서버, 게이트웨이, 데몬처럼 "그냥 항상 떠 있어야 하는" 것. 사람이 볼 화면이 필요 없고, 로그 파일로 충분한 것.

내 경우 orca serve는 명백히 후자였다. 그래서 tmux는 검증용 임시 단계로만 썼다. 눈에 보이는 상태에서 한 번 제대로 도는지 확인하고, 그다음 launchd로 굳혔다. 처음부터 launchd로 가면 백그라운드라 뭐가 잘못됐을 때 원인 찾기가 번거롭다.


6. 삽질 기록

같은 걸 하려는 사람을 위한 함정 두 개.

Electron 단일 인스턴스 락

SSH로 붙어서 서버를 띄우려는데 이게 떴다.

[single-instance] Another Orca instance is already running for this userData profile;
exiting this launch after requesting the existing window.

집에서 데스크톱 앱을 켜놓고 나온 게 원인이었다. Electron 앱은 같은 userData 프로필로 두 인스턴스를 못 띄운다. 원격이라 화면으로 닫을 수 없으니 프로세스를 정리해야 했다.

ps aux | grep -i orca | grep -v grep   # 뭐가 떠 있는지 먼저 확인
pkill -i orca

원격에서 GUI 앱을 죽이기 전에 ps로 먼저 확인하라. 작업 중인 세션이 있으면 날아간다.

재시작하면 페어링 토큰이 바뀐다

서버를 재시작할 때마다 device token이 새로 발급됐다.

# 1회차
deviceToken: 3f2b9c81e4a70d55c1ab8e42f9d6031c...
# launchd 재시작 후
deviceToken: 7d1e4a90c3f28b61de05a7c34b92f0e8...

즉 URL을 북마크해둬도 KeepAlive가 재시작을 걸면 무효가 될 수 있다. 새 값은 로그에서 꺼내면 된다.

cat ~/orca-serve.log
# Web client URL: http://100.101.102.103:6768/web-index.html#pairing=...

이래서 로그 경로(StandardOutPath)를 plist에 지정해두는 게 중요하다. 포그라운드로 돌 땐 화면에 찍히던 게, 데몬이 되면 어디에도 안 남는다.


7. 결과

최종 구조 회사 PC에는 아무것도 설치하지 않는다 회사 Windows 브라우저 설치 X · 권한 X 북마크 하나 Tailscale 100.x.x.x 사설망 공개 포트 없음 Mac Mini (집) 항상 켜져 있음 launchd dev.orca.serve.plist RunAtLoad KeepAlive 부팅 시 자동 시작 죽으면 부활 orca serve → ws://0.0.0.0:6768 워크트리 · 터미널 · 에이전트 세션 ~/workspace/* git · Codex · Claude Code — 전부 여기서 실행 Tailscale = 어디서든 닿는 경로 · launchd = 항상 살아있는 서비스 · 브라우저 = 설치가 필요 없는 클라이언트

회사 PC에는 아무것도 설치하지 않았다. 브라우저 주소창 하나면 집에 있는 개발 환경이 그대로 열린다. Mac Mini가 재부팅돼도, 프로세스가 죽어도 알아서 살아난다.

Tailscale이 여기서 조용히 큰 일을 한다. 포트포워딩도, 고정 IP도, 공유기 설정도 없다. 그리고 인터넷에 포트를 여는 게 아니라 내 기기끼리만 묶는 사설망이라 공격 표면이 사실상 없다. 6768 포트는 내 Tailnet 안에서만 존재한다.

정리하면 세 겹이다.

역할 없으면
Tailscale어디서든 닿는 경로포트포워딩 + 공인 IP 노출
launchd항상 살아 있는 서비스재부팅마다 손으로 살림
브라우저설치가 필요 없는 클라이언트회사 PC에 설치 권한 필요

tmux는 이 셋 중 어디에도 없다. 하지만 없었으면 여기까지 못 왔다. 손에 잡히는 중간 단계가 있어야 다음으로 넘어갈 수 있다.

728x90