자바스크립트가 아닌 리소스 번들링

JavaScript에서 다양한 유형의 애셋을 가져오고 번들로 묶는 방법을 알아봅니다.

웹 앱을 작업하고 있다고 가정해 보겠습니다. 이 경우 JavaScript 모듈뿐 아니라 웹 작업자 (JavaScript도 있지만 일반 모듈 그래프의 일부가 아님), 이미지, 스타일시트, 글꼴, WebAssembly 모듈 등 모든 종류의 다른 리소스도 처리해야 할 수 있습니다.

이러한 리소스 중 일부에 대한 참조를 HTML에 직접 포함할 수도 있지만 이러한 리소스는 재사용 가능한 구성요소에 논리적으로 결합되는 경우가 많습니다. 예를 들어 JavaScript 부분에 연결된 맞춤 드롭다운의 스타일시트, 툴바 구성요소에 연결된 아이콘 이미지 또는 JavaScript 글루에 연결된 WebAssembly 모듈이 여기에 해당합니다. 이런 경우에는 JavaScript 모듈에서 리소스를 직접 참조하고 해당 구성 요소가 로드될 때 (또는 로드되면) 동적으로 로드하는 것이 더 편리합니다.

JS로 가져온 다양한 유형의 애셋을 시각화하는 그래프

그러나 대부분의 대규모 프로젝트에는 번들링 및 축소와 같은 콘텐츠의 추가 최적화 및 재구성 작업을 실행하는 빌드 시스템이 있습니다. 코드를 실행하고 실행 결과를 예측할 수 없으며 JavaScript에서 가능한 모든 문자열 리터럴을 탐색하고 리소스 URL인지 아닌지 추측할 수도 없습니다. 그렇다면 JavaScript 구성요소에서 로드한 동적 애셋을 '인식'하고 빌드에 포함하려면 어떻게 해야 할까요?

번들러의 맞춤 가져오기

일반적인 접근 방식은 정적 가져오기 문법을 재사용하는 것입니다. 일부 번들러에서는 파일 확장자를 기준으로 형식을 자동 감지할 수 있지만, 다른 번들러에서는 다음 예와 같이 플러그인이 맞춤 URL 스키마를 사용할 수 있도록 허용합니다.

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

번들러 플러그인이 인식하는 확장자가 있거나 이러한 명시적 맞춤 스키마 (위 예시의 asset-url:js-url:)가 있는 가져오기를 찾으면 참조된 애셋을 빌드 그래프에 추가하고 최종 대상에 복사하며 애셋 유형에 적용 가능한 최적화를 실행하고 런타임 중에 사용할 최종 URL을 반환합니다.

이 접근 방식의 이점: JavaScript 가져오기 문법을 재사용하면 모든 URL이 정적이며 현재 파일에 상대적이라는 점을 보장할 수 있으므로 빌드 시스템에서 이러한 종속 항목을 쉽게 찾을 수 있습니다.

하지만 한 가지 중요한 단점이 있습니다. 이러한 코드는 브라우저에서 직접 작동할 수 없습니다. 브라우저가 이러한 사용자 지정 가져오기 스킴이나 확장 프로그램을 처리하는 방법을 모르기 때문입니다. 모든 코드를 제어하고 개발 시 번들러에 의존하는 경우에는 괜찮을 수 있지만, 장애를 줄이기 위해 적어도 개발 도중에는 브라우저에서 직접 JavaScript 모듈을 사용하는 것이 점점 더 일반화되고 있습니다. 소규모 데모를 작업하는 경우에는 프로덕션 환경에서도 번들러가 전혀 필요하지 않을 수 있습니다.

브라우저 및 번들러의 범용 패턴

재사용 가능한 구성요소를 작업하는 경우 브라우저에서 직접 사용되든 더 큰 앱의 일부로 사전 빌드되든 어느 환경에서든 작동하도록 해야 합니다. 대부분의 최신 번들러는 JavaScript 모듈에서 다음 패턴을 허용하여 이를 허용합니다.

