스크립트 평가 및 장기 작업

스크립트를 로드할 때 실행 전에 브라우저에서 스크립트를 평가하는 데 시간이 걸리므로 이로 인해 긴 작업이 발생할 수 있습니다. 스크립트 평가의 작동 방식과 페이지 로드 중에 스크립트 평가로 인해 장기 작업이 발생하지 않도록 할 수 있는 방법을 알아보세요.

다음 페인트에 대한 상호작용 (INP) 최적화와 관련된 대부분의 조언은 상호작용 자체를 최적화하라는 것입니다. 예를 들어 장기 작업 최적화 가이드에서는 setTimeout 등을 사용하여 생성하는 등의 기법을 설명합니다. 이러한 기법은 긴 작업을 피함으로써 기본 스레드에 약간의 여유 공간이 생기므로 유용합니다. 이를 통해 상호작용 및 다른 활동이 하나의 긴 작업을 더 빨리 실행할 수 있는 기회가 많아집니다.

하지만 스크립트 자체를 로드하는 데 드는 긴 작업은 어떨까요? 이러한 작업은 사용자 상호작용을 방해하고 로드 중에 페이지의 INP에 영향을 줄 수 있습니다. 이 가이드에서는 브라우저에서 스크립트 평가로 시작된 작업을 어떻게 처리하는지 살펴보고, 스크립트 평가 작업을 분리하여 페이지가 로드되는 동안 기본 스레드가 사용자 입력에 더 잘 반응하도록 할 수 있는 방법을 살펴봅니다.

스크립트 평가란 무엇인가요?

많은 자바스크립트를 제공하는 애플리케이션을 프로파일링한 경우, 문제의 원인에 평가 스크립트라는 라벨이 지정된 장기 작업을 본 적이 있을 것입니다.

스크립트 평가는 Chrome DevTools의 성능 프로파일러에 시각화된 대로 작동합니다. 이 작업은 시작 중에 긴 작업을 유발하여 사용자 상호작용에 응답하는 기본 스레드의 기능을 차단합니다.
스크립트 평가는 Chrome DevTools의 성능 프로파일러에 표시된 대로 작동합니다. 이 경우 작업은 기본 스레드가 다른 작업(사용자 상호작용을 유도하는 작업 포함)을 맡지 못하도록 차단하는 긴 작업을 발생시키기에 충분합니다.

JavaScript는 실행 직전에 컴파일되므로 스크립트 평가는 브라우저에서 JavaScript를 실행하는 데 필요한 부분입니다. 스크립트가 평가되면 먼저 오류가 있는지 파싱됩니다. 파서가 오류를 찾지 못하면 스크립트가 바이트 코드로 컴파일된 후 실행을 계속할 수 있습니다.

필요하기는 하지만, 사용자가 페이지가 처음 렌더링된 직후에 페이지와 상호작용하려고 할 수 있기 때문에 스크립트 평가는 문제가 될 수 있습니다. 하지만 페이지가 렌더링되었다고 해서 페이지의 로드가 완료된 것은 아닙니다. 페이지가 스크립트를 평가하는 중이기 때문에 로드 중에 발생하는 상호작용이 지연될 수 있습니다. 이 시점에서 상호작용이 일어날 수 있다는 보장은 없지만(이를 담당하는 스크립트가 아직 로드되지 않았을 수 있기 때문에), 준비된 JavaScript에 종속되거나 상호작용이 JavaScript에 전혀 의존하지 않을 수 있습니다.

스크립트와 스크립트를 평가하는 작업의 관계

스크립트 평가를 담당하는 작업이 시작되는 방식은 로드하는 스크립트가 일반적인 <script> 요소로 로드되는지 아니면 스크립트가 type=module로 로드된 모듈인지에 따라 다릅니다. 브라우저는 작업을 다르게 처리하는 경향이 있으므로 주요 브라우저 엔진이 스크립트 평가를 처리하는 방식은 스크립트 평가 동작이 다양한 부분에서 다루어질 것입니다.

<script> 요소로 로드된 스크립트

일반적으로 스크립트 평가를 위해 전달된 작업의 수는 페이지의 <script> 요소 수와 직접적인 관계가 있습니다. 각 <script> 요소는 요청된 스크립트를 평가하는 작업을 시작하여 파싱, 컴파일, 실행할 수 있습니다. Chromium 기반 브라우저, Safari, Firefox가 여기에 해당합니다.

