n8n을 마지막으로 봤을 때 30개가 넘는 launchd 플로우가 돌고 있었다. 지금 그 자리에는 launchd plist 하나와 JSON 파일 하나만 남아 있다.

시각적 워크플로우는 복잡성을 감출 뿐, 해결하지 않는다. 노드들을 선으로 이어붙이는 건 직관적이다. 문제도 거기서 시작한다 — 직관적이니까 자꾸 추가하게 된다. 한 번 걸어놓으면 “이미 만들어뒀으니 도는” 워크플로우가 쌓인다. niklas luhmann이 지적했듯, 일단 만든 구조는 그 자체로 존속 이유를 만들어낸다.

하나로 30개 워크플로우 중 진짜 필요했던 건 절반도 안 됐다. 나머지는 “언젠가 도움이 될 수도”라는 희망으로 정당화된 사중 손실이었다.

이 글에서 다루는 것

n8n을 도입한 이유와 제거한 이유 — 시각적 오케스트레이션의 환상 30개 워크플로우 중 진짜 살아남은 12개 launchd 하나 + JSON 하나로의 교체 과정 self-healing — KeepAlive + watchdog이 docker-compose보다 단순했던 이유 줄일수록 고장률이 줄어든 실제 데이터

왜 launchd인가

macOS에는 launchd가 내장되어 있다. systemd처럼 프로세스의 라이프사이클을 관리하는 데몬이다. KeepAlive를 설정하면 죽었을 때 10초 안에 다시 살려준다. RunAtLoad를 켜두면 재부팅 후 로그인만 해도 자동으로 뜬다.

내가 원한 건 딱 이거였다:

60초마다 한 번씩 jobs.json을 읽고, 실행해야 할 잡이 있으면 실행하는 스크립트 하나 죽으면 자동으로 재시작되는 구조 별도의 모니터링 없이도 장애를 감지할 수 있는 장치

n8n으로 이걸 하려면 docker-compos cron n8n을 띄우고, 모든 워크플로우를 GUI로 배치하고, 워크플로우 하나하나에 에러 핸들링을 붙이고, n8n 자체가 죽었을 때의 복구 전략을 짜야 했다. launchd는 그냥 plist 파일 하나면 끝이다.

n8n → launchd 교체 n8n: docker-compose + GUI workflow editor × 30 + per-workflow error handling + n8n crash recovery launchd: plist 1개 + jobs.json 885줄 + 60s 틱 + KeepAlive 10초

Slice 1: 60초 틱 — jobs.json이 유일한 진실

drewgent_cron.py는 272줄이다. launchd가 60초마다 이 스크립트를 실행한다. 스크립트가 하는 일은 단순하다: jobs.json을 읽고, 지금 실행해야 할 잡이 있는지 보고, 있으면 실행한다.

스케줄링 로직도 30줄 남짓이다. cron expression을 파싱해서 “daily at hour X”, “every N minutes”, “weekly at day X time Y” 정도의 5가지 패턴으로만 분류한다. 30개 워크플로우의 조건 분기가 30줄짜리 파서 하나로 줄었다.

중요한 건 jobs.json이 모든 것의 단일 소스라는 점이다. n8n에서는 워크플로우 설정이 GUI에 흩어져 있었다. 어떤 잡이 언제 도는지 한눈에 보려면 웹 UI에 접속해서 30개 워크플로우를 일일이 열어봐야 했다. 이제 cat jobs.json 한 번이면 끝이다.

jobs.json 구조 id: 잡 고유 식별자 (hex) schedule: {“kind”: “cron”, “expr”: “0 /6 “} script: 실행할 shell 스크립트 경로 deliver: Discord 채널 ID (알림 받을 곳) enabled/state: true + “scheduled” 여야 cron dispatcher가 실행 885줄. 잡 18개. 모든 스케줄이 여기에 있다.

Slice 2: Script fastpath — LLM 없이 shell 실행

n8n에서는 모든 워크플로우가 HTTP 콜이나 커스텀 노드를 거쳐야 했다. 단순한 shell 스크립트 실행 하나에도 n8n 노드를 추가하고, 에러 핸들링을 별도로 붙여야 했다.

