스크립트 평가 및 장기 작업

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

다음 페인트에 대한 상호작용 (INP) 최적화와 관련하여 접하게 되는 조언은 대부분 상호작용을 직접 최적화하는 것입니다. 예를 들어 장기 작업 최적화 가이드에는 setTimeout, isInputPending 등을 사용한 수익 창출과 같은 기법이 설명되어 있습니다. 이러한 기법은 긴 작업을 피하여 기본 스레드에 약간의 호흡 공간을 제공하므로 단일 긴 작업을 기다려야 하는 경우보다 상호작용 및 기타 활동의 더 많은 기회를 더 빨리 실행할 수 있으므로 유용합니다.

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

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

많은 수의 JavaScript를 제공하는 애플리케이션을 프로파일링한 경우, 원인이 Evaluate Script인 장기 작업을 본 적이 있을 것입니다.

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

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

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

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

스크립트 평가를 담당하는 작업이 시작되는 방식은 로드하는 스크립트가 일반 <script> 요소를 통해 로드되는지 또는 스크립트가 type=module로 로드된 모듈인지에 따라 다릅니다. 브라우저는 이를 다르게 처리하는 경향이 있으므로 주요 브라우저 엔진이 스크립트 평가를 처리하는 방법은 브라우저마다 스크립트 평가 동작이 다른 지점에 영향을 줍니다.

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

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

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

많은 양의 자바스크립트를 로드하는 것을 방지하여 스크립트 평가 작업을 중단하고 추가 <script> 요소를 사용하여 더 작은 개별 스크립트를 더 많이 로드할 수 있습니다.

페이지 로드 중에는 항상 JavaScript를 가능한 한 적게 로드하도록 노력해야 하지만, 스크립트를 분할하면 기본 스레드를 차단할 수 있는 하나의 대규모 작업 대신 기본 스레드를 전혀 차단하지 않는 더 많은 작은 작업(또는 적어도 시작한 작업보다 적게)을 확보할 수 있습니다.

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

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

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

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

Chromium 기반 브라우저

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

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

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

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

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

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

Safari 및 Firefox

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

동적 import()로 스크립트 로드

동적 import()는 스크립트를 로드하는 또 다른 메서드입니다. ES 모듈의 상단에 있어야 하는 정적 import 문과 달리 동적 import() 호출은 스크립트의 아무 곳에나 표시되어 JavaScript 청크를 주문형으로 로드할 수 있습니다. 이 기법을 코드 분할이라고 합니다.

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

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

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

웹 작업자에 스크립트 로드

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

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

장단점 및 고려사항

스크립트를 별도의 작은 파일로 나누면 더 적은 수의 훨씬 큰 파일을 로드하는 것과 달리 긴 작업을 제한하는 데 도움이 되지만, 스크립트를 분할하는 방법을 결정할 때는 몇 가지 사항을 고려하는 것이 중요합니다.

압축 효율

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

Bundler는 웹사이트에서 사용하는 스크립트의 출력 크기를 관리하는 데 적합한 도구입니다.

  • 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 측정항목의 점수를 높여 더 나은 사용자 경험을 제공하는 데 도움이 됩니다.

Unsplash의 히어로 이미지(Markus Spiske 제작)