중요한 이유 프로덕션 스크립트를 관리하기 위해 번들러를 사용하고 있고 페이지에서 실행하는 데 필요한 모든 것을 하나의 스크립트로 번들로 묶도록 번들러를 구성했다고 가정해 보겠습니다. 자신의 웹사이트에서 이러한 경우라면 해당 스크립트를 평가하기 위해 단일 작업이 전달될 것으로 예상할 수 있습니다. 나쁜 것인가요? 반드시 그런 것은 아닙니다. 이 스크립트가 거대한 경우는 예외입니다.

많은 양의 JavaScript를 로드하지 않도록 하여 스크립트 평가 작업을 분할하고 추가 <script> 요소를 사용하여 더 작은 개별 스크립트를 더 로드할 수 있습니다.

항상 페이지 로드 중에 최소한의 JavaScript를 로드하도록 노력해야 하지만, 스크립트를 분할하면 기본 스레드를 차단할 수 있는 큰 작업 하나를 얻는 대신 기본 스레드를 전혀 차단하지 않거나 시작했던 작업보다 작은 작업을 더 많이 할 수 있습니다.

Chrome DevTools의 성능 프로파일러에 시각화된 스크립트 평가와 관련된 여러 작업 큰 스크립트가 적은 대신 여러 개의 작은 스크립트가 로드되기 때문에 작업이 장기 작업이 될 가능성이 줄어들어 기본 스레드가 사용자 입력에 더 빠르게 반응할 수 있습니다.
페이지의 HTML에 여러 <script> 요소가 포함되어 있어 스크립트를 평가하기 위해 여러 작업이 생성됩니다. 이 방법은 큰 스크립트 번들 하나를 사용자에게 보내는 것보다 낫습니다. 이렇게 하면 기본 스레드가 차단될 가능성이 커집니다.

스크립트 평가를 위한 작업을 분할하는 것은 상호작용 중에 실행되는 이벤트 콜백 중에 수익 창출과 다소 비슷하다고 생각할 수 있습니다. 그러나 스크립트 평가를 사용할 때 생성 메커니즘은 로드하는 JavaScript를 기본 스레드를 차단할 가능성이 더 큰 소수의 큰 스크립트보다 작은 여러 개의 작은 스크립트로 분할합니다.

<script> 요소 및 type=module 속성으로 로드된 스크립트

이제 <script> 요소의 type=module 속성을 사용하여 ES 모듈을 브라우저에서 기본적으로 로드할 수 있습니다. 스크립트 로드에 대한 이 접근 방식은 특히 지도 가져오기와 함께 사용할 때 프로덕션 용도로 코드를 변환할 필요가 없는 등의 개발자 경험상 이점을 제공합니다. 그러나 이 방법으로 스크립트를 로드하면 브라우저마다 다른 작업이 예약됩니다.

Chromium 기반 브라우저

Chrome과 같은 브라우저 또는 Chrome에서 파생된 브라우저에서 type=module 속성을 사용하여 ES 모듈을 로드하면 type=module를 사용하지 않을 때 일반적으로 표시되는 것과 다른 유형의 작업이 생성됩니다. 예를 들어 각 모듈 스크립트의 작업은 Compile module이라는 활동이 포함된 작업이 실행됩니다.

모듈 컴파일은 Chrome DevTools에 시각화된 것처럼 여러 작업에서 작동합니다.
Chromium 기반 브라우저의 모듈 로드 동작입니다. 각 모듈 스크립트는 평가 전에 콘텐츠를 컴파일하는 Compile module 호출을 생성합니다.

모듈이 컴파일되고 나면 이후에 모듈에서 실행되는 모든 코드는 평가 모듈이라는 라벨이 지정된 활동을 시작합니다.

Chrome DevTools의 성능 패널에 시각화된 모듈의 적시 평가
모듈의 코드가 실행되면 모듈이 적시에 평가됩니다.

이로 인해 Chrome 및 관련 브라우저에서는 ES 모듈을 사용할 때 컴파일 단계가 분리됩니다. 이는 장기 작업을 관리한다는 측면에서 분명한 이득입니다. 하지만 그 결과 모듈 평가 작업을 수행하면 여전히 피할 수 없는 비용이 발생합니다. 가능한 한 적은 JavaScript를 제공하기 위해 노력해야 하지만, 브라우저에 상관없이 ES 모듈을 사용하면 다음과 같은 이점이 있습니다.

  • 모든 모듈 코드는 엄격 모드에서 자동으로 실행되므로, 엄격하지 않은 컨텍스트에서는 할 수 없었던 JavaScript 엔진이 최적화할 수 있습니다.
  • type=module를 사용하여 로드된 스크립트는 기본적으로 지연된 것처럼 처리됩니다. type=module로 로드된 스크립트에서 async 속성을 사용하여 이 동작을 변경할 수 있습니다.

