브라우저 유휴 시간 활용하기: requestIdleCallback 심층 분석
브라우저가 사용자 입력이나 애니메이션 처리로 바쁠 때, 중요하지만 긴급하지 않은 작업을 어디에 배치할 것인가? requestIdleCallback은 이한 고민에 대한 해답을 제시하는 API로, 메인 스레드가 여유로운 순간에 후순위 작업을 실행할 수 있게 해준다.
핵심 메커니즘
이 API는 브라우저의 이벤트 루프에서 렌더링과 사용자 이벤트 처리가 끝난 틈을 포착하여 등록된 콜백을 실행한다. 각 콜백은 IdleDeadline 객체를 인자로 받아, 현재 틀에 남은 시간을 확인하고 그 안에서 작업을 마칠 수 있다.
// 등록 및 해제
const id = requestIdleCallback(onIdle, { timeout: 3000 });
cancelIdleCallback(id);
function onIdle(deadline) {
// deadline.didTimeout: 강제 실행 여부
// deadline.timeRemaining(): 현재 잔여 시간(ms)
}
IdleDeadline 객체의 실체
timeRemaining()은 최대 50ms까지 반환하며, 이는 사용자 상호작용의 지연을 50ms 이내로 유지하기 위한 설계다. 이 값이 0에 가까워지면 즉시 작업을 중단하고 다음 틀을 기다려야 한다.
requestIdleCallback((deadline) => {
const budget = deadline.timeRemaining();
console.log(`현재 틀에서 사용 가능한 시간: ${budget.toFixed(2)}ms`);
if (deadline.didTimeout) {
console.warn('타임아웃으로 강제 실행됨 - 최소한의 작업만 수행');
}
});
실전 패턴: 청크 단위 처리기
대량의 데이터를 한 번에 처리하면 프레임 롭이 발생한다. 다음 IdleTaskScheduler는 작업을 작은 청크로 쪼개 유휴 시간에 맞춰 실행한다.
class IdleTaskScheduler {
constructor() {
this.taskQueue = [];
this.running = false;
}
enqueue(tasks) {
this.taskQueue.push(...tasks);
if (!this.running) this.pump();
}
pump() {
this.running = true;
requestIdleCallback((deadline) => this.runSlice(deadline));
}
runSlice(deadline) {
const startLen = this.taskQueue.length;
while (
this.taskQueue.length > 0 &&
(deadline.timeRemaining() > 0 || deadline.didTimeout)
) {
const task = this.taskQueue.shift();
task();
}
const processed = startLen - this.taskQueue.length;
console.log(`이번 틀에서 ${processed}개 작업 처리`);
if (this.taskQueue.length > 0) {
requestIdleCallback((deadline) => this.runSlice(deadline));
} else {
this.running = false;
}
}
}
// 활용
const scheduler = new IdleTaskScheduler();
scheduler.enqueue(
Array.from({ length: 1000 }, (_, i) => () => console.log(`작업 ${i}`))
);
실전 패턴: 지연 로그 전송
사용자 행동 로그를 즉시 서버로 보내면 네트워크 대역과 배터리가 낭비된다. requestIdleCallback으로 버퍼링하면 효율적이다.
class DeferredLogger {
constructor(endpoint, maxBatch = 30) {
this.endpoint = endpoint;
this.maxBatch = maxBatch;
this.buffer = [];
this.flushPending = false;
}
record(entry) {
this.buffer.push({ ...entry, recordedAt: Date.now() });
this.scheduleFlush();
}
scheduleFlush() {
if (this.flushPending || this.buffer.length === 0) return;
this.flushPending = true;
requestIdleCallback(
(deadline) => this.flush(deadline),
{ timeout: 4000 }
);
}
flush(deadline) {
const payload = [];
while (
this.buffer.length > 0 &&
payload.length < this.maxBatch &&
(deadline.timeRemaining() > 0 || deadline.didTimeout)
) {
payload.push(this.buffer.shift());
}
if (payload.length > 0) {
this.transmit(payload);
}
this.flushPending = false;
if (this.buffer.length > 0) {
this.scheduleFlush();
}
}
async transmit(batch) {
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
keepalive: true,
});
} catch (err) {
console.error('로그 전송 실패:', err);
this.buffer.unshift(...batch);
}
}
}
const appLogger = new DeferredLogger('/analytics/events');
appLogger.record({ event: 'page_view', path: '/dashboard' });
appLogger.record({ event: 'click', target: '#submit-btn' });
실전 패턴: 가상 스크롤 셀 재활용
화면 밖으로 스크롤된 DOM 노드를 재활용할 때, 실제 DOM 조작은 유휴 시간으로 미룰 수 있다.
class RecycledList {
constructor(viewport, itemHeight) {
this.viewport = viewport;
this.itemHeight = itemHeight;
this.recycleBin = [];
this.pendingRecycles = [];
this.isRecycling = false;
}
onScroll(totalItems) {
const visibleStart = Math.floor(this.viewport.scrollTop / this.itemHeight);
const visibleEnd = Math.ceil(
(this.viewport.scrollTop + this.viewport.clientHeight) / this.itemHeight
);
for (let i = 0; i < totalItems; i++) {
if (i < visibleStart || i > visibleEnd) {
this.queueRecycle(i);
}
}
}
queueRecycle(index) {
this.pendingRecycles.push(index);
if (!this.isRecycling) {
this.isRecycling = true;
requestIdleCallback((deadline) => this.processRecycles(deadline));
}
}
processRecycles(deadline) {
const fragment = document.createDocumentFragment();
while (
this.pendingRecycles.length > 0 &&
deadline.timeRemaining() > 0
) {
const idx = this.pendingRecycles.shift();
const node = document.getElementById(`item-${idx}`);
if (node) {
node.remove();
this.recycleBin.push(node);
}
}
if (this.pendingRecycles.length > 0) {
requestIdleCallback((deadline) => this.processRecycles(deadline));
} else {
this.isRecycling = false;
}
}
}
Promise 기반 래퍼
콜백 지옥을 피하고 async/await 패턴을 적용하면 가독성이 크게 향상된다.
function awaitIdleWindow(msTimeout) {
return new Promise((resolve) => {
requestIdleCallback(
(deadline) => resolve(deadline),
msTimeout ? { timeout: msTimeout } : undefined
);
});
}
async function incrementalCompute(dataset, computeFn) {
const results = [];
for (const chunk of dataset) {
const deadline = await awaitIdleWindow(2000);
if (deadline.timeRemaining() > 5 || deadline.didTimeout) {
results.push(computeFn(chunk));
}
}
return results;
}
성능 계측 래퍼
프로덕션 환경에서 유휴 콜백의 대기 시간과 실행 시간을 모니터링하면 병목을 파악할 수 있다.
function instrumentedIdleCallback(fn, opts) {
const queuedAt = performance.now();
return requestIdleCallback((deadline) => {
const startedAt = performance.now();
const waitDuration = startedAt - queuedAt;
fn(deadline);
const elapsed = performance.now() - startedAt;
const remaining = deadline.timeRemaining();
if (elapsed > 50) {
console.warn(`유휴 콜백 과다 소요: ${elapsed.toFixed(2)}ms`);
}
// 실제로는 여기서 analytics 수집
sendBeacon('idle_metrics', {
waitDuration: Math.round(waitDuration),
executionTime: Math.round(elapsed),
finalBudget: Math.round(remaining),
timedOut: deadline.didTimeout,
});
}, opts);
}
안전한 실행 환경 구축
예외가 전파되면 후속 콜백이 무너질 수 있으므로 반드시 경계에서 처리해야 한다.
function guardedIdleCallback(task, fallback) {
return requestIdleCallback((deadline) => {
try {
task(deadline);
} catch (err) {
console.error('유휴 작업 실패:', err);
if (typeof fallback === 'function') fallback(err);
}
});
}
폴리필과 대응 전략
모던 브라우저 대부분이 지원하지만, Safari나 구형 환경을 위해 setTimeout 기반 폴백을 준비해야 한다.
const requestIdleCallbackPolyfill =
window.requestIdleCallback ||
function (cb, options) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
const cancelIdleCallbackPolyfill =
window.cancelIdleCallback || clearTimeout;
// 통합 인터페이스
export { requestIdleCallbackPolyfill as requestIdle, cancelIdleCallbackPolyfill as cancelIdle };
주의사항과 반패턴
- 동기적 DOM 쓰기 금지:
requestIdleCallback내에서 레이아웃을 유발하는 DOM 변경은 다음 프레임 지연을 초래한다. - 무한 재귀 방지: 조건 없이 자기 자신을 재등록하면 메모리 누수가 발생할 수 있다.
- 타임아웃 남용 주의:
timeout을 과도하게 짧게 설정하면 유휴 시간 활용의 의미가 퇴색된다.
// 위험: 강제 동기화로 인한 레이아웃 스레싱
requestIdleCallback(() => {
const box = document.getElementById('metrics');
box.style.width = '100px'; // 쓰기
console.log(box.offsetHeight); // 읽기 → 강제 동기 레이아웃!
});
// 개선: 쓰기와 읽기 분리, rAF에서 읽기
requestIdleCallback(() => {
const box = document.getElementById('metrics');
box.style.width = '100px';
requestAnimationFrame(() => {
console.log(box.offsetHeight);
});
});
마무리
requestIdleCallback은 메인 스레드의 여유 시간을 정밀하게 스케줄링할 수 있는 강력한 도구다. 중요한 점은 이 API가 '언젠가 실행된다'는 보장만 제공하며, 실제 실행 시점은 브라우저가 판단한다는 것이다. 따라서 작업을 작은 단위로 쪼개고, timeRemaining()을 적극적으로 확인하며, 필요시 타임아웃으로 마감 기한을 설정하는 습관이 필수적이다.