AI가 스스로 고장 난 자동화를 수정하게 하세요: 무인 와치독(Unattended Watchdog) 구축하기
요약
AI가 고장 난 자동화 스크립트를 스스로 감지하고 수리하며, 검증 과정을 거쳐 프로덕션에 배포하는 '무인 와치독(Unattended Watchdog)' 시스템의 설계 및 구현 방법을 다룹니다.
핵심 포인트
- AI의 자기 보고를 맹신하지 않고 독립적인 검증 스크립트로 수리 여부를 확인해야 함
- 감지, 수정, 검증, 배포로 이어지는 안전한 가드레일 워크플로우 구축
- 프로젝트별로 탐지 전용(detect)과 전체 복구(full) 모드를 구분하여 운영 가능
- 검증 실패 시 자동 롤백 및 사용자 알림을 통한 안정성 확보
충분히 많은 사이드 프로젝트를 출시하다 보면, 자동화 시스템 중 무언가가 항상 조용히 타오르고(on fire) 있기 마련이며, 대개 며칠이 지나서야 그 사실을 알게 됩니다. 이것은 저의 "사이드 프로젝트 대량 생산을 위한 자동화 기초" 시리즈의 다음 편으로, "Claude Code의 메모리를 4개의 계층으로 나누는 방법"과 "한 대의 머신에서 Claude Code와 Codex를 함께 실행하기"에 이어지는 내용입니다. 이번에는 **AI가 고장 난 자동화 스크립트를 감지, 수리 및 검증하고, 검증을 통과했을 때만 승인 없이 변경 사항을 프로덕션(production)에 푸시할 수 있게 하는 무인 와치독(unattended watchdog)**의 설계와 구현 과정을 살펴보겠습니다. 실제 코드를 기반으로, AI가 "제한 없이 날뛰는" 대신 안전하게 작동할 수 있도록 가드레일(guardrails)을 쌓는 방법을 설명하겠습니다.
왜 "고장 나면 스스로 수정하기"가 필요해졌는가
사이드 프로젝트를 대량으로 생산하다 보면, 항상 켜져 있는 자동화 스크립트의 수가 계속 늘어납니다. 제휴 기사 자동 포스팅, 스크레이퍼(scrapers), Discord로 브리프 전달, iOS 앱의 리뷰 상태 확인 등등. 각각은 launchd 또는 cron 환경에서 실행되므로, 하나가 고장 나면 누구도 알아차리지 못한 채 며칠이 지나갈 수 있습니다.
모든 것을 수동으로 모니터링하고 수정하는 것은 금방 한계에 부딪힙니다. 하지만 단순히 "AI가 수정하게 두는 것"은 위험을 수반합니다. 의도치 않은 코드 재작성, 비밀 정보(secrets) 커밋, 또는 실제로는 여전히 고장 난 상태임에도 수리가 완료되었다고 보고하고 이를 프로덕션에 푸시하는 등의 위험이 있습니다.
그래서 저는 self-repair.sh를 설계했습니다. 아이디어는 간단합니다.
감지(Detect) → Claude가 원인을 파악하고 수정함 → 와치독(watchdog)이 독립적으로 검증(verify) 실행
→ 검증 통과 시에만 커밋/푸시/배포(commit/push/deploy)
→ 실패 시 편집 내용을 롤백(roll back)하고 사람에게 알림
Claude의 자기 보고(self-report)를 믿지 마라가 핵심 포인트입니다. Claude가 "수정했습니다"라고 말하더라도, 와치독이 직접 검증 스크립트를 실행하며, 종료 코드가 0(exit 0)일 때만 변경 사항을 반영합니다.
와치독의 전체 구조
파일 구성은 다음과 같습니다.
~/.claude/self-repair/
├── registry.tsv # 모니터링 대상 프로젝트 목록
├── checks/
...
메인 루프는 단순히 registry.tsv를 읽고 각 프로젝트에 대해 process()를 호출합니다.
while IFS=$' ' read -r slug dir mode _rest; do
case "$slug" in ''|\#*) continue ;; esac
[ -n "$ONLY" ] && [ "$slug" != "$ONLY" ] && continue
...
registry.tsv 형식은 slug\tdir\tmode의 세 개 컬럼으로 구성됩니다.
# slug dir mode
affiliate ~/dev/affiliate-factory full
brief-discord ~/dev/brief detect
...
mode는 full (탐지(detect) + 복구(repair)) 또는 detect (탐지 및 알림만 수행)가 될 수 있습니다. Git으로 관리되지 않거나 자동 복구가 위험한 프로젝트는 detect로 설정해야 합니다.
상태 체크(health check) 및 검증(verification) 스크립트 작성 방법
핵심은 health.sh와 verify.sh를 완전히 분리하는 것입니다.
health.sh는 "지금 고장 났는가?"를 결정합니다. 문제를 감지하면 0이 아닌 종료 코드(non-0)로 종료하며, 표준 출력(stdout)에 에러 컨텍스트를 출력합니다 (이 출력은 Claude에게 보내는 복구 요청에 사용됩니다).
# 예시: affiliate-factory-health.sh
#!/usr/bin/env bash
# 지난 24시간 동안 기사가 생성되지 않았다면 고장난 것으로 간주
...
verify.sh는 "복구 후에 실제로 작동하는가?"를 결정합니다. health.sh보다 더 엄격할 수도 있고, 동일할 수도 있습니다. 중요한 점은 Claude의 복구와 독립적인 프로세스가 이를 실행한다는 것입니다.
# 예시: affiliate-factory-verify.sh
#!/usr/bin/env bash
# 실제로 기사를 하나 생성하고 깔끔하게 종료되면 통과
...
7가지 가드레일: 무인 복구가 안전하게 유지되는 이유
스크립트 상단의 주석에는 "진정한 안전은 가드레일에 있다"라고 적혀 있습니다. 7가지 가드(guard)를 모두 쌓음으로써, 무인 복구(unattended repair)는 실행 가능한 수준이 됩니다.
가드 1: 독립적인 검증 (Claude의 자기 보고를 믿지 마세요)
가장 중요한 요소입니다. process() 내부에서 Claude를 호출한 후, 와치독(watchdog) 자체가 bash "$verify"를 실행합니다.
# Claude 호출 후...
local vrc=0
run_capped "$VERIFY_TIMEOUT" bash "$verify" >/tmp/sr-$slug-verify.log 2>&1 || vrc=$?
...
run_capped는 gtimeout을 사용할 수 있는 경우 타임아웃과 함께 실행되는 래퍼 (wrapper)입니다. 이는 검증 (verification) 프로세스가 무한히 실행되는 것을 방지합니다.
run_capped() { # run_capped <timeout_sec> <cmd...>
local t="$1"; shift
if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" "$t" "$@"; else "$@"; fi
...
Guard 2: 실패 시 즉각적인 롤백 (Immediate rollback on failure)
만약 검증 (verify) 결과가 0이 아니면, restore()가 베이스라인 (baseline)으로 되돌립니다.
restore() { # restore <dir> <baseline_sha>
local dir="$1" base="$2"
(cd "$dir" && {
...
git reset --hard는 커밋 전 상태로 되돌리고, git clean -fdq는 Claude가 새로 추가한 파일들을 제거하며, 스태시 (stash) 되어 있던 모든 것도 삭제합니다. 이는 git 저장소 (git repo)임을 가정하므로, git을 사용하지 않는 프로젝트는 detect 모드로 동작해야 합니다 (스크립트는 또한 git rev-parse를 통해 확인하며, git 저장소가 아닌 경우 자동 복구 (auto-repair)를 건너뜁니다).
Guard 3: 비밀 정보 스캐닝 (Secret scanning)
푸시 (push) 하기 전에, 정규 표현식 (regular expressions)을 사용하여 스테이지된 디프 (staged diff)를 스캔합니다.
secret_in_staged() { # secret_in_staged <repo>
local repo="$1"
if (cd "$repo" && git ls-files --cached | grep -qE '(^|/)\.env$'); then
...
만약 .env 파일이 추적 (tracking) 대상이라면 즉시 실패 처리됩니다. 그 외에도 Stripe의 pk_/sk_live_, AWS의 AKIA, GitHub 개인 액세스 토큰 (ghp_), Slack 토큰 (xox), 개인 키 헤더 (private key headers), 그리고 Google API 키 (AIza)를 탐지합니다.
이 비밀 정보 스캔은 스크립트 자체에 내장되어 있습니다. 왜냐하면 Claude의 훅 (hooks)은 launchd 환경에서 실행될 때 트리거되지 않기 때문입니다. 핵심은 훅에 의존하는 대신 스스로를 스캔하는 것입니다.
실제로 커밋 전에 스캔을 수행하는 것은 secret_in_staged_after_add()입니다. 이는 git add -A를 실행한 다음 비밀 정보를 스캔하며, 깨끗할 경우 그대로 커밋합니다.
secret_in_staged_after_add() {
local dir="$1" reason
(cd "$dir" && git add -A) || true
...
Guard 4: 시도 횟수 제한 (Attempt cap)
동일한 슬러그 (slug)는 하루에 최대 MAX_ATTEMPTS (기본값 2) 번까지 시도될 수 있습니다. 상태 파일 (state file)이 카운터 (counter)로 사용됩니다.
MAX_ATTEMPTS="${SELF_REPAIR_MAX_ATTEMPTS:-2}"
attempts_today() { cat "$STATE/$1-$TODAY.count" 2>/dev/null || echo 0; }
...
한도(cap)에 도달하면 수리를 건너뛰고 Discord를 통해 "사람의 확인이 필요합니다"라고 알립니다. 이는 수리 루프(repair loop)가 무한히 회전하며 토큰을 낭비하는 것을 방지합니다.
Guard 5: 비용 한도 (Cost cap)
5시간 블록의 출력 토큰(output tokens)이 임계값을 초과하면 전체 실행 사이클을 건너뜁니다.
BUDGET_CAP_TOK="${SELF_REPAIR_BUDGET_CAP:-380000}"
budget_ok() {
...
BUDGET_ADVISOR는 Claude Code의 사용량을 JSON 형식으로 반환하는 별도의 스크립트입니다. 이는 과도한 비용 청구(billing runaway)를 막기 위한 마지막 방어선입니다.
Guard 6: 범위 제한 (Scope restriction, allowedTools로 벽 세우기)
Claude는 --allowedTools를 통해 사용할 수 있는 도구를 제한받으며, 프로젝트 디렉토리 내에서만 편집하도록 설정됩니다.
out=$(cd "$dir" && printf '%s' "$PROMPT" | run_capped "$REPAIR_TIMEOUT" "$CLAUDE" -p \
--model "$MODEL" --output-format text \
--allowedTools "Read,Write,Edit,Bash,Grep,Glob" \
...
저는 --skip-permissions를 사용하지 않습니다. 대신 --allowedTools로 도구를 좁히고 프롬프트에서 범위를 명시적으로 지정하여 벽을 구축합니다.
Guard 7: 푸시 대상 소유자 검증 (Push destination owner verification)
대상 GitHub 원격 저장소(remote)가 본인의 계정이 아닌 경우 푸시하지 않습니다. 이는 의도치 않게 제3자의 포크(fork)에 코드를 작성하는 것을 방지합니다.
ALLOWED_OWNERS="bokuwalily"
push_if_safe() {
...
AI에게 수리 요청을 구성하는 방법
Claude에게 전달할 프롬프트는 process() 내부에서 동적으로 조립됩니다. 핵심은 health.sh의 출력값과 최근 로그를 컨텍스트(context)로 전달하고, 제약 사항(constraints)으로서 무엇을 해서는 안 되는지를 명시적으로 기술하는 것입니다.
local PROMPT="당신은 자율 수리 에이전트입니다. 자동화 '${slug}'(디렉토리: ${dir})가 고장 났습니다. 원인을 식별하고, **최소한의 변경**으로 수정하십시오.
# 제약 사항 (엄수)
...
중요한 부분은 UNFIXABLE: 종료 키워드입니다. 수정할 수 없거나 수정해서는 안 되는 경우, "어떻게든 다시 작성하기"와 같은 폭주 동작을 방지합니다. 원인이 $dir 외부(의존성 API 중단, cron 설정 등)에 있을 때, 이 설계는 Claude가 해당 영역을 건드리지 못하게 유지합니다.
상태 확인(health check) 출력은 head -40으로, 로그는 tail -60으로 잘린(trimmed) 후 전달됩니다. 컨텍스트(context)가 너무 크면 판단이 흔들릴 수 있습니다.
베이스라인(baseline) 기록 및 커밋되지 않은 변경 사항 저장
수리하기 전에 현재의 HEAD(베이스라인)를 기록합니다. 커밋되지 않은 변경 사항(uncommitted changes)이 있으면 복구(restore) 시 상태가 지저분해질 수 있으므로, 먼저 git stash -u를 사용하여 이를 스태시(stash)합니다.
local baseline; baseline=$(cd "$dir" && git rev-parse HEAD 2>/dev/null || echo "")
(cd "$dir" && git stash -u -q 2>/dev/null) || true
-u는 추적되지 않는 파일(untracked files)까지 함께 스태시하는 옵션입니다. 만약 Claude가 수리에 실패하면, restore()는 git reset --hard baseline으로 돌아간 뒤 스태시도 삭제합니다.
"감지 전용(Detect only)" 모드
mode=detect로 설정하면 Claude를 호출하지 않고 "무언가 고장 났음을 감지하고 알림만 보내는" 상태가 됩니다. 하루에 한 번만 알림을 보내기 위해 state/ 디렉토리 아래에 플래그 파일(flag file)을 생성합니다.
if [ "$mode" = detect ]; then
if [ ! -f "$STATE/$slug-$TODAY.detected" ]; then
local hd; hd=$(head -3 /tmp/sr-$slug-health.log 2>/dev/null | tr '\n' ' ' | cut -c1-200)
...
Git으로 관리되지 않는 프로젝트, 수리가 부작용(side effects, 외부 API 쓰기 작업 필요 등)을 일으킬 수 있는 프로젝트, 그리고 verify.sh가 없는 프로젝트는 detect 모드로 두는 것이 가장 안전합니다. 또한 스크립트는 verify.sh가 존재하지 않을 경우 자동으로 detect와 동일한 동작(알림만 전송)으로 전환됩니다.
[ -x "$verify" ] || {
log "$slug: verify無し→無人修復は危険なのでskip(人間へ)"
notify alerts "🛠 $slug が異常だが verify 未整備のため自動修復せず。要確認。"
...
launchd를 통한 실행
실제 운영 시에는 launchd plist를 통해 주기적으로 실행됩니다. 최소 경로(minimal-PATH) 문제 때문에, 스크립트는 내부적으로 PATH를 명시적으로 설정합니다.
<!-- ~/Library/LaunchAgents/com.lily.self-repair.plist -->
<plist version="1.0">
<dict>
...
StartCalendarInterval을 Minute: 30으로 설정하면 매 시간 30분마다 실행됩니다. 대부분의 경우 30분 간격의 주기(cadence)면 충분합니다.
launchd를 통해 실행할 때는nvm이 로드되지 않기 때문에,CLAUDE환경 변수를 통해claude명령의 전체 경로를 전달하거나, 스크립트 상단에서 명시적으로export PATH를 수행해야 합니다.
내가 겪은 함정들 (Pitfalls)
git stash -u를 잊으면 복구 과정이 지저분해짐
커밋되지 않은 작업 중인 파일(work-in-progress files)이 있는 상태에서 Claude가 수리하도록 두면, git reset --hard 이후에도 추적되지 않는 파일(untracked files)이 남아있게 됩니다. stash → repair → 실패 시 drop으로 이어지는 흐름을 따르지 않으면, 스스로 수동 정리 작업을 만들어내게 됩니다.
verify.sh를 health.sh와 동일하게 만드는 것은 무의미함
health.sh와 verify.sh에 동일한 스크립트를 사용하면, "수리 직후 우연히 통과한 상황"조차 통과(pass)로 처리됩니다. verify.sh에는 실제로 프로세스를 실행하는 테스트가 포함되어야 하며, 따라서 --dry-run 모드에서도 전체 코드 경로(code paths)를 실행할 수 있어야 합니다.
비밀 정보 스캐닝(secret-scanning) 정규식은 항상 직접 구축할 것
GitHub의 푸시 보호(push protection)에 의존한다는 것은 탐지가 푸시 이후에 발생한다는 것을 의미합니다. 핵심은 푸시하기 전에 스스로 스캔하는 것입니다. 또한, 환경 변수에 할당된 값은 일반적인 정규식(regex)으로 포착되지 않기 때문에, 저는 위험한 리터럴(literals)을 탐지하기 위해 git diff --cached를 직접 스캔합니다.
gtimeout이 없으면 타임아웃이 작동하지 않음
macOS의 timeout 명령은 Homebrew를 통해 GNU coreutils의 gtimeout으로 제공됩니다. 저는 command -v gtimeout으로 존재 여부를 확인하며, 만약 없다면 래퍼(wrapper)가 그대로 통과되도록 만듭니다. gtimeout이 없으면 Claude가 응답을 멈췄을 때 launchd가 다음 실행 작업을 계속 쌓아 올리게 됩니다.
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || true)"
[ -z "$TIMEOUT_BIN" ] && [ -x /opt/homebrew/bin/gtimeout ] && TIMEOUT_BIN=/opt/homebrew/bin/gtimeout
"비밀 정보와 작업 디렉토리"는 스크립트의 영향력 밖에 있음
주석에 언급된 것처럼, 이 와치독(watchdog)은 git 리포지토리로 관리되는 프로젝트에서 코드가 깨지는 방식만을 처리할 수 있습니다.
.env파일 내의 비밀 키(secret) 만료 → Claude가 수정할 수 없음 (범위 외)launchd plist자체가 손상됨 → git 리포지토리 외부의 문제이므로 접근 불가- DB 스키마(schema)가 깨짐 →
verify.sh에 의존하지만, 마이그레이션(migration)은 위험하므로 탐지(detect) 모드 권장
Claude가 프롬프트로부터 "접근 불가" 상태를 정확히 판단할 수 있도록, 저는 명시적인 탈출구(escape hatch)를 제공합니다: "수정할 수 없는 경우, 오직 UNFIXABLE:이라고만 명시하고 종료하십시오."
모드 탐지 오류는 모든 프로젝트를 'full' 모드로 만듭니다
만약 registry.tsv의 세 번째 열을 누락하면, mode는 빈 값이 되며 ${mode:-full}에 의해 full 모드로 대체됩니다. 만약 detect 모드로 설정하려던 프로젝트에서 이 열을 누락하면 의도치 않은 자동 복구(auto-repair)가 실행되므로, tsv를 작성할 때는 항상 모드를 명시적으로 기입해야 합니다.
요약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기