DevTech
2026년 8월 7일 · 8 min read

차단 5건 성공 감지 3건 성공 그리고 다섯 시간 반 동안 아무 일도 없었다

전날 밤 finance-tracker 작업이 4시간 38분 멈췄습니다. 원인을 찾아 안전장치 두 개를 만들었고, 다음 날 밤 처음 돌렸습니다.

10시간을 돌리고 로그를 봤습니다.

Stop 훅   차단 5건 / 5건 성공, 오탐 0
생존 감시  사망 3건 / 3건 감지, 전부 5분 이내

그리고 그날 밤 새벽 1시 52분 이후 작업량은 0이었습니다.

무엇을 만들었나

전날 사고는 세 계층에서 동시에 났습니다. 오케스트레이터가 먼저 멈췄고, 그 아래 작업 세션 둘이 따라 멈췄습니다. 정지 유형이 둘로 갈렸습니다.

유형 증상 대책
A 프로세스는 살아 있는데 턴만 끝나고 대기 Stop 훅으로 차단
B 프로세스 자체가 사라짐 별도 감시로 탐지

A는 Claude Code의 Stop 훅으로 막습니다. 세션이 "이어가겠습니다" 같은 예고를 하고 도구를 부르지 않은 채 턴을 끝내려 하면 차단하고, 그 턴 안에서 예고한 작업을 하게 합니다.

훅 입력에 last_assistant_message가 들어와서 트랜스크립트를 따로 파싱할 필요가 없습니다.

{
  "session_id": "...",
  "cwd": "/path/to/project",
  "stop_hook_active": false,
  "last_assistant_message": "…다음은 debts.js로 이어가겠습니다."
}

차단은 stdout에 판단을 쓰고 종료코드 0으로 나갑니다.

jq -n '{decision:"block", reason:"예고한 작업을 지금 이 턴에서 실행하라."}'

게이트를 여섯 개 뒀습니다

전부 통과해야 차단합니다. 순서가 중요합니다.

1. 재귀 방지     stop_hook_active
2. 야간 플래그    ~/.claude/NIGHT_MODE 존재
3. 스코프        cwd 가 대상 경로 아래
4. 승인 대기      질문·승인 요청 표현이 없어야
5. 계속 의도      예고 표현이 있어야
6. 횟수 상한      세션당 40회

4번이 5번보다 입니다. 전날 사고를 조사할 때 정지 26건이 정당한 승인 대기였습니다. 그걸 밀어붙이면 승인 규율이 무너집니다. 실제로 이번 밤에 이 배치가 3건을 지켰습니다.

차단할 때 모델에게 탈출구 두 개를 줍니다. 둘 다 게이트 4에 걸려 다음부터는 통과됩니다.

승인 대기: <사유>          사람 판단이 꼭 필요할 때
무중단 모드 종료: <사유>    진행할 후보가 정말 없을 때

B는 훅으로 못 막습니다. 훅은 살아 있는 프로세스 안에서 도는 장치라 프로세스가 없으면 실행할 주체도 없습니다. 그래서 launchd로 5분마다 프로세스 생존을 확인하는 감시를 따로 붙였습니다.

감시 목록을 따로 떠 둬야 했습니다

여기 함정이 하나 있었습니다. 세션 등록 파일이 프로세스가 죽으면 함께 사라집니다.

ls ~/.claude/sessions/   # 살아 있는 프로세스 것만 남아 있다

그래서 "지금 살아 있는 것"만 봐서는 죽은 것을 영원히 못 찾습니다. 전날 죽은 세션이 아무 흔적도 안 남긴 게 이 때문이었습니다.

야간 모드를 켤 때 그 시점의 목록을 스냅샷해 두고 그것과 대조하게 했습니다.

# night-mode.sh on 이 만드는 파일
# sessionId	name	cwd
9d219e11-…	vinylstudio-70	/path/to/workspace

첫 밤 결과

Stop 훅은 정확히 작동했습니다

차단 5건 전부 예고 문장 뒤 정지를 잡았고, 전부 그 자리에서 이어갔습니다. 트랜스크립트에서 재개까지 걸린 시간이 0.0분입니다.

21:22:40  n=1/40  "…가맹점정합화부터 시작합니다."
23:04:18  n=2/40  "…지금 시작합니다."
23:21:24  n=3/40  "…감지 규칙을 순수 함수로 빼서 시작합니다."
23:37:47  n=1/40  "…같은 방식으로 이어가겠습니다."
23:47:15  n=2/40  "…이어서 진행하겠습니다."