Safari 및 Firefox

Safari와 Firefox에서 모듈을 로드하면, 각 모듈이 별도의 작업으로 평가됩니다. 즉, 이론적으로는 정적 import 문으로만 구성된 단일 최상위 모듈을 다른 모듈에 로드할 수 있으며, 로드된 모든 모듈에는 평가를 위한 별도의 네트워크 요청 및 작업이 발생합니다.

동적 import()을(를) 포함하여 로드된 스크립트

동적 import()는 스크립트를 로드하는 또 다른 방법입니다. ES 모듈의 상단에 있어야 하는 정적 import 문과 달리 동적 import() 호출은 필요에 따라 JavaScript 청크를 로드하기 위해 스크립트의 어디에나 나타날 수 있습니다. 이 기법을 코드 분할이라고 합니다.

동적 import()에는 INP 개선과 관련하여 두 가지 이점이 있습니다.

  1. 나중에 로드되도록 지연되는 모듈은 시작 시 로드되는 JavaScript의 양을 줄여 시작 시 기본 스레드 경합을 줄입니다. 이렇게 하면 기본 스레드가 확보되어 사용자 상호작용에 더 잘 반응할 수 있습니다.
  2. 동적 import() 호출이 실행될 때 각 호출은 각 모듈의 컴파일 및 평가를 자체 작업으로 효과적으로 분리합니다. 물론 매우 큰 모듈을 로드하는 동적 import()는 다소 큰 스크립트 평가 작업을 시작하며, 상호작용이 동적 import() 호출과 동시에 발생하는 경우 사용자 입력에 응답하는 기본 스레드의 기능을 방해할 수 있습니다. 따라서 가급적 적은 JavaScript를 로드하는 것이 매우 중요합니다.

동적 import() 호출은 모든 주요 브라우저 엔진에서 유사하게 동작합니다. 즉, 생성되는 스크립트 평가 작업은 동적으로 가져오는 모듈의 수와 동일합니다.

웹 작업자에 로드된 스크립트

웹 작업자는 특수한 JavaScript 사용 사례입니다. 웹 작업자는 기본 스레드에 등록되고 작업자 내의 코드는 자체 스레드에서 실행됩니다. 이는 웹 작업자를 등록하는 코드는 기본 스레드에서 실행되지만 웹 작업자 내의 코드는 실행되지 않는다는 점에서 매우 유용합니다. 이렇게 하면 기본 스레드 정체가 줄어들고 기본 스레드가 사용자 상호작용에 더 잘 반응하도록 할 수 있습니다.

기본 스레드 작업을 줄이는 것 외에도 웹 작업자는 importScripts 또는 모듈 작업자를 지원하는 브라우저의 정적 import 문을 통해 작업자 컨텍스트에서 사용할 외부 스크립트를 자체 로드할 수 있습니다. 결과적으로 웹 작업자가 요청한 모든 스크립트는 기본 스레드를 벗어나 평가됩니다.

장단점 및 고려사항

스크립트를 별도의 작은 파일로 분할하면 적은 수의 큰 파일을 로드하는 대신 긴 작업을 제한하는 데 도움이 되지만, 스크립트를 분할하는 방법을 결정할 때는 몇 가지 사항을 고려해야 합니다.

압축 효율

압축은 스크립트를 분할할 때 중요한 요소입니다. 스크립트가 작으면 압축의 효율성이 다소 떨어집니다. 스크립트가 클수록 압축 시 더 큰 이점이 있습니다. 압축 효율성을 높이면 스크립트의 로드 시간을 가능한 한 낮게 유지하는 데 도움이 되지만, 시작 중에 더 나은 상호작용을 용이하게 하기 위해 스크립트를 충분히 작은 청크로 분할하도록 하려면 약간의 균형을 맞추어야 합니다.

번들러는 웹사이트에서 사용하는 스크립트의 출력 크기를 관리하는 데 이상적인 도구입니다.

  • webpack의 경우 SplitChunksPlugin 플러그인이 도움이 될 수 있습니다. 애셋 크기 관리에 도움이 되도록 설정할 수 있는 옵션은 SplitChunksPlugin 문서를 참고하세요.
  • Rollupesbuild와 같은 다른 번들러의 경우 코드에서 동적 import() 호출을 사용하여 스크립트 파일 크기를 관리할 수 있습니다. Webpack뿐만 아니라 번들러는 동적으로 가져온 애셋을 자동으로 자체 파일로 분리하므로 초기 번들 크기가 커지는 것을 방지합니다.

캐시 무효화

