에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
실험실/블로그 자동화

주말 4시간, 44개 잡을 한 화면에 띄웠어요

반응형
주말 4시간, 44개 잡을 한 화면에 띄웠어요
개발자도구 · 자동화

주말 4시간, 44개 잡을 한 화면에 띄웠어요

안녕하세요, 에디입니다. 어제 토요일 오후에 Claude Code 하나 띄워두고 자동화 대시보드 v2.7 까지 박았어요. 시작은 launchctl list | grep com.user. 만 보고 잡 상태 점검하던 데서 출발했고, 끝은 5탭짜리 로컬 웹 대시보드예요.

Claude Code FastAPI launchd Spec→Plan→Subagent 로컬 운영

launchctl list 만 떴어요, 그래서 만들었어요

제 맥 위에 launchd 잡이 44개 돌아요. 블로그 발행 10개, 운영·통계 8개, 정보 브리프 22개, 인프라 4개. 매일 새벽부터 밤까지 자기들끼리 알아서 돌아가는데, 문제는 "오늘 잘 돌고 있어?" 를 한눈에 볼 방법이 없었다는 거예요.

매일 아침에 했던 일:

bash
launchctl list | grep com.user.
# 컬럼 3개 — PID, last exit, label
# exit code 0/-15/1/2/127 을 사람이 직접 해석
tail -f logs/ediblog-run-daily.out.log
ls -lt 발행예정/AI/ | head

이 패턴이 매일 반복되는데, 정말 짜증나는 건 exit 0 인데도 실제 발행은 안 된 경우가 종종 있다는 거예요. 작년에 한 번 사고 났어요 — wrapper 가 SIGTERM 으로 끝나서 exit -15 로 찍히는데 child playwright 가 그 후에 publish 완수하고 있던 경우. 로그만 보면 "발행 0편" 인데 실제로는 발행돼있던 거죠.

그래서 만들었어요. 단순한 launchctl wrapper 가 아니라 Claude Code 에게 spec 박고 plan 시키고 subagent 로 분담시킨 풀 빌드. 어차피 토요일이고, AI 자동화 1년차 자산 정리도 겸할 수 있겠다 싶었어요.

자동화 대시보드 Overview 탭 — 42잡 요약 + 48시간 타임라인
▲ Overview 탭 화면 — 42잡 요약 카드 5장 + 48h 타임라인 (점 색깔 = 잡 상태) + 다음 5건 예정 표

4시간 타임랩스 — v0 spec 에서 v2.7 까지

총 분량은 commits 30개. 시작은 spec, 끝은 잡별 맞춤 prompt 까지.

v0spec + plan + writing-plans 스킬

brainstorming → 5 가지 결정 (스코프·실행 형태·신호 정의·이력·조작 권한) → spec 420 줄 + plan 20 태스크. 첫 commit 까지 30분.

v1FastAPI 백엔드 + 단일 페이지 (40잡 추적)

subagent-driven-development 스킬로 태스크 19개 분담. 각 태스크마다 implementer + spec reviewer + code quality reviewer 3-stage. 23 commits 1시간 50분.

v25탭 확장 (Schedule · Queue · Action · Health)

"한 페이지에 다 보고 싶다 → 탭으로 분리" 사용자 피드백. 4 detector 모듈 + 113 schedule cells + 75 publish queue + 잡 description 시스템. 50분.

v2.7휴지통 · Ghostty launch · 잡별 맞춤 prompt

Read-only 시작 → 점진적 쓰기 권한. Action 카드 클릭 → Ghostty 새 윈도우 + Claude 자동 prompt. 40분.

30 commits 사이에 superpowers 스킬 ( brainstorming / writing-plans / subagent-driven-development ) 을 거의 다 활용했어요. 핵심은 spec 을 글로 명확히 박은 다음 plan 으로 분해하면 subagent 가 알아서 한다는 거였어요.

5번 깨지고 5개 배웠어요

