Node.js에서 30일 뒤 실행하도록 등록한 작업이 곧바로 시작된다면, setTimeout에 넘긴 밀리초 값부터 확인한다. 지연값이 2,147,483,647보다 크면 Node.js는 이를 1ms로 바꾼다. 30일은 2,592,000,000ms이므로 이 범위를 넘는다. 숫자를 크게 썼다고 더 오래 기다리는 것은 아니다.
이 글은 Node.js 기반 AI 도구나 일반 서버에서 예약 작업을 구현할 때 생기는 타이머 입력 문제를 다룬다. 특정 MCP SDK의 예약 기능에 관한 설명은 아니다. 공식 근거는 Node.js v24.22.1 문서와 해당 버전 소스이며, 예제는 Linux·Node.js v24.22.1에서 실행했다. 확인일은 2026년 11월 6일 KST다. 모든 예제는 합성 검사이며 외부 요청·발송·파일 삭제를 하지 않는다.
30일을 넣었는데 왜 1ms가 되는가
Node.js v24.22.1의 setTimeout 문서는 delay가 상한보다 크거나, 1보다 작거나, NaN이면 1로 설정한다고 명시한다. 소수 지연값은 정수 부분만 사용한다. 같은 문서는 콜백의 정확한 실행 시각과 실행 순서를 보장하지 않는다고 설명한다. 따라서 “1ms로 설정됐다”와 “정확히 1ms 뒤 실행됐다”를 구분해야 한다.
상한은 약 24.855일이다. 여기서 “일”은 24시간으로 계산한 기간이며, “다음 달 같은 날짜”라는 달력 예약과는 다르다. 30일 계산 자체가 JavaScript의 안전한 정수 범위를 넘은 것도 아니다. 해당 버전의 내부 타이머 소스는 입력을 경계값과 비교한 뒤 범위 밖의 값을 1로 설정한다. 이 동작을 JavaScript의 숫자가 저절로 32비트 오버플로한 결과라고 설명하면 원인을 잘못 짚게 된다.
외부 동작 없이 조기 실행을 재현한다
아래를 overflow-demo.mjs로 저장하고 node overflow-demo.mjs로 실행한다. 콜백은 경과시간만 출력한다. 실제 발송이나 정산 함수를 연결해서 재현하지 않는다. 진단 출력은 stderr로 보내므로 MCP stdio의 프로토콜 출력과도 분리할 수 있다.
import { performance } from 'node:perf_hooks';
const delayMs = 30 * 24 * 60 * 60 * 1000;
const started = performance.now();
console.error({ requestedMs: delayMs });
setTimeout(() => {
console.error({ elapsedMs: Number((performance.now() - started).toFixed(2)) });
}, delayMs);
이 환경에서는 requestedMs: 2592000000과 TimeoutOverflowWarning이 출력됐고, 이어서 지연값이 1로 설정됐다는 경고가 나왔다. 한 번의 실행에서 관측한 elapsedMs는 2.4였다. 이 수치는 성능 기준이나 보장값이 아니다. 여기서 확인한 결과는 30일 예약이 매우 짧은 지연으로 바뀌었다는 것이다. 이벤트 루프가 바쁘면 더 늦게 실행될 수 있다.
조용히 보정하지 말고 입력 경계에서 거부한다
운영 코드에서 경고를 숨기거나 Math.min(delay, MAX_DELAY_MS)로 잘라 버리면 예약 의미가 달라질 수 있다. 30일을 약 24.9일로 줄여 놓고 원래 예약이 보존됐다고 볼 수 없다. 단일 타이머만 허용하는 함수라면 허용 범위를 먼저 정하고, 범위 밖의 입력을 호출자에게 돌려주는 편이 명확하다.
다음은 1부터 상한까지의 정수 밀리초만 받도록 정한 애플리케이션 정책이다. Node.js가 거부하는 입력 전체를 그대로 재현한 함수는 아니다. 문자열과 소수도 의도적으로 거부하며, 즉시 실행이 필요하다면 별도의 명시적 경로를 둔다. delay-guard.mjs로 저장하고 node delay-guard.mjs로 실행한다.
import assert from 'node:assert/strict';
const MAX_DELAY_MS = 2_147_483_647;
function checkedDelayMs(value) {
if (!Number.isInteger(value) || value < 1 || value > MAX_DELAY_MS) {
throw new RangeError('delayMs must be an integer from 1 to 2147483647');
}
return value;
}
for (const value of [1, 1000, MAX_DELAY_MS]) {
assert.equal(checkedDelayMs(value), value);
}
for (const value of [0, -1, 1.5, NaN, Infinity, '1000',
MAX_DELAY_MS + 1, 30 * 24 * 60 * 60 * 1000]) {
assert.throws(() => checkedDelayMs(value), RangeError);
}
console.error('PASS: accepted 3, rejected 8');
실제 실행 결과는 PASS: accepted 3, rejected 8이었다. 상한값은 검증 함수만 통과시켰으며 24.9일 동안 기다리는 타이머를 실행하지 않았다. 이 함수의 반환값을 setTimeout(callback, checkedDelayMs(delayMs))처럼 사용하면 범위를 벗어난 지연값으로 타이머가 등록되는 것을 막을 수 있다. 콜백의 오류 처리와 업무 완료 확인은 별도로 필요하다.
이 검사는 단위를 알아내 주지는 않는다. 30초를 뜻하며 숫자 30을 넘겨도 유효한 30ms로 통과한다. 입력 필드를 delayMs처럼 구분하고, 초·분 단위 설정은 밀리초로 변환한 뒤 같은 검사를 적용한다. 환경 변수의 문자열을 숫자로 바꾸는 과정에서도 빈 문자열, 단위가 섞인 값, 허용 범위를 따로 정해야 한다.
장기 예약은 실행 시각과 복구 방법을 함께 저장한다
장기 예약이 제품 요구라면 큰 숫자를 단일 타이머에 넣는 대신 예약 레코드와 실행기를 분리하는 설계를 검토한다. 아래는 이 입력 제한에서 출발한 설계 제안이며, 특정 큐 제품의 기능이나 정확히 한 번 실행을 보장한다는 설명은 아니다.
예약 레코드: 작업 ID, 목표 시각, 상태와 재시도 정보를 영속 저장한다. “30×24시간 뒤”인지 “특정 시간대의 다음 달 같은 날”인지 먼저 정한다. 후자라면 달력·시간대 규칙을 반영해 목표 시각을 계산해야 한다.
실행기: 저장된 목표 시각과 현재 시각을 비교해 실행할 작업을 고른다. 가까운 시각에 다시 확인하기 위해 범위 안의 타이머를 사용할 수 있지만, 깨어날 때 목표 시각을 다시 검사해야 한다. 긴 대기를 여러 타이머로 나누더라도 프로세스가 재시작되면 메모리의 예약만으로는 복구할 수 없다.
복구와 중복: 실행기 여러 개가 같은 작업을 집지 않도록 원자적 선점 등 동시 실행 제어를 설계한다. 작업 도중 프로세스가 종료되면 “실행되지 않음”과 “실행됐지만 완료 기록을 남기지 못함”을 구분하기 어려울 수 있다. 재시도에는 멱등성 키나 실제 결과 확인이 필요하다. 큐에 넣었다는 사실만으로 중복 실행 문제가 끝나지는 않는다.
시스템 시계 변경, 이미 지난 예약, 취소, 재시작 뒤 밀린 작업을 어떻게 처리할지도 정책에 포함한다. 경과시간을 재는 실험에서는 위 예제처럼 performance.now()를 쓸 수 있지만, 그 프로세스의 상대 시간을 영속적인 달력 예약 시각 대신 저장해서는 안 된다.
수정 뒤 확인할 것은 실행 횟수와 예약 의미다
최소 회귀 검사는 상한 바로 아래·상한·상한 바로 위, 30일, 잘못된 단위와 비정상 입력을 포함해야 한다. 잘못된 지연값에서 외부 작업이 실행되지 않는지 확인하고, 장기 예약을 별도 실행기로 옮겼다면 재시작·취소·동시 선점·결과 미확인 상태도 검사한다. 여기서 실행한 것은 조기 실행 예제와 입력 검증 11건뿐이며, 영속 큐·운영 서비스·30일 대기는 검증하지 않았다.
타이머가 프로세스를 계속 살려 두는 문제는 Node.js 타이머와 종료 조건 진단에서 다룬다. 이번 글의 핵심은 타이머 수명이 아니라 입력한 지연값이 의도한 예약을 표현하는지 확인하는 데 있다. 짧은 대기의 범위 검사와 장기 예약의 저장·복구 책임을 나누면, 숫자 하나의 보정이 실제 업무를 앞당기는 사고를 막기 쉬워진다.