장기 작업 최적화

'기본 스레드를 차단하지 마세요'와 '장기 작업 중단하기'라는 말을 들었습니다. 그런데 이러한 작업을 하는 것의 의미는 무엇인가요?

제레미 와그너
제레미 바그너

웹 성능에 관한 글을 많이 읽어보면 JavaScript 앱을 빠르게 유지하기 위한 조언에 다음과 같은 내용이 포함됩니다.

  • "기본 스레드를 차단하지 마세요."
  • "장기 작업을 중단하세요."

그게 무슨 뜻일까요? 자바스크립트를 더 적게 제공하는 것이 좋지만, 그렇게 하면 페이지 수명 주기에서 사용자 인터페이스가 자동으로 빨라질까요? 그럴 수도 있고 그렇지 않을 수도 있습니다.

자바스크립트에서 작업을 최적화하는 것이 중요한 이유를 이해하려면 작업의 역할과 브라우저에서 작업을 처리하는 방식을 이해해야 합니다. 먼저 작업이 무엇인지 이해해야 합니다.

작업이란 무엇인가요?

작업은 브라우저가 실행하는 개별적인 작업입니다. 작업에는 렌더링, HTML 및 CSS 파싱, 작성한 JavaScript 코드 실행 및 직접 제어할 수 없는 기타 작업이 포함됩니다. 이 모든 것 중에서도 웹에 작성하고 배포하는 자바스크립트는 작업의 주요 소스입니다.

Chrome DevTools의 성능 프로파일러에 표시된 작업의 스크린샷 작업은 스택 상단에 있으며 클릭 이벤트 핸들러, 함수 호출 및 그 아래에 더 많은 항목이 있습니다. 이 작업에는 오른쪽에 일부 렌더링 작업도 포함되어 있습니다.
Chrome DevTools의 성능 프로파일러에서 click 이벤트 핸들러에 의해 시작된 작업의 묘사

작업은 여러 가지 방법으로 성능에 영향을 미칩니다. 예를 들어, 시작 시 브라우저가 JavaScript 파일을 다운로드할 때, 브라우저는 JavaScript가 실행될 수 있도록 해당 JavaScript를 파싱하고 컴파일하는 작업을 대기열에 추가합니다. 페이지 수명 주기의 후반부에서는 이벤트 핸들러, 자바스크립트 기반 애니메이션, 백그라운드 활동(예: 분석 수집)을 통해 상호작용을 유도하는 등 자바스크립트가 작동할 때 작업이 시작됩니다. 웹 작업자 및 이와 유사한 API를 제외하고 이러한 모든 작업은 기본 스레드에서 발생합니다.

기본 스레드는 무엇인가요?

기본 스레드는 브라우저에서 대부분의 작업이 실행되는 위치입니다. 기본 스레드라고 부르는 이유는 다음과 같습니다. 기본 스레드는 개발자가 작성하는 거의 모든 JavaScript가 작업을 실행하는 하나의 스레드이기 때문입니다.

기본 스레드는 한 번에 하나의 작업만 처리할 수 있습니다. 작업이 특정 지점(정확하게 50밀리초) 이상으로 확장되는 경우 장기 작업으로 분류됩니다. 장시간 작업이 실행되는 동안 사용자가 페이지와 상호작용하려고 하거나 중요한 렌더링 업데이트가 필요한 경우, 브라우저가 해당 작업을 처리하는 데 지연이 발생합니다. 이로 인해 상호작용 또는 렌더링 지연 시간이 발생합니다.

Chrome DevTools의 성능 프로파일러에 있는 긴 작업 작업의 차단 부분 (50밀리초 초과)이 빨간색 대각선 줄무늬로 표시됩니다.
Chrome 성능 프로파일러에 설명된 긴 작업입니다. 긴 작업은 작업 모서리에 빨간색 삼각형으로 표시되며 작업의 차단 부분이 빨간색 대각선 패턴으로 채워집니다.

할 일을 분해해야 합니다. 즉, 긴 작업 하나를 개별 실행에 걸리는 시간이 더 적은 작은 작업으로 분할한다는 의미입니다.