한 번에 잘 박힌 건 없었어요. 매번 사용자가 화면 보고 "이거 이상한데?" 하면 그제야 잡혀요. 깨진 순서대로:

1 하이브리드 3-시그널 — 로그 false negative 트라우마
"잘 됐다" 판정을 launchctl exit code 하나만 보면 안 됐어요. wrapper 가 SIGTERM 으로 끝나도 child 가 발행 완수한 경우가 있어서, exit + 완료 마커 + published_log.json mtime 3개 중 2개 이상 통과해야 "성공" 으로 박았어요. 작년 사고 (2026-04-15 중복 발행) 가 만든 hard rule 이었죠.
2 단일 진실원 catalog — 신규 잡 추가 시 한 파일만
잡 메타데이터를 job_catalog.py 한 파일에 박았어요. 신규 잡 추가하면 자동으로 dashboard 에 unknown_jobs 알림 뜨고, catalog 갱신 + kill -9 재시작 1번이면 끝. 신규 잡 친구의 english-tutor-morning 도 그렇게 등록했어요.
3 휴지통 → 영구 삭제 (사용자 결정 번복)
처음엔 _trash_<YYYY-MM-DD>/ 폴더로 이동하는 휴지통 패턴 박았어요. 근데 사용자가 한 번 써보더니 "이름만 바뀌는데 그냥 삭제해줘" 라고. 휴지통 폴더가 발행 큐에 그대로 보이는 부작용 발견. Path.unlink() 영구 삭제로 갈아엎었어요. 사용자 confirm 다이얼로그 한 번이면 충분.
4 Ghostty bash -lc 함정 — claude PATH 미적용
Action 카드 클릭 → 새 터미널 + claude 명령 자동 실행 박는데 bash: claude: command not found 가 떴어요. Ghostty-e 플래그가 login -flp user bash -lc 로 wrap 하면서 && 가 outer shell 에서 처리되고, login bash 의 PATH 에 /opt/homebrew/bin 이 없어서 claude 못 찾는 거였어요. 해결: .command 파일 + open -a Ghostty.app 패턴 + claude full path 박기.
5 _archive_ 폴더 누락 — 13일 갭의 정체
Publish Queue 가 51건 떴는데 사용자가 "AI 업데이트_20260506 글이 5/19 발행 예정? 13일 갭 너무 큰데?" 라고. 원인은 발행예정/_archive_2026-05-16/AI/ 폴더 안 12편을 publish_queue scan 이 픽업하던 거. _published_* · _trash_* 는 제외하는데 _archive_* 만 빠뜨림. 큐 51→41 정상화.

5개 다 사용자가 화면 보고 발견해줬어요. 코드 작성 단계에서는 절대 못 봤을 거예요. 실제 데이터 위에서 직접 클릭해보는 게 spec 검증의 최종 단계였어요.

5탭 구조 — 한 화면에 다 들어가요

처음엔 단일 페이지 (요약 카드 + 타임라인 + 표) 였는데, 정보가 늘어나면서 탭으로 쪼갰어요.

📊
Overview
v1 화면 그대로. 24h/7d 타임라인 + 최근 실행 표.
📅
Weekly Schedule
월~일 × 24시간 그리드. 잡 칩 hover 시 description popover.
📥
Publish Queue
발행 예정 3 소스 (에디·네이버픽·외부학습). 10일+ 갭은 빨간 강조.
Action Needed
인터뷰·검수·제안·예약 잡. 클릭 시 Ghostty + Claude 자동.
🔧
Health Issues
6 룰 자동 발굴 (마커 미스·실패·미실행 등). 클릭 → Claude 자동 분석.

URL fragment 라우팅 박혀있어서 #schedule 처럼 직접 깊은 링크 가능해요. 30초마다 자동 polling, 5초 server cache. read-only 가 디폴트지만 v2.2 부터 휴지통 + Claude launch 같은 쓰기 액션 박았어요.

