JavaScript로 개발하다 보면 일정 시간 후에 코드를 실행하거나 반복적으로 작업을 수행해야 할 때가 많다. 대부분의 개발자들은 setTimeout과 setInterval을 사용하면서도 타이머가 예상대로 작동하지 않거나 메모리가 계속 증가하는 문제를 겪게 된다. 다만 이 둘의 정확한 동작 원리와 제대로 정리하는 방법을 모르는 경우가 대부분이다. 이번에는 setTimeout과 setInterval의 차이가 정확히 뭔지, 왜 메모리 누수가 발생하는지, 어떻게 안전하게 정리하는지 완벽하게 정리해서 소개하겠다.

 

setTimeout과 setInterval 기초 개념
setTimeout은 지정된 시간(밀리초) 후에 한 번만 함수를 실행하고, setInterval은 지정된 시간 간격으로 반복해서 함수를 실행한다. 둘 다 비동기 작업이며, 타이머 ID를 반환하는데 이 ID를 이용해 나중에 타이머를 취소할 수 있다.

 

setTimeout - 일회성 지연 실행

setTimeout은 가장 기본적인 타이머다. 문법은 다음과 같다:

setTimeout(함수, 지연시간(ms), ...인자);

지연시간이 1000이면 1초 후에 함수가 한 번 실행된다. 중요한 점은 이 함수가 메인 스레드를 블로킹하지 않으며, 콜 스택이 비워진 후에 태스크 큐에서 실행된다는 것이다.

✗ 잘못된 사용 - 타이머 정리 안 함
function startTimer() {
  setTimeout(() => {
    console.log('3초 후 실행');
  }, 3000);
}

startTimer();
startTimer();
startTimer(); // 3번 호출되면 콜백이 3번 쌓인다
✓ 올바른 사용 - 타이머 ID 반환
function startTimer() {
  const timerId = setTimeout(() => {
    console.log('3초 후 실행');
  }, 3000);
  return timerId; // 나중에 취소할 수 있도록 ID 반환
}

const id = startTimer();

 

setInterval - 반복 실행

setInterval은 지정된 시간마다 계속 반복 실행한다. 반드시 clearInterval로 정리하지 않으면 페이지를 떠날 때까지 계속 실행된다.

✗ 위험한 패턴 - 정리 안 함
setInterval(() => {
  console.log('1초마다 실행');
}, 1000);
// 페이지를 떠날 때까지 계속 실행되어 메모리 누수 발생
✓ 안전한 패턴 - clearInterval로 정리
const intervalId = setInterval(() => {
  console.log('1초마다 실행');
}, 1000);

// 필요할 때 정리
clearInterval(intervalId);

 

실전 예제 - 타이머 메모리 누수 해결

 

문제 상황: 컴포넌트 재생성 시 타이머가 계속 누적

싱글 페이지 애플리케이션에서 같은 컴포넌트를 여러 번 생성할 때 타이머가 정리되지 않으면 메모리가 계속 증가한다.

✗ 문제 코드 - 타이머 정리 없음
class Timer {
  constructor() {
    this.intervalId = null;
  }

  start() {
    this.intervalId = setInterval(() => {
      fetch('/api/data')
        .then(res => res.json())
        .then(data => this.render(data));
    }, 5000);
  }

  destroy() {
    // 타이머 정리 누락!
  }
}

// 페이지 이동할 때마다 새로운 Timer 인스턴스 생성
const timer1 = new Timer();
timer1.start();
// 페이지 이동
const timer2 = new Timer(); // 첫 번째 타이머는 여전히 실행 중
timer2.start();
// 이렇게 반복하면 타이머가 계속 쌓인다
✓ 해결 코드 - 명시적 정리
class Timer {
  constructor() {
    this.intervalId = null;
    this.timeoutId = null;
  }

  start() {
    this.intervalId = setInterval(() => {
      fetch('/api/data')
        .then(res => res.json())
        .then(data => this.render(data));
    }, 5000);
  }

  destroy() {
    // 반드시 모든 타이머 정리
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
  }

  render(data) {
    console.log(data);
  }
}

// 페이지 이동 시 destroy() 호출
let timer = new Timer();
timer.start();

// 페이지 떠날 때
window.addEventListener('beforeunload', () => {
  timer.destroy(); // 타이머 정리
});

 

React 컴포넌트에서 타이머 관리

React에서는 useEffect의 cleanup 함수에서 타이머를 정리하는 것이 필수다.

✗ 문제 코드 - cleanup 함수 없음
import { useEffect, useState } from 'react';

function CountdownComponent() {
  const [count, setCount] = useState(10);

  useEffect(() => {
    setInterval(() => {
      setCount(prev => prev - 1);
    }, 1000);
    // cleanup 함수 없음 = 컴포넌트 언마운트 시에도 타이머 계속 실행
  }, []);

  return <div>{count}</div>;
}

export default CountdownComponent;
✓ 해결 코드 - cleanup 함수로 정리
import { useEffect, useState } from 'react';

function CountdownComponent() {
  const [count, setCount] = useState(10);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(prev => prev - 1);
    }, 1000);

    // cleanup 함수: 컴포넌트 언마운트 또는 의존성 변경 시 실행
    return () => {
      clearInterval(intervalId);
    };
  }, []); // 빈 배열 = 컴포넌트 마운트 시 한 번만 실행

  return <div>{count}</div>;
}

