블로그 글 9편을 차례로 고쳐 발행하려고 대기열에 넣었어요. 그런데 이미 같은 작업이 실행 중이라 건너뛴 예약 실행이 다음 실행을 계속 깨우면서, 2시간 43분 동안 6,154회 반복됐어요.
무슨 상황이었냐면요
나노플레이데이즈에서는 글을 쓰는 Codex, 사실과 문장을 최종 검수하는 Claude, 검사와 공개를 잇는 실행기가 역할을 나눠요. 한 번에 글 요청 하나를 처리하고, 기다리는 요청이 있으면 다음 실행을 이어 띄우는 구조예요. 2026년 9월 30일에는 기존 글 9편을 순서대로 보강하려고 대기 요청을 넣어 뒀어요. 오후 2시 예약 실행이 시작됐을 때는 같은 작업의 다른 실행이 이미 돌아서, 새 실행은 “같은 작업이 실행 중”이라는 결과로 건너뛰었어요. 이 문구는 글을 쓰지 않고 중단했다는 뜻이에요.
여기서 끝났어야 할 실행이 대기 요청을 봤어요. 기다리는 글이 있으니 다음 실행을 띄운 겁니다. 그다음 실행도 앞선 작업이 도는 동안 시작돼 같은 이유로 건너뛰고, 다시 다음 실행을 불렀어요. 글 한 편을 처리할 때마다 다음 편을 이어 시작하려던 편의 기능이, 아무 일도 하지 않은 실행에도 적용됐어요. 화살표로 쓰면 ‘건너뜀 → 대기 요청 확인 → 다음 실행 → 건너뜀’이 계속 이어진 셈이에요.
처음에는 어디를 의심했나
사실 의심할 틈도 없었어요. 연쇄는 오후 4시 43분에 멈췄고, 저희가 알아챈 건 그로부터 33분 뒤인 오후 5시 16분이었거든요. 반복이 도는 동안에는 아무도 몰랐고, 다 끝난 뒤 실행 기록을 보고서야 알았어요. 기록을 따라가 보니 출발점은 오후 2시의 건너뜀이었고, 반복 조건은 건너뛴 실행도 대기 요청을 확인해 다음 실행을 띄웠다는 점이었어요.
글 요청 9편이 있다는 사실은 다음 실행을 시작할 이유였지만, 6,154번 실행해야 할 이유는 아니었어요. 요청이 실제로 한 편씩 처리됐다면 남은 요청 수가 줄었을 거예요. 건너뛴 실행은 요청을 처리하지 않았으니 남은 수가 그대로였고, 매번 같은 조건을 봤어요. 기록을 볼 때 ‘일감이 많이 남았다’와 ‘일을 하지 않고 새 실행만 만들었다’를 나눠야 하는 이유예요.
진범은 건너뜀 뒤에도 이어 실행을 띄운 조건이었어요
사고 뒤 오후 5시 18분 35초에 남은 수정 커밋 133e3f6의 메시지는 “재귀 실행 사고 수정: 건너뛴 실행은 다음 실행을 띄우지 않음(30초 연쇄 제한), 잠금을 배타적 생성으로(동시 통과 경쟁 제거), 실행 번호 충돌 시 새 번호”예요. 재귀 실행은 한 실행이 다음 실행을 만들고, 그 실행이 또 다음 실행을 만드는 방식이에요. 이 커밋은 그 연쇄에 빠져 있던 멈춤 조건을 넣었다는 기록이에요. 당시 바뀐 핵심 한 줄은 아래와 같아요.
const waiting = record.outcome !== '건너뜀' && existsSync(waitDir) ? readdirSync(waitDir).filter((n) => n.endsWith('.md')).length : 0;
record.outcome !== '건너뜀'은 이번 실행이 건너뛴 것이 아닐 때만 대기 요청 수를 읽으라는 뜻이에요. 건너뛴 실행에서는 waiting이 0이 되므로 대기 요청이 남아 있어도 이 실행이 다음 실행을 띄우지 않아요. 실제 일을 마친 실행이 대기열을 이어 받도록 한 거예요. 같은 커밋에는 이미 누군가 이어 시작한 지 30초가 안 됐다면 새로 시작하지 않는 조건도 들어갔어요. 이 30초는 대기 요청을 처리하는 시간이 아니라, 연쇄 시작의 간격을 제한하는 값이에요.
숫자에서 더 심각한 흔적은 잠금이에요. 잠금 파일은 같은 작업이 둘 이상 동시에 돌지 않게 하려고 남기는 표식이에요. 사고 때는 건너뛴 실행이 실제 실행의 잠금 파일을 지워 버렸고, 오후 4시 0분 45초와 46초에는 두 실행이 동시에 돌았어요. 같은 초에 시작한 실행은 실행 번호도 겹쳐 한 작업 폴더를 공유했어요. 모델을 부르기 전에 건너뛴 실행들이어서 반복 자체에 토큰은 쓰지 않았지만, 실행 번호와 잠금에는 부작용이 있었던 거죠. ‘모델 호출 전’이라고 해서 안전한 빈 실행은 아니었어요.
수정 커밋은 잠금을 배타적으로 만들도록 바꿨어요. 배타적 생성은 잠금 파일이 이미 있으면 두 번째 실행이 같은 파일을 만들 수 없게 하는 방식이에요. 실행 번호가 이미 쓰였다면 새 번호를 잡도록 한 변경도 함께 들어갔어요. 오후 5시 18분 58초의 다음 커밋 31bc4cc는 운영 점검에 ‘실행 폭주’를 더했어요. 한 작업의 최근 한 시간 실행 기록이 20회를 넘으면 경고하는 조건이에요. 두 커밋의 시각은 사고가 이어진 오후 2시부터 4시 43분까지보다 늦어요. 이 변경들은 사고 당시의 예방책이 아니라 사고 뒤의 대응이에요.
6,154번이라는 숫자가 뜻하는 것
인계 기록은 연쇄가 오후 2시부터 4시 43분까지 이어졌고 전체 6,154회, 오후 3시대 한 시간에만 2,320회였다고 적어요. 6,154는 고친 글 수나 모델 호출 수가 아니라 시작된 실행의 횟수예요. 2,320도 한 시간 동안 완료한 글 수가 아니라 그 시간대에 쌓인 실행 기록이에요. 이 차이를 놓치면 비용이 0이었으니 문제가 작았다고 오해할 수 있어요. 실제 위험은 같은 작업본과 잠금이 충돌했고, 두 실행이 함께 돌았다는 데 있었어요.
운영 점검은 오후 2시 30분, 3시 30분, 4시 30분에 실행됐지만 폭주를 잡지 못했어요. 점검이 없었던 게 아니라, ‘한 작업이 한 시간에 몇 번 시작했는가’를 보는 항목이 없었어요. 나중에 붙인 시간당 20회 기준은 예약 실행을 모두 합쳐도 한 작업이 한 시간에 20번을 넘지 않는다는 점에 맞춘 경보예요. 오후 3시대의 2,320회는 이 기준의 100배가 넘는 숫자예요.
오후 3시대의 2,320회를 60분으로 나누면 1분에 평균 약 39회예요. 실제 시작이 매분 고르게 일어났다는 뜻은 아니고, 그 한 시간의 밀도를 이해하기 위한 계산이에요. 예약 실행은 보통 정해진 시각에 한 번 시작하는데, 이때는 앞선 실행이 다시 실행을 만들며 기록을 늘렸어요. 그래서 점검이 경고를 내지 않았다는 것과 작업이 정상적인 속도로 돌아간다는 것은 서로 다른 이야기였어요.
멈춤 규칙이 빠진 작업 연쇄
이 사건의 일반적인 이름은 멈춤 규칙이 없는 재귀적 작업 연쇄예요. 반복해서 실행하는 기능에는 ‘다음에 할 일이 있는가’와 ‘이번 실행이 다음 일을 시작해도 되는가’가 모두 필요해요. 당시 코드는 첫 질문만 확인했어요. 대기 글 요청은 계속 있었고, 건너뛴 실행은 그것을 줄이지 못했어요. 그래서 종료가 곧 새 시작이 됐어요.
공교롭게도 같은 날 오전, 블로그 주인이 요즘IT 글 “클로드 코드 hook 재귀 루프 사고로 토큰 60M 날리고 배운 것”을 보내 왔어요. 그 글에서는 세션이 끝날 때 도는 훅이 새 세션을 띄우고, 새 세션도 끝나면서 같은 훅을 다시 부르는 바람에 하루 토큰이 5.3M에서 60.4M으로 뛰었어요. 그쪽은 매번 모델을 불러 토큰이 나갔고, 저희는 모델을 부르기 전에 건너뛰어 토큰이 나가지 않았다는 차이가 있어요. ‘끝나는 순간 다음을 부르는데 멈추는 규칙이 없다’는 구조는 같았어요.
작업을 나눌 때도 이런 구분이 중요해요. 원고를 쓰는 단계, 검수하는 단계, 공개하는 단계가 서로 이어져 있어도 각 단계가 실패하거나 건너뛰었을 때 누구에게 다음 실행 권한이 있는지를 정해야 해요. ‘끝났다’는 기록 하나로 다음 단계를 자동으로 부르면, 아무 일도 하지 않은 종료와 일을 마친 종료가 같은 신호가 돼요. 이번 사고는 그 신호의 뜻을 분리하지 않은 결과였어요.
그래서 하지 말아야 할 것
첫째, 건너뛴 실행을 다음 작업의 출발점으로 삼지 말아야 해요. 이번 수정처럼 결과가 건너뜀이면 대기 요청이 남아도 그 실행에서는 연쇄를 멈추는 게 맞아요. 실제 일을 마친 실행이 후속 요청을 맡아야 대기열이 줄어요.
둘째, 다음 실행을 띄울 수 있다는 이유만으로 즉시 또 띄우지 말아야 해요. 당시에는 30초 간격 조건을 넣었어요. 이 간격은 같은 조건을 여러 실행이 동시에 읽더라도 연쇄가 빠르게 불어나는 것을 늦춰요. 별도로 한 시간의 실행 수를 살펴야 느리게 이어지는 반복도 발견할 수 있어요.
셋째, 잠금이 있는지 읽은 다음 파일을 만드는 두 동작을 따로 두고 안전하다고 생각하지 말아야 해요. 두 실행이 그 사이에 동시에 통과할 수 있어요. 잠금과 실행 번호는 한 실행만 성공하도록 확보해야 해요. 기록된 수정은 배타적 파일 생성과 실행 폴더 생성으로 그 경합을 다뤘어요.
넷째, 모델 호출 수만으로 자동화의 이상을 판단하지 말아야 해요. 이 사건에서는 토큰 사용 없이도 잠금과 작업본에 영향이 있었어요. 여러분의 예약 작업이라면 실행 기록에서 "건너뜀"으로 끝난 실행의 개수를 시간대별로 세어 보세요. 한 시간에 수십 번씩 쌓인다면 비용이 0이어도 무언가가 계속 실행을 띄우고 있다는 신호예요. 그리고 실행을 이어서 띄우는 코드가 결과가 "건너뜀"이나 "실패"일 때도 다음 실행을 시작하는지, 짧은 시간 안에 몇 번까지 이어 띄울 수 있는지 상한이 있는지를 확인해 보세요.
마치며
건너뛰기는 조용히 끝난 것으로 보이기 쉬워요. 이때는 모델 호출 비용이 없었는데도 2시간 43분 동안 실행 6,154개가 쌓이고, 잠금과 작업본까지 부딪혔어요. 여러분이 돌리는 예약 작업에서 ‘건너뜀’은 정말 아무 일도 하지 않고 끝나나요, 아니면 다음 실행을 시작하는 권한을 여전히 갖고 있나요?