5건 중 3건이 현재 서술형입니다. 시작합니다. 처음 만들 때는 미래형(~하겠습니다)만 봤습니다. 전날 감사에서 어떤 세션이 판정 문서를 씁니다. 한 문장으로 턴을 끝내고 여덟 시간 멈춘 걸 발견해서 현재 서술형을 추가했는데, 그 확장이 없었으면 이 3건은 그대로 샜습니다.

오탐은 0건이었습니다. 통과시킨 4건 중 3건은 실제로 사용자 판단을 기다리는 정지였습니다.

생존 감시도 정확히 작동했습니다

01:52:21  DOWN  vinylstudio-70  프로세스 없음
05:02:28  DOWN  vinylstudio-ad  프로세스 없음
06:52:31  DOWN  vinylstudio-4e  프로세스 없음

각 DOWN 직전 점검에서는 정상이었으니 실제 사망은 감지 5분 이내입니다. 점검 122회를 610분에 걸쳐 돌았고 정확히 5.0분 간격, 누락 0회였습니다.

그런데 아무도 되살리지 않았습니다

세션 셋이 다 죽었습니다. 감시는 5분 안에 잡았고 Discord로 알림도 보냈습니다.

그 알림을 읽을 사람이 자고 있었습니다.

첫 사망이 새벽 1시 52분입니다. 아침 7시 31분에 확인할 때까지 5시간 39분 동안 아무 일도 일어나지 않았습니다.

설계 때 이미 보이던 자리였습니다

감시 스크립트를 만들 때 이렇게 적어뒀습니다.

if [ -z "$alive" ]; then
    echo "$(date -Iseconds) $uuid has no live process" >> "$LOG"
    # 알림만 보낼지 자동 재개할지는 운영 판단
fi

판단을 미룬 것 자체는 문제가 아닙니다. 미룬 채로 무인 실행에 들어간 것이 문제입니다.

무인 실행에서 알림은 사람이 개입할 수 있을 때만 대책입니다. 사람이 없는 시간대에 쓰는 장치가 사람의 개입을 전제하면, 그 시간대에 그 장치는 없는 것과 같습니다.

감지·판단·조치 세 단계 중 하나라도 사람에게 넘기면 무인 실행은 거기서 끊깁니다. 알림은 조치가 아니라 조치를 기록하는 수단입니다.

STALE 신호는 알림 없이 로그만 남깁니다

감시에 부가 신호를 하나 더 넣었습니다. 프로세스는 살아 있는데 트랜스크립트가 45분 이상 안 자란 경우입니다. Stop 훅이 놓친 정지를 잡는 백스톱이고, 시끄러워지지 않도록 알림 없이 기록만 합니다.

이번 밤에 192건 쌓였습니다. 그중 114건이 §아래에서 다룰 vinylstudio-4e 한 세션 것입니다.

21:22:08  STALE  vinylstudio-4e  idle=242m  (프로세스는 생존)
...
06:47:31  STALE  vinylstudio-4e  idle=808m  (프로세스는 생존)

같은 세션이 계속 걸리면 로그가 한쪽으로 쏠립니다. 다음엔 세션별로 한 번만 남기고 idle 값만 갱신하는 쪽이 낫겠습니다.

계측이 장치를 오판했습니다

여기서 예상 못 한 게 하나 나왔습니다.

사고를 조사할 때 쓴 유휴 정의가 있었습니다.

유휴 = end_turn 시점 → 다음 외부 입력(promptSource) 시점

첫 가동 결과에 이걸 그대로 적용했더니 미재개 정지 6건이 나왔습니다. 장치가 실패한 것처럼 보였습니다.

훅 로그와 대조하니 그중 4건은 훅이 차단해서 세션이 스스로 이어간 것이었습니다. 차단된 정지는 외부 입력 없이 이어갑니다. 그러니 "다음 외부 입력"을 찾는 방식으로는 영원히 미재개로 잡힙니다.

판정을 바꿔서 다시 셌습니다.

# 이전: 다음 promptSource 를 찾는다  →  차단 성공이 미재개로 잡힘
nxt = next((x for x in rows[i+1:] if x.get("promptSource")), None)

# 이후: 어떤 레코드든 있었는지 본다
after = [r for r in rows if r["dt"] > e["dt"]]

실제 유휴는 2건이었고, 그 2건도 훅이 규칙대로 통과시킨 승인 대기였습니다.

시각 훅 판정
21:25:40 → 22:54:13 88.5분 pass(awaiting-user)
22:11:10 → 22:54:14 43.1분 pass(awaiting-user(full))

훅이 막았어야 할 유휴갭은 0건입니다.

사고를 재던 지표는 장치가 없는 세계를 전제합니다. 장치는 그 전제를 깨뜨립니다. 개입 성공의 흔적이 실패의 흔적과 같아 보이면 그 계측으로는 장치를 평가할 수 없습니다.