Weekly Schedule 탭 — 월~일 × 24시간 잡 그리드
▲ Weekly Schedule 탭 — 월~일 × 24시간 잡 그리드. 잡 칩 hover 시 popover 로 description·다음 실행·마지막 결과 한 번에 노출
python
# dashboard/job_catalog.py — 단일 진실원
@dataclass(frozen=True)
class JobMeta:
    job_id: str
    display_name: str
    category: Category  # publish / ops / brief / infra / english-tutor
    completion_markers: tuple[str, ...]
    published_log_path: str | None
    always_on: bool = False
    description: str = ""
    effective_weekdays: tuple[int, ...] = ()  # script 안 요일 체크 잡
    extra_slots: tuple[str, ...] = ()         # plist 외 추가 시각
    skip_slot_estimation: bool = False        # conditional 잡 (track-b 등)
    user_action_hint: str = ""                # Action 카드용
    user_action_prompt: str = ""              # 클릭 시 Claude 가 받는 prompt

처음엔 필드 6개였는데 사용자 요청 받으면서 4개 더 늘었어요. 신규 잡 추가는 여기 dict 한 줄 박으면 끝.

지금 떠있는 실 데이터예요

v2.7 까지 박힌 후 현재 상태:

추적 잡
44
발행 10 · 운영 8 · 브리프 22 · 인프라 4
발행 큐
41
ediblog 41 + naver 14 + youtube 3
예약 셀
95
이번 주 월~일 시간별
Health
16
marker_miss 8 · fail 7 · unknown 1
Action
3
제안 1 + 사용자 액션 2
테스트
82
전부 통과 · 0.3s

매일 약 5편 발행 (run-daily 3편 + external-learning 1편 + 요일별 추가). 큐 41건이면 약 8일치 백로그라 시의성 글은 빠르게 위로 올려야 해요. 그래서 10일+ 갭 글은 대시보드에 빨간 칩으로 표시해서 한눈에 보고 🗑 삭제 결정 가능하게 했어요.

잡별 맞춤 prompt — 사용자 액션을 Claude 가 잇게

v2.7 의 마지막 변경. Action Needed 탭 카드 클릭하면 잡 종류별로 다른 prompt 가 Claude 에게 자동 전달돼요.

python
# dashboard/job_catalog.py 안 user_action_prompt 예시
"naver-shopping-publish": JobMeta(
    ...
    user_action_prompt=(
        "네이버_쇼핑커넥트/발행예정 큐의 가장 오래된 .md 1편을 읽고 "
        "(1) 네이버 약관 위반 가능 부분 (과장 표현·금지 키워드) 체크, "
        "(2) 통과면 본문·이미지 점검, "
        "(3) 모두 OK 면 사용자에게 임시저장 페이지 발행 안내. "
        "발행 버튼 절대 자동 클릭 X."
    ),
),

즉 카드 클릭 → Ghostty 새 윈도우 + cd 자동화 && claude "이 prompt..." 자동 실행 → 사용자는 이어서 대화. 매일 반복하던 "오늘 네이버 검수 → 발행 → 애드포스트 체크" 같은 흐름이 클릭 한 번에서 시작돼요.

Anthropic 의 Skills 가 progressive disclosure 패턴 박았던 게 이거랑 비슷한데, 저는 잡 자체가 사용자 액션 요청의 트리거가 되는 구조로 박았어요.

read-only 부터 시작했어요, 천천히

처음에 박은 spec 의 5개 결정 중 하나가 "조작 권한 = 읽기 전용" 이었어요. 잡 재실행·일시중지 같은 거 안 박고, 단순히 "보기만" 박는 거. 이유는 단순해요 — 첫 빌드는 디버깅·시각화에 집중하고, 위험한 액션은 검증된 후에 박자는 거.

