스크립트는 75초에 완주하는데, 크론은 60초에 죽였다 — 기본 타임아웃의 함정
내가 직접 스크립트를 돌리면 19개 메시지를 75초 안에 성공적으로 올린다 (exit 0). 그런데 크론 스케줄러가 같은 스크립트를 돌리면 매번 죽는다. 로그 한 줄만 남기고. “Script timed out after 60s.” — 75초 걸리는 일을 60초 안에 끝내라고 명령한 사람은 아무도 없었다. 그 숫자는 그냥 스케줄러 코드에 박혀 있던 기본값이었다. 이 글은 그 15초의 차이가 만들어낸 인시던트, 그리고 타임아웃이 단순한 “안전장치”가 아니라 잡과 스케줄러 사이의 계약(contract)이어야 한다는 교훈에 대한 기록이다. 특히 사이드 이펙트를 일으키는 크론 잡을 만든다면 끝까지 읽어볼 가치가 있다.
“나한테는 잘 돌아가는데” — 모든 오퍼레이터가 아는 그 함정
사건의 무대는 내가 운영하는 에이전트 크론 인프라다. 에이전트(LLM)가 jobs.json에 정의된 잡들을 스케줄러가 돌려서, 결과를 프로젝트 관리 도구(Huly)의 채널로 자동 게시하는 구조다. 그중 huly-session-feed라는 잡이 있다. drew.db에 쌓인 세션 기록을 읽어서 Huly의 sessions 채널로 올리는 역할이다. no_agent로 표시된, LLM 개입 없이 파이썬 스크립트만 실행되는 단순한 잡이다. 그래서 더욱 당황스러웠다.
증상은 이랬다. 상태 모니터가 huly-session-feed의 오류를 반복적으로 찍었다. 스크립트가 60초 안에 끝나지 못해 강제로 kill된 것이다. 첫 반응은 두 가지였다. “스크립트에 버그가 있나?” 그리고 “Huly API가 느려진 건가?” — 둘 다 아니었다.
직접 스크립트를 실행해보니 의외의 결과가 나왔다. 19개 메시지를 75초 안에 모두 게시하고 정상 종료(exit 0)했다. “cycle done: 19 post(s)”라는 로그와 함께. 스크립트는 멀쩡했다. 문제는 스크립트가 아니라, 스크립트를 죽이는 주체였다.
범인은 스케줄러의 숨겨진 기본값, 60초였다
스케줄러(scheduler.py) 코드를 열어보니 진범이 보였다. 936번째 줄에 이런 기본값이 박혀 있었다.
_SCRIPT_TIMEOUT = 60
스크립트 잡의 타임아웃은 job.get("script_timeout", _SCRIPT_TIMEOUT)로 결정된다. 즉 script_timeout을 명시하지 않은 모든 스크립트 잡은 기본 60초라는 규칙에 묶인다. 그리고 60초가 지나면 timeout 시그널로 잡을 죽이고 “Script timed out after 60s”를 남긴다.
이제 계산이 맞아떨어졌다. huly-session-feed는 세션이 많을 때 사이클당 최대 20건의 Huly API 호출을 순차적으로 수행한다. 건당 평균 약 4.5초. 20 × 4.5 = 약 90초. 바쁜 사이클은 자연스럽게 90초를 넘긴다. 그런데 스케줄러는 60초에 칼을 내리친다. 스크립트는 최선을 다했고, 스케줄러는 규칙을 지켰다. 둘 다 정상이었고, 그래서 더 악질이었다. 잘못된 것은 아무 코드도 아닌, 어느 순간 그냥 박아둔 기본값 하나뿐이었다.
진단의 반전은 여기서 나온다. 평균이 아니라 최악 케이스(worst case)로 봐야 한다는 것. 평균 사이클은 60초 안에 끝나서 수년간 잡혀 있지 않았다. 세션이 몰리는 어느 순간 90초를 넘었고, 그때부터 간헐적으로 죽기 시작했다. “가끔 실패하는 크론”의 전형적인 원인이다.
수정은 한 줄이었지만, 고쳐진 건 하나가 아니었다
해결은 단순했다. jobs.json에서 해당 잡에 script_timeout을 명시해주면 된다. 잡의 최악 실행시간(~90초)에 여유를 더한 180초를 줬다.
{
"id": "huly-session-feed",
"schedule": "*/5 * * * *",
"script": "huly_session_feed.py",
"no_agent": true,
"script_timeout": 180
}
스케줄러는 매 tick마다 load_jobs()로 jobs.json을 다시 읽는다. 즉 재시작 없이 즉시 반영된다. 설정 파일을 고치고 프로세스를 껐다 켤 필요가 없다는 건, 에이전트가 스스로 인프라를 운영하는 환경에서는 중요하다.
그런데 이 사고의 진짜 수확은 여기에 있지 않았다. 타임아웃을 고치면서 드러난 부작용이 핵심 교훈이다.
킬(kill) 뒤에 숨어 있던 부메랑: 중복 게시
이 스케줄러는 잡의 실행 상태를 사이클이 끝날 때만 상태 파일(jobs_state.json)에 저장한다. 그런데 타임아웃으로 kill되면 사이클이 끝나기 전에 죽으므로 상태가 저장되지 않는다. 그리고 상태가 없다는 건, 스케줄러가 “이 잡이 아직 안 돌았네”라고 판단해 다음 사이클에 다시 실행한다는 뜻이다.
결과는? 같은 메시지가 두 번 게시된다. 첫 사이클이 30초에서 죽어도 이미 게시된 메시지가 있다. 그런데 상태 기록은 없다. 재시도가 그 메시지를 다시 올린다. 타임아웃 인시던트는 겉보기엔 “잡 하나가 죽은 것”이었지만, 실상은 중복 게시라는 데이터 오염을 동반하는 잠복 버그였다. 60초 기본값을 고쳤더라도 이 문제는 다른 잡에서 재발할 수 있었다.
이것이 타임아웃의 본질이다. 타임아웃은 스케줄러가 잡을 죽이는 방식일 뿐, 잡이 이미 일으킨 사이드 이펙트를 되돌리지 않는다. 게시·쓰기·전송 같은 외부 효과가 있는 잡이라면, “시간 초과 → 재시도”는 곧 “at-least-once” 배달이 된다. 그리고 그 잡이 멱등하지 않으면 중복은 피할 수 없다.
타임아웃은 안전장치가 아니라 잡과의 계약이다
이 사고를 정리하면 세 가지가 잡힌다.
- 기본값은 항상 의심하라.
_SCRIPT_TIMEOUT = 60은 누구도 결정한 적 없는 숫자였지만, 가장 큰 피해를 냈다. “명시하지 않으면”이라는 문법 자체가 계약 회피의 온상이다. 최악 실행시간을 측정하고script_timeout을 명시하는 것을 잡 등록의 기본 원칙으로 삼자. - 평균이 아니라 최악 케이스를 기준으로 삼아라. 평균 40초라도 최악이 90초면 60초 타임아웃은 간헐적 실패를 만든다. 간헐 실패는 재현이 어려워서 가장 고치기 힘든 버그다. (실제로 내 인프라에서 같은 패턴의
huly-ops-feed,huly-server-feed도 타임아웃을 명시해주는 리팩토링이 뒤따랐다.) - kill 이후의 동작을 설계하라. 상태 저장이 사이클 끝에만 있으면, 타임아웃은 중복 실행을 낳는다. 사이드 이펙트 발생 직전에 상태를 저장하거나, 잡을 멱등하게 만들거나, 둘 중 하나를 선택해야 한다. 타임아웃 시간을 늘리는 것만으로는 이 버그는 사라지지 않는다.
흥미로운 대비가 하나 있다. 클라우드 CI/CD 시장의 대표주자 GitHub Actions는 잡의 기본 타임아웃을 360분으로 잡아둔다. 내 스케줄러는 60초, 그들은 6시간. (아래 근거 출처 참고) 어떤 기본값이 “옳다”는 게 아니다. 다만 그 기본값이 잡의 현실과 어긋났을 때, 그 대가를 치르는 것은 코드가 아니라 나 같은 오퍼레이터라는 점은 분명하다.
당신의 jobs.json을 열어보라 — 그리고 시간을 명시하라
이 글을 읽는 동안, 당신의 크론·스케줄러·워커 설정에 이런 줄이 있다면 지금 확인해보자.
- 잡마다 타임아웃이 명시돼 있는가, 아니면 전역 기본값에 기대고 있는가?
- 당신 잡의 최악 실행시간을 재본 적 있는가? 로그에 시작·종료 타임스탬프를 찍고 일주일치 최대값을 보면 10분이면 끝난다.
- 타임아웃으로 죽은 잡이 재시도될 때, 같은 일을 두 번 하지는 않는가? (상태 저장 시점 vs 사이드 이펙트 시점의 순서를 확인)
내 사고는 script_timeout: 180 한 줄로 끝났지만, 그 한 줄은 “스케줄러의 기본값”이라는 제3의 당사자를 인식하고 나서야 가능했다. 사이드 이펙트를 일으키는 크론 잡이라면, 오늘 그 시간을 명시하라. 내 인프라에서 이미 중복 게시를 겪은 사람으로서 말하건대, 중복은 로그로 안 보이는 데이터 오염이라 발견이 제일 늦다. 같은 함정을 본 사람은 어떤 잡에서 만났는지 댓글로 알려달라.
근거 출처
외부 정보(하베스터 수집) — GitHub Actions의 잡 타임아웃 기본값: jobs.<job_id>.timeout-minutes의 기본값은 360분이며 명시하지 않으면 이 값이 적용된다는 것은 GitHub Actions 공식 문서(Workflow syntax for GitHub Actions, “The maximum number of minutes to let a job run before GitHub automatically cancels it. Default: 360”)에서 확인한 사실이다.
상호작용(에이전트-드루 대화·작업) — 본문의 진단과 수치는 전부 2026-08-07 실제 인시던트에서 얻은 경험이다. huly-session-feed(no_agent 스크립트 잡)가 사이클당 최대 20건의 Huly API 호출(건당 ~4.5초)을 순차 수행해 바쁜 사이클이 ~90초에 달했고, cron/scheduler.py의 기본 스크립트 타임아웃 60초(_SCRIPT_TIMEOUT)를 초과해 “Script timed out after 60s”가 발생했다. 수동 실행 시 19개 메시지가 75초 안에 정상 게시(exit 0)된 것, jobs.json에 script_timeout: 180을 추가한 것(기존 컨벤션: gjc-memory-ingest=300, memory-secret-scrub=600, drew-db-embed=7200), 스케줄러가 매 tick load_jobs()로 설정을 재읽어 재시작 없이 반영된 것, 그리고 타임아웃 kill 시 상태 파일이 미저장되어 재시도 시 중복 게시가 발생할 수 있다는 부작용(상태는 사이클 종료 시에만 저장)까지 모두 나와 에이전트의 실제 대화·작업 산출물이다. 같은 패턴이 huly-ops-feed·huly-server-feed에도 적용된 것은 후속 작업 기록 기반이다.