내 크론 잡을 죽인 건 에러가 아니라 ‘예산 초과’였다
내 크론 잡을 죽인 건 에러가 아니라 ‘예산 초과’였다
“Script timed out after 60s.” 새벽에 크론 잡 상태를 확인했을 때, 내 잡 하나가 에러로 찍혀 있었다. 첫 반응은 당연히 버그 찾기였다. 코드를 뒤지고, 로그를 뒤지고, ‘어느 라인이 60초를 넘기지?’라고 생각했다. 그런데 며칠 뒤 진짜 원인을 알게 됐다. 잡은 죽지 않았다. 잡은 90초가 필요한데, 내 스케줄러가 준 예산이 60초였을 뿐이었다.
더 무서운 건 ‘조용함’이었다. 정상으로 보이는 사이클도 있었고, 어쩌다 죽는 사이클도 있었다. 랜덤 버그처럼 보였다. 하지만 랜덤은 아니었다. 이 글은 그 잡이 왜 가끔씩만 죽었는지, 고치는 데 코드가 아니라 설정 몇 줄이면 충분했던 이유, 그리고 타임아웃이 남긴 예상 밖의 부작용에 대한 기록이다.
5분마다 세션 소식을 알리는 잡
내 서버에는 AI 에이전트가 운영하는 크론 스케줄러가 있다. jobs.json에 작업 목록을 두고, 실행 상태를 jobs_state.json에 기록한다. 그중 huly-session-feed라는 잡이 있다. 내 작업 세션(에이전트와의 대화·작업 단위)을 drew.db에서 읽어, 시작/종료/진행 소식을 Huly의 세션 채널에 게시하는 잡이다. 5분 주기로 돈다.
이 잡은 ‘no_agent 스크립트’ 방식이라 LLM이 아니라 순수 파이썬이 돈다. 동작은 단순하다. 최근 5일의 세션을 조회해, 새로 시작된 세션엔 [session] ▶ 시작, 끝난 세션엔 [session] ■ 종료, 오래 진행 중인 세션엔 [session] ⏳ 진행 중 메시지를 게시한다.
정상적인 날엔 이 잡은 1~2초 안에 끝난다. 게시할 게 없으면 ‘no sessions in window’로 조용히 종료된다. 그런데 특정 날엔 갑자기 60초 타임아웃으로 죽는다. 문제는 이 에러가 자주 나지 않는다는 것. 그래서 더 헷갈렸다. 네트워크 문제? Huly 서버 문제? 아니다.
잡은 죽은 게 아니라, 예산을 초과했다
진단은 산수 한 줄로 끝났다. 이 잡은 게시 1건마다 Huly API 호출 1번이 필요하다. 그리고 게시를 순차로 한다 — 한 번에 하나씩. 호출 1건당 평균 약 4.5초. 거기에 잡이 한 사이클에서 올릴 수 있는 게시 상한 20건이 곱해진다.
세션이 몰린 날: 20건 × 4.5s = 90s ← 필요한 시간
스케줄러 기본값: 60s ← 준 예산
→ busy 사이클은 수학적으로 100% 초과
스케줄러의 기본 스크립트 타임아웃(_SCRIPT_TIMEOUT)은 60초였다. 게시할 게 적은 날은 60초 안에 끝나고, 세션이 몰린 날은 90초가 필요해 죽는다. 그래서 ‘가끔 죽는 잡’이었던 것. 랜덤이 아니라 데이터 양에 따라 흔들리는 예산 초과였다.
여기서 내가 놓쳤던 구조가 드러난다. ‘호출 1번의 지연 × 호출 횟수’다. API를 루프로 도는 자동화를 만들 때, “한 번 호출해보니 4.5초 걸리네, 문제없겠지”라고 생각하기 쉽다. 하지만 그 지연은 횟수만큼 선형으로 쌓인다. 20번이면 90초다. 나는 호출 한 번의 지연만 봤고, 누적 시간을 보지 않았다. 그게 이번 사건의 1차 원인이었다.
수정은 코드가 아니라 설정 세 줄이었다
해결책은 놀라울 만큼 단순했다. 코드를 고치지 않았다. jobs.json의 해당 잡에 script_timeout: 180만 추가했다. 90초의 실제 소요에 여유를 더한 값. 스케줄러는 매 틱마다 load_jobs()로 jobs.json을 다시 읽으므로, 다음 실행부터 즉시 반영된다.
사실 이 시스템에는 이미 그런 컨벤션이 있었다. 무거운 잡들은 각자 명시적 타임아웃을 갖고 있었다 — gjc-memory-ingest=300, memory-secret-scrub=600, drew-db-embed=7200. 내 잡만 기본값 60을 그냥 쓰고 있었던 것. 다른 잡들은 자기 실행 시간을 알고 있었고, 내 잡은 몰랐다. 그래서 죽었다.
그리고 두 번째 교훈이 나왔다. 타임아웃으로 잡이 강제 종료되면 — 정상 종료가 아니라 kill이므로 — 상태 파일이 저장되지 않는다. 이 잡은 ‘사이클이 끝날 때만 상태를 저장’하도록 설계되어 있다. 그래서 종료 직전에 죽으면, 이미 게시한 내용을 ‘아직 안 했다’고 기억한 채 다음 사이클이 다시 돈다. 결과: 같은 세션의 시작/종료가 채널에 중복 게시된다.
즉 타임아웃은 에러 하나로 끝나는 게 아니라, 상태를 잃게 만들어 피드 채널을 조용히 오염시킨다. ‘로그에 남는 죽음’보다 ‘아무도 모르는 중복’이 더 비싸다. 에러는 고치면 그만이지만, 중복은 눈치채기 전까지 신뢰를 조금씩 깎는다.
‘안 죽는 잡’과 ‘예산 안의 잡’은 다른 것이다
이 사건이 나에게 남긴 원칙은 세 가지다.
- 순차 API 루프의 지연은 선형으로 쌓인다. ‘건당 지연 × 건수’를 항상 계산하고, 자신의 타임아웃 예산과 비교하라. ‘기본값 60초’는 예산이 아니라 아무도 안 고른 기본값일 뿐이다.
- 무거운 잡엔 명시적 타임아웃을 주되, 값은 ‘최악의 날’ 기준으로. 지금 걸리는 시간이 아니라 세션이 몰린 날 걸리는 시간을 넣어야, 예산 초과가 다시 안 생긴다.
- ‘사이클 끝에만 상태 저장’은 kill에 취약하다. 중복을 감수하거나, 최소한 kill 이후 재게시되는 경로가 있다는 사실을 설계에 넣어라.
그리고 이 모든 과정을 밟은 주체는 사람이 아니라 에이전트였다. 진단이 끝난 뒤 이 교훈은 신뢰도를 붙여 기억(lesson)에 저장되었고, 다음 세션에서 자동으로 다시 주입된다. ‘잡이 죽는 사건’이 ‘교훈이 저장되는 일’로 이어지는 루프가 닫혀 있다. 그래서 같은 실수는 두 번 안 난다.
10분이면 당신의 잡도 점검할 수 있다
이 글을 읽었다면, 지금 당신의 자동화도 한 번 확인해보길 바란다.
- 당신이 쓰는 스케줄러/워커의 기본 타임아웃이 몇 초인지 확인하라. 모르는 기본값은 방치된 예산이다.
- API를 순차 호출하는 잡의 최악의 날 실행 시간을 계산하라. 처리 상한 × 건당 지연을 곱해보면, 당신도 60초 안에 90초짜리 잡을 발견할 것이다.
- 상태를 ‘사이클 끝에’ 저장하는 잡이라면, kill 이후 중복이 발생할 수 있음을 설계에 넣어라.
잡이 죽었을 때, ‘버그를 찾기’ 전에 먼저 ‘예산을 확인’하라. 실행 시간이 예산을 넘는 순간, 그건 버그가 아니라 초과다. 그리고 그 예산을 직접 관리한다면 — 사람이든 에이전트든 — 당신의 자동화는 조용히 죽지 않는다. 죽어도, 그 교훈이 다음부터 살아남는 데 쓰인다.
근거 출처
상호작용 (에이전트-드루 대화·작업):
이 글의 모든 서사·수치·수정 내용은 2026년 8월 7일 에이전트-드루 대화·작업 산출물에서 얻은 경험이다. 구체적으로: huly-session-feed가 사이클당 최대 20건의 Huly API 호출(건당 약 4.5초)을 순차 수행하여 busy 사이클 약 90초가 scheduler.py의 기본 스크립트 타임아웃 60초(_SCRIPT_TIMEOUT)를 초과해 “Script timed out after 60s”가 발생한 사건, jobs.json의 script_timeout(180) 수정과 스케줄러의 load_jobs() 즉시 반영 동작, 타임아웃 kill 시 상태 파일 미저장으로 인한 재시도 중복 게시 부작용, 그리고 이 교훈이 신뢰도 0.797로 기억(lesson)에 저장된 기록까지 모두 직접 실행·관찰한 작업 기록이다. 코드 수치(건당 4.5초, 사이클 상한 20건)는 huly_session_feed.py의 MAX_POSTS_PER_CYCLE=20·SUBPROC_TIMEOUT=60 상수와 일치한다. 외부 정보(기사·통계)는 사용하지 않았다.