스크립트 평가 및 장기 작업

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

다음 페인트에 대한 상호작용 (INP)을 최적화하는 방법에 관한 대부분의 조언은 상호작용 자체를 최적화하라는 것입니다. 예를 들어 긴 작업 최적화 가이드에서는 setTimeout로 생성하는 등의 기법을 설명합니다. 이러한 기술은 긴 작업을 피하여 기본 스레드에 여유를 제공하므로 유용합니다. 따라서 단일 긴 작업을 기다려야 하는 대신 상호작용 및 기타 활동을 더 일찍 실행할 수 있습니다.

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

JavaScript를 많이 제공하는 애플리케이션을 프로파일링한 경우 원인이 스크립트 평가로 라벨이 지정된 긴 작업이 표시될 수 있습니다.

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를 사용하지 않을 때 일반적으로 표시되는 것과 다른 종류의 작업이 생성됩니다. 예를 들어 모듈 컴파일로 라벨이 지정된 활동이 포함된 각 모듈 스크립트의 태스크가 실행됩니다.

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

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

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

적어도 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 모듈이 또 다른 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 측정항목에서 더 나은 점수를 얻을 수 있으므로 더 나은 사용자 환경을 제공할 수 있습니다.