단일 긴 태스크와 동일한 태스크가 더 짧은 태스크로 나뉩니다. 긴 작업은 하나의 큰 직사각형인 반면 청크로 분할된 작업은 5개의 작은 상자로, 이 상자들은 전체적으로 긴 작업과 총 너비가 동일합니다.
단일 장기 태스크와 동일한 태스크를 시각화하여 짧은 태스크 5개로 나누었습니다.

작업이 분리되면 브라우저가 우선순위가 더 높은 작업에 응답할 기회가 많아지고 여기에는 사용자 상호작용이 포함되기 때문에 중요합니다.

작업 분리가 어떻게 사용자 상호작용을 용이하게 할 수 있는지에 대한 묘사 맨 위에서 장기 작업은 작업이 완료될 때까지 이벤트 핸들러의 실행을 차단합니다. 분할 작업은 하단의 작업을 통해 이벤트 핸들러가 그렇지 않은 경우보다 더 빨리 실행되도록 합니다.
작업이 너무 길고 브라우저가 상호작용에 빠르게 반응하지 못할 때 더 긴 작업이 더 작은 태스크로 나뉘어 발생하는 경우와 비교하여 어떤 상호작용이 발생하는지 시각화합니다.

위 그림의 위에서는 사용자 상호작용으로 인해 대기열에 추가된 이벤트 핸들러가 실행될 수 있도록 단일 긴 작업까지 기다려야 했습니다. 이는 상호작용이 발생하는 것을 지연시킵니다. 하단에서 이벤트 핸들러가 더 빨리 실행될 수 있습니다. 이벤트 핸들러는 작은 작업 간에 실행될 수 있기 때문에 긴 작업이 완료될 때까지 기다려야 하는 경우보다 더 빨리 실행됩니다. 상단 예에서는 사용자가 지연을 확인했을 수 있지만 하단에서는 상호작용이 즉각적으로 느껴졌을 수 있습니다.

하지만 문제는 이러한 작업을 실행하는 방법을 이미 알고 있지 않다면 '장기 작업 분리'와 '기본 스레드를 차단하지 마세요'라는 조언이 구체적이지 않다는 점입니다. 이 가이드에서는 그 내용을 설명합니다.

업무 관리 전략

소프트웨어 아키텍처에서 일반적으로 조언하는 조언은 작업을 더 작은 함수로 나누는 것입니다. 이렇게 하면 코드 가독성이 향상되고 프로젝트 유지관리가 쉬워집니다. 이렇게 하면 테스트를 더 쉽게 작성할 수 있습니다.

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

이 예시에는 양식 유효성 검사, 스피너 표시, 데이터 전송 등 작업을 실행하기 위해 함수 5개를 호출하는 saveSettings()라는 함수가 있습니다. 이는 개념적으로 잘 구성되어 있습니다. 이러한 함수 중 하나를 디버그해야 하는 경우 프로젝트 트리를 순회하여 각 함수의 기능을 파악할 수 있습니다.

그러나 문제는 자바스크립트가 이러한 각 함수를 saveSettings() 함수 내에서 실행되기 때문에 별도의 작업으로 실행하지 않는다는 것입니다. 즉, 5개 함수가 모두 단일 태스크로 실행됩니다.

Chrome의 성능 프로파일러에 표시된 대로 saveSettings 함수. 최상위 함수는 5개의 다른 함수를 호출하지만, 모든 작업은 기본 스레드를 차단하는 하나의 긴 작업에서 발생합니다.
함수 5개를 호출하는 단일 함수 saveSettings()입니다. 작업은 하나의 긴 모놀리식 태스크의 일부로 실행됩니다.

최상의 사례 시나리오에서는 이러한 함수 중 하나만이라도 작업의 총 길이에 50밀리초 이상 기여할 수 있습니다. 최악의 경우, 이러한 작업 중 상당수가 특히 리소스가 제한된 기기에서 훨씬 오래 실행될 수 있습니다. 다음은 작업을 분류하고 우선순위를 지정하는 데 사용할 수 있는 일련의 전략입니다.

