avatar
requestIdleCallback으로 최적화하기
avatar

yceffort

·9

사이트와 애플리케이션에는 실행해야할 스크립트가 잔뜩 쌓여있다. 이러한 자바스크립트가 최대한 빨리 실행되야 하는 것이 좋지만, 그와 동시에 사용자의 방해가 되지 않도록 해야 한다. 사용자가 페이지를 스크롤 할 때 데이터를 보내거나, DOM에 element를 추가해야 하는 경우 웹 애플리케이션이 응답하지 않아 사용자 경험이 저하될 수 있다.

이를 해결하기 위해 requestIdleCallback이라는 API가 있다. requestAnimationFrame을 사용하면 애니메이션을 적절하게 스케쥴링하고, 60fps를 달성하는데 도움을 줄 수 있는 것 처럼, requestIdleCallback은 프레임이 끝나는 지점에 있거나, 사용자가 비활성화 상태일 때 작업을 예약할 수 있다.

requestIdleCallback인가

필수적이지 않은 작업을 스케쥴링해서 처리하는 것은 매우 어렵다. requestAnimationFrame 콜백을 실행한 후 스타일 연산, 레이아웃, 페인팅 및 기타 브라우저 내부에서 실행해야하는 작업을 수행하기 때문에, 현재 남은 프레임 시간을 정확히 파악하는 것은 어렵다. 개발자가 여기에서 해볼 수 있는 시도는 많지 않다. 사용자가 어떤 방식으로든 인터랙션을 하지 못하게 하려면, 사용자가 할 수 있는 모든 종류의 인터랙션 (스크롤, 터치, 클릭 등)에 listener를 달아야 한다. 반면 브라우저는 프레임 작업이 끝난 이후에 얼마나 여유가 있는지, 그리고 사용자가 인터랙션 중인지 알 고 있기 때문에 requestIdleCallback을 사용해 가능한 효율적으로 이 빈 시간을 활용할 수 있는 api를 쓸 수 있다.

requestIdleCallback

IE에서는 사용이 불가능하고, safari에서는 (여전히) 실험적 기능으로 제공되고 있다.

polyfill

requestIdleCallback 사용해보기

requestIdleCallbackrequestAnimationFrame과 매우 비슷하다.

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을 넘길 수 있는데, 이 설정된 시간이 초과된 경우 idle 상태와 상관없이 그냥 실행해버린다.

// 2초는 내가 기다려본다...
requestIdleCallback(processPendingAnalyticsEvents, {timeout: 2000})

이렇게 시간 초과로 인해 콜백이 실행되는 경우 아래 두가지를 확인할 수 있다.

  • timeRemaining()은 0을 반환
  • didTimeout이 true가 됨
function myNonEssentialWork(deadline) {
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    tasks.length > 0
  )
    doWorkIfNeeded()

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

이 timeout으로 인해 사용자 작업이 중단될 수도 있으므로 (작업으로 인해 애플리케이션이 응답하지 않거나 오류가 나거나), 이 인수를 사용할 때는 주의해야 한다.

데이터 분석을 위해 requestIdleCallback사용하기

requestIdleCallback를 사용하는 예제를 살펴보자. 이 경우 메뉴를 클릭하는 것과 같은 이벤트를 추적할 수 있다 그러나 일반적으로 메뉴를 클릭하면 화면에 애니메이션이 함께 표시되므로, google analytics에 이 이벤트를 즉시 보내지 않도록 설정해보자.

var eventsToSend = []

function onNavOpenClick() {
  // 메뉴를 여는 이벤트
  menu.classList.add('open')

  // 보낼 이벤트를 저장해둔다.
  eventsToSend.push({
    category: 'button',
    action: 'click',
    label: 'nav',
    value: 'open',
  })

  schedulePendingEvents()
}

requestIdleCallback를 활용하여 이 이벤트를 실행해보자.

function schedulePendingEvents() {
  // isRequestIdleCallbackScheduled 가 있으면 예약하지 않는다.
  if (isRequestIdleCallbackScheduled) return

  // 없으면 작업시작 준비
  isRequestIdleCallbackScheduled = true

  if ('requestIdleCallback' in window) {
    // 최대 2초 대기
    requestIdleCallback(processPendingAnalyticsEvents, {timeout: 2000})
  } else {
    processPendingAnalyticsEvents()
  }
}