launchd cron으로 옮기면서 script fastpath를 도입했다. jobs.json의 “script” 필드에 shell 스크립트 경로를 지정하면, cron dispatcher가 LLM을 거치지 않고 바로 실행한다. trend scorer, log rotation, cron health check, dashboard push — 이 모든 게 LLM 개입 없이 돌아간다.

LLM이 필요 없는 작업에 LLM을 태우는 건 비용 낭비를 넘어 신뢰성 문제다. LLM은 실패할 수 있다. 토큰 제한에 걸릴 수 있다. 프롬프트에 민감하게 반응한다. 단순한 cron job에서 이런 불확실성을 제거하는 것만으로도 장애의 절반은 사라졌다. Groq의 GPT-OSS-20b 같은 무료 모델을 크론 작업에 쓰는 것도 같은 이유에서다 — 검증/라우팅에는 충분하고, 비용은 0이다.

Slice 3: Self-healing — KeepAlive + watchdog

Drewgent의 모든 launchd 서비스는 같은 패턴을 따른다:

KeepAlive { SuccessfulExit: false ThrottleInterval: 10 } RunAtLoad: true

SuccessfulExit를 false로 둔다는 건, 정상 종료해도 재시작한다는 뜻이다. cron dispatcher는 계속 떠 있어야 하기 때문에 exit 0으로 종료되면 안 된다. ThrottleInterval 10초 — 죽으면 10초 안에 다시 살려준다.

여기에 Drewgent watchdog을 추가했다. 5분마다 모든 launchd 서비스 상태를 체크해서, launchd마저 못 살린 경우(극히 드물지만) Discord로 알림을 보낸다. docker-compose의 health check + restart policy + 모니터링 도구가 plist 설정 3줄 + watchdog 스크립트 하나로 대체된 셈이다.

Slice 4: 잡마다 다른 모델 — 비용 최적화

jobs.json에서는 잡마다 model, provider, skills를 지정할 수 있다. content-manager-periodic은 deepseek-v4-pro가 필요하고, trend-evaluate-trigger는 deepseek-v4-flash면 충분하다. housekeeper의 light check는 LLM 없이 shell로, deep clean은 opencode run으로.

n8n에서는 이걸 하려면 워크플로우마다 별도의 HTTP 노드와 API 키를 설정해야 했다. 지금은 JSON 필드 하나 바꾸는 걸로 모델 라우팅이 결정된다. 자세한 이야기는 3-tier 모델 라우팅 설계 글에서 다뤘다.

설계 결정과 실패 모드

n8n의 실패 launchd의 대응 왜 더 나은가

워크플로우 설정이 GUI에 흩어짐 jobs.json 단일 파일 git으로 추적 가능. grep으로 검색 가능

n8n 자체가 죽으면 모든 워크플로우 정지 launchd가 10초 내 재시작 OS 레벨에서 관리. Docker 의존성 제거

단순 shell 실행도 HTTP 노드 + 에러 핸들링 필요 script fastpath — subprocess.call() LLM 비용 0. 실패 지점 1/3로 감소

워크플로우 하나 추가할 때마다 노드 N개 증가 jobs.json에 entry 하나 추가 O(1) 복잡도. 컨텍스트 전환 없음

스케줄 문자열 파싱 실패 → 크래시 동일한 함정. {“kind”:”cron”,”expr”:”…”} 형식 강제 문서화 + 코드 리뷰로 방지. 디버깅이 직관적

줄일수록 고장률이 줄었다

이게 이 글의 진짜 thesis다. 30개 워크플로우에서 12개로 줄면서, 장애 건수가 드라마틱하게 줄었다. 정확히 말하면:

n8n 자체의 docker-compose 장애 — 제로 (docker-compose가 사라졌으니까) 워크플로우 간 의존성 충돌 — 제로 (잡들은 독립적으로 실행되니까) GUI 설정 오류 (노드 연결 실수 등) — 제로 (GUI가 사라졌으니까) cron expression 파싱 오류 — 1회 (jobs.json 마이그레이션 중 string schedule 함정)

