requestIdleCallback 사용

많은 사이트와 앱에는 실행해야 할 스크립트가 많습니다. JavaScript는 최대한 빨리 실행해야 하지만 동시에 사용자에게 방해가 되지 않도록 해야 합니다. 사용자가 페이지를 스크롤할 때 분석 데이터를 전송하거나 사용자가 우연히 버튼을 탭하는 동안 DOM에 요소를 추가하면 웹 앱이 응답하지 않아 사용자 환경이 저하될 수 있습니다.

requestIdleCallback을 사용하여 필수가 아닌 작업을 예약합니다.

다행히 이제 도움이 되는 API(requestIdleCallback)가 생겼습니다. requestAnimationFrame를 채택하여 애니메이션을 적절하게 예약하고 60fps에 도달할 가능성을 극대화할 수 있었던 것과 마찬가지로 requestIdleCallback는 프레임 끝에 여유 시간이 있거나 사용자가 비활성 상태일 때 작업을 예약합니다. 이는 사용자를 방해하지 않고 작업을 수행할 기회가 있다는 의미입니다. Chrome 47부터 사용할 수 있으므로 지금 Chrome Canary를 사용해 보세요. 이 기능은 실험용 기능이며 사양은 계속 변경되므로 추후에 상황이 변경될 수 있습니다.

requestIdleCallback을 사용해야 하는 이유는 무엇인가요?

필수적이지 않은 작업을 직접 예약하는 것은 매우 어렵습니다. requestAnimationFrame 콜백이 실행된 후에는 실행해야 하는 스타일 계산, 레이아웃, 페인트 및 기타 브라우저 내부 요소가 있기 때문에 남은 프레임 시간을 정확히 파악할 수 없습니다. 홈롤 솔루션은 이러한 상황을 모두 고려할 수 없습니다. 사용자가 어떤 식으로든 상호작용하지 않도록 하려면 기능에 필요하지 않더라도 모든 종류의 상호작용 이벤트 (scroll, touch, click)에 리스너를 연결해야 합니다. 그래야 사용자가 상호작용하고 있지 않음을 확실히 확신할 수 있기 때문입니다. 반면 브라우저는 프레임 끝에서 사용할 수 있는 시간을 정확히 알고 있으며 사용자가 상호작용하고 있는지 알기 때문에 requestIdleCallback를 통해 가능한 한 가장 효율적인 방법으로 여가 시간을 활용할 수 있는 API를 얻습니다.

좀 더 자세히 살펴보고 이를 어떻게 활용할 수 있는지 알아보겠습니다.

requestIdleCallback 확인 중

requestIdleCallback는 아직 초기 단계이므로 사용하기 전에 사용 가능한지 확인해야 합니다.

if ('requestIdleCallback' in window) {
    // Use requestIdleCallback to schedule work.
} else {
    // Do what you’d do today.
}

동작을 심할 수도 있으며 이 경우 setTimeout로 대체됩니다.

window.requestIdleCallback =
    window.requestIdleCallback ||
    function (cb) {
    var start = Date.now();
    return setTimeout(function () {
        cb({
        didTimeout: false,
        timeRemaining: function () {
            return Math.max(0, 50 - (Date.now() - start));
        }
        });
    }, 1);
    }

window.cancelIdleCallback =
    window.cancelIdleCallback ||
    function (id) {
    clearTimeout(id);
    }

setTimeout를 사용하는 것은 requestIdleCallback처럼 유휴 시간에 관해 알지 못하기 때문에 좋지 않지만 requestIdleCallback를 사용할 수 없는 경우 함수를 직접 호출하게 되므로 이런 식으로 시밍하는 것은 더 이상 좋지 않습니다. shim을 사용하면 requestIdleCallback를 사용할 수 있는 경우 통화가 자동으로 리디렉션됩니다.

하지만 지금은 이것이 존재한다고 가정해 보겠습니다.

requestIdleCallback 사용

requestIdleCallback 호출은 콜백 함수를 첫 번째 매개변수로 사용한다는 점에서 requestAnimationFrame와 매우 유사합니다.

requestIdleCallback(myNonEssentialWork);

