JavaScript로 개발하다 보면 일정 시간 후에 코드를 실행하거나 반복적으로 작업을 수행해야 할 때가 많다. 대부분의 개발자들은 setTimeout과 setInterval을 사용하면서도 타이머가 예상대로 작동하지 않거나 메모리가 계속 증가하는 문제를 겪게 된다. 다만 이 둘의 정확한 동작 원리와 제대로 정리하는 방법을 모르는 경우가 대부분이다. 이번에는 setTimeout과 setInterval의 차이가 정확히 뭔지, 왜 메모리 누수가 발생하는지, 어떻게 안전하게 정리하는지 완벽하게 정리해서 소개하겠다.
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은 지정된 시간마다 계속 반복 실행한다. 반드시 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에서는 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;
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);
타이머 ID를 변수에 저장하지 않으면 나중에 취소할 수 없다.
✗ 취소 불가능한 패턴
setInterval(() => {
console.log('반복');
}, 1000);
// 이제 이 타이머를 중지할 방법이 없다
✓ 취소 가능한 패턴
const intervalId = setInterval(() => {
console.log('반복');
}, 1000);
// 필요할 때 중지
clearInterval(intervalId);
함수가 반복 호출되면서 새로운 타이머가 계속 생성되는데 이전 타이머를 정리하지 않는 경우가 많다.
✗ 타이머 누적 문제
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 |
|---|---|---|
| 실행 횟수 | 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 패턴을 참고해 프로젝트에 적용하면, 타이머로 인한 메모리 누수 걱정 없이 안정적인 웹 애플리케이션을 구축할 수 있을 것이다.