'기본 스레드를 차단하지 마세요'와 '장기 작업 중단하기'라는 말을 들었습니다. 그런데 이러한 작업을 하는 것의 의미는 무엇인가요?
웹 성능에 관한 글을 많이 읽어보면 JavaScript 앱을 빠르게 유지하기 위한 조언에 다음과 같은 내용이 포함됩니다.
- "기본 스레드를 차단하지 마세요."
- "장기 작업을 중단하세요."
그게 무슨 뜻일까요? 자바스크립트를 더 적게 제공하는 것이 좋지만, 그렇게 하면 페이지 수명 주기에서 사용자 인터페이스가 자동으로 빨라질까요? 그럴 수도 있고 그렇지 않을 수도 있습니다.
자바스크립트에서 작업을 최적화하는 것이 중요한 이유를 이해하려면 작업의 역할과 브라우저에서 작업을 처리하는 방식을 이해해야 합니다. 먼저 작업이 무엇인지 이해해야 합니다.
작업이란 무엇인가요?
작업은 브라우저가 실행하는 개별적인 작업입니다. 작업에는 렌더링, HTML 및 CSS 파싱, 작성한 JavaScript 코드 실행 및 직접 제어할 수 없는 기타 작업이 포함됩니다. 이 모든 것 중에서도 웹에 작성하고 배포하는 자바스크립트는 작업의 주요 소스입니다.
작업은 여러 가지 방법으로 성능에 영향을 미칩니다. 예를 들어, 시작 시 브라우저가 JavaScript 파일을 다운로드할 때, 브라우저는 JavaScript가 실행될 수 있도록 해당 JavaScript를 파싱하고 컴파일하는 작업을 대기열에 추가합니다. 페이지 수명 주기의 후반부에서는 이벤트 핸들러, 자바스크립트 기반 애니메이션, 백그라운드 활동(예: 분석 수집)을 통해 상호작용을 유도하는 등 자바스크립트가 작동할 때 작업이 시작됩니다. 웹 작업자 및 이와 유사한 API를 제외하고 이러한 모든 작업은 기본 스레드에서 발생합니다.
기본 스레드는 무엇인가요?
기본 스레드는 브라우저에서 대부분의 작업이 실행되는 위치입니다. 기본 스레드라고 부르는 이유는 다음과 같습니다. 기본 스레드는 개발자가 작성하는 거의 모든 JavaScript가 작업을 실행하는 하나의 스레드이기 때문입니다.
기본 스레드는 한 번에 하나의 작업만 처리할 수 있습니다. 작업이 특정 지점(정확하게 50밀리초) 이상으로 확장되는 경우 장기 작업으로 분류됩니다. 장시간 작업이 실행되는 동안 사용자가 페이지와 상호작용하려고 하거나 중요한 렌더링 업데이트가 필요한 경우, 브라우저가 해당 작업을 처리하는 데 지연이 발생합니다. 이로 인해 상호작용 또는 렌더링 지연 시간이 발생합니다.
할 일을 분해해야 합니다. 즉, 긴 작업 하나를 개별 실행에 걸리는 시간이 더 적은 작은 작업으로 분할한다는 의미입니다.
작업이 분리되면 브라우저가 우선순위가 더 높은 작업에 응답할 기회가 많아지고 여기에는 사용자 상호작용이 포함되기 때문에 중요합니다.
위 그림의 위에서는 사용자 상호작용으로 인해 대기열에 추가된 이벤트 핸들러가 실행될 수 있도록 단일 긴 작업까지 기다려야 했습니다. 이는 상호작용이 발생하는 것을 지연시킵니다. 하단에서 이벤트 핸들러가 더 빨리 실행될 수 있습니다. 이벤트 핸들러는 작은 작업 간에 실행될 수 있기 때문에 긴 작업이 완료될 때까지 기다려야 하는 경우보다 더 빨리 실행됩니다. 상단 예에서는 사용자가 지연을 확인했을 수 있지만 하단에서는 상호작용이 즉각적으로 느껴졌을 수 있습니다.
하지만 문제는 이러한 작업을 실행하는 방법을 이미 알고 있지 않다면 '장기 작업 분리'와 '기본 스레드를 차단하지 마세요'라는 조언이 구체적이지 않다는 점입니다. 이 가이드에서는 그 내용을 설명합니다.
업무 관리 전략
소프트웨어 아키텍처에서 일반적으로 조언하는 조언은 작업을 더 작은 함수로 나누는 것입니다. 이렇게 하면 코드 가독성이 향상되고 프로젝트 유지관리가 쉬워집니다. 이렇게 하면 테스트를 더 쉽게 작성할 수 있습니다.
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
이 예시에는 양식 유효성 검사, 스피너 표시, 데이터 전송 등 작업을 실행하기 위해 함수 5개를 호출하는 saveSettings()
라는 함수가 있습니다. 이는 개념적으로 잘 구성되어 있습니다. 이러한 함수 중 하나를 디버그해야 하는 경우 프로젝트 트리를 순회하여 각 함수의 기능을 파악할 수 있습니다.
그러나 문제는 자바스크립트가 이러한 각 함수를 saveSettings()
함수 내에서 실행되기 때문에 별도의 작업으로 실행하지 않는다는 것입니다. 즉, 5개 함수가 모두 단일 태스크로 실행됩니다.
최상의 사례 시나리오에서는 이러한 함수 중 하나만이라도 작업의 총 길이에 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();
}
}
결과적으로 한때 모놀리식이던 작업이 이제 별도의 작업으로 분리됩니다.
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()
를 호출합니다. 그렇지 않으면 다음 태스크를 큐의 맨 앞으로 이동하여 계속 실행합니다. 더 이상 작업이 남아 있지 않을 때까지 이 작업이 수행됩니다.
생성형 메커니즘과 함께 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'});
};
여기서 작업의 우선순위는 사용자 상호작용과 같이 브라우저에 우선순위가 지정된 작업이 제자리에 들어갈 수 있는 방식으로 예약됩니다.
다음은 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()
를 호출합니다.
scheduler.yield()
의 이점은 연속입니다. 즉, 작업 집합의 중간에 양보하면 다른 예약된 작업은 양보 지점 이후에도 동일한 순서로 계속됩니다. 이렇게 하면 서드 파티 스크립트의 코드가 코드 실행 순서를 잘못 사용하지 않습니다.
결론
작업을 관리하기란 어렵지만, 그렇게 하면 페이지가 사용자 상호작용에 더 빠르게 대응할 수 있습니다. 작업 관리 및 우선순위 지정에 관한 한 가지 조언은 없습니다. 오히려 여러 다른 기술입니다. 재차 강조하지만, 작업을 관리할 때 고려해야 할 주요 사항은 다음과 같습니다.
- 사용자에게 표시되는 중요한 작업의 경우 기본 스레드에 양도합니다.
isInputPending()
를 사용하여 사용자가 페이지와 상호작용하려고 할 때 기본 스레드에 양보합니다.postTask()
를 사용하여 작업의 우선순위를 지정합니다.- 마지막으로 함수에서 가능한 한 적은 작업을 실행합니다.
이러한 도구 중 하나 이상을 사용하면 애플리케이션의 작업을 구조화하여 사용자의 요구 사항에 우선순위를 둘 수 있으면서도 중요도가 낮은 작업이 완료되도록 할 수 있습니다. 이렇게 하면 더 나은 사용자 환경을 만들어 더 빠르게 반응하고 더 즐겁게 사용할 수 있습니다.
이 도움말에 대한 기술적인 검토를 제공해 주신 필립 월튼에게 진심으로 감사드립니다.
히어로 이미지 출처: Unsplash, Amirali Mirhashemian 제공