myNonEssentialWork가 호출되면 작업까지 남은 시간을 나타내는 숫자를 반환하는 함수가 포함된 deadline 객체가 제공됩니다.

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0)
    doWorkIfNeeded();
}

timeRemaining 함수를 호출하여 최신 값을 가져올 수 있습니다. timeRemaining()가 0을 반환하면 아직 할 작업이 더 있다면 다른 requestIdleCallback를 예약할 수 있습니다.

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0 && tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

함수를 보장하는 것을

상황이 매우 바쁘면 어떻게 하나요? 콜백이 호출되지 않을까 봐 걱정이 될 수 있습니다. requestIdleCallbackrequestAnimationFrame와 비슷하지만 선택적 두 번째 매개변수, 즉 Timeout 속성이 있는 옵션 객체를 사용한다는 점에서 다릅니다. 이 시간 제한을 설정하면 브라우저가 콜백을 실행해야 하는 시간(밀리초)을 제공합니다.

// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });

시간 초과 발생으로 인해 콜백이 실행되면 다음 두 가지 사항을 확인할 수 있습니다.

  • timeRemaining()는 0을 반환합니다.
  • deadline 객체의 didTimeout 속성이 true입니다.

didTimeout가 true인 것을 확인하면 대부분 작업을 실행하고 그 작업을 끝내는 것이 좋습니다.

function myNonEssentialWork (deadline) {

    // Use any remaining time, or, if timed out, just run through the tasks.
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) &&
            tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

이 시간 초과로 인해 사용자에게 중단이 발생할 수 있으므로 (이로 인해 앱이 응답하지 않거나 버벅거릴 수 있음) 이 매개변수를 설정할 때는 주의해야 합니다. 가능한 경우 브라우저가 콜백을 호출할 시점을 결정하도록 합니다.

requestIdleCallback을 사용하여 분석 데이터 전송

requestIdleCallback를 사용하여 분석 데이터를 전송하는 방법을 살펴보겠습니다. 이 경우 탐색 메뉴 탭하기와 같은 이벤트를 추적하는 것이 좋습니다. 그러나 일반적으로는 화면에 애니메이션이 적용되므로 이 이벤트를 Google 애널리틱스로 즉시 전송하지 않는 것이 좋습니다. 전송할 이벤트의 배열을 만들고 향후 특정 시점에 전송되도록 요청합니다.

var eventsToSend = [];

function onNavOpenClick () {

    // Animate the menu.
    menu.classList.add('open');

    // Store the event for later.
    eventsToSend.push(
    {
        category: 'button',
        action: 'click',
        label: 'nav',
        value: 'open'
    });

    schedulePendingEvents();
}

이제 requestIdleCallback를 사용하여 대기 중인 이벤트를 처리해야 합니다.

function schedulePendingEvents() {

    // Only schedule the rIC if one has not already been set.
    if (isRequestIdleCallbackScheduled)
    return;

    isRequestIdleCallbackScheduled = true;

    if ('requestIdleCallback' in window) {
    // Wait at most two seconds before processing events.
    requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
    } else {
    processPendingAnalyticsEvents();
    }
}

여기에서 시간 제한을 2초로 설정했지만 이 값은 애플리케이션에 따라 다릅니다. 분석 데이터의 경우 미래의 특정 시점이 아니라 합당한 기간 내에 데이터가 보고되도록 타임아웃을 사용하는 것이 합리적입니다.

마지막으로 requestIdleCallback가 실행할 함수를 작성해야 합니다.

function processPendingAnalyticsEvents (deadline) {

    // Reset the boolean so future rICs can be set.
    isRequestIdleCallbackScheduled = false;

    // If there is no deadline, just run as long as necessary.
    // This will be the case if requestIdleCallback doesn’t exist.
    if (typeof deadline === 'undefined')
    deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
    var evt = eventsToSend.pop();

    ga('send', 'event',
        evt.category,
        evt.action,
        evt.label,
        evt.value);
    }

    // Check if there are more events still to send.
    if (eventsToSend.length > 0)
    schedulePendingEvents();
}

이 예에서는 requestIdleCallback가 존재하지 않으면 애널리틱스 데이터를 즉시 전송해야 한다고 가정했습니다. 그러나 프로덕션 애플리케이션에서는 모든 상호작용과 충돌하여 버벅거림을 유발하지 않도록 시간 초과로 전송을 지연하는 것이 더 나을 수 있습니다.

