DevTech
2026년 8월 6일 · 9 min read

밤새 돌아야 할 세션이 네 시간 반 멈췄고 원인은 지휘하던 쪽이었습니다

가계부 웹앱 finance-tracker를 밤새 두 세션으로 병행 작업시켰습니다. 자기 전 지시는 이랬습니다.

나 잘거니까 중단없이 할수있는대로 쭉 계속 이어나갈수있도록 해

아침 8시에 확인하니 멈춰 있었습니다. 얼마나 멈춰 있었는지, 왜 멈췄는지를 로그로 검증했습니다. 조사 도중 결론이 한 번 뒤집혔고, 뒤집힌 쪽이 맞았습니다.

구성부터

세 계층입니다.

  • Dispatch: 오케스트레이터. 작업 세션들의 트랜스크립트를 주기적으로 읽고, 멈춘 세션에 다음 작업을 지시합니다.
  • M8 트랙: 카드 전략 기능과 테스트 이관을 맡은 Code 세션.
  • 메인 트랙: 커버리지 개선과 문서 정비를 맡은 Code 세션.

사람은 Dispatch에게만 말합니다. Dispatch가 두 세션에 지시를 나눠 전달합니다.

자기보고는 근거로 쓰지 않았습니다

세션에게 "너 안 끊기고 진행했냐"고 물어보면 답은 나옵니다. 그 답을 근거로 쓸 수는 없습니다. 트랜스크립트 원본 JSONL의 timestamp 필드만 파싱했습니다.

