avatar
Published on

requestIdleCallback으로 최적화하기

Author
  • avatar
    Name
    yceffort

사이트와 애플리케이션에는 실행해야할 스크립트가 잔뜩 쌓여있다. 이러한 자바스크립트가 최대한 빨리 실행되야 하는 것이 좋지만, 그와 동시에 사용자의 방해가 되지 않도록 해야 한다. 사용자가 페이지를 스크롤 할 때 데이터를 보내거나, 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
}

더 읽어보기