들어가며

주기적으로 어떤 작업을 반복할 때 보통 두 가지 방법을 사용한다.

// 방법 1: setInterval
setInterval(() => doSomething(), 1000);
 
// 방법 2: setTimeout 재귀
function loop() {
  doSomething();
  setTimeout(loop, 1000);
}
loop();

두 방식 모두 1초마다 doSomething을 실행하는 것처럼 보인다.

하지만 다음 실행 시점이 언제 결정되는가에서 중요한 차이가 있다. 이 차이는 비동기 작업에선 race condition을 만들고, React에선 stale closure 문제로 이어질 수 있다.


1. 기본 동작 차이

setInterval: 시간 기준으로 반복 예약

setInterval은 콜백 실행 여부와 무관하게 지정한 시간마다 Task Queue에 콜백을 추가한다.

setInterval(() => {
  console.log("tick");
}, 1000);

브라우저 내부 동작을 개념적으로 표현하면 이렇다.

// 브라우저 내부 동작 (개념적 표현)
while (!cleared) {
  wait(interval); // interval = 1000ms
  taskQueue.enqueue(callback); // 실행 중인 콜백과 무관하게 추가
}

콜백이 실행 중이든, Task Queue에 이미 작업이 쌓여 있든 상관없이 지정한 시간마다 새로운 콜백이 추가된다. 이처럼 taskQueue.enqueue(callback)은 현재 콜백 상태를 고려하지 않는다.

setTimeout 재귀: 작업 기준으로 다음 예약

function loop() {
  console.log("tick");
  setTimeout(loop, 1000); // 현재 실행이 끝나야 이 줄에 도달한다
}
loop();

핵심은 setTimeout(loop, 1000)현재 작업이 끝난 뒤에 실행된다는 점이다. loop() 실행이 끝나야 타이머가 등록되고, 그로부터 1000ms 후에 다음 실행이 Task Queue에 들어온다.

현재 작업이 끝나기 전에는 다음 예약 자체가 일어나지 않는다.

정리하면 다음과 같다.

  • setInterval → 시간 기준 scheduling
  • setTimeout 재귀 → 작업 기준 scheduling

2. 이벤트 루프 레벨에서 보기

setTimeout과 setInterval은 JS 엔진이 아니다

setTimeout, setInterval브라우저 Web API다.
JS 엔진(V8)은 이 함수를 직접 실행하지 않고 브라우저의 타이머 시스템에 전달한다.
타이머가 등록되면 JS 엔진은 결과를 기다리지 않고 즉시 다음 코드를 계속 실행한다.

동작 흐름은 다음과 같다.

  1. JS 엔진이 타이머를 브라우저 Web API에 등록
  2. 브라우저가 지정한 시간을 측정
  3. 시간이 되면 callback을 Task Queue에 enqueue
  4. Event Loop가 Call Stack이 비어있을 때 Task Queue에서 콜백을 꺼내 실행

여기서 중요한 점은 타이머가 시간이 되면 실행되는 것이 아니라 Task Queue에 등록된다는 것이다. 따라서 정확한 실행 시간이 보장되지 않는다.

setTimeout(fn, 1000);
// 정확히 1000ms에 실행되는 것이 아니라
// 최소 1000ms 이후, Call Stack이 비어야 실행

setInterval의 overlap 문제

setInterval은 실행 여부와 관계없이 주기마다 Task Queue에 작업을 추가한다. 이미 대기 중인 작업이 있어도 인터벌은 멈추지 않는다.

실행 시간이 인터벌보다 길어지면 이전 작업이 아직 끝나지 않았는데도 새로운 작업이 계속 큐에 쌓인다.

JS는 싱글 스레드이므로 작업이 실제로 동시에 실행되지는 않는다. 하지만 이전 실행이 끝나는 순간 대기 중이던 작업들이 연달아 실행된다.

이 현상을 interval backlog 또는 interval overlap이라고 한다.

async 콜백을 사용하면 이 문제가 더 쉽게 나타난다. async 콜백은 await를 만나는 순간 Promise를 반환하고 함수 실행을 빠져나온다.

