코드 분할 자바스크립트

대규모 JavaScript 리소스를 로드하면 페이지 속도에 큰 영향을 미칩니다. JavaScript를 작은 청크로 분할하고 페이지가 시작될 때 작동하는 데 필요한 항목만 다운로드하면 페이지의 로드 응답성을 크게 개선할 수 있으며, 이는 페이지의 다음 페인트까지의 상호작용 (INP)을 개선할 수 있습니다.

페이지에서 대용량 JavaScript 파일을 다운로드, 파싱, 컴파일하는 동안 일정 시간 동안 응답하지 않을 수 있습니다. 페이지 요소는 페이지의 초기 HTML의 일부이며 CSS로 스타일이 지정되므로 표시됩니다. 하지만 이러한 대화형 요소를 지원하는 데 필요한 JavaScript와 페이지에서 로드하는 다른 스크립트가 작동하려면 JavaScript를 파싱하고 실행해야 할 수 있습니다. 그 결과 사용자는 상호작용이 크게 지연되거나 완전히 중단된 것처럼 느낄 수 있습니다.

JavaScript는 기본 스레드에서 파싱되고 컴파일되므로 기본 스레드가 차단되어 이 문제가 발생하는 경우가 많습니다. 이 프로세스가 너무 오래 걸리면 대화형 페이지 요소가 사용자 입력에 충분히 빠르게 응답하지 않을 수 있습니다. 이 문제를 해결하는 한 가지 방법은 페이지가 작동하는 데 필요한 JavaScript만 로드하고 코드 분할이라는 기법을 통해 나중에 로드할 다른 JavaScript를 지연하는 것입니다. 이 모듈에서는 후자의 기법에 중점을 둡니다.

코드 분할을 통해 시작 시 JavaScript 파싱 및 실행 줄이기

LighthouseJavaScript 실행이 2초를 초과하면 경고를 표시하고 3.5초를 초과하면 실패합니다. 과도한 JavaScript 파싱 및 실행은 페이지 수명 주기의 어느 시점에서든 잠재적인 문제입니다. 사용자가 페이지와 상호작용하는 시점이 JavaScript를 처리하고 실행하는 기본 스레드 작업이 실행되는 순간과 일치하면 상호작용의 입력 지연이 증가할 수 있기 때문입니다.

과도한 JavaScript 실행 및 파싱은 특히 초기 페이지 로드 중에 문제가 됩니다. 이때는 사용자가 페이지와 상호작용할 가능성이 높기 때문입니다. 실제로 로드 응답성 측정항목인 총 차단 시간 (TBT)INP상관관계가 높습니다. 이는 사용자가 초기 페이지 로드 중에 상호작용을 시도하는 경향이 높다는 것을 시사합니다.

페이지에서 요청하는 각 JavaScript 파일을 실행하는 데 걸린 시간을 보고하는 Lighthouse 감사는 코드 분할의 후보가 될 수 있는 스크립트를 정확하게 식별하는 데 도움이 된다는 점에서 유용합니다. 그런 다음 Chrome DevTools의 범위 도구를 사용하여 페이지 로드 중에 사용되지 않는 페이지의 JavaScript 부분을 정확하게 식별할 수 있습니다.

코드 분할은 페이지의 초기 JavaScript 페이로드를 줄일 수 있는 유용한 기법입니다. 이를 통해 JavaScript 번들을 다음 두 부분으로 분할할 수 있습니다.

  • 페이지 로드 시 필요한 JavaScript로, 다른 시간에는 로드할 수 없습니다.
  • 나중에 로드할 수 있는 나머지 JavaScript입니다. 일반적으로 사용자가 페이지에서 특정 대화형 요소와 상호작용하는 시점에 로드됩니다.

코드 분할은 동적 import() 구문을 사용하여 실행할 수 있습니다. 이 구문은 시작 중에 지정된 JavaScript 리소스를 요청하는 <script> 요소와 달리 페이지 수명 주기 중에 나중에 JavaScript 리소스를 요청합니다.

document.querySelectorAll('#myForm input').addEventListener('blur', async () => {
  // Get the form validation named export from the module through destructuring:
  const { validateForm } = await import('/validate-form.mjs');

  // Validate the form:
  validateForm();
}, { once: true });

위 JavaScript 스니펫에서 validate-form.mjs 모듈은 사용자가 양식의 <input> 필드 중 하나를 흐리게 처리할 때만 다운로드되고, 파싱되고, 실행됩니다. 이 경우 양식의 유효성 검사 로직을 실행하는 JavaScript 리소스는 실제로 사용될 가능성이 가장 높은 경우에만 페이지와 관련됩니다.

webpack, Parcel, Rollup, esbuild와 같은 JavaScript 번들러는 소스 코드에서 동적 import() 호출을 발견할 때마다 JavaScript 번들을 더 작은 청크로 분할하도록 구성할 수 있습니다. 이러한 도구 대부분은 이 작업을 자동으로 실행하지만 특히 esbuild에서는 이 최적화를 선택해야 합니다.

코드 분할에 관한 유용한 참고사항