왜 죽는지는 여전히 모릅니다

세션 셋이 마지막 활동으로부터 각각 이만큼 지나서 죽었습니다.

세션 마지막 활동 사망 감지 간격
메인 트랙 23:33:49 01:52:21 2.3시간
M8 재기동 23:58:47 05:02:28 5.1시간
요구사항 17:19:17 06:52:31 13.6시간

전날 죽은 세션까지 넷 다 같은 형태입니다. 멈춰 있다가 몇 시간 뒤에 죽습니다.

"정지가 길어지면 죽는다"로 보입니다. 그런데 이건 관찰이지 원인이 아닙니다. 표본이 넷이고 교란 요인을 통제하지 않았습니다. 죽는 순간 무슨 일이 있었는지는 어느 로그에도 없습니다. 유휴 타임아웃인지, 메모리 압박인지, 시스템 슬립인지 구분할 데이터가 없습니다.

그래서 자동 재기동을 아직 안 붙였습니다. 원인이 유휴 시간과 무관하면 재기동 → 유휴 → 사망 → 재기동이 밤새 반복됩니다. 자동 조치는 원인 모형을 전제로 동작하고, 그 가정이 틀리면 조치가 사고를 증폭합니다.

계측이 먼저입니다. 지금 감시가 아는 건 "없어졌다"뿐이고, 다음 사고에서도 아는 건 그것뿐일 겁니다.

이미 멈춘 건 못 깨웁니다

감시 대상 셋 중 하나(vinylstudio-4e)는 장치를 켜기 네 시간 전부터 이미 멈춰 있었습니다.

켜자마자 첫 점검에서 STALE idle=242m으로 잡혔습니다. 그 사실을 가동 보고에도 적었습니다. 그리고 밤새 한 번도 움직이지 않다가 13.6시간 뒤 죽었습니다.

Stop 훅은 세션이 턴을 끝내려 할 때 발동합니다. 이미 끝나 있으면 발동 계기가 없습니다. 구조적 한계고 설계 문서에도 적혀 있었습니다.

문제는 그 한계를 알면서 멈춘 세션을 대상 목록에 넣은 채 시작한 겁니다. 가동 보고에 이상 신호를 적어두는 건 처리가 아닙니다.

첫 가동에서 봐야 할 것

장치를 만들고 처음 돌리면 보통 이 둘을 봅니다.

  • 겨냥한 실패를 잡았는가
  • 오탐이 있었는가

이 둘만 보면 "5/5 차단, 3/3 감지, 오탐 0"이 나오고, 그 숫자로는 작업량이 0이었던 5시간 39분이 보이지 않습니다.

세 번째가 필요합니다.

  • 개입한 뒤 상황이 종결됐는가. 감지에서 끝났는지, 조치까지 갔는지

그리고 무인 실행이라면 하나 더 봐야 합니다.

  • 정지를 깬 것이 장치인가 사람인가

전날 사고 조사에서 같은 질문을 적었고 그때 답은 "사람"이었습니다. 이번엔 장치가 5회, 사람이 2회, 그리고 3회는 아무도 깨지 못했습니다. 나아졌지만 도달하진 않았습니다.

다음

자동 재기동을 붙이되 세 가지를 같이 넣을 생각입니다. launchctl 쪽 주기는 그대로 두고 조치 부분만 확장합니다.

조건 이유
재시도 횟수 상한 같은 원인으로 계속 죽으면 무한 재기동
재시도 사유 로깅 몇 번째인지 왜인지 안 남으면 다음 조사가 처음부터
상한 도달 시 알림 자동 복구가 포기한 시점은 사람이 알아야 함

그 전에 사망 직전 상태(RSS, CPU, 프로세스 나이)를 5분마다 같이 남기는 계측부터 붙입니다. 다음 사망 때 비교할 게 있어야 원인을 좁힐 수 있습니다.

세 줄 요약

감지까지만 만든 안전장치는 사람이 깨어 있는 시간에만 안전장치입니다. 무인 실행용이라면 감지·판단·조치 세 단계를 모두 자동으로 닫아야 하고, 알림은 조치가 아닙니다.

장치를 넣으면 계측 정의도 같이 바뀝니다. 사고를 재던 지표를 장치 평가에 그대로 쓰면 개입 성공이 실패로 집계됩니다. 손으로 한 번은 대조해야 합니다.

원인을 모르는 채 자동 조치를 붙이면 사고가 증폭됩니다. Conventional Commits처럼 규약으로 굳힐 수 있는 것과 달리, 자동 복구는 원인 모형이 맞아야 동작합니다. 계측이 먼저입니다.

Sponsored

댓글