JS 입장에서는 콜백 실행이 끝난 것처럼 보이기 때문에 이전 비동기 작업이 아직 진행 중이어도 다음 interval 콜백이 실행될 수 있다.

setInterval vs setTimeout 재귀 — 실제 동작 비교

두 시나리오 모두 다음 조건을 가정한다.

  • fetch('/api') 응답 시간: 1.5초
  • 타이머 간격: 1초
// setInterval 방식 — 데모의 왼쪽 탭
setInterval(async function poll() {
  const data = await fetch("/api");
  setState(data);
}, 1000);
 
// setTimeout 재귀 방식 — 데모의 오른쪽 탭
async function poll() {
  const data = await fetch("/api");
  setState(data);
  setTimeout(poll, 1000);
}
poll();

fetch('/api') 응답 시간: 1.5s | 타이머 간격: 1s

t = 0.0s
JS 엔진이 setInterval을 호출합니다. 이 호출은 Web API에 전달되고, JS 엔진은 즉시 다음 코드로 넘어갑니다.
Call Stack
setInterval(poll, 1000)
Web APIs
Task Queue
비어있음
Event LoopTask Queue 대기 중...
1 / 9

실제 브라우저에서는 Promise continuation이 Microtask Queue에서 실행되지만, 이 글에서는 설명을 단순화하기 위해 Task Queue로 표현했다.

작업이 오래 걸릴 때 차이

인터벌: 1000ms | 작업 시간: 200ms — 작업이 인터벌보다 짧아 안전합니다

setInterval
시작 버튼을 눌러주세요
setTimeout 재귀항상 1개 ✓
시작 버튼을 눌러주세요

빠른 작업(200ms) 모드에서는 두 방법 모두 문제없다. 작업(200ms)이 인터벌(1000ms)보다 짧아서 setInterval도 겹치지 않는다.

느린 작업(1500ms) 모드에서 차이가 드러난다. setInterval은 1초마다 새 작업을 시작하므로 이전 작업과 겹친다. setTimeout 재귀는 작업이 끝난 뒤 1초를 기다리므로 항상 순차 실행한다.

실제 실행 간격을 비교하면 이렇다.

setIntervalsetTimeout 재귀
작업 시간 < 인터벌≈ 인터벌작업시간 + 인터벌
작업 시간 > 인터벌overlap 발생작업시간 + 인터벌

3. polling에서 setInterval이 위험한 이유

지금까지 본 차이는 실제 서비스 코드에서도 그대로 나타난다.
특히 네트워크 요청을 일정 간격으로 반복하는 polling에서 문제가 된다.

// 위험한 패턴
setInterval(async () => {
  const data = await fetch("/api/data");
  setState(data); // 어떤 응답이 마지막인지 알 수 없다
}, 1000);

앞에서 본 것처럼 async 콜백은 await 이후 Promise를 반환하고 곧바로 실행을 빠져나온다. 그래서 fetch 요청이 아직 진행 중이어도 다음 interval 콜백이 실행될 수 있다.

서버 응답이 2초 걸린다면 위 코드에서는 동시에 두 개의 요청이 진행되는 상태가 반복된다.

문제는 응답이 항상 요청 순서대로 도착하지 않는다는 점이다. 두 번째 요청이 먼저 완료되어 UI를 업데이트했는데, 뒤늦게 도착한 첫 번째 응답이 그것을 덮어쓸 수 있다. 더 나중에 보낸 요청의 응답이 화면에 최종 반영된다는 보장이 없다.

더 심각한 상황도 있다. 요청이 계속 쌓이면 서버 부하가 높아지고, 응답이 더 느려진다. 느려질수록 더 많은 요청이 쌓인다. 컴포넌트가 언마운트된 후에도 진행 중인 요청의 응답이 도착하면 이미 없어진 컴포넌트의 state를 업데이트하려 할 수 있다.

// 안전한 패턴
async function poll() {
  const data = await fetch("/api/data"); // 응답을 기다린다
  setState(data); // 항상 하나의 요청만 처리 중
  setTimeout(poll, 1000); // 응답이 온 후에야 다음 예약
}
poll();

