node-schedule RecurrenceRule.year를 string으로 넘기면 무한 루프에 빠지는 이슈
`RecurrenceRule.year`를 문자열로 넘기면 `_nextInvocationDate`의 escape condition이 동작하지 않아 무한 루프에 빠지는 문제와 원인 분석.
발단
스케줄 기반 작업 제어에 자주 쓰는 node-schedule을 사용하던 중, RecurrenceRule.year에 문자열을 할당했을 때 서버가 응답 없이 멈추는 현상을 마주쳤습니다. 같은 값이라도 숫자로 넘기면 정상 동작하는데, 외부 입력을 그대로 전달하다 보니 의도치 않게 문자열이 들어가는 케이스가 있었습니다.
이슈는 node-schedule#756으로 등록해 두었습니다.
재현 코드
const schedule = require('node-schedule')
const rule = new schedule.RecurrenceRule()
rule.year = '2026' // ❌ 문자열을 넘기면 무한 루프
rule.month = 0
rule.date = 1
rule.hour = 0
rule.minute = 0
rule.second = 0
schedule.scheduleJob(rule, () => {
console.log('Happy New Year 2026!')
})
이 코드를 실행하면 콜백이 등록되지 않고 이벤트 루프가 멈춘 것처럼 응답이 사라집니다. CPU가 100% 가까이 치솟고 stack을 떠 보면 같은 함수 내부에서 빠져나오지 못한다는 사실을 확인할 수 있습니다.
원인
문제 지점은 lib/Invocation.js 151번째 줄입니다.
// lib/Invocation.js:151
if ((typeof this.year == 'number') && (this.year < fullYear)) {
return null
}
다음 invocation을 계산하는 _nextInvocationDate 함수는 위 escape condition으로 루프를 종료합니다. 그런데 비교문이 typeof this.year == 'number' 라서, this.year가 '2026' 같은 문자열이면 조건이 항상 false가 되어 종료 분기를 절대 타지 않습니다.
결국 fullYear를 증가시키며 다음 실행 시점을 계산하는 루프가 종료 조건을 만나지 못한 채 영원히 도는 상태가 됩니다.
우회 방법
라이브러리 수정 전까지는 호출 측에서 명시적으로 숫자로 변환하면 됩니다.
rule.year = Number(rawYear)
// 혹은
rule.year = parseInt(rawYear, 10)
외부 입력을 받는 경로라면 단순한 Number(...) 한 단계로 끝내기보다는 Number.isFinite(value) && Number.isInteger(value) 정도의 가드를 함께 두는 편이 안전합니다.
제안한 수정안
이슈에 함께 적어 둔 수정 방향은 두 가지입니다.
1. setter 또는 validation 단계에서 문자열을 숫자로 강제 변환
if (typeof this.year === 'string') {
this.year = parseInt(this.year, 10)
}
2. escape condition 자체에서 양쪽 타입을 모두 허용
const yearValue = Number(this.year)
if (!Number.isNaN(yearValue) && yearValue < fullYear) {
return null
}
타입 강제 변환은 좀 더 명시적이지만, 1.x/2.x 호환을 고려하면 escape condition 쪽을 보강하는 편이 영향 범위가 작습니다.
직접 만든 수정 PR (#762)
버그가 여전히 master에 남아 있어, 이슈를 보고한 입장에서 직접 수정 PR을 만들었습니다 → PR #762. 이슈(#756) 보고자가 자기가 올린 이슈를 직접 고치는 자연스러운 흐름입니다.
무엇을 했나
numericYear() 헬퍼로 정규화 로직을 한곳에 모으고, 회귀 테스트를 함께 추가했습니다.
// 숫자 / 숫자형 문자열만 number로 정규화, 그 외(Range/Array/빈값/NaN)는 null
function numericYear(year) {
if (typeof year === 'number') {
return year
}
if (typeof year === 'string' && year.trim() !== '') {
const parsed = Number(year)
return Number.isNaN(parsed) ? null : parsed
}
return null
}
// 두 escape condition에서 동일하게 사용
const scalarYear = numericYear(this.year)
if ((scalarYear !== null) && (scalarYear < fullYear)) {
return null
}
설계 의도는 다음과 같습니다.
this.year를 덮어쓰지 않습니다 → 호출자가 넘긴 객체에 부수효과가 없습니다.Number('abc')같은 잘못된 입력은NaN이 되는데, 이를null로 흡수해 guard에서 안전하게 무시합니다.Range/Array형태의year는 건드리지 않고 기존recurMatch에 그대로 위임합니다.
테스트는 두 가지를 추가했습니다.
// 과거 연도를 문자열로 → null 반환 (이전에는 무한 루프)
rule.year = '2000'
// nextInvocationDate(base) === null
// 미래 연도를 문자열로 → 정상적으로 해당 날짜 계산
rule.year = '2040'
// nextInvocationDate(base) === new Date(2040, 0, 1, 0, 0, 0, 0)
npm test는 284/284 통과, npm run lint도 클린이며, 무한 루프 없이 정상 종료되는 것을 확인했습니다.
현재 상태
PR #762는 Fixes #756으로 연결되어 OPEN(리뷰 대기) 상태입니다. 머지되기 전까지는 여전히 호출 측 숫자 변환(위의 우회 방법)을 유지하는 편이 안전합니다.
정리
- node-schedule v2.1.1 기준,
RecurrenceRule.year에 문자열을 넣으면 무한 루프에 진입해 프로세스가 멈춥니다. - 원인은 escape condition이
typeof === 'number'로 좁게 잡혀 있는 것입니다. - 이슈(#756)를 보고한 뒤,
numericYear()헬퍼 + 회귀 테스트를 포함한 수정 PR #762를 직접 제출했고 현재 OPEN 상태입니다. - 따라서 라이브러리 패치(머지) 전까지는 호출 시점에 반드시 정수형 숫자로 변환해 넘겨야 합니다.
month,date,hour등 다른 RecurrenceRule 필드도 동일한 패턴이 없는지 입력 검증 단계에서 같이 점검하는 것이 좋습니다.
관련 링크
- 원본 이슈: node-schedule/node-schedule#756
- 직접 올린 수정 PR: node-schedule/node-schedule#762
- 유사 사례: node-schedule/node-schedule#495
댓글 0
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!