수동으로 코드 실행 연기

개발자가 작업을 더 작은 작업으로 나누는 데 사용한 방법 중 하나는 setTimeout()입니다. 이 기법을 사용하여 함수를 setTimeout()에 전달합니다. 이렇게 하면 제한 시간을 0로 지정하더라도 콜백 실행이 별도의 작업으로 연기됩니다.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

순차적으로 실행해야 하는 일련의 함수가 있는 경우 이 방법이 효과적이지만 코드가 항상 이러한 방식으로 구성되는 것은 아닙니다. 예를 들어 루프에서 처리해야 하는 대량의 데이터가 있을 수 있으며, 항목이 수백만 개 있다면 작업에 매우 오랜 시간이 걸릴 수 있습니다.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

여기서 setTimeout()를 사용하면 인체공학으로 인해 구현하기가 어렵고 각 항목을 매우 빠르게 처리할 수 있더라도 전체 데이터 배열을 처리하는 데 매우 오랜 시간이 걸릴 수 있기 때문에 문제가 발생합니다. 모두 합쳐지며 setTimeout()는 작업에 적합한 도구가 아닙니다. 적어도 이런 방식으로 사용할 때는 그렇지 않습니다.

setTimeout() 외에도 코드 실행을 후속 작업으로 지연할 수 있는 API가 몇 가지 있습니다. 하나는 더 빠른 시간 제한을 위해 postMessage()를 사용하는 것과 관련이 있습니다. requestIdleCallback()를 사용하여 작업을 중단할 수도 있지만 주의해야 합니다. requestIdleCallback()는 가능한 가장 낮은 우선순위로 브라우저 유휴 시간에만 작업을 예약합니다. 기본 스레드가 정체되면 requestIdleCallback()로 예약된 작업이 전혀 실행되지 않을 수 있습니다.

async/await를 사용하여 양보 지점 생성

이 가이드의 나머지 부분에서 'yield to the main thread(기본 스레드에 수익 창출)'이라는 문구를 보게 되는데, 이것이 무엇을 의미할까요? 왜 해야 할까요? 언제 해야 하나요?

작업이 분할되면 브라우저의 내부 우선순위 체계에 따라 다른 작업의 우선순위가 더 높게 지정될 수 있습니다. 기본 스레드에 양도하는 한 가지 방법은 setTimeout() 호출로 확인되는 Promise의 조합을 사용하는 것입니다.

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

saveSettings() 함수에서 각 함수 호출 후 yieldToMain() 함수를 await하면 각 작업 후 기본 스레드에 양보할 수 있습니다.

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

결과적으로 한때 모놀리식이던 작업이 이제 별도의 작업으로 분리됩니다.

Chrome의 성능 프로파일러에 묘사된 것과 동일한 saveSettings 함수를 생성하며만 가능합니다. 결과적으로 한때 모놀리식 작업을 수행했던 작업이 함수마다 하나씩, 총 5개의 개별 작업으로 분류됩니다.
이제 saveSettings() 함수가 하위 함수를 별도의 작업으로 실행합니다.

setTimeout()를 수동으로 사용하는 대신 프라미스 기반 접근 방식을 사용하여 수익을 얻는 이점이 인체공학에 더 좋습니다. 양보 포인트가 선언적이 되므로 읽고, 이해하고, 더 쉽게 작성할 수 있습니다.

필요한 경우에만 제공

작업이 많지만 사용자가 페이지와 상호작용하려고 하는 경우에만 작업을 반환하려면 어떻게 해야 할까요? 이러한 목적으로 isInputPending()가 개발되었습니다.

isInputPending()는 사용자가 페이지 요소와 상호작용하려고 하는지 확인하기 위해 언제든지 실행할 수 있는 함수입니다. isInputPending()를 호출하면 true가 반환됩니다. 그 외의 경우에는 false를 반환합니다.

실행해야 하는 작업 대기열이 있지만 입력에 방해가 되지 않는다고 가정해 보겠습니다. isInputPending()와 맞춤 yieldToMain() 함수를 모두 사용하는 이 코드는 사용자가 페이지와 상호작용하려고 하는 동안 입력이 지연되지 않도록 합니다.

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