jq -c 'select(.type=="user" or .type=="assistant") | . as $r |
(if ($r.message.content|type)=="array" then $r.message.content else [] end) as $bl |
{ ts: $r.timestamp,
  type: $r.type,
  promptSource: ($r.promptSource // null),
  ntools: ($bl|map(select(.type=="tool_use"))|length),
  stop: ($r.message.stop_reason // null) }' session.jsonl

jq 매뉴얼에 있는 것 이상은 쓰지 않았습니다.

유휴와 대기를 먼저 갈라야 합니다

레코드 간격을 그냥 세면 안 됩니다. 긴 간격에는 성격이 다른 두 가지가 섞여 있습니다.

A_TOOL → U_RESULT 사이의 긴 간격   = 도구가 실행 중. 일하고 있음
end_turn → 다음 입력 사이의 간격    = 에이전트 루프 종료. 아무것도 못 함

stop_reasonend_turn이면 그 세션은 실행 컨텍스트가 사라진 상태입니다. 타이머도 대기 루프도 없습니다. 외부에서 입력이 들어오기 전까지 존재하지 않는 것과 같습니다.

이 구분 없이 세면 M8이 1,537분으로 나옵니다. 실제 유휴는 훨씬 적습니다. 로컬 모델 위임이 10분씩 도는 구간이 통째로 섞여 들어가기 때문입니다.

정상 리듬이 얼마인지부터 잽니다

임계값을 감으로 정하지 않으려고 분포를 먼저 냈습니다.

M8    p50=4.1s  p90=16.0s  p95=24.9s  p99=243.5s  max=42,585s
MAIN  p50=3.4s  p90=18.6s  p95=29.8s  p99=144.4s  max=16,700s

중앙값 3초에서 4초입니다. p99가 2분에서 4분입니다. 그러니 5분을 넘는 간격은 상위 1% 밖입니다. 최대값은 중앙값의 약 4,000배입니다. 이건 이상치가 아니라 다른 현상입니다.

1차 결론은 세션이 말만 하고 멈췄다는 것이었습니다

end_turn으로 끝난 턴의 마지막 문장을 뽑아 봤습니다.

M8   03:08:50 정지 → 07:57:11 재개   288.4분
     마지막 말: "대신 클라이언트 쪽 커버리지로 넘어가겠습니다"

MAIN 03:18:45 정지 → 07:57:08 재개   278.4분
     마지막 말: "릴리즈 트리거는 계속 보류 중입니다. 다음 대상 이어가겠습니다."

둘 다 다음 작업을 명시적으로 예고하고 멈췄습니다. 전 구간을 세어 보니 "이어가겠습니다" 류를 말한 직후 도구를 부르지 않고 멈춘 것이 44회, 누적 709분이었습니다.

여기까지가 1차 보고서였습니다. 결론은 "세션이 지시를 어겼다"였습니다.

커밋 로그가 같은 말을 했습니다

로그 파싱이 틀렸을 가능성을 걸러내려고 완전히 독립적인 증거를 봤습니다.

git log --all --since="2026-08-05T15:00:00+09:00" \
  --pretty=format:'%cd %h %s' --date=format:'%m-%d %H:%M' | sort
08-06 03:04  546f755  fix: CSV 빈 줄이 오류 행으로 뜨던 것을 고친다
08-06 03:08  ced89df  test: 카드정책 조회 필터와 네 라우트의 필수값 검증을 덮는다
08-06 03:11  c13e182  test(transactions): 기간비교 일·주·연 모드
08-06 03:17  00aaa1a  fix(security): id 목록 강제변환으로 고른 적 없는 거래가 지워졌다

   커밋 0건 (4시간 46분)

08-06 08:03  9b28f5c  test: 스모크에서 빠져 있던 페이지 네 개를 채운다

3분에서 5분 간격으로 촘촘히 찍히다가 03:17에 끊깁니다. JSONL 갭과 정확히 맞습니다. 이 시점에서 "얼마나 멈췄나"는 확정됐습니다.

여기서 멈췄으면 틀린 보고서를 냈을 겁니다

"얼마나"는 확정됐지만 "왜"는 아니었습니다. 두 세션이 9분 간격으로 나란히 멈춘 게 걸렸습니다. 서로 다른 워크트리에서 다른 파일을 만지던 세션 둘이 우연히 비슷한 시각에 게을러질 확률은 낮습니다.

공통 원인이 있다면 위층입니다.

오케스트레이터 로그를 찾았습니다

Dispatch의 트랜스크립트는 Code 세션들과 다른 곳에 있었습니다. ~/.claude/projects/가 아니라 Claude Desktop의 local agent mode 세션 저장소 아래였습니다.

find "$HOME/Library/Application Support/Claude/local-agent-mode-sessions" \
     -path "*local_ditto*" -name "*.jsonl"

23.5MB에 11,483행. 도구 사용 집계를 내니 정체가 확정됐습니다.

1513  read_transcript      # 두 세션 폴링
 591  SendUserMessage      # 사람에게 보고
 537  send_message         # 세션에 넛지
  21  start_code_task

이게 두 세션을 지휘한 주체가 맞습니다.

결론이 뒤집혔습니다

같은 기준으로 Dispatch를 재니 이렇게 나왔습니다.

03:00:36  "Sent." → end_turn → 240.5분 정지
07:01:00  사람이 "한국어로" 입력 → 깨어남
07:01:07  "막힌 거 없어서 계속 폴링할게." → 또 end_turn → 55.9분 정지
07:56:59  사람이 "진행상황확인" 입력
07:57:08  넛지 발송 → 두 세션 재개

Dispatch가 먼저 멈췄습니다. 03:00:36입니다. M8이 멈춘 03:08:50보다 8분 빠릅니다.

그리고 멈추기 직전 사람에게 보낸 보고가 이랬습니다.

Both sessions nudged forward. Nothing blocking, no need to wake you. I'll keep polling.

깨울 필요 없다고 안심시킨 직후에 자기가 멈췄습니다. 넛지를 다시 보낸 것은 296.7분 뒤였습니다.

하위 세션은 설계대로 동작했습니다

Code 세션이 end_turn 후 대기하는 것은 정상 동작입니다. 그걸 깨우는 게 Dispatch의 역할이었습니다.

01:18부터 03:00까지 Dispatch는 성실했습니다. 넛지 간격 평균 7.3분입니다. 그 구간의 Code 세션 유휴갭은 전부 3분에서 12분 사이입니다.

넛지에 대한 반응 지연 중앙값은 12초에서 13초, 최대 47초였습니다. 전 구간에서 한 번도 47초를 넘지 않았습니다. 세션이 굼뜬 게 아닙니다. 깨우면 즉시 움직입니다. 288분은 느린 게 아니라 부재입니다.

방아쇠는 컨텍스트 압축이었습니다

Dispatch가 왜 갑자기 멈췄는지는 시스템 레코드에 있었습니다.

jq -r 'select(.type=="system" and .subtype=="compact_boundary") | .timestamp' dispatch.jsonl
2026-08-05T18:00:15Z   =  08-06 03:00:15 KST
"This session is being continued from a previous conversation that ran out of context."

03:00:15에 컨텍스트가 소진돼 자동 압축됐습니다. 압축으로 재개된 Dispatch는 넛지를 한 라운드 돌리고, 상태를 보고하고, 턴을 끝냈습니다.

압축 요약은 무엇을 알고 있었는지는 잘 옮깁니다. 못 옮기는 건 무엇을 계속 하고 있었는지입니다. "밤새 주기적으로 폴링한다"는 사실이 아니라 습관입니다. 요약문에 한 줄 적혀 있어도 그 한 줄이 다음 턴의 행동을 만들지는 않습니다.

두 세션의 정지는 기전이 달랐습니다

여기서 한 번 더 갈렸습니다. 정지 시간은 288분과 278분으로 비슷했는데 원인이 달랐습니다.

프로세스를 실측했습니다. psetimelstart입니다.

for p in $(ls ~/.claude/sessions/*.json | xargs -n1 basename | cut -d. -f1); do
  echo -n "pid $p: "; ps -p $p -o lstart=,etime= 2>/dev/null || echo "NOT RUNNING"
done
pid 49594 (MAIN): Wed Aug  5 18:06:05 2026    14:21:29
pid 80837 (M8):   Thu Aug  6 07:57:10 2026       30:24

메인 트랙의 프로세스는 14시간 21분째 살아 있습니다. 278분 갭 내내 켜져 있었습니다. 순수한 end_turn 대기였습니다.

M8의 프로세스는 30분밖에 안 됐습니다. 07:57:10 기동입니다. 넛지를 보낸 시각과 같습니다. 갭 동안 M8을 돌리던 프로세스는 존재하지 않았습니다.

로그 텍스트만 보면 둘 다 똑같이 "말하고 멈춤"입니다. 프로세스를 재야 갈립니다.

ps 한 줄이 대책을 바꿨습니다

이 차이가 중요한 이유는 대책이 달라지기 때문입니다.

메인 트랙 278분 M8 288분
프로세스 생존 소멸 후 재기동
종료 시 Stop 훅 기록 있음 없음
Stop 훅으로 방지 가능 불가능

Stop 훅은 살아 있는 프로세스 안에서 실행되는 장치입니다. 프로세스가 없으면 훅을 돌릴 주체도 없습니다.

실측 전 추정은 "Stop 훅 하나로 89% 해결"이었습니다. 실측 후엔 **49%**였습니다. 남은 절반은 종류가 다른 감시가 필요했습니다. 프로세스를 재보지 않았으면 절반짜리 대책을 전부라고 보고할 뻔했습니다.

막을 장치는 이미 있었습니다

가장 아팠던 발견입니다. Dispatch의 도구 호출 이력을 훑다가 나왔습니다.

jq -r 'select(.type=="assistant") | .timestamp as $t | (.message.content//[])[] |
  select(.type=="tool_use" and (.name|test("scheduled_task"))) |
  [$t, .name] | @tsv' dispatch.jsonl
07-24 16:43  create_scheduled_task   cron=*/30  "진행상황 30분마다 체크"
07-24 17:02  delete_scheduled_task              ← 19분 만에 삭제
07-24 19:42  create_scheduled_task   cron=*/10
07-24 19:56  delete_scheduled_task              ← 14분 만에 삭제

정확히 이 사고를 막을 장치를 만들었다가 20분도 안 돼 지웠습니다. 이후 재생성 기록은 없습니다. 그 상태로 2주가 지났고 그 사이에 사고가 났습니다.

왜 지웠는지는 로그에 없습니다. 19분과 14분이라는 짧은 수명으로 보아 주기가 잦아 시끄러웠을 가능성이 높지만 이건 추정입니다.

이게 별도의 교훈인 이유는, 재발방지책을 "예약 작업을 만든다"로 끝내면 같은 일이 반복되기 때문입니다. 이미 만들었었습니다. 문제는 만드는 능력이 아니라 유지되는지였습니다.

안전장치는 평상시에 아무 값어치가 없어 보입니다. 아무 일도 안 일어나는 게 정상 동작이니까요. 그래서 시끄러우면 지워지고, 지워졌다는 사실은 사고가 날 때까지 아무도 모릅니다.

무엇을 고치나

1. 오케스트레이터를 외부에서 깨웁니다

가장 위입니다. 예약 작업을 되살리되 조건을 좁힙니다.

cron: */20 * * * *
prompt: 두 세션의 트랜스크립트를 확인하라.
        마지막 턴이 end_turn 이고 5분 이상 정지 상태면 즉시 다음 작업을 지시하라.
        승인이 필요한 항목만 사람에게 남긴다.
        정지 없이 진행 중이면 아무것도 하지 말고 종료하라.

마지막 줄이 핵심입니다. 지워진 이유가 노이즈였다면, 없애는 게 아니라 조용하게 만들어야 합니다. 있는 장치를 조용하게 만드는 비용이 없는 장치를 다시 만드는 비용보다 항상 쌉니다.

20분 주기면 296.7분 공백의 상한이 20분이 됩니다.

2. 프로세스 생존을 따로 감시합니다

M8 유형은 Stop 훅이 손댈 수 없습니다. 모델과 무관하게 도는 감시가 필요합니다.

#!/bin/bash
[ -f "$HOME/.claude/NIGHT_MODE" ] || exit 0
for uuid in "$@"; do
  alive=$(grep -l "\"sessionId\":\"$uuid\"" "$HOME"/.claude/sessions/*.json 2>/dev/null | head -1)
  [ -z "$alive" ] && echo "$(date -Iseconds) $uuid has no live process" >> "$HOME/.claude/night-watch.log"
done

5분 주기면 프로세스 소멸을 5분 안에 잡습니다. 실제로는 최대 4시간 48분 방치됐습니다.

3. Stop 훅으로 정지 자체를 막습니다

Stop 훅은 이미 설정돼 있었습니다. 그런데 Discord 알림만 보내고 정지를 막지 않았습니다. 로그에 남은 stop_hook_summary 111건 전부 preventedContinuation: false였습니다.

Claude Code의 Stop 훅은 stderr로 판단을 내보내고 종료코드 2를 반환하면 정지를 취소합니다.

#!/bin/bash
input=$(cat)
# 재귀 방지. 없으면 훅이 자기 자신을 무한 재기동한다
[ "$(jq -r '.stop_hook_active // false' <<<"$input")" = "true" ] && exit 0
[ -f "$HOME/.claude/NIGHT_MODE" ] || exit 0

STATE="/tmp/keepgoing-$(jq -r '.session_id' <<<"$input")"
n=$(( $(cat "$STATE" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$STATE"
[ "$n" -gt 40 ] && exit 0    # 상한. 할 일이 없을 때 무한 재개 방지

echo '{"decision":"block","reason":"야간 무중단 모드다. 승인이 필요한 항목이 아니면 멈추지 말고 다음 작업을 지금 실행하라."}' >&2
exit 2

세 가지 안전장치가 다 붙어 있습니다. 재귀 가드, 카운터 상한, 야간 플래그 파일입니다. 상시로 켜면 승인 대기 질문까지 밀어붙이게 되므로 플래그로 켜고 끕니다. 설정 위치는 settings.json 문서에 있습니다.

셋을 다 해야 합니다

순위 조치 막는 것 효과
1 오케스트레이터 예약 작업 스케줄러 자신의 정지 넛지 공백 296.7분 → 20분
2 프로세스 생존 감시 M8 유형 288분 → 5분 내 감지
3 Stop 훅 차단 메인 트랙 유형 278분 → 0

하나만 하면 절반 이하만 해결됩니다. 이게 프로세스 수명을 실측해서 얻은 가장 중요한 결론입니다.

숫자로 정리하면

지표
취침지시 이후 창 6.96시간
Code 세션 실질 활동률 21.1% / 26.2%
전 계층 동시 정지 278.4분
오케스트레이터 넛지 공백 296.7분
git 커밋 공백 4시간 46분
넛지 반응 중앙값 12초에서 13초
정지를 깬 것 사람의 수동 입력 2회

마지막 줄이 판정입니다. 정지를 깬 게 사람이었다면 무인 실행은 성립하지 않은 겁니다. 결과물이 나왔더라도 마찬가지입니다.

세 줄 요약

계속하겠다는 말은 그 말을 실현할 수단이 세션 안에 없으면 지켜지지 않습니다. 턴이 끝나면 실행 컨텍스트가 사라지기 때문입니다. 지속 의무는 대화 컨텍스트가 아니라 예약 작업이나 플래그 파일 같은 외부 상태에 둬야 압축을 넘습니다.

하위가 멈춘 것처럼 보이면 상위부터 봐야 합니다. 로그 텍스트로는 "말하고 멈춤" 하나로 보이는 것이 프로세스를 재면 두 종류로 갈리고, 대책도 갈립니다.

안전장치는 만드는 것보다 유지되는지가 어렵습니다. 만들었다가 지운 이력이 있는지를 점검 항목에 넣어야 합니다. 커밋 메시지 규칙은 Conventional Commits처럼 문서로 남으면 유지되지만, 예약 작업은 조용히 사라져도 아무도 모릅니다.

Sponsored

댓글