코드 분할은 초기 페이지 로드 중에 기본 스레드 경합을 줄이는 효과적인 방법이지만, 코드 분할 기회를 위해 JavaScript 소스 코드를 감사하기로 결정한 경우 몇 가지 사항을 염두에 두는 것이 좋습니다.

가능한 경우 번들러 사용

개발자가 개발 과정에서 JavaScript 모듈을 사용하는 것은 일반적인 관행입니다. 코드 가독성과 유지 관리성을 개선하는 훌륭한 개발자 환경 개선사항입니다. 하지만 JavaScript 모듈을 프로덕션에 제공할 때 최적화되지 않은 성능 특성이 발생할 수 있습니다.

가장 중요한 것은 번들러를 사용하여 코드 분할하려는 모듈을 포함한 소스 코드를 처리하고 최적화해야 한다는 것입니다. 번들러는 JavaScript 소스 코드에 최적화를 적용하는 데 매우 효과적일 뿐만 아니라 번들 크기와 압축률과 같은 성능 고려사항의 균형을 맞추는 데도 매우 효과적입니다. 압축 효과는 번들 크기에 따라 증가하지만 번들러는 스크립트 평가로 인해 긴 작업이 발생하지 않도록 번들이 너무 크지 않도록 합니다.

번들러는 네트워크를 통해 번들로 묶이지 않은 모듈을 많이 전송하는 문제도 방지합니다. JavaScript 모듈을 사용하는 아키텍처는 크고 복잡한 모듈 트리가 있는 경향이 있습니다. 모듈 트리가 번들 해제되면 각 모듈은 별도의 HTTP 요청을 나타내며 모듈을 번들로 묶지 않으면 웹 앱의 상호작용이 지연될 수 있습니다. <link rel="modulepreload"> 리소스 힌트를 사용하여 가능한 한 빨리 대규모 모듈 트리를 로드할 수 있지만 로드 성능 관점에서는 JavaScript 번들이 여전히 더 좋습니다.

스트리밍 컴파일을 실수로 사용 중지하지 않음

Chromium의 V8 JavaScript 엔진은 프로덕션 JavaScript 코드가 최대한 효율적으로 로드되도록 기본적으로 여러 최적화를 제공합니다. 이러한 최적화 중 하나는 스트리밍 컴파일이라고 하며, 브라우저로 스트리밍되는 HTML의 증분 파싱과 마찬가지로 네트워크에서 도착하는 스트리밍된 JavaScript 청크를 컴파일합니다.

Chromium에서 웹 애플리케이션의 스트리밍 컴파일이 발생하도록 하는 방법은 다음과 같습니다.

  • JavaScript 모듈을 사용하지 않도록 프로덕션 코드를 변환합니다. 번들러는 컴파일 타겟을 기반으로 JavaScript 소스 코드를 변환할 수 있으며 타겟은 특정 환경에 따라 다릅니다. V8은 모듈을 사용하지 않는 모든 JavaScript 코드에 스트리밍 컴파일을 적용하며, 번들러를 구성하여 JavaScript 모듈 코드와 기능을 사용하지 않는 구문으로 변환할 수 있습니다.
  • JavaScript 모듈을 프로덕션에 제공하려면 .mjs 확장 프로그램을 사용하세요. 프로덕션 JavaScript에서 모듈을 사용하는지 여부와 관계없이 모듈을 사용하는 JavaScript와 그렇지 않은 JavaScript 간에 특별한 콘텐츠 유형은 없습니다. V8의 경우 .js 확장 프로그램을 사용하여 프로덕션에서 JavaScript 모듈을 제공하면 스트리밍 컴파일이 효과적으로 선택 해제됩니다. JavaScript 모듈에 .mjs 확장 프로그램을 사용하는 경우 V8은 모듈 기반 JavaScript 코드의 스트리밍 컴파일이 중단되지 않도록 할 수 있습니다.

이러한 고려사항으로 인해 코드 분할을 사용하지 않는 것은 좋지 않습니다. 코드 분할은 사용자에게 전송되는 초기 JavaScript 페이로드를 줄이는 효과적인 방법이지만 번들러를 사용하고 V8의 스트리밍 컴파일 동작을 유지하는 방법을 알면 프로덕션 JavaScript 코드가 사용자에게 최대한 빠르게 제공되도록 할 수 있습니다.

동적 가져오기 데모

webpack

webpack은 번들러가 JavaScript 파일을 분할하는 방식을 구성할 수 있는 SplitChunksPlugin라는 플러그인과 함께 제공됩니다. webpack은 동적 import() 및 정적 import 문을 모두 인식합니다. SplitChunksPlugin의 동작은 구성에서 chunks 옵션을 지정하여 수정할 수 있습니다.

  • chunks: async는 기본값이며 동적 import() 호출을 나타냅니다.
  • chunks: initial는 정적 import 호출을 나타냅니다.
  • chunks: all는 동적 import() 및 정적 가져오기를 모두 포함하므로 asyncinitial 가져오기 간에 청크를 공유할 수 있습니다.