그래서 v1 은 100% read-only. v2.1 부터 휴지통 (사용자 confirm 필수). v2.1.3 부터 Claude launch (외부 프로세스 호출). 각 단계마다 path traversal 가드 + AppleScript escape + confirm 다이얼로그 박았어요. 쓰기 권한은 한 번에 박지 말고 잘게 쪼개서, 매번 검증한 후 다음 권한이 안전해요.

또 한 가지 — 모든 변경에 spec 갱신을 동반시켰어요. v2 / v2.1 / v2.2 / v2.5 / v2.6 / v2.7 각 단계마다 spec 의 결정 표가 누적돼요. 3주 후 다시 봤을 때 "왜 이렇게 만들었지?" 가 안 생기게.

1년차 자동화 운영에서 가장 단단해진 부분이 이거 같아요 — spec 박고 plan 박고 subagent 분담하고 결과 검증하는 사이클. 디자인 doc 작성이 무거워 보이지만, 4시간 빌드에서 spec + plan 합쳐 1시간 30분 박은 게 가장 효율적이었어요. 나머지 2시간 30분이 실제 빌드 + 사용자 검증 + 사고 회복.

그래서 만든 것 + 안 만든 것

만든 거:

  • localhost:8765 5탭 대시보드 (FastAPI + Vanilla JS, KeepAlive 잡으로 상시 가동)
  • 44잡 catalog (단일 진실원, 한 줄 description 박힘)
  • 발행 예정 시각 추정 (mtime 오래된 순 + 매일 발행 슬롯 매칭)
  • Action 카드 → Ghostty + Claude 자동 prompt (잡별 맞춤)
  • Health 6 룰 자동 발굴 + 클릭 시 자동 분석·수정

안 만든 거 (의도적):

  • 잡 강제 재실행·일시중지 (위험 → 터미널 직접)
  • 30일 이상 장기 추세 그래프 (YAGNI)
  • 외부 인터넷 노출 (localhost 만, 인증 X)
  • 발행 잡 직접 트리거 (검증 X 액션은 검수 게이트 통해서만)

마지막 결정이 가장 중요해요. Simon Willison 이 2026-05-06 normalization of deviance 라고 부른 패턴 — "한 번 룰 어겨도 사고 없으면 정상화" — 가 자동화에서 사고 누적의 원인이라 했어요. 발행 같은 destructive 액션은 항상 사용자 확인 게이트 필수예요.

참고 사고 — 2026-04-15
중복 발행 12편. 자동 잡이 1단계 완료 후 2단계 hang → Claude 가 PID 만 보고 전체 실패로 판정 → 수동 재실행 → 같은 글 12편 중복. 이 사고가 만든 hard rule = "완료 마커 확인 = 발행 화면 직접 확인 1순위, 로그는 보조". 이번 대시보드 v2 의 하이브리드 3-시그널 판정이 이 hard rule 의 시각화예요.

핵심 코드 5조각 — 직접 가져다 써도 돼요

제 대시보드 안에서 가장 자주 쓰는 패턴 5개예요. 비슷한 거 만들 때 그대로 가져다 변형하시면 돼요.

1. launchctl list 파싱 — 3 컬럼 (PID, exit, label)

python
def parse_launchctl_list(output: str) -> list[LaunchctlEntry]:
    entries = []
    for line in output.splitlines():
        parts = line.split("\t")
        if len(parts) != 3 or parts[0] == "PID": continue
        if not parts[2].startswith("com.user."): continue
        pid = None if parts[0] == "-" else int(parts[0])
        entries.append(LaunchctlEntry(
            job_id=parts[2][len("com.user."):],
            pid=pid, exit_code=int(parts[1]),
        ))
    return entries

2. plist StartCalendarInterval → 다음 실행 시각

python
def next_run_after(info: PlistInfo, after: datetime) -> datetime | None:
    if info.start_interval is not None:
        return after + timedelta(seconds=info.start_interval)
    if not info.calendar_entries: return None
    # 분 단위 스캔, 최대 30일
    candidate = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
    end = after + timedelta(days=30)
    while candidate <= end:
        for entry in info.calendar_entries:
            if _matches_calendar(entry, candidate):
                return candidate
        candidate += timedelta(minutes=1)
    return None

