로컬 AI 인프라 노트 (3/15) — launchd로 서비스 자동화: 부팅 시 에이전트 자동 시작
macOS의 서비스 관리자 launchd로 홈서버 프로세스를 자동화한 실전기
핵심 요약
- macOS에서 서비스를 자동 실행하려면 launchd를 써야 한다. cron이나 수동 실행은 서버 운영에 적합하지 않다
- plist 파일 하나로 부팅 자동시작 + 크래시 자동 재시작을 설정할 수 있다. 핵심은 RunAtLoad와 KeepAlive 두 키
- 외장 드라이브에서 실행할 경우 TCC 제한에 부딪힌다. 내부 스토리지에 실행 복사본을 두고 sync하는 우회가 필요하다
개요
macOS에서 홈서버 프로세스를 안정적으로 자동화하려면 launchd를 사용해야 한다. Linux의 systemd에 해당하는 macOS 고유의 서비스 관리자로, PID 1로 부팅과 함께 시작되며 시스템 전체의 프로세스 라이프사이클을 관리한다.
cron은 단순 주기 실행에는 쓸 수 있지만, 부팅 시 자동 시작·크래시 복구·환경변수 관리 같은 서버 운영 요건을 충족하지 못한다. launchd는 이 모든 것을 plist 파일 하나로 처리한다.
본문
1. launchd의 기본 개념 — Linux systemd와의 비교
launchd는 macOS의 PID 1 프로세스입니다. 시스템 부팅부터 사용자 로그인까지 모든 프로세스 라이프사이클을 관리합니다.
systemd와 비교하면 이렇습니다:
| 항목 | systemd (Linux) | launchd (macOS) |
|---|---|---|
| 설정 파일 | .service (INI 형식) |
.plist (XML 형식) |
| 등록 명령 | systemctl enable |
launchctl load (또는 bootstrap) |
| 상태 확인 | systemctl status |
launchctl list |
| 로그 | journalctl |
파일 기반 (직접 경로 지정) |
| 의존성 관리 | After=, Requires= |
제한적 (수동 처리) |
핵심 차이는 의존성 관리입니다. systemd는 서비스 간 순서를 정밀하게 지정할 수 있지만, launchd는 이 부분이 약합니다. 대신 설정 자체가 훨씬 단순합니다.
2. plist 파일 작성법 — 핵심 키 5개면 충분하다
launchd의 설정 파일은 .plist(Property List)라는 XML 파일입니다. 처음 보면 장황해 보이지만, 실제로 필요한 키는 5개입니다.
<?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>com.example.my-service</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/python3</string>
<string>/Users/username/scripts/listener.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/username/scripts</string>
</dict>
</plist>
각 키의 역할:
- Label: 서비스의 고유 식별자. 역순 도메인 형식이 관례 (
com.myserver.listener) - ProgramArguments: 실행할 명령어. 배열의 첫 번째가 실행 바이너리, 나머지가 인자
- RunAtLoad:
true면 plist 로드 시(= 부팅 시) 자동 실행 - KeepAlive:
true면 프로세스가 죽었을 때 자동 재시작 - WorkingDirectory: 프로세스의 작업 디렉토리
RunAtLoad + KeepAlive 조합이 핵심입니다. 이 두 개만 true로 설정하면 "부팅 시 자동 시작 + 크래시 시 자동 복구"가 완성됩니다.
3. LaunchAgents vs LaunchDaemons — 어디에 놓을 것인가
plist 파일의 위치에 따라 동작이 달라집니다:
| 경로 | 유형 | 실행 시점 | 권한 |
|---|---|---|---|
~/Library/LaunchAgents/ |
Agent | 사용자 로그인 시 | 사용자 권한 |
/Library/LaunchDaemons/ |
Daemon | 시스템 부팅 시 | root 권한 |
홈서버 용도라면 LaunchAgents를 권장합니다. 이유:
- 대부분의 서비스가 사용자 권한으로 충분합니다
- Daemon은 GUI 접근 불가, 환경변수 제한 등 제약이 많습니다
- macOS 자동 로그인을 설정하면 Agent도 부팅 직후 실행됩니다
자동 로그인은 시스템 설정 → 사용자 및 그룹 → 자동 로그인에서 설정합니다. 이렇게 하면 LaunchAgents도 사실상 부팅 시 자동 실행과 동일해집니다.
4. 외장 드라이브와 TCC 제한 — 가장 삽질한 부분
macOS에서 외장 드라이브에 있는 스크립트를 launchd로 실행하면, TCC(Transparency, Consent, and Control) 제한에 걸립니다.
증상: - plist 로드는 성공하지만 프로세스가 바로 죽음 - 로그에 "Operation not permitted" 또는 권한 관련 에러 - 같은 스크립트를 터미널에서 직접 실행하면 정상 동작
원인은 macOS의 보안 정책입니다. launchd가 실행하는 프로세스는 사용자가 터미널에서 직접 실행하는 것과 다른 권한 컨텍스트를 가집니다. 외장 드라이브는 "이동식 볼륨"으로 분류되어 추가적인 접근 제한이 걸립니다.
해결책 — 내부 스토리지 복사본 + sync 스크립트:
#!/bin/bash
EXTERNAL="/Volumes/ExternalDrive/project/scripts"
INTERNAL="$HOME/local-mirror/scripts"
rsync -a --delete "$EXTERNAL/" "$INTERNAL/"
cd "$INTERNAL"
exec python3 listener.py
이 래퍼 스크립트를 내부 스토리지에 두고, plist에서는 이 스크립트를 실행하도록 설정합니다. 외장 드라이브의 최신 코드를 내부로 복사한 뒤 실행하는 방식입니다.
시스템 설정 → 개인정보 및 보안 → 전체 디스크 접근 권한에 관련 바이너리를 추가하는 방법도 있지만, macOS 업데이트마다 리셋될 수 있어서 sync 방식이 더 안정적입니다.
5. 로그 관리 — 문제 추적의 생명선
launchd는 systemd의 journalctl처럼 중앙 로그 시스템이 없습니다. 대신 plist에서 직접 로그 파일 경로를 지정합니다:
<key>StandardOutPath</key>
<string>/Users/username/logs/my-service.stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/username/logs/my-service.stderr.log</string>
로그 관리 팁:
- stdout과 stderr를 분리하면 디버깅이 편합니다
- 로그 디렉토리는 미리 생성해야 합니다. launchd가 자동으로 만들어주지 않습니다
- 로그 로테이션은 별도로 관리해야 합니다. 간단한 방법은 logrotate 대신 cron으로 주기적 truncate
0 0 * * 0 : > /Users/username/logs/my-service.stdout.log
6. 실전 사례 — Python 리스너를 launchd로 등록하기
실제로 Python 기반의 메시지 리스너를 launchd에 등록한 과정입니다.
1단계: plist 작성
<?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>com.homeserver.message-listener</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/python3</string>
<string>/Users/username/services/listener/main.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/username/services/listener</string>
<key>StandardOutPath</key>
<string>/Users/username/logs/listener.stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/username/logs/listener.stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
2단계: 등록 및 시작
cp com.homeserver.message-listener.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.homeserver.message-listener.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.homeserver.message-listener.plist
3단계: 확인
launchctl list | grep homeserver
주요 주의사항
EnvironmentVariables 누락 문제. launchd 실행 환경은 사용자 터미널과 달리 PATH가 최소한으로 설정된다. pip install로 설치한 패키지 경로가 포함되지 않아 임포트 오류가 발생한다. plist의 EnvironmentVariables 키에 필요한 경로를 명시적으로 지정해야 한다.
KeepAlive 무한 재시작 루프. 프로세스가 시작 직후 종료되는 버그가 있으면 KeepAlive가 반복 재시작을 유발한다. CPU 과점유와 로그 파일 폭증으로 이어진다. ThrottleInterval 키(기본 10초)로 재시작 간격을 제어할 수 있지만, 근본적으로 프로세스를 단독 실행해서 안정성을 확인한 후 launchd에 등록해야 한다.
launchctl load vs bootstrap 혼란. macOS 버전에 따라 load/unload 명령이 deprecated 경고를 띄운다. bootstrap/bootout이 공식 권장 방식이다. 두 명령 모두 동작하지만, 신규 설정에는 bootstrap을 사용한다.
마무리
launchd는 macOS 홈서버 운영의 기반입니다. systemd만큼 다양한 기능은 없지만, "부팅 시 자동 시작 + 크래시 시 자동 복구"라는 서버의 기본 요건을 plist 파일 하나로 충족합니다.
핵심 정리:
1. plist 파일에 Label, ProgramArguments, RunAtLoad, KeepAlive 4개 키를 설정한다
2. ~/Library/LaunchAgents/에 넣고 launchctl bootstrap으로 등록한다
3. 외장 드라이브 실행 시 TCC 제한이 있으니 내부 스토리지 sync 방식을 쓴다
4. StandardOutPath/StandardErrorPath로 로그를 반드시 남긴다
시리즈 전체 안내: 시리즈 목차
댓글
댓글 쓰기