이 예제에서는 2초로 설정했지만, 애플리케이션에 따라 이 값이 달라질 수 있다.데이터 분석의 경우, 데이터를 미래의 특정 시점에 리포트 하는 것이 아니라 적절한 시간에 리포팅 해야 한다.

function processPendingAnalyticsEvents(deadline) {
  // false 상태로 만들어 다음 작업도 받게함
  isRequestIdleCallbackScheduled = false

  // deadline이 없다면, 바로 실행
  if (typeof deadline === 'undefined')
    deadline = {
      timeRemaining: function () {
        return Number.MAX_VALUE
      },
    }

  // 작업이 남아있고, 여유가 있는 경우 실행
  while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
    var evt = eventsToSend.pop()

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

  // 해야할 작업이 있다면 다시 예약
  if (eventsToSend.length > 0) schedulePendingEvents()
}

이 예제에서는, requestIdleCallback가 없으면 바로 전송하도록 해두었다. 그러나 프로덕션 애플리케이션에서는 사용자의 상호작용과 충돌하지 않고 에러가 발송하지 않도록 timeout으로 지연해서 전송하는 것이 좋다.

requestIdleCallback으로 DOM 조작하기

requestIdleCallback이 성능에 도움이 될 수 있는 또다른 상황은, 필수적이지 않은 DOM을 변경해야 하는 경우가 있다. 예를 들어, 지속적으로 children 하단에 붙어서 로딩되는 DOM과 같은 것을 들 수 있다.

먼저, 브라우저가 지속적으로 사용중이어서, 작업을 할 수 있는 여유시간이 없는 경우도 가정해야 한다. 이 경우, 프레임별로 setImmediate를 실행해야 한다.

프레임이 끝나는 지점에서 콜백이 실행되면, 현재 프레임이 커밋된 이후에 실행할 수 있도록 스케쥴링 될 것이다. 즉, 스타일 변경사항이 적용되고, 레이아웃이 다시 계산될 것이다. idle callback내에서 DOM을 조작하려면, 레이아웃 계산이 취소될 수 있다. 다음 프레임에서 getBoundingClientRect이나 clientWidth와 같이 현재 레이아웃을 읽어오는 메소드가 있는 경우, 강제 동기식 레이아웃을 수행해야 하는데 이 경우 브라우저에서 성능 저하가 일어날 수 있다.

idle callback에서 DOM 조작을 트리거하지 않는 또다른 이유는, DOM 에 걸리는 시간을 예측할 수 없기 때문에, 브라우저에서 제공한 deadline을 쉽게 넘길 수 있기 때문이다.

따라서 가장 좋은 방법은 브라우저가 스스로 스케쥴링할 수 있는 requestAnimationFrame 콜백 내부에서 DOM 조작을 하는 것이다. 하나 주의해야할 것은, 만약 가상돔 라이브러리를 사용한다면 requestIdleCallback에서 변경작업을 수행하지만, idle callback이 아닌 다음 requestAnimationFrame에서 DOM 변경작업을 적용한다.

function processPendingElements(deadline) {
  // deadline이 없으면, 바로 실행
  if (typeof deadline === 'undefined')
    deadline = {
      timeRemaining: function () {
        return Number.MAX_VALUE
      },
    }

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

  // 작업에 여유가 있고, 작업이 있으면 바로 실행
  while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {
    var elToAdd = elementsToAdd.pop()
    var el = document.createElement(elToAdd.tag)
    el.textContent = elToAdd.content

    documentFragment.appendChild(el)

    // 바로 실행하는 것이 아니고, 다음 requestAnimationFrame 까지 대기
    scheduleVisualUpdateIfNeeded()
  }

  if (elementsToAdd.length > 0) scheduleElementCreation()
}
function scheduleVisualUpdateIfNeeded() {
  if (isVisualUpdateScheduled) return

  isVisualUpdateScheduled = true

  requestAnimationFrame(appendDocumentFragment)
}

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

더 읽어보기