본문으로 건너뛰기
기술스택 · stack/javascript/타이머: setTimeout · setInterval과 정확하지 않은 시간

JavaScript · 6장 함수 심화

타이머: setTimeout · setInterval과 정확하지 않은 시간

"3초 뒤에 알림 닫기", "5초마다 새 글 확인", "남은 시간 카운트다운"은 모두 타이머로 만들어요. 쓰는 법은 간단하지만, 타이머의 시간은 약속이 아니라 최소한의 기다림이에요. 이 차이를 모르면 멈추지 않는 반복, 밀리는 카운트다운 같은 버그를 만나요. 실제로 얼마나 밀리는지 직접 재 보면서 정리해요.

#6.5

01. setTimeout: 정해진 시간 뒤에 한 번

setTimeout(함수, 밀리초)는 그 시간이 지난 뒤 함수를 한 번 불러요. 돌려주는 값은 타이머 번호이고, 실행 전에 clearTimeout(번호)로 취소할 수 있어요. 함수에 넘길 인자는 세 번째 자리부터 써요.

console.log('1. 예약해요');

const id = setTimeout((name) => {
  console.log(`3. ${name}님, 알림을 닫을게요`);
}, 100, '하늘');

const cancelled = setTimeout(() => console.log('이건 안 찍혀요'), 50);
clearTimeout(cancelled);                 // 실행 전에 취소

setTimeout(() => console.log('2. 0ms 타이머'), 0);
console.log('1-2. 지금 코드가 먼저 끝나요');

0ms로 예약해도 바로 실행되지 않고, 지금 돌고 있는 코드가 다 끝난 뒤에 실행돼요. 타이머는 "시간이 되면 실행할 일 목록"에 함수를 올려 둘 뿐이고, 자바스크립트는 하던 일을 마친 다음에야 목록을 들여다보기 때문이에요. 이 목록과 순서 규칙(이벤트 루프)은 11장에서 자세히 봐요.

02. setInterval: 반복하고, 꼭 멈추기

setInterval(함수, 밀리초)는 그 간격마다 함수를 계속 불러요. 스스로 멈추지 않아요. 멈출 조건이 되면 clearInterval(번호)를 불러야 하고, 화면이 사라지거나 모달이 닫힐 때도 정리해야 해요. 멈추지 않은 타이머는 그 함수가 기억하는 값까지 붙잡아 메모리 누수가 돼요(정리하는 습관).

let checks = 0;
const poll = setInterval(() => {
  checks++;
  console.log(`새 글 확인 ${checks}번째`);
  if (checks === 3) clearInterval(poll); // 세 번 하고 멈춰요
}, 50);

03. 타이머가 정확하지 않은 네 가지 이유

타이머에 준 시간은 "이 시간 전에는 실행하지 않는다"는 뜻이에요. 실제로는 이보다 늦어질 수 있고, 늦어지는 이유는 네 가지예요.

  • 지금 코드가 안 끝났어요. 메인 스레드가 무거운 계산으로 바쁘면, 시간이 돼도 계산이 끝날 때까지 기다려요(메인 스레드).
  • 타이머 안에서 또 타이머를 걸면 다섯 단계를 넘어서부터는 최소 4ms씩 기다려요. 0ms로 끝없이 반복해 브라우저를 멈추지 못하게 하려는 규칙이에요.
  • 보이지 않는 탭에서는 배터리를 아끼려고 타이머를 1초에 한 번 이하로 줄여요. 오래 숨겨 둔 탭은 더 줄이기도 해요.
  • setInterval은 밀린 걸 따라잡지 않아요. 함수가 오래 걸리거나 탭이 숨겨졌다 돌아오면, 놓친 회차는 그냥 건너뛰어요. 그래서 "몇 번 불렸는지"로 시간을 세면 틀려요.
const start = Date.now();
setTimeout(() => {
  const waited = Date.now() - start;
  console.log('50ms를 부탁했는데 300ms 넘게 기다렸나요?', waited >= 300);
}, 50);

// 메인 스레드를 300ms 동안 막아요
const until = Date.now() + 300;
while (Date.now() < until) {}

버튼을 누르면 이 페이지에서 타이머를 직접 걸고 performance.now()로 실제 기다린 시간을 재요. 결과는 기기와 브라우저 상태에 따라 매번 조금씩 달라요.

04. 실무 패턴: 재귀 setTimeout과 시간 기준 계산

요청이 끝난 뒤 다음을 예약해요. 서버에 주기적으로 묻는 폴링을 setInterval로 하면, 응답이 느릴 때 요청이 겹쳐 쌓여요. 할 일을 마친 뒤에 setTimeout으로 다음 회차를 예약하면 겹치지 않고, 실패하면 간격을 늘리는 것도 쉬워요.