new URL('./relative-path', import.meta.url)

이 패턴은 마치 특수 문법인 것처럼 도구에 의해 정적으로 감지될 수 있지만 브라우저에서 직접 작동하는 유효한 JavaScript 표현식입니다.

이 패턴을 사용하면 위의 예를 다음과 같이 다시 작성할 수 있습니다.

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

기본 원리 구체적으로 살펴보겠습니다. new URL(...) 생성자는 상대 URL을 첫 번째 인수로 취하고 두 번째 인수로 제공된 절대 URL과 비교하여 확인합니다. 이 경우 두 번째 인수는 현재 JavaScript 모듈의 URL을 제공하는 import.meta.url이므로 첫 번째 인수는 모듈을 기준으로 하는 임의의 경로일 수 있습니다.

동적 가져오기와 비슷한 장단점이 있습니다. import(someUrl)와 같은 임의의 표현식과 함께 import(...)를 사용할 수도 있지만, 번들러는 컴파일 시간에 알려진 종속 항목을 사전 처리하면서 동적으로 로드되는 자체 청크로 분할하는 방법으로 정적 URL import('./some-static-url.js')이 있는 패턴을 특수 처리합니다.

마찬가지로 new URL(relativeUrl, customAbsoluteBase)와 같은 임의의 표현식과 함께 new URL(...)를 사용할 수 있지만 new URL('...', import.meta.url) 패턴은 번들러가 기본 JavaScript와 함께 종속 항목을 전처리하고 포함하도록 하는 명확한 신호입니다.

모호한 상대 URL

번들러가 다른 일반적인 패턴(예: new URL 래퍼가 없는 fetch('./module.wasm'))을 감지할 수 없는 이유는 무엇일까요?

이는 가져오기 문과 달리 모든 동적 요청이 현재 JavaScript 파일이 아닌 문서 자체에 대해 상대적으로 확인되기 때문입니다. 다음과 같은 구조가 있다고 가정해 보겠습니다.

  • index.html:
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

main.js에서 module.wasm를 로드하려면 fetch('./module.wasm')와 같은 상대 경로를 사용하고 싶을 수 있습니다.

그러나 fetch는 실행되는 JavaScript 파일의 URL을 알지 못하며 대신 문서를 기준으로 URL을 확인합니다. 그 결과 fetch('./module.wasm')는 의도한 http://example.com/src/module.wasm 대신 http://example.com/module.wasm를 로드하려고 시도하여 실패하거나(또는 더 나쁜 경우 의도한 것과 다른 리소스를 자동으로 로드함)

상대 URL을 new URL('...', import.meta.url)로 래핑하면 이 문제를 방지하고 제공된 URL이 로더로 전달되기 전에 현재 JavaScript 모듈 (import.meta.url)의 URL을 기준으로 확인됩니다.

fetch('./module.wasm')fetch(new URL('./module.wasm', import.meta.url))로 대체하면 예상 WebAssembly 모듈이 성공적으로 로드되고 번들러가 빌드 시간에 이러한 상대 경로를 찾을 수 있는 방법도 제공됩니다.

도구 지원

번들러

다음 번들러는 이미 new URL 스키마를 지원합니다.

WebAssembly

WebAssembly를 사용할 때는 일반적으로 Wasm 모듈을 수동으로 로드하지 않고 대신 도구 모음에서 내보낸 JavaScript 글루를 가져옵니다. 다음 도구 모음은 설명된 new URL(...) 패턴을 내부에서 내보낼 수 있습니다.

Emscripten을 통한 C/C++

Emscripten을 사용할 때 다음 옵션 중 하나를 통해 JavaScript 글루를 일반 스크립트 대신 ES6 모듈로 내보내도록 요청할 수 있습니다.

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

