본문으로 건너뛰기
개발 머꼬
개발 노트Node.js
hohyeon.dev2

30일 뒤에 보낼 메일을 setTimeout으로 걸었더니 바로 발송된 이유

  • #Common Pitfall
  • #Engineering Note
  • #Node.js

문제 발생

체험 기간이 끝나기 하루 전에 안내 메일을 보내려고, 가입하는 시점에 타이머를 걸어 두는 코드였습니다.

const delay = trialEndsAt.getTime() - Date.now() - ONE_DAY;
setTimeout(() => sendTrialReminder(user.id), delay);

7일 체험 상품으로 테스트할 때는 6일 뒤에 정확히 나갔습니다. 그런데 30일 체험 상품을 열자마자 방금 가입한 사람에게 "내일 체험이 끝납니다" 메일이 바로 나갔습니다. 에러 로그는 한 줄도 없었습니다.

원인 분석

setTimeout의 지연 시간에는 상한이 있습니다. Node.js 문서의 setTimeout 항목에 한 줄로 적혀 있습니다 — delay가 2147483647보다 크거나, 1보다 작거나, NaN이면 delay는 1로 설정됩니다. 2147483647ms는 약 24.8일입니다. 30일 체험이면 지연 값이 29일, 2,505,600,000ms라 그 선을 넘습니다. 그러니 1ms 뒤에 실행된 겁니다. 7일 체험은 6일, 518,400,000ms라 한참 아래였고요.

MDN도 같은 이야기를 합니다. 지연 값은 부호 있는 32비트 정수로 변환되기 때문에 2147483647ms, 대략 24.8일이 한계이고, 그보다 크면 정수 오버플로가 일어난다고요. 그리고 Node.js에서는 이 값보다 큰 타임아웃이 즉시 실행된다고 따로 적어 둡니다. 브라우저에서는 넘친 값에 따라 바로 실행되기도 하고, 엉뚱하게 몇 초 뒤에 실행되기도 해서 더 헷갈립니다.

에러도 경고도 없이 "조금 빨리" 실행될 뿐이라, 짧은 기간으로만 테스트했다면 잡을 방법이 없었습니다.

해결 방안

  1. 며칠 단위 일정은 타이머가 아니라 DB에 둡니다. 프로세스 메모리에 걸어 둔 타이머는 상한 문제가 없더라도 재배포 한 번에 사라집니다. 발송 예정 시각을 저장해 두고, 주기적으로 도는 작업이 지금 보낼 것만 꺼내서 보냅니다.
INSERT INTO scheduled_mails (user_id, kind, send_at) VALUES (?, 'trial_reminder', ?);
  1. 지연 값을 넘기기 전에 검사합니다. 상한을 넘는 값뿐 아니라 음수나 NaN도 조용히 1로 바뀝니다. 날짜 계산이 한 번 꼬여서 NaN이 들어가도 바로 실행돼 버립니다.
const MAX_DELAY = 2 ** 31 - 1;

if (!Number.isFinite(delay) || delay < 0 || delay > MAX_DELAY) {
  throw new RangeError(`invalid delay: ${delay}`);
}
  1. 꼭 타이머로 기다려야 한다면 나눠서 겁니다. 상한보다 길면 상한만큼 기다린 뒤 남은 시간으로 다시 겁니다.
function setLongTimeout(fn, delay) {
  if (delay > MAX_DELAY) {
    return setTimeout(() => setLongTimeout(fn, delay - MAX_DELAY), MAX_DELAY);
  }
  return setTimeout(fn, delay);
}

다만 단계가 넘어갈 때마다 타이머가 새로 만들어져서, 처음 받은 핸들로는 clearTimeout을 할 수 없습니다. 취소가 필요한 일정이라면 1번이 맞습니다.

  1. setInterval도 똑같습니다. 문서의 setInterval 항목에도 같은 문장이 있습니다. 한 달 주기 작업을 setInterval로 걸면 한 달마다가 아니라 1ms마다 돕니다.

  2. 25일이 넘는 경우를 테스트에 하나 넣어 둡니다. 이번에도 7일짜리만 확인하고 넘어갔던 게 문제였습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.