requestIdleCallback을 사용하여 DOM 변경

requestIdleCallback가 성능에 큰 도움이 될 수 있는 또 다른 상황은 필수적이지 않은 DOM 변경사항이 있는 경우입니다(예: 계속 늘어나는 지연 로드 목록의 끝에 항목을 추가하는 경우). requestIdleCallback가 실제로 일반적인 프레임에 어떻게 맞춰지는지 살펴보겠습니다.

일반적인 프레임입니다.

브라우저가 너무 바빠서 특정 프레임에서 콜백을 실행할 수 없을 수도 있으므로 프레임 끝부분에 더 많은 작업을 할 수 있는 여유 시간이 있을 것으로 예상해서는 안 됩니다. 따라서 프레임별로 실행되는 setImmediate와 같은 것과 다릅니다.

프레임 끝에서 콜백이 실행되면 현재 프레임이 커밋된 후에 콜백이 실행되도록 예약됩니다. 즉, 스타일 변경사항이 적용되고 특히 레이아웃이 계산됩니다. 유휴 콜백 내부에서 DOM을 변경하면 레이아웃 계산이 무효화됩니다. 다음 프레임에 어떤 종류의 레이아웃 읽기가 있는 경우(예: getBoundingClientRect, clientWidth 등) 브라우저는 잠재적인 성능 병목 현상인 강제 동기식 레이아웃을 실행해야 합니다.

유휴 콜백에서 DOM 변경을 트리거하지 않는 또 다른 이유는 DOM 변경으로 인한 시간 영향을 예측할 수 없기 때문에 브라우저가 제공한 기한을 쉽게 지날 수 있기 때문입니다.

가장 좋은 방법은 requestAnimationFrame 콜백 내부에서만 DOM을 변경하는 것입니다. DOM이 이러한 유형의 작업을 염두에 두고 브라우저에서 예약되기 때문입니다. 즉, 코드에서 문서 프래그먼트를 사용해야 하며, 이 프래그먼트는 다음 requestAnimationFrame 콜백에 추가될 수 있습니다. VDOM 라이브러리를 사용하는 경우 requestIdleCallback를 사용하여 변경하지만 다음 requestAnimationFrame 콜백에서는 유휴 콜백이 아닌 DOM 패치를 적용합니다.

이를 염두에 두고 코드를 살펴보겠습니다.

function processPendingElements (deadline) {

    // If there is no deadline, just run as long as necessary.
    if (typeof deadline === 'undefined')
    deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

    if (!documentFragment)
    documentFragment = document.createDocumentFragment();

    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {

    // Create the element.
    var elToAdd = elementsToAdd.pop();
    var el = document.createElement(elToAdd.tag);
    el.textContent = elToAdd.content;

    // Add it to the fragment.
    documentFragment.appendChild(el);

    // Don't append to the document immediately, wait for the next
    // requestAnimationFrame callback.
    scheduleVisualUpdateIfNeeded();
    }

    // Check if there are more events still to send.
    if (elementsToAdd.length > 0)
    scheduleElementCreation();
}

여기서 요소를 만들고 textContent 속성을 사용하여 값을 채우지만 요소 생성 코드가 더 복잡할 수 있습니다. 요소를 만든 후 scheduleVisualUpdateIfNeeded가 호출되면 단일 requestAnimationFrame 콜백이 설정되고 이 콜백은 문서 프래그먼트를 본문에 추가합니다.

function scheduleVisualUpdateIfNeeded() {

    if (isVisualUpdateScheduled)
    return;

    isVisualUpdateScheduled = true;

    requestAnimationFrame(appendDocumentFragment);
}

function appendDocumentFragment() {
    // Append the fragment and reset.
    document.body.appendChild(documentFragment);
    documentFragment = null;
}

이제 DOM에 항목을 추가할 때 버벅거림이 훨씬 줄어들었습니다. 멋집니다!