// 브라우저에서: 응답이 오고 나서 5초 뒤에 다시 물어요 (이 블록은 실행되지 않아요)
async function poll() {
  try {
    const res = await fetch('/api/notifications');
    render(await res.json());
  } finally {
    timer = setTimeout(poll, 5000);
  }
}
let timer = setTimeout(poll, 0);
// 화면을 떠날 때: clearTimeout(timer);

남은 시간은 목표 시각에서 계산해요. 카운트다운을 "1초마다 1씩 빼기"로 만들면, 타이머가 밀리거나 탭이 숨겨졌을 때 실제 시간과 어긋나요. 끝나는 시각을 정해 두고, 매번 지금 시각과의 차이를 계산하면 몇 번 불렸든 늘 정확해요.

const endAt = Date.now() + 300;            // 0.3초 뒤에 끝나는 세일이라고 해요
const tick = setInterval(() => {
  const left = Math.max(0, endAt - Date.now());
  if (left === 0) {
    console.log('세일 끝');
    clearInterval(tick);
  }
}, 50);
// 남은 시간 = 목표 시각 − 지금. 몇 번 불렸는지와 상관없어요.

3, 2, 1을 세고 "시작!"을 한 번 찍어야 하는데, "시작!"이 계속 찍혀요. 고쳐서 3, 2, 1, 시작! 네 줄만 나오게 해 보세요.

let left = 3;
const id = setInterval(() => {
  if (left > 0) {
    console.log(left);
  } else {
    console.log('시작!');
  }
  left--;
}, 50);
해답 보기

setInterval은 스스로 멈추지 않아요. "시작!"을 찍는 순간 clearInterval(id)로 멈춰요.

let left = 3;
const id = setInterval(() => {
  if (left > 0) {
    console.log(left);
    left--;
  } else {
    console.log('시작!');
    clearInterval(id);
  }
}, 50);

05. 실전 포인트

  • 언제 쓰나요? 알림 자동 닫기 · 지연 표시는 setTimeout, 폴링은 할 일이 끝난 뒤 다시 거는 재귀 setTimeout, 짧은 주기의 화면 갱신은 setInterval을 쓰고, 만든 타이머 번호는 늘 저장해 두었다가 정리해요.
  • 대신 쓰는 것: 애니메이션은 화면 그리기에 맞춰 불리는 requestAnimationFrame이나 CSS 애니메이션, 무거운 계산은 Web Worker, 입력이 멈출 때까지 기다리기는 디바운스(디바운스와 스로틀)를 써요.
  • 흔한 실수: clearInterval을 잊는 것, 타이머가 정확하다고 믿고 횟수로 시간을 세는 것, 화면을 떠날 때 타이머를 정리하지 않는 것, setTimeout(fn(), 1000)처럼 함수를 바로 불러 결과를 넘기는 것이에요.

정리

  • setTimeout은 한 번, setInterval은 반복이에요. 둘 다 번호를 돌려주고 clear…로 취소해요.
  • 타이머 시간은 최소한의 기다림이에요. 지금 코드가 끝난 뒤에야 실행돼요.
  • 메인 스레드가 바쁘거나, 중첩되거나, 탭이 숨겨지면 더 늦어져요.
  • 폴링은 재귀 setTimeout, 남은 시간은 목표 시각에서 계산해요.
setTimeout(f, 0) 바로 다음 줄의 console.log와 f 중 무엇이 먼저 실행될까요?

다음 줄의 console.log예요. 타이머 함수는 지금 돌고 있는 코드가 모두 끝난 뒤에야 실행돼요.

탭을 숨겼다가 돌아오니 1초마다 1씩 줄던 카운트다운이 실제 시간과 어긋났어요. 이유와 대처법은?

숨겨진 탭에서는 타이머가 덜 자주 불려서, 불린 횟수만큼만 줄었기 때문이에요. 끝나는 시각을 정해 두고 매번 "끝 시각 − 지금"으로 남은 시간을 계산해요.

폴링에 setInterval보다 재귀 setTimeout을 권하는 이유는?

setInterval은 이전 요청이 끝났는지 상관없이 다음을 시작해서, 응답이 느리면 요청이 겹쳐요. 재귀 setTimeout은 할 일을 마친 뒤 다음을 예약해서 겹치지 않고, 간격도 상황에 맞게 바꿀 수 있어요.

SIMPLE · 기술스택 / JavaScript — 6장 함수 심화