saveSettings()가 실행되는 동안 큐의 태스크를 루프 처리합니다. 루프 중에 isInputPending()true를 반환하면 saveSettings()는 사용자 입력을 처리할 수 있도록 yieldToMain()를 호출합니다. 그렇지 않으면 다음 태스크를 큐의 맨 앞으로 이동하여 계속 실행합니다. 더 이상 작업이 남아 있지 않을 때까지 이 작업이 수행됩니다.

Chrome의 성능 프로파일러에서 실행되는 saveSettings 함수를 보여주는 그림 결과로 반환되는 작업은 isInputPending에서 true를 반환할 때까지 기본 스레드를 차단합니다. 이 시점에 작업은 기본 스레드에 양도됩니다.
saveSettings()이 태스크 5개의 태스크 큐를 실행하지만 두 번째 작업 항목이 실행되는 동안 사용자가 클릭하여 메뉴를 열었습니다. isInputPending()는 기본 스레드에 양도하여 상호작용을 처리하고 나머지 작업 실행을 재개합니다.

생성형 메커니즘과 함께 isInputPending()를 사용하면 브라우저가 처리 중인 작업을 중지하여 중요한 사용자 대상 상호작용에 응답할 수 있습니다. 이렇게 하면 많은 작업이 진행 중인 여러 상황에서 사용자에게 응답하는 페이지의 기능을 개선하는 데 도움이 됩니다.

isInputPending()를 사용하는 또 다른 방법, 특히 이를 지원하지 않는 브라우저에 대체를 제공하려고 하는 경우 시간 기반 접근 방식을 선택적 체이닝 연산자와 함께 사용하는 것입니다.

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

이 접근 방식을 사용하면 기한을 사용 (및 조정)하여 isInputPending()를 지원하지 않는 브라우저의 대체를 받을 수 있습니다. 그러면 사용자 입력에 따라 또는 특정 시점에 작업이 분리되도록 할 수 있습니다.

현재 API의 격차

지금까지 언급한 API는 작업을 분할하는 데 도움이 될 수 있지만, 상당한 단점이 있습니다. 후속 작업에서 실행되도록 코드를 지연하여 기본 스레드에 양도하면 해당 코드가 작업 대기열의 맨 끝에 추가됩니다.

페이지의 모든 코드를 제어하는 경우 작업의 우선순위를 지정하는 기능이 있는 자체 스케줄러를 만들 수 있지만, 서드 파티 스크립트는 사용자의 스케줄러를 사용하지 않습니다. 실제로 이러한 환경에서는 작업의 우선순위를 지정할 수 없습니다. 데이터를 분할하거나 사용자 상호작용에 명시적으로 양보할 수만 있습니다.

다행히도 현재 이러한 문제를 해결하는 전용 스케줄러 API가 개발 중입니다.

전용 스케줄러 API

스케줄러 API는 현재 이 함수 작성 시점에 Chromium 브라우저 및 Firefox에서 플래그 뒤에 사용할 수 있는 postTask() 함수를 제공합니다. postTask()를 사용하면 작업을 더 세밀하게 예약할 수 있으며, 이는 우선순위가 낮은 작업이 기본 스레드에 넘겨지도록 브라우저가 작업의 우선순위를 지정하는 데 도움이 되는 한 가지 방법입니다. postTask()는 프로미스를 사용하고 priority 설정을 허용합니다.

postTask() API에는 3가지 우선순위를 사용할 수 있습니다.

  • 'background': 우선순위가 가장 낮은 작업
  • 'user-visible': 우선순위가 중간인 작업 priority이 설정되지 않은 경우 기본값입니다.
  • 'user-blocking': 높은 우선순위로 실행해야 하는 중요한 작업

다음 코드를 예로 들어보겠습니다. 여기서 postTask() API는 가능한 가장 높은 우선순위로 작업 3개를 실행하고 나머지 작업 두 개를 가능한 가장 낮은 우선순위로 실행하는 데 사용합니다.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