장애의 양이 줄어든 것도 중요하지만, 더 중요한 건 장애의 종류가 바뀌었다는 점이다. n8n 시절의 장애는 대부분 “어디서 뭐가 꼬였는지 찾는 데 시간이 걸리는” 유형이었다. launchd 시절의 장애는 “jobs.json이 잘못됐다” 또는 “스크립트가 실패했다”로 직결된다. 원인 추적이 trivial하다.

Known Trap — jobs.json schedule schedule 필드가 string이면 parse_schedule()이 crash한다. 반드시 {“kind”: “cron”, “expr”: “…”} 형식을 사용할 것. 이 함정은 n8n에서도 동일하게 존재한다. cron 노드 설정 미스는 런타임에야 발견된다. 차이는 launchd에선 cat jobs.json | grep schedule로 1초 만에 진단할 수 있다는 점.

30→12: 살아남은 잡은 무엇인가

필터를 통과한 12개의 기준은 단순했다: 이 잡이 안 돌면 24시간 안에 문제가 생기는가?

매시간: housekeeper (시스템 펄스 체크) 3시간: content-manager (블로그 발행) 6시간: trend 수집 + trend scorer (성장 파이프라인) 매일 03:00: SEO 분석 매일 04:00: housekeeper deep clean + log rotation + wiki lint 매일 05:00: content taste diff + cron health check 매일 06:00: usage watch + content graph engine 매일 09:00: harmony check 매일 10:00: trend evaluate trigger 매일 20:00: daily retro 매주: wiki compile (일), trend retire (월), SEO trend trigger (월) 주 2회: taste review (화/금)

나머지 18개는 “만들어놨으니 도는” 사중 손실이었다. 충돌 감지 안 된 중복 잡, 한 번 쓰고 방치된 워크플로우, “혹시 몰라서” 추가한 알림들. 이걸 제거하는 과정 자체가 Ponytail 원칙의 실전 적용이었다 — Contraction 철학 글에서 더 자세히 다뤘다.

n8n이 나쁜 도구인가 — 아니다

여기서 분명히 해둘 게 있다. n8n은 좋은 도구다. 비개발자가 복잡한 자동화를 구축할 때, 여러 서비스의 webhook을 연결할 때, 빠르게 프로토타입을 만들 때 n8n만 한 도구는 드물다.

다만 내 use case에는 맞지 않았다. 내가 관리하는 건 비개발자용 시각적 워크플로우가 아니라, 단일 머신에서 도는 18개의 스케줄 잡이었다. 이 정도 규모에서는 시각적 추상화가 비용을 정당화하지 못한다. 오히려 방해가 된다.

이게 taste의 본질이다. “좋은 도구”와 “내게 맞는 도구”는 다른 질문이다. n8n은 충분히 좋은 도구였지만, 내 시스템에는 과잉이었다. 그걸 인정하고 제거한 게 핵심이다.

교체 후 얻은 것

돌아보면 n8n→launchd 교체는 v0.8 architecture compression의 가장 임팩트 있는 결정 중 하나였다. 얻은 걸 나열하자면:

Docker 의존성 제거 — docker-compose로 띄우던 서비스 하나가 완전히 사라짐 설정 파일 하나 — 885줄짜리 jobs.json이 모든 스케줄의 truth source git 추적 가능 — cron job 변경 내역이 커밋 히스토리에 남음 LLM 비용 감소 — script fastpath 덕분에 단순 작업의 LLM 호출 0 장애 디버깅 시간 단축 — “jobs.json을 봐라”로 대부분 해결 신규 잡 추가 비용 — JSON entry 하나 + shell 스크립트 하나. 5분

무엇보다 “이 잡이 정말 필요한가”를 매번 묻게 됐다. GUI로 끌어다 놓는 것과 JSON 파일에 한 줄 추가하는 것 사이에는 심리적 장벽의 차이가 있다. JSON은 “진짜 필요한 게 맞지?”라는 자문을 강제한다.

이 글은 Season 1: Taste Engineering의 Phase 2 Architectural Deep-Dives 중 Ep.13이다. 이전 글: 에이전트의 뇌를 7개 레이어로 쌓은 방법. Drewgent 빌드 로그의 다른 에피소드는 Build Log 태그에서 볼 수 있다.

읽어줘서 고맙다.