기본적으로 webpack은 동적 import() 문을 발견할 때마다 해당 모듈의 별도 청크를 만듭니다.

/* main.js */

// An application-specific chunk required during the initial page load:
import myFunction from './my-function.js';

myFunction('Hello world!');

// If a specific condition is met, a separate chunk is downloaded on demand,
// rather than being bundled with the initial chunk:
if (condition) {
  // Assumes top-level await is available. More info:
  // https://v8.dev/features/top-level-await
  await import('/form-validation.js');
}

위 코드 스니펫의 기본 webpack 구성은 다음과 같은 두 개의 별도 청크를 생성합니다.

  • main.js./my-function.js 모듈을 포함하며 webpack에서 initial 청크로 분류하는 main.js 청크
  • form-validation.js만 포함하는 async 청크 (구성된 경우 리소스 이름에 파일 해시 포함). 이 청크는 conditiontruthy인 경우에만 다운로드됩니다.

이 구성을 사용하면 실제로 필요할 때까지 form-validation.js 청크 로드를 지연할 수 있습니다. 이렇게 하면 초기 페이지 로드 중에 스크립트 평가 시간이 줄어 로드 응답성이 개선될 수 있습니다. form-validation.js 청크의 스크립트 다운로드 및 평가는 지정된 조건이 충족될 때 발생하며, 이 경우 동적으로 가져온 모듈이 다운로드됩니다. 한 가지 예는 특정 브라우저에만 폴리필이 다운로드되는 조건일 수 있습니다. 또는 앞의 예와 같이 가져온 모듈이 사용자 상호작용에 필요할 수 있습니다.

반면 SplitChunksPlugin 구성을 변경하여 chunks: initial을 지정하면 코드가 초기 청크에서만 분할됩니다. 정적으로 가져오거나 webpack의 entry 속성에 나열된 청크와 같은 청크입니다. 위의 예시를 보면 결과 청크는 단일 스크립트 파일에서 form-validation.js main.js의 조합이 되므로 초기 페이지 로드 성능이 저하될 수 있습니다.

SplitChunksPlugin 옵션을 구성하여 큰 스크립트를 여러 개의 작은 스크립트로 분리할 수도 있습니다. 예를 들어 maxSize 옵션을 사용하여 maxSize에 지정된 값을 초과하는 경우 청크를 별도의 파일로 분할하도록 webpack에 지시할 수 있습니다. 큰 스크립트 파일을 작은 파일로 나누면 로드 응답성을 개선할 수 있습니다. 경우에 따라 CPU 집약적인 스크립트 평가 작업이 작은 작업으로 나뉘어 기본 스레드를 장기간 차단할 가능성이 줄어들기 때문입니다.

또한 JavaScript 파일이 커지면 스크립트에서 캐시 무효화가 발생할 가능성이 높아집니다. 예를 들어 프레임워크와 퍼스트 파티 애플리케이션 코드가 모두 포함된 매우 큰 스크립트를 제공하는 경우 프레임워크만 업데이트되고 번들 리소스의 다른 항목은 업데이트되지 않으면 전체 번들이 무효화될 수 있습니다.

반면 스크립트 파일이 작을수록 재방문자가 캐시에서 리소스를 가져올 가능성이 높아져 재방문 시 페이지 로드 속도가 빨라집니다. 하지만 작은 파일은 큰 파일보다 압축의 이점이 적으며, 초기화되지 않은 브라우저 캐시를 사용하여 페이지를 로드할 때 네트워크 왕복 시간이 늘어날 수 있습니다. 캐싱 효율성, 압축 효과, 스크립트 평가 시간 간의 균형을 유지해야 합니다.

webpack 데모

webpack SplitChunksPlugin 데모

학습한 내용 테스트

코드 분할을 실행할 때 어떤 유형의 import 문이 사용되나요?

동적 import()
정답입니다.
정적 import.
다시 시도해 보세요.

어떤 유형의 import 문이 JavaScript 모듈의 맨 위에 있어야 하고 다른 위치에는 없어야 하나요?

동적 import()
다시 시도해 보세요.
정적 import.
정답입니다.

webpack에서 SplitChunksPlugin을 사용할 때 async 청크와 initial 청크의 차이점은 무엇인가요?

async 청크는 동적 import()를 사용하여 로드되고 initial 청크는 정적 import를 사용하여 로드됩니다.
정답입니다.
async 청크는 정적 import를 사용하여 로드되고 initial 청크는 동적 import()를 사용하여 로드됩니다.
다시 시도해 보세요.

다음: 이미지 및 <iframe> 요소 지연 로드

JavaScript는 상당히 비용이 많이 드는 리소스 유형이지만 로드를 지연할 수 있는 유일한 리소스 유형은 아닙니다. 이미지 및 <iframe> 요소는 그 자체로 비용이 많이 드는 리소스일 수 있습니다. JavaScript와 마찬가지로 이 과정의 다음 모듈에 설명된 지연 로드를 통해 이미지와 <iframe> 요소의 로딩을 지연할 수 있습니다.