이 구조는 항상 이전 요청이 완료된 뒤에 다음 요청을 보낸다. 동시 요청이 1개를 넘지 않으며 응답 순서가 뒤집히는 문제도 발생하지 않는다.

fetch를 취소해야 하는 경우라면 AbortController를 함께 쓴다. setTimeout 재귀 패턴에서는 cancelled 플래그로 다음 예약을 막을 수 있어 cleanup도 단순하다.


4. React에서 setInterval이 더 위험한 이유

React에서는 stale closure 문제가 추가로 발생한다. 이는 setInterval 자체의 문제가 아니라 React의 렌더링 모델과 JavaScript 클로저가 충돌하면서 생기는 문제다.

stale closure가 생기는 과정

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    setInterval(() => {
      console.log(count); // 항상 0이 찍힌다
    }, 1000);
  }, []);
 
  return <button onClick={() => setCount(count + 1)}>+</button>;
}

위 코드에서는 버튼을 눌러 count 값이 증가해도 콘솔에는 계속 0이 출력된다.

그 이유를 이해하려면 먼저 React 함수 컴포넌트의 렌더링 방식을 알아야 한다. React에서 함수 컴포넌트는 렌더링될 때마다 컴포넌트 함수가 다시 실행되고, 그때마다 새로운 렉시컬 환경이 만들어진다. 즉, 매 렌더는 이전 렌더와 별개의 실행 컨텍스트를 가진다. 첫 번째 렌더에서는 count = 0인 상태로 컴포넌트 함수가 실행된다.

이후 useEffect가 실행되면서 setInterval이 등록된다. 이때 interval의 콜백 함수는 첫 렌더 시점의 렉시컬 환경을 캡처한다. JavaScript에서 클로저는 함수가 생성될 당시의 스코프를 기억하기 때문에, 이 콜백은 count = 0을 참조하는 클로저로 만들어진다.

버튼을 클릭하면 setCount(1)이 호출되고 React는 새로운 렌더를 수행한다. 이 렌더에서는 count 값이 1이다. 하지만 useEffect의 dependency 배열이 []로 비어 있기 때문에 effect는 다시 실행되지 않는다. 여기서 중요한 점은 []setInterval의 실행 여부를 제어하는 것이 아니라 effect의 재실행 여부를 결정한다는 것이다.

결과적으로 setInterval은 컴포넌트가 마운트될 때 한 번만 등록되고 이후에는 교체되지 않는다. 컴포넌트가 여러 번 다시 렌더링되더라도 처음 등록된 interval은 그대로 동작하며 1초마다 계속 콜백을 실행한다. 그리고 그 콜백은 첫 렌더에서 캡처한 count = 0을 계속 참조하게 된다.

이처럼 클로저가 최신 값이 아니라 과거 렌더에서 캡처한 오래된 값을 계속 참조하는 현상stale closure라고 한다.

setInterval에서 특히 자주 보일까

stale closure는 setInterval만의 문제는 아니다. 오래 유지되는 callback이라면 이벤트 리스너나 WebSocket 콜백처럼 다른 상황에서도 동일하게 발생할 수 있다.

다만 setInterval에서는 interval을 컴포넌트가 마운트될 때 한 번만 등록하는 패턴이 많기 때문에 이 문제가 특히 자주 나타난다. useEffect(() => {}, []) 패턴에서는 effect가 이후 렌더에서 다시 실행되지 않기 때문에 interval에 등록된 콜백도 교체되지 않는다.

반면 React는 렌더링될 때마다 컴포넌트 함수가 다시 실행되면서 새로운 렉시컬 환경과 클로저를 만든다. 이 두 조건이 겹치면 렌더에서는 최신 state를 사용하지만 interval 콜백은 초기 렌더의 값을 계속 참조하는 상태가 되고, 그 결과 stale closure 문제가 발생한다.

해결 방법