3. 하이브리드 3-시그널 — exit + 마커 + published_log mtime

python
def _decide_status(exit_class, marker_ok, published_log_fresh, is_publish):
    if exit_class == ExitClass.SIGTERM:
        if is_publish and published_log_fresh: return JobStatus.OK_SOFT
        return JobStatus.WARN
    s1 = exit_class == ExitClass.OK
    s2 = marker_ok
    s3 = bool(published_log_fresh) if is_publish else True
    score = sum([s1, s2, s3])
    if score == 3: return JobStatus.OK
    if score == 2: return JobStatus.OK_SOFT
    if score == 1: return JobStatus.WARN
    return JobStatus.FAIL

4. Ghostty 새 창 + Claude 자동 prompt (zsh 환경 보존)

python
script_lines = [
    "#!/bin/zsh -i",
    'export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH"',
    f"cd {safe_cwd!r}",
    f"/opt/homebrew/bin/claude '{safe_prompt}'",
    "exec zsh -i",  # 작업 끝나도 shell 유지
]
fd, tmp_path = tempfile.mkstemp(suffix=".command")
os.write(fd, "\n".join(script_lines).encode())
os.close(fd); os.chmod(tmp_path, 0o755)
subprocess.run(["open", "-a", "/Applications/Ghostty.app", tmp_path])

5. Path traversal 가드 — 쓰기 액션 보안

python
def delete_file(path: Path, allowed_root: Path) -> Path:
    resolved = path.resolve()
    allowed = allowed_root.resolve()
    try:
        resolved.relative_to(allowed)  # ValueError 면 외부
    except ValueError:
        raise ActionError(f"path outside allowed root: {resolved}")
    if not resolved.exists(): raise ActionError(f"not found: {resolved}")
    if resolved.is_dir(): raise ActionError(f"refusing to delete directory")
    resolved.unlink()
    return resolved

5개 중 3-시그널 판정이 가장 단단해진 부분이에요. 단일 시그널로는 항상 false negative/positive 가 났는데, 3개 중 2개 룰로 모호함이 사라졌어요. 이건 작년 사고 (2026-04-15 중복 발행 12편) 가 만든 hard rule 의 시각화이기도 해요.

다음 단계 — 발행 빈도 vs 큐 백로그

지금 큐가 41건이고 매일 5편 발행이라 약 8일 백로그. 시의성 글 (OpenAI 새 발표 같은) 이 들어와도 8일 뒤에 발행되면 의미 없어요. 그래서 v2.6 에서 발행 예정 시각 표기 + v2.7 에서 10일+ 갭 빨간 강조 박았어요. 사용자가 화면 보고 직접 archive 결정.

v3 에서는 시의성 우선 발행 (mtime 순 아닌 importance 순) 도 박을지 고민 중이에요. 단순 mtime 정렬이 가장 안전한 디폴트지만, 시의성 메타데이터 (예: OpenAI 발표일) 가 글 안에 박혀있으면 그게 우선이어야 맞으니까. 아직 결정 안 했어요.

전체 코드는 자동화/dashboard/ 안에 있고, spec 은 docs/superpowers/specs/2026-05-17-blog-dashboard-design.md, plan 은 docs/superpowers/plans/2026-05-17-blog-dashboard{-v2,-v2.1}.md. 1주일 더 굴려보고 안정되면 GitHub 에 올릴 예정이에요.

CODA

자동화의 정수는 더 똑똑한 에이전트가 아니라, 내가 매일 보는 화면 한 장이에요.

이 글은 본인이 직접 빌드한 시스템 운영 기록입니다. 코드·spec·plan 은 본인 자동화 프로젝트 안에 있고, 외부 배포 전입니다. 발행 잡·텔레그램 채널·블로그 운영은 본인 자산입니다.
반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."