이 옵션을 사용하면 번들러가 연결된 Wasm 파일을 자동으로 찾을 수 있도록 출력에서 내부적으로 new URL(..., import.meta.url) 패턴을 사용합니다.

-pthread 플래그를 추가하여 WebAssembly 스레드에서 이 옵션을 사용할 수도 있습니다.

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

이 경우 생성된 웹 작업자가 동일한 방식으로 포함되며 번들러와 브라우저에서 모두 검색할 수 있습니다.

wasm-pack / wasm-bindgen을 통한 Rust

WebAssembly의 기본 Rust 도구 체인인 wasm-pack에도 여러 출력 모드가 있습니다.

기본적으로 WebAssembly ESM 통합 제안서를 사용하는 JavaScript 모듈을 내보냅니다. 이 글을 작성하는 시점에서 이 제안은 아직 실험 단계이며 출력은 Webpack과 번들로 묶인 경우에만 작동합니다.

대신 wasm-pack에 --target web를 통해 브라우저 호환 ES6 모듈을 내보내도록 요청할 수 있습니다.

$ wasm-pack build --target web

출력은 설명된 new URL(..., import.meta.url) 패턴을 사용하며 Wasm 파일은 번들러에 의해 자동으로 검색됩니다.

Rust와 함께 WebAssembly 스레드를 사용하려면 조금 더 복잡해집니다. 자세한 내용은 가이드의 해당 섹션을 참고하세요.

간단히 말해 임의의 스레드 API는 사용할 수 없지만 Rayon을 사용하는 경우 wasm-bindgen-rayon 어댑터와 결합하여 웹에서 작업자를 생성할 수 있습니다. wasm-bindgen-rayon에서 사용하는 JavaScript 글루는 내부적으로 new URL(...) 패턴을 포함하므로 워커는 번들러에 의해 검색되고 포함될 수 있습니다.

출시 예정 기능

import.meta.resolve

전용 import.meta.resolve(...) 호출은 향후 개선될 수 있습니다. 추가 매개변수 없이 더 간단한 방식으로 현재 모듈에 상대적인 지정자를 확인할 수 있습니다.

new URL('...', import.meta.url)
await import.meta.resolve('...')

또한 import와 동일한 모듈 해상도 시스템을 거치므로 가져오기 맵 및 맞춤 리졸버와 더 잘 통합됩니다. URL와 같은 런타임 API에 종속되지 않는 정적 문법이므로 번들러에 더 강력한 신호가 됩니다.

import.meta.resolve는 이미 Node.js의 실험으로 구현되었지만 웹에서 작동하는 방식에 관한 해결되지 않은 질문이 아직 있습니다.

가져오기 어설션

가져오기 어설션은 ECMAScript 모듈 이외의 유형을 가져올 수 있는 새로운 기능입니다. 현재는 JSON으로 제한됩니다.

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

번들러에서 사용하고 현재 new URL 패턴으로 다루는 사용 사례를 대체할 수도 있지만 가져오기 어설션의 유형은 케이스별로 추가됩니다. 현재는 JSON만 다루며 곧 CSS 모듈이 추가될 예정이지만 다른 유형의 애셋에는 여전히 더 일반적인 솔루션이 필요합니다.

이 기능에 대해 자세히 알아보려면 v8.dev 기능 설명을 확인하세요.

결론

보시는 것처럼 웹에 JavaScript가 아닌 리소스를 포함하는 방법에는 여러 가지가 있지만, 이러한 방법에는 여러 가지 단점이 있으며 다양한 도구 모음에서 작동하지 않습니다. 향후 제안서를 통해 특수 문법으로 이러한 애셋을 가져올 수 있을 수도 있지만 아직은 그렇지 않습니다.

그때까지는 new URL(..., import.meta.url) 패턴이 현재 브라우저, 다양한 번들러, WebAssembly 도구 모음에서 이미 작동하는 가장 유망한 솔루션입니다.