FAQ

  • 폴리필이 있나요? 안타깝게도 setTimeout으로 투명한 리디렉션을 원하는 경우 shim이 있습니다. 이 API가 존재하는 이유는 웹 플랫폼의 실질적인 격차를 메우기 때문입니다. 활동 부족을 추론하는 것은 어렵지만 프레임 끝의 여유 시간을 결정하는 JavaScript API는 없으므로 추측해야 합니다. setTimeout, setInterval 또는 setImmediate와 같은 API를 사용하여 작업을 예약할 수 있지만, requestIdleCallback와 같은 방식으로 사용자 상호작용을 방지하기 위한 시간이 지정되지는 않습니다.
  • 기한을 초과하면 어떻게 되나요? timeRemaining()에서 0을 반환하지만 더 오래 실행하기로 했다면 브라우저에서 작업이 중지될까 봐 걱정할 필요 없이 실행할 수 있습니다. 하지만 브라우저에서 사용자에게 원활한 환경을 제공할 수 있는 기한을 부여하므로, 특별한 이유가 없는 한 항상 기한을 준수해야 합니다.
  • timeRemaining()가 반환하는 최댓값이 있나요? 예, 현재 50ms입니다. 반응형 애플리케이션을 유지하려고 할 때 사용자 상호작용에 대한 모든 응답을 100ms 미만으로 유지해야 합니다. 사용자가 50ms 동안 상호작용하면 대부분의 경우 유휴 콜백이 완료되고 브라우저가 사용자의 상호작용에 응답할 수 있도록 허용해야 합니다. 브라우저에서 실행 시간이 충분하다고 판단한 경우 여러 개의 유휴 콜백이 연달아 예약될 수 있습니다.
  • requestIdleCallback에서 실행하면 안 되는 작업이 있나요? 비교적 예측 가능한 특성을 가진 작은 작업 단위 (마이크로태스크)로 작업하는 것이 이상적입니다. 예를 들어 특히 DOM을 변경하면 스타일 계산, 레이아웃, 페인팅 및 합성이 트리거되므로 실행 시간을 예측할 수 없게 됩니다. 따라서 위에 제안된 것처럼 requestAnimationFrame 콜백에서 DOM만 변경해야 합니다. 주의해야 할 또 다른 사항은 프로미스를 해결 (또는 거부)하는 것입니다. 더 이상 남은 시간이 없더라도 유휴 콜백이 완료된 직후 콜백이 실행되기 때문입니다.
  • 프레임 끝에 항상 requestIdleCallback이 표시되나요? 항상 그런 것은 아닙니다. 브라우저는 프레임 끝에 자유 시간이 있거나 사용자가 비활성 상태일 때마다 콜백을 예약합니다. 콜백이 프레임별로 호출될 것으로 기대해서는 안 되며, 지정된 시간 내에 콜백을 실행해야 하는 경우 시간 제한을 사용해야 합니다.
  • requestIdleCallback 콜백을 여러 개 사용할 수 있나요? 예, 가능합니다. 여러 개의 requestAnimationFrame 콜백을 사용할 수 있습니다. 그러나 첫 번째 콜백이 콜백 중에 남은 시간을 다 사용하면 다른 콜백에 더 이상 남은 시간이 없다는 것을 기억해야 합니다. 그러면 다른 콜백은 실행되기 전에 브라우저가 다음 유휴 상태가 될 때까지 기다려야 합니다. 완료하려는 작업에 따라 단일 유휴 콜백을 사용하고 여기서 작업을 나누는 것이 더 나을 수 있습니다. 또는 타임아웃을 사용하여 콜백이 시간 부족으로 처리되지 않도록 할 수 있습니다.
  • 유휴 상태 콜백을 다른 콜백 내부에 새로 설정하면 어떻게 되나요? 새로운 유휴 상태 콜백은 현재 프레임이 아닌 다음 프레임에서 시작하여 최대한 빨리 실행되도록 예약됩니다.

자리 비움

requestIdleCallback를 사용하면 사용자를 방해하지 않으면서 코드를 실행할 수 있습니다. 사용이 간편하고 매우 유연합니다. 하지만 아직 초기 단계이고 사양이 완전히 확정된 것은 아니므로 어떤 의견이든 좋습니다.

Chrome Canary에서 기능을 확인하고 프로젝트를 활용해 보고 의견을 알려주세요.