캐시 무효화는 재방문 시 페이지가 로드되는 속도에 큰 역할을 합니다. 대용량의 모놀리식 스크립트 번들을 제공하면 브라우저 캐싱과 관련하여 불이익을 받게 됩니다. 패키지 업데이트 또는 버그 수정 배송을 통해 퍼스트 파티 코드를 업데이트하면 전체 번들이 무효화되어 다시 다운로드해야 하기 때문입니다.

스크립트를 분할하면 스크립트 평가 작업을 작은 작업에 걸쳐 분할할 뿐만 아니라 재방문 방문자가 네트워크가 아닌 브라우저 캐시에서 더 많은 스크립트를 가져올 가능성도 높아집니다. 따라서 페이지 로드 속도가 전반적으로 빨라집니다.

중첩된 모듈과 로드 성능

ES 모듈을 프로덕션에 제공하고 type=module 속성으로 로드하는 경우 모듈 중첩이 시작 시간에 어떤 영향을 미칠 수 있는지 알아야 합니다. 모듈 중첩은 ES 모듈이 다른 ES 모듈을 정적으로 가져오는 다른 ES 모듈을 정적으로 가져오는 경우를 의미합니다.

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

ES 모듈이 함께 번들로 묶이지 않으면 위 코드로 인해 네트워크 요청 체인이 생성됩니다. <script> 요소에서 a.js가 요청되면 b.js에 대한 다른 네트워크 요청이 전달되고, 여기에 c.js또 다른 요청이 포함됩니다. 이를 피하는 한 가지 방법은 번들러를 사용하는 것입니다. 하지만 스크립트 평가 작업을 분산하도록 스크립트를 분할하도록 번들러를 구성해야 합니다.

번들러를 사용하지 않으려는 경우 중첩된 모듈 호출을 우회하는 또 다른 방법은 modulepreload 리소스 힌트를 사용하는 것입니다. 이 리소스는 네트워크 요청 체인을 방지하기 위해 ES 모듈을 미리 미리 로드합니다.

결론

브라우저에서 스크립트 평가를 최적화하는 작업은 의심의 여지가 없습니다. 이 방법은 웹사이트의 요구사항 및 제약조건에 따라 달라집니다. 그러나 스크립트를 분할하면 스크립트 평가 작업이 수많은 작은 작업에 분산되므로 기본 스레드가 기본 스레드를 차단하지 않고 사용자 상호작용을 더 효율적으로 처리할 수 있게 됩니다.

요약하자면 대규모 스크립트 평가 작업을 분할하기 위해 할 수 있는 몇 가지 작업은 다음과 같습니다.

  • type=module 속성 없이 <script> 요소를 사용하여 스크립트를 로드할 때는 크기가 매우 큰 스크립트를 로드하지 마세요. 이렇게 하면 기본 스레드를 차단하는 리소스 집약적인 스크립트 평가 작업이 시작됩니다. 스크립트를 더 많은 <script> 요소에 배포하여 이 작업을 분할합니다.
  • type=module 속성을 사용하여 브라우저에서 기본적으로 ES 모듈을 로드하면 별도의 각 모듈 스크립트에 관한 평가를 위한 개별 작업이 시작됩니다.
  • 동적 import() 호출을 사용하여 초기 번들의 크기를 줄입니다. 이는 번들러에서도 작동합니다. 번들러는 동적으로 가져온 각 모듈을 '분할 지점'으로 취급하여 동적으로 가져온 각 모듈에 대해 별도의 스크립트가 생성되기 때문입니다.
  • 압축 효율성과 캐시 무효화와 같은 장단점을 고려해야 합니다. 스크립트가 클수록 압축은 더 잘 되지만, 더 적은 작업에서 비싼 스크립트 평가 작업이 필요할 수 있으며 브라우저 캐시 무효화가 발생하여 캐싱 효율이 전반적으로 낮아집니다.
  • 번들링 없이 기본적으로 ES 모듈을 사용하는 경우 modulepreload 리소스 힌트를 사용하여 시작 시 모듈의 로드를 최적화합니다.
  • 항상 그렇듯이 최대한 적은 수의 JavaScript를 제공하세요.

이는 당연히 균형을 이루는 작업입니다. 하지만 스크립트를 분할하고 동적 import()로 초기 페이로드를 줄이면 시작 성능을 향상하고 중요한 시작 기간 동안 사용자 상호작용을 더 잘 수용할 수 있습니다. 이렇게 하면 INP 측정항목에서 더 높은 점수를 받아 더 나은 사용자 경험을 제공하는 데 도움이 됩니다.

Markus SpiskeUnsplash 히어로 이미지