Claude Code로 자동화 워크플로우를 짜다 보면, 어느 시점에서 반드시 마주치는 질문이 있습니다. “이 작업을 매시간 돌리고 싶은데, /schedule 슬래시 명령어를 쓰는 게 맞을까요, 아니면 macOS의 launchd로 묶는 게 맞을까요?” 두 방식은 비슷해 보이지만, 실제로 동작하는 환경이 완전히 다르기 때문에 잘못 선택하면 인증 단계에서 즉시 실패하거나, 반대로 노트북을 켜둬야 하는 작업을 클라우드에 옮기지 못해 헛수고를 하게 됩니다. 이 글에서는 두 스케줄러의 차이와 선택 기준을 정리합니다.
목차
두 스케줄러의 본질적 차이
한 줄로 요약하면 이렇습니다. Claude Code의 /schedule은 Anthropic 클라우드에서 띄워지는 원격 에이전트이고, launchd는 여러분의 macOS 사용자 세션에서 띄워지는 로컬 데몬입니다. 같은 cron 표현식을 쓸 수 있다는 사실 때문에 둘이 비슷해 보이지만, 실행 컨텍스트가 완전히 다릅니다.
| 구분 | Claude Code /schedule (Routines) | macOS launchd |
|---|---|---|
| 실행 위치 | Anthropic 클라우드 인프라 | 로컬 macOS 사용자 세션 |
| 노트북 전원 | 꺼져 있어도 동작 | 슬립/종료 시 미동작 (깨어나면 따라잡기) |
| 로컬 파일 접근 | 불가 (.env, SQLite, 토큰 파일 모두 안 보임) | 전체 사용자 권한으로 접근 |
| 설정 위치 | Anthropic 콘솔의 Routines 탭 | ~/Library/LaunchAgents/*.plist |
| 비용 체계 | 실행마다 모델 토큰 과금 | 인프라 비용 0 (로컬 CPU만 사용) |
| 관측성 | 콘솔 세션 로그 + 결과 | StandardOutPath/StandardErrorPath 로그 파일 |
| 트리거 | cron + HTTP 엔드포인트 + GitHub 이벤트 | StartCalendarInterval (cron 유사) |
Claude Code /schedule을 써야 할 때
Claude Code Routines는 프롬프트와 저장소를 등록해두면 Anthropic 인프라가 정해진 시각마다 새 세션을 띄워 작업을 처리해주는 클라우드 서비스입니다. 사용자 노트북이 꺼져 있어도 동작한다는 점이 가장 큰 강점입니다. 다음과 같은 작업이 적합합니다.
- 공개 API/웹 폴링: 인증 없이 접근 가능하거나, GitHub Personal Access Token처럼 토큰을 시크릿 매니저에 안전하게 옮길 수 있는 외부 데이터 수집 작업입니다.
- GitHub 이벤트 트리거: PR이 열릴 때, main에 푸시될 때, 이슈에 코멘트가 달릴 때 후속 작업을 자동 실행하는 시나리오에 잘 맞습니다.
- 장기 출장/정전 대비: 1주일 동안 노트북을 꺼두는 상황에서도 매일 보고서가 나와야 하는 종류의 작업입니다.
- HTTP 웹훅으로 호출되는 자율 작업: Routine마다 전용 엔드포인트가 부여되므로, 외부 서비스가 webhook으로 트리거할 수 있습니다.
반대로, 여러분 노트북에만 존재하는 자원이 작업에 한 톨이라도 필요하면 /schedule은 즉시 부적합해집니다. 클라우드 세션은 stateless로 새로 뜨므로, ~/.config에 캐싱한 OAuth refresh token도, .env에 둔 비밀번호도, 어제 SQLite DB에 적재한 누적 데이터도 보이지 않습니다.
launchd 조합을 써야 할 때
launchd는 macOS의 표준 작업 관리 데몬으로, 사용자 LaunchAgent로 등록하면 로그인 세션이 살아 있는 동안 정해진 일정대로 스크립트를 실행합니다. cron보다 우월한 점은, 예약 시각에 노트북이 슬립 상태였다면 깨어난 직후 자동으로 따라잡기 실행을 해준다는 것입니다. 다음과 같은 작업이 적합합니다.
- 로컬 토큰/세션 의존 작업: WordPress App Password, Threads 액세스 토큰, 카카오톡 데스크톱 인증, 네이버 쿠키처럼 “내 맥북에 묶여 있는 인증 자원”을 사용하는 모든 작업입니다.
- 로컬 데이터베이스/캐시 누적 파이프라인: SQLite로 어제까지의 상태를 누적해 비교하는 폴러, 디렉토리에 파일을 떨어뜨려 다른 프로세스가 처리하는 큐 패턴 등입니다.
- 토큰 비용을 0으로 만들고 싶은 결정적 스크립트: AI 추론이 필요 없고 단순히 “OpenDART에서 받아서 파싱하고 텔레그램으로 보낸다” 정도의 결정적 작업이라면, 클라우드 모델 호출 비용 없이 로컬에서 돌리는 것이 합리적입니다.
- 1회성 예약 발행: 오늘 저녁 9시에 SNS에 글 한 번 올리고 자기 정리되는 ad-hoc 작업도 launchd의 1회 plist로 깔끔하게 처리됩니다.
launchd plist의 최소 골격
매시 정각에 파이썬 스크립트를 돌리는 가장 단순한 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.example.myjob</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/anaconda3/bin/python3</string>
<string>/Users/me/projects/myjob/run.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/me/projects/myjob</string>
<key>StartCalendarInterval</key>
<dict><key>Minute</key><integer>0</integer></dict>
<key>StandardOutPath</key>
<string>/Users/me/.myjob.log</string>
<key>StandardErrorPath</key>
<string>/Users/me/.myjob.log</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
이 파일을 ~/Library/LaunchAgents/com.example.myjob.plist에 두고 launchctl load로 등록하면 끝납니다. 주의할 세 가지가 있습니다. 첫째, 파이썬 인터프리터 경로는 반드시 절대 경로로 적습니다. launchd는 로그인 셸의 PATH를 상속하지 않아서 python3만 적으면 못 찾습니다. 둘째, WorkingDirectory를 명시해야 .env가 제대로 로드됩니다. 셋째, RunAtLoad는 false로 두지 않으면 launchctl load를 실행한 그 순간에도 1회 실행되어 의도치 않은 동작을 만듭니다.
실전 예시: InfoBox 프로젝트의 분류
참고가 될 만한 실제 사례로, 필자의 InfoBox 프로젝트는 다음과 같이 두 스케줄러를 분리해서 씁니다.
| 작업 | 선택 | 이유 |
|---|---|---|
| 네이버 카페 핫포스트 폴링 (NCafeMonitor) | launchd | 네이버 로그인 쿠키 + 로컬 SQLite 누적 비교 |
| ALIO 채용공고 폴링 (AlioMonitor) | launchd | 로컬 .env + HWP 파일을 로컬 디렉토리에 다운로드 |
| OpenDART 배당 공시 모니터 (dividend_monitor) | launchd | 로컬 SQLite의 KOSPI Top100 티커 + 텔레그램 봇 토큰 |
| SNS 1회성 예약 발행 (SocialPost) | launchd (1회성 plist) | Threads/Facebook 액세스 토큰 + WP 발행상태 가드 |
| 외부 공개 RSS 모니터링 | /schedule 적합 | 로컬 자원 의존 없음 + 항상 동작 보장 가치 있음 |
| GitHub PR 자동 라벨링 | /schedule 적합 | GitHub 이벤트 트리거 + GH 토큰 클라우드 시크릿 가능 |
핵심은 “어떤 자원이 어디에 묶여 있는가”입니다. 로컬 토큰·로컬 DB·로컬 캐시가 한 톨이라도 들어가면, 클라우드 routine은 무리입니다.
하이브리드 패턴
두 스케줄러를 한 워크플로우 안에서 합치는 정석적인 방법이 있습니다. “클라우드는 외부에서 일어난 일을 감지하고, 로컬은 내 자원을 만진다”는 분업입니다.
/scheduleRoutine이 매일 아침 외부 데이터를 수집해 GitHub 저장소에 PR을 엽니다 (로컬 자원 불필요).- 로컬 launchd 잡이 매시간 GitHub의 PR 상태를 폴링해, 머지된 PR이 있으면 그 결과를 로컬 SQLite에 적재하고 후속 SNS 발행을 트리거합니다.
이렇게 분업하면, 클라우드 routine은 stateless해도 괜찮고 (PR이 곧 영구 저장소), 로컬 launchd는 정시에 깨어 있을 필요가 없으며 (PR은 머지될 때까지 기다려도 손해 없음), 두 환경이 직접 통신하지 않으니 디버깅도 쉬워집니다. GitHub이 사실상 메시지 큐 역할을 해주는 셈입니다.
혼동하기 쉬운 함정 세 가지
- “세션 안의
/loop“는 스케줄러가 아닙니다. Claude Code 터미널 세션 안에서 쓰는/loop명령은 그 세션이 살아 있는 동안만 반복되는 “현재 대화 안의 반복”입니다. 터미널이 닫히면 사라지므로, 진짜 자동화 용도로는/schedule이나 launchd를 써야 합니다. - cron이 있다고 launchd를 안 쓸 이유는 없습니다. macOS에서도 전통 cron이 동작하지만, 슬립 중 일정을 그냥 건너뛴다는 약점 때문에 Apple은 launchd로의 이전을 권장합니다. 데스크톱 mac에서는 큰 차이가 없지만, 노트북이라면 launchd가 압도적으로 안전합니다.
- 로컬 토큰을 클라우드 routine에 “옮기면” 된다는 발상은 위험합니다. 일부 토큰은 디바이스 바인딩(예: 카카오톡 데스크톱 인증)이라 옮길 방법 자체가 없고, 옮길 수 있는 토큰이라도 안전한 시크릿 보관 방식을 따로 설계해야 합니다. 단순히 환경변수에 쑤셔 넣는 건 사고 직전입니다.
마무리
요약하면 이렇습니다. 로컬 자원이 한 톨이라도 들어가면 launchd, 그렇지 않고 노트북이 꺼져 있어도 돌아야 한다면 /schedule입니다. 둘 다 필요한 워크플로우라면 GitHub 같은 영구 저장소를 사이에 두고 분업시키는 하이브리드 패턴이 가장 안정적입니다. 처음부터 적합한 쪽을 고르면, 인증 단계에서 무한히 실패하는 routine을 디버깅하느라 보내는 시간을 통째로 절약할 수 있습니다.