맥·홈서버

launchd, screen으로 디스코드 봇 24시간 상주시키기

홈랩지기 2026. 8. 18. 11:49

얼마 전, 클로드 기반의 디스코드 챗봇을 하나 만들었다. 꽤 쓸만했다.

이걸 내 맥에서 24시간 내내 돌리고 싶었다. 내 터미널을 점유하지 않으면서, 맥을 껐다 켜도 자동으로 다시 살아나는 방식으로 말이다.

macOS 환경에서 데몬을 띄우는 표준 방식은 launchd를 쓰는 거다. 그리고 터미널 기반 앱을 백그라운드에서 돌리려면 screen이 딱이지. 완벽한 조합이라고 생각했다.

시작은 창대했지만... 뭐가 문제였을까? 🤔

자신만만하게 launchd plist 파일을 만들었다. 봇 실행 스크립트를 지정하고, KeepAlive 옵션도 넣었다.

그런데 뭔가 이상했다. 봇이 디스코드에 '온라인'으로 뜨긴 하는데, 메시지에 전혀 반응이 없는 거다.

혹시나 싶어 screen -r로 세션에 들어가 봤다. 화면은 깨져있었고, 뭔가 입력 프롬프트 같은 게 계속 떠 있었다. 당황스러웠다. 로그도 제대로 안 쌓였다.

봇이 실제로 살아있는지 확인하려고 screen hardcopy 명령어로 화면을 캡처해봤다. 그런데 웬걸? 파일은 만들어지는데, 내용은 텅 비어 있었다. TUI 화면이 캡처가 안 되는 건지, 아니면 아예 내용이 없는 건지 알 수가 없었다.

ps로 보면 프로세스는 분명히 돌고 있었다. 그런데 왜 반응이 없을까? 대체 뭐가 문제일까?

삽질 끝에 찾아낸 원인들 💡

삽질 끝에 몇 가지 핵심적인 원인을 찾아냈다.

첫째는 작업 디렉토리 신뢰 문제였다. 내가 만든 봇은 claude CLI 도구를 사용했다.

이 도구가 특정 디렉토리에서 시작할 때, macOS의 보안 정책에 따라 폴더 신뢰 여부를 묻는 프롬프트에 걸리는 경우가 있었다. launchd는 상호작용이 불가능하니, 이 프롬프트에 무한 대기하고 있었던 거다.

launchd는 주로 /~ 같은 기본 디렉토리에서 스크립트를 실행하는데, 내 경우 /Users/me 자체는 신뢰 안 된 디렉토리로 취급된 모양이었다.

 

두 번째는 launchd 환경 변수 문제였다.

launchd가 띄운 프로세스는 우리가 터미널에서 보는 것과 달리, 환경 변수가 굉장히 빈약하다. 특히 TERM이나 LANG 같은 변수가 설정되어 있지 않았다.

screen이나 다른 TUI 앱들이 화면을 제대로 그리기 위해서는 이 변수들이 필수적이다. 그래서 화면이 깨지고 글자가 뭉개졌던 거다.

 

세 번째는 screen hardcopy의 한계였다. hardcopy는 단순 텍스트 화면을 덤프하는 데는 유용했지만, claude처럼 좀 더 복잡한 TUI 앱의 동적인 화면은 제대로 잡아내지 못했다.

빈 파일만 덩그러니 남는 이유가 여기에 있었던 것 같다.

 

마지막이자 가장 중요했던 건, 프로세스가 살아있다고 해서 서비스가 정상 작동하는 건 아니라는 점이었다.

ps로 프로세스가 떠 있는 걸 확인해도, 실제 디스코드 메시지에는 응답하지 않는 '먹통' 상태가 있었다.

봇이 게이트웨이에 연결만 되어 '온라인'으로 보여도, 내부적으로는 뻗어있을 수 있다는 걸 간과했다.

그래서 어떻게 해결했냐면요 ✅

원인을 알았으니 해결은 시간 문제였다.

먼저, 작업 디렉토리 문제는 봇 실행 스크립트에 cd ~/Claudecode 명령을 맨 앞에 추가하는 것으로 해결했다.