export default CountdownComponent;

 

주의사항 - 흔한 실수와 해결법

 

실수 1: 타이머 지연시간을 정확히 제어하지 못함

setInterval은 콜백 함수가 실행되는 데 걸리는 시간을 고려하지 않는다. 만약 콜백이 500ms 걸리는데 interval을 1000ms로 설정하면, 실제 간격은 1000ms + 500ms = 1500ms가 된다.

✗ 정확하지 않은 패턴
setInterval(() => {
  // 이 작업이 300ms 걸림
  heavyCalculation();
  console.log('1초마다');
}, 1000);
// 실제 간격: 1000ms + 300ms
✓ 더 정확한 패턴 - setTimeout 반복
function accurateInterval(callback, delay) {
  const start = Date.now();
  let count = 0;

  function run() {
    count++;
    const nextTime = count * delay - (Date.now() - start);
    setTimeout(run, Math.max(0, nextTime));
    callback();
  }

  return setTimeout(run, delay);
}

// 또는 간단하게
function repeatWithDelay(callback, delay) {
  const run = () => {
    callback();
    setTimeout(run, delay);
  };
  setTimeout(run, delay);
}

const id = repeatWithDelay(() => {
  console.log('정확히 1초 간격');
}, 1000);

 

실수 2: 타이머 ID를 저장하지 않음

타이머 ID를 변수에 저장하지 않으면 나중에 취소할 수 없다.

✗ 취소 불가능한 패턴
setInterval(() => {
  console.log('반복');
}, 1000);
// 이제 이 타이머를 중지할 방법이 없다
✓ 취소 가능한 패턴
const intervalId = setInterval(() => {
  console.log('반복');
}, 1000);

// 필요할 때 중지
clearInterval(intervalId);

 

실수 3: 클로저로 인한 이전 타이머 정리 불가

함수가 반복 호출되면서 새로운 타이머가 계속 생성되는데 이전 타이머를 정리하지 않는 경우가 많다.

✗ 타이머 누적 문제
function setupAutoRefresh() {
  setInterval(() => {
    fetch('/api/data').then(res => res.json());
  }, 5000);
}

// 사용자가 버튼을 여러 번 클릭
document.getElementById('refreshBtn').addEventListener('click', () => {
  setupAutoRefresh(); // 클릭할 때마다 새로운 타이머 추가
});
✓ 이전 타이머 정리 후 새로 생성
let currentIntervalId = null;

function setupAutoRefresh() {
  // 이전 타이머가 있으면 정리
  if (currentIntervalId) {
    clearInterval(currentIntervalId);
  }

  currentIntervalId = setInterval(() => {
    fetch('/api/data').then(res => res.json());
  }, 5000);
}

document.getElementById('refreshBtn').addEventListener('click', () => {
  setupAutoRefresh(); // 항상 하나의 타이머만 실행

 

setTimeout과 setInterval 차이 비교
특징 setTimeout setInterval
실행 횟수 1회만 실행 반복 실행
정리 필요성 필수 아님(자동 정리) 필수(명시적 정리)
메모리 누수 위험 낮음 높음
취소 방법 clearTimeout(id) clearInterval(id)
사용 사례 지연 실행, 재시도, 폴링(초기) 실시간 업데이트, 상태 체크

 

실무 팁: 타이머 정리 자동화 패턴

매번 명시적으로 타이머를 정리하기보다는 래퍼 함수를 만들어서 자동으로 관리하는 것이 실수를 줄인다.

// 타이머 관리자 클래스
class TimerManager {
  constructor() {
    this.timers = new Set();
  }

  setTimeout(callback, delay) {
    const id = setTimeout(() => {
      callback();
      this.timers.delete(id);
    }, delay);
    this.timers.add(id);
    return id;
  }

  setInterval(callback, delay) {
    const id = setInterval(callback, delay);
    this.timers.add(id);
    return id;
  }

  clearTimer(id) {
    clearTimeout(id);
    clearInterval(id);
    this.timers.delete(id);
  }

  clearAll() {
    this.timers.forEach(id => {
      clearTimeout(id);
      clearInterval(id);
    });
    this.timers.clear();
  }
}

// 사용
const timerManager = new TimerManager();

timerManager.setTimeout(() => {
  console.log('3초 후 실행');
}, 3000);

timerManager.setInterval(() => {
  console.log('1초마다 실행');
}, 1000);

// 컴포넌트 언마운트 시
window.addEventListener('beforeunload', () => {
  timerManager.clearAll(); // 모든 타이머 정리
});

 

마무리

setTimeout과 setInterval은 웹 개발에서 가장 기본적인 타이머인 동시에 잘못 사용하면 심각한 메모리 누수를 야기할 수 있다. 특히 setInterval은 명시적으로 정리하지 않으면 페이지를 떠난 후에도 백그라운드에서 계속 실행되어 성능을 저하시킨다. 타이머 ID를 반드시 변수에 저장하고, 불필요해지면 바로 clearTimeout이나 clearInterval로 정리하는 습관이 버그 없는 코드를 만드는 첫걸음이다. 이 글의 TimerManager 패턴을 참고해 프로젝트에 적용하면, 타이머로 인한 메모리 누수 걱정 없이 안정적인 웹 애플리케이션을 구축할 수 있을 것이다.