여기서 작업의 우선순위는 사용자 상호작용과 같이 브라우저에 우선순위가 지정된 작업이 제자리에 들어갈 수 있는 방식으로 예약됩니다.

Chrome의 성능 프로파일러에 표시된 대로 saveSettings 함수를 사용하지만 postTask. postTask를 사용하면 각 함수 saveSettings를 분할하고 사용자 상호작용이 차단되지 않고 실행될 수 있도록 우선순위를 지정합니다.
saveSettings()가 실행되면 함수는 postTask()를 사용하여 개별 함수를 예약합니다. 사용자에게 표시되는 중요한 작업은 높은 우선순위로 예약되고 사용자가 모르는 작업은 백그라운드에서 실행되도록 예약됩니다. 이렇게 하면 작업이 적절하게 구분되고 우선순위가 지정되므로 사용자 상호작용을 더 빠르게 실행할 수 있습니다.

다음은 postTask() 사용 방법의 간단한 예입니다. 필요에 따라 여러 TaskController 인스턴스의 우선순위를 변경하는 기능을 포함하여 작업 간에 우선순위를 공유할 수 있는 다양한 TaskController 객체를 인스턴스화할 수 있습니다.

scheduler.yield를 통한 연속적인 기본 수익

스케줄러 API에서 제안되는 부분 중 하나는 scheduler.yield입니다. 이 API는 브라우저의 기본 스레드에 양보하기 위해 특별히 설계된 API이며 현재 오리진 트라이얼로 사용해 볼 수 있습니다. 사용 방식은 이 도움말 앞부분에서 설명한 yieldToMain() 함수와 유사합니다.

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

위 코드는 대체로 익숙하지만 yieldToMain()를 사용하는 대신 및 await scheduler.yield()를 호출합니다.

양보, 양보, 양보와 연속이 없는 작업을 보여주는 3개의 다이어그램 양보하지 않으면 긴 작업이 수반됩니다. 양보를 사용하면 더 짧지만 관련 없는 다른 작업에 의해 중단될 수 있는 작업이 더 많아집니다. 양보와 연속을 사용하면 더 짧은 작업이 더 많아지지만 실행 순서는 유지됩니다.
양보, 양보, 양보 및 지속과 함께 작업 실행을 시각화합니다. scheduler.yield()를 사용하면 양보 지점 이후에도 작업이 중단된 부분부터 다시 시작됩니다.

scheduler.yield()의 이점은 연속입니다. 즉, 작업 집합의 중간에 양보하면 다른 예약된 작업은 양보 지점 이후에도 동일한 순서로 계속됩니다. 이렇게 하면 서드 파티 스크립트의 코드가 코드 실행 순서를 잘못 사용하지 않습니다.

결론

작업을 관리하기란 어렵지만, 그렇게 하면 페이지가 사용자 상호작용에 더 빠르게 대응할 수 있습니다. 작업 관리 및 우선순위 지정에 관한 한 가지 조언은 없습니다. 오히려 여러 다른 기술입니다. 재차 강조하지만, 작업을 관리할 때 고려해야 할 주요 사항은 다음과 같습니다.

  • 사용자에게 표시되는 중요한 작업의 경우 기본 스레드에 양도합니다.
  • isInputPending()를 사용하여 사용자가 페이지와 상호작용하려고 할 때 기본 스레드에 양보합니다.
  • postTask()를 사용하여 작업의 우선순위를 지정합니다.
  • 마지막으로 함수에서 가능한 한 적은 작업을 실행합니다.

이러한 도구 중 하나 이상을 사용하면 애플리케이션의 작업을 구조화하여 사용자의 요구 사항에 우선순위를 둘 수 있으면서도 중요도가 낮은 작업이 완료되도록 할 수 있습니다. 이렇게 하면 더 나은 사용자 환경을 만들어 더 빠르게 반응하고 더 즐겁게 사용할 수 있습니다.

이 도움말에 대한 기술적인 검토를 제공해 주신 필립 월튼에게 진심으로 감사드립니다.

히어로 이미지 출처: Unsplash, Amirali Mirhashemian 제공