봇 관련 파일들이 있는, 신뢰된 디렉토리로 이동한 후에 claude 명령을 실행하도록 바꾼 것이다.

 

환경 변수 문제는 스크립트 내에서 export TERM=xterm-256colorexport LANG=ko_KR.UTF-8를 명시적으로 설정하고, 혹시 모를 다른 환경 변수 문제까지 대비해 zsh -l (로그인 셸 환경 로드)로 실행하도록 조정했다.

이제 screen 세션에 들어가면 화면이 깔끔하게 보였다.

 

봇 상태 확인은 여러 방법을 조합했다. screen hardcopy가 안 되니, bun server.ts 프로세스가 실제로 떠 있는지 확인하고, lsof 명령으로 봇이 디스코드 게이트웨이와 ESTABLISHED 상태의 TCP 연결을 유지하고 있는지 확인하는 식으로 바꿨다.

이 정도면 봇이 '살아있다'고 판단할 근거는 충분했다.

 

하지만 앞서 말했듯, '살아있음'이 '응답 가능함'을 보장하진 않았다.

그래서 궁극적으로는 최근 응답 기록을 확인하는 와치독 루프를 도입하기로 했다.

launchdKeepAlive 옵션으로 내가 만든 와치독 스크립트(~/.claude/start-channels.sh)만 감시하도록 했다.

이 와치독 스크립트가 30초마다 claude-channels라는 screen 세션 안에서 돌고 있는 봇의 상태를 점검하고, 만약 응답이 없거나 프로세스가 죽어 있다면 screen 세션을 종료하고 봇을 claude --channels plugin:discord@... 명령으로 다시 시작하는 구조였다.

이렇게 하니 launchd는 튼튼한 와치독만 관리하고, 와치독은 봇이 제 역할을 하는지 실시간으로 감시하는 이상적인 구조가 완성되었다.

이번 삽질로 얻은 뼈아픈 교훈 🔥

이번 삽질을 통해 몇 가지 중요한 교훈을 얻었다.

첫째, launchd 환경은 생각보다 고립되어 있다는 것. 셸 스크립트가 터미널에서 잘 돌아도 launchd에서는 예상치 못한 환경 문제에 부딪힐 수 있다. PATH나 환경 변수, 작업 디렉토리에 특히 신경 써야 한다.

둘째, TUI(Text User Interface) 애플리케이션은 TERM, LANG 같은 환경 변수가 없으면 무용지물이라는 사실. 이런 기본적인 환경 설정이 얼마나 중요한지 다시금 깨달았다.

셋째, screen hardcopy 같은 도구는 제한적이라는 점. 눈에 보이는 화면이 전부가 아니며, 때로는 다른 방식으로 시스템 내부를 들여다봐야 한다. 프로세스 목록, 네트워크 연결 상태 등을 복합적으로 봐야 진짜 상태를 알 수 있었다.

넷째, 가장 뼈아팠던 부분인데,

프로세스 생존이 서비스 생존을 보장하지 않는다는 명제. 겉으로는 살아있는 것처럼 보여도, 핵심 기능이 먹통인 경우가 얼마든지 있다. 실제 서비스 로직이 의도대로 동작하는지 확인하는, 더 고차원적인 감시가 필수적이다.

마지막으로, launchdKeepAlive는 어디까지나 '지정한 스크립트'가 살아있는지 감시하는 거지, 그 스크립트가 띄운 '하위 서비스'까지 책임지지 않는다.

복잡한 서비스라면 launchd는 상위 감시자 역할만 하고, 실제 서비스 재기동은 별도의 와치독 프로세스에게 맡기는 게 훨씬 견고한 구조를 만든다는 걸 배웠다.

자동화된 모니터링과 복구 메커니즘은 선택이 아니라 필수라는 걸 다시 한번 되새기며, 다음 파이프라인에는 더 단단한 와치독을 심어야겠다고 다짐했다.

※ 이 글은 직접 겪은 작업 경험을 바탕으로 AI의 도움을 받아 작성했습니다.