stale closure 문제는 오래 살아있는 callback이 최신 state를 참조하지 못할 때 발생한다. 따라서 해결 방법은 크게 세 가지 방향으로 나눌 수 있다.

  1. state를 직접 읽지 않도록 구조를 바꾼다
  2. 최신 값을 참조할 수 있는 별도 저장소(ref)를 사용한다
  3. interval 자체를 추상화하거나 구조를 변경한다

방법 1: functional update

state 값을 직접 읽지 않고 업데이터 함수를 사용한다.

setInterval(() => {
  setCount((c) => c + 1); // React가 최신 state를 인자로 넣어준다
}, 1000);

c => c + 1은 “현재 값에서 +1 해 달라”는 요청이다. React는 updater 함수에 항상 최신 state를 전달하기 때문에 callback이 state 값을 직접 읽을 필요가 없다. 따라서 stale closure 문제도 발생하지 않는다.

다만 이 방법은 state 값을 직접 읽어야 하는 로직에는 사용할 수 없다.

console.log(count);

이런 경우에는 최신 값을 따로 참조할 수 있는 방법이 필요하다.

방법 2: useRef

최신 값을 ref에 동기화하면 callback이 항상 최신 값을 읽을 수 있다.

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
 
  useEffect(() => {
    countRef.current = count;
  }, [count]);
 
  useEffect(() => {
    setInterval(() => {
      console.log(countRef.current);
    }, 1000);
  }, []);
}

setInterval 콜백은 countRef 객체 자체를 캡처한다. ref 객체의 참조는 변하지 않기 때문에 .current 값을 통해 항상 최신 state를 읽을 수 있다.

방법 3: useInterval custom hook

앞에서 본 문제는 interval 콜백이 오래된 클로저를 참조한다는 것이었다. 이를 해결하는 한 가지 방법은 interval은 그대로 유지하고 실행할 callback만 최신 값으로 교체하는 것이다.

이 방식은 polling을 구현하는 라이브러리에서도 자주 사용된다. 예를 들어 TanStack QueryrefetchInterval 옵션을 통해 일정 주기마다 데이터를 다시 가져오는 polling 기능을 제공한다.

이 패턴을 React hook으로 일반화하면 다음과 같이 구현할 수 있다.

function useInterval(callback, delay) {
  const savedCallback = useRef();
 
  // 최신 callback을 ref에 저장
  useEffect(() => {
    savedCallback.current = callback;
  });
 
  // delay가 바뀔 때만 interval 재생성
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
 
    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}
 
// 사용 예시
function Counter() {
  const [count, setCount] = useState(0);
 
  useInterval(() => {
    console.log(count); // 항상 최신값
  }, 1000);
}

interval 자체는 유지하면서 매 렌더마다 최신 callback을 ref에 저장해 실행 시점에 호출한다.

React에서 더 안전한 패턴: recursive setTimeout + cleanup

비동기 작업이 포함된 경우라면 재귀 setTimeout 패턴이 더 안전한 경우가 많다.

useEffect(() => {
  let cancelled = false;
 
  async function loop() {
    await fetchData();
 
    if (!cancelled) {
      setTimeout(loop, 1000);
    }
  }
 
  loop();
 
  return () => {
    cancelled = true;
  };
}, []);

cancelled 플래그를 사용하면 컴포넌트가 언마운트된 이후 다음 setTimeout 예약을 막을 수 있다.

clearIntervalclearTimeout이미 Task Queue에 들어간 콜백을 취소하지 못한다. 이미 큐에 들어간 콜백 한 번은 실행될 수 있지만, 그 안에서 다음 setTimeout이 예약되지 않기 때문에 루프는 자연스럽게 종료된다.

이 때문에 비동기 작업이 포함된 polling에서는 setInterval보다 재귀 setTimeout 패턴이 더 안정적인 경우가 많다.

useEffect cleanup에서 clearInterval을 빠뜨리면 컴포넌트가 언마운트된 뒤에도 interval이 계속 실행된다. 메모리 누수로 이어질 수 있으므로 타이머는 항상 cleanup에서 정리해야 한다.


5. 정확한 주기가 필요할 때

JavaScript 타이머는 setInterval(fn, 1000)처럼 설정해도 정확히 1000ms마다 실행된다는 보장은 없다. 콜백이 Task Queue에 들어오는 순간 Call Stack이 비어있다는 보장이 없기 때문이다. Task Queue에서 꺼내지기까지의 지연이 누적되면 drift가 발생한다.

게임 루프나 실시간 시스템처럼 정확한 주기가 필요한 경우에는 작업 시간을 측정해 다음 delay에서 빼주는 방식을 사용할 수 있다.

function loop() {
  const start = performance.now();
 
  task();
 
  const elapsed = performance.now() - start;
  setTimeout(loop, Math.max(0, 1000 - elapsed));
}

작업이 200ms 걸렸다면 다음 타이머는 800ms 후에 등록된다. 이렇게 하면 실행 시간을 보정하면서 전체 주기가 1000ms에 가깝게 유지된다.

브라우저 timer clamping

Event Loop 지연 외에도 타이머 정확도를 떨어뜨리는 요소가 있다. 브라우저는 특정 상황에서 타이머 실행을 강제로 늦추는 정책(timer clamping) 을 적용한다.

대표적인 경우는 다음과 같다.

  • 중첩된 setTimeout : 5단계 이상 중첩되면 최소 4ms delay가 강제 적용된다.

  • 백그라운드 탭 : 비활성 탭의 타이머는 최소 1000ms 이상으로 늦춰질 수 있다.

  • 배터리 절약 모드 : OS나 브라우저 정책에 따라 추가 지연이 발생할 수 있다.

따라서 setInterval(fn, 100)이라고 설정해도 탭이 백그라운드로 가는 순간 실제 실행 간격이 크게 늘어날 수 있다.

애니메이션처럼 타이밍이 중요한 작업이라면 setInterval 대신 requestAnimationFrame 을 사용하는 것이 더 적합하다.


정리

setIntervalsetTimeout 재귀
타이머 등록 시점한 번매 실행 완료 후
Task Queue enqueue주기적으로실행 완료 후
작업 겹침가능없음
비동기 작업위험안전
React stale closure발생 가능상대적으로 안전

setInterval이 적합한 경우

  • 작업 시간이 인터벌보다 충분히 짧고 겹칠 일이 없을 때
  • useInterval custom hook으로 stale closure를 처리한 경우

setTimeout 재귀가 적합한 경우

  • fetch, DB 쿼리 등 비동기 작업의 polling
  • 작업 시간이 가변적이거나 인터벌보다 길어질 수 있을 때
  • React에서 cleanup이 중요한 경우

setInterval은 시간 기준으로 예약하고, setTimeout 재귀는 작업 완료 기준으로 예약한다.


마치며

평소에는 반복 작업을 구현할 때 setIntervalsetTimeout을 크게 구분하지 않고 사용해 왔다. 둘 다 일정 시간 간격으로 코드를 실행한다는 점에서 비슷해 보였고, 간단한 경우에는 플래그로 실행 흐름을 제어하면 큰 문제를 느끼지 못했기 때문이다.

하지만 내부 동작을 Event Loop 관점에서 살펴보고 React에서 stale closure 문제가 발생하는 이유까지 따라가 보니 두 타이머는 단순히 API 형태만 다른 것이 아니라 작업을 예약하는 방식 자체가 다른 도구라는 점이 인상적이었다.

특히 polling처럼 반복 작업을 구현할 때 많은 코드에서 setTimeout 재귀 패턴을 사용하는 이유도 조금 더 자연스럽게 이해할 수 있었다. 이전에는 단순한 구현 스타일 정도로 생각했는데 실제로는 overlap을 막고 실행 흐름을 제어하기 위한 선택이라는 점이 흥미로웠다.

가벼운 마음으로 타이머 동작을 정리해 보려고 시작했는데, Event Loop와 Task Queue, 그리고 React의 렌더링 모델까지 연결되면서 생각보다 많은 동작 원리가 얽혀 있다는 점도 재미있었다.

참고