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

원문: Bundling non-JavaScript resources - Ingvar Stepanyan / 라이선스: CC BY 4.0 - 원문을 한국어로 번역

여러분이 웹앱을 만들고 있다고 가정해보자. 하지만 자바스크립트 모듈 외에 모듈 그래프에 포함되지 않는 웹 워커와 이미지, 스타일시트, 폰트, WebAssembly 모듈 등 리소스들도 처리해야 할 것이다.

이런 리소스를 HTML에서 바로 참조할 수는 있지만, 재사용 가능한 컴포넌트와 로직의 결합도가 높은 경우가 많다. 예를 들어, 자바스크립트 영역과 커스텀 드롭다운을 위한 스타일시트는 엮여있고, 아이콘 이미지와 툴바 컴포넌트도 마찬가지다. WebAssembly 모듈은 해당 모듈을 붙여주는 자바스크립트 glue(역: 브라우저의 문서상에서 바로 동작시킬 수 없는 다른 언어의 함수Javascript/wasm을 번역해주는 자바스크립트 코드)와 결합되어 있다. 이런 경우, 해당 리소스들을 자바스크립트 모듈에서 직접 참조하고 해당 컴포넌트가 로드되면 동적으로 불러오도록 하는 게 훨씬 편리하다.

image

하지만, 대부분의 대규모 프로젝트의 빌드 시스템은 최적화와 minify 과정 등의 콘텐츠 정리를 수행한다. 이런 시스템에서는 코드를 실행할 수도 없고, 실행 결과를 예측할 수도 없다. 자바스크립트의 모든 문자열 리터럴을 순회할 수도, 그 문자열이 리소스의 URL인지 아닌지 구분할 수도 없다. 그렇다면, 여러분은 어떻게 빌드 시스템이 자바스크립트 컴포넌트에 의해 동적 에셋(asset)을 로드 하도록 만들 수 있을까?

번들러의 커스텀 import

가장 흔한 접근법은 static import 문법을 재사용하는 방법이다. 일부 번들러는 파일의 확장자를 자동 인식하기도 하고, 대부분은 예제 코드처럼 커스텀 URL 스키마를 사용할 수 있도록 플러그인을 제공해준다.

// 일반적인 자바스크립트 import
import { loadImg } from './utils.js';

// 에셋을 위한 특별한 "URL import"
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);

번들러 플러그인이 import시 알고 있는 특정 확장자와 커스텀 스키마(예제 코드의 asset-url:js-url:)를 발견하면, 참조된 에셋을 빌드 그래프에 추가하고 최종 결과물 경로에 복사해준다. 그리고 에셋의 타입에 따라 적용할 수 있는 최적화를 진행하고, 런타임에 사용할 에셋의 URL을 반환한다.

이 방법은 자바스크립트의 import 문법을 재사용 해서, 모든 URL은 반드시 절대 경로거나 현재 파일의 상대 경로다. 그로인해, 빌드 시스템이 각 의존성의 위치를 쉽게 파악할 수 있다.

반면에, 치명적인 단점이 있다. 이런 코드는 브라우저에서 동작하지 않는다는 것이다. 브라우저 입장에서는 이런 커스텀 import 스키마나 확장자를 어떻게 처리해야 할지 모르기 때문이다. 여러분이 모든 코드를 관리하고 있고, 개발 진행을 위해 번들러에 의존할 수밖에 없다면 괜찮을 수 있다. 하지만, 브라우저에서 자바스크립트 모듈을 직접 가져다 쓰는 일이 점점 더 많아지는 상황인데, 개발중이 아니라면 이런 마찰은 줄이는 게 좋다. 번들러 가 전혀 필요하지 않을 만큼 소규모 데모를 개발하는 사람은 프로덕션 빌드에서도 번들러가 필요하지 않을 수 있다.

브라우저와 번들러를 위한 공용 패턴

여러분이 재사용 가능한 컴포넌트를 만들고 있다면, 그 컴포넌트를 브라우저에 바로 로드 하거나 대규모 애플리케이션에 사용하기 위해 미리 빌드한 모듈 두 방법 모두로 사용하고 싶을 것이다. 대부분의 모던 번들러는 아래의 패턴으로 자바스크립트 모듈을 가져올 수 있게 해준다.

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

이 패턴은 도구에 의해 정적으로 감지되고, 마치 특별한 문법인 것처럼 보이지만 브라우저가 직접 사용할 수 있는 유효한 자바스크립트 표현식이다.

아래 예제처럼 작성하면 된다.

// 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(...) 생성자는 상대 경로를 첫 번째 인자로 받고, 두 번째 인자를 통해 절대 경로로 치환한다. 예제를 보면, new URL('...') 의 두 번째 인자는 import.meta.url인데, 이 URL은 현재 자바스크립트 모듈의 URL이다. 그러므로 첫 번째 인자는 상대 경로가 될 것이다.

이 방법은 dynamic import와 같은 트레이드-오프가 있다. import(someUrl)처럼 import(...)를 쓰는 것도 가능하지만, 특정 패턴의 정적 URL은 번들러가 컴파일 시점에 디펜던시를 전처리하기 위해 동적으로 로드되는 개별 청크로 분리한다.

이와 유사하게, 여러분은 new URL(...)를 패턴 대신에 new URL(relativeUrl, customAbsoluteBase)와 같이 사용할 수 도 있다. 하지만, 아직까지는 new URL('...', import.meta.url)와 같은 패턴이 번들러에게 전처리 한 뒤에 메인 자바스크립트의 디펜던시로 포함하도록 알려주기에 가장 좋은 패턴이다.

상대 URL의 모호함

아마 여러분은 어째서 new URL로 감싸지 않은 fetch('./module.wasm')처럼 일반적인 형태는 번들러가 이해하지 못하는지 궁금할 것이다. 그 이유는, import문과는 다르게 동적 요청은 현재 자바스크립트 파일의 상대 경로가 아닌, 문서 경로의 상대 경로로 처리하기 때문이다. 여러분의 프로젝트가 다음과 같은 구조라고 가정해보자.

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

    • main.js
    • module.wasm

만약 module.wasmmain.js에서 로드하려고 한다면, 아마도 fetch('./module.wasm')을 사용해서 상대 경로를 사용하고 싶을 것이다.

하지만, fetch는 현재 실행 중인 자바스크립트 파일의 URL을 모른다. 그 대신, 현재 문서의 상대 경로가 사용된다. 결과적으로 fetch('./module.wasm')은 여러분이 의도한 http://example.com/src/module.wasm이 아닌, http://example.com/modules.wasm을 조용히 로드 하려고 시도하고 실패(혹은 그 경로에 있는 여러분이 의도와는 다른 리소스를 로드)할 것이다.

상대 경로를 new URL('...', import.meta.url)로 감쌈으로써 위와 같은 문제가 일어나지 않게 할 수 있고, 그 어떤 URL이라도 현재 자바스크립트 모듈 파일(import.meta.url) 의 상대 경로로 변환되어 로더에게 전달되는 걸 보장할 수 있다.

fetch('./module.wasm')fetch(new URL('./module.wasm', import.meta.url))로 바꾸면 원하는 WebAssembly 모듈을 성공적으로 로드할 수 있고, 번들러에게 빌드 시점에 상대 경로를 어떻게 찾을지 알려줄 수 있다.

도구 지원

번들러

이미 new URL 스키마를 지원하고있는 번들러 목록이다.

WebAssembly

WebAssembly를 사용 중이라면 직접 WebAssembly 모듈을 로드하지 않는 대신, 툴체인에 의해 생성된 자바스크립트 glue를 import 할 것이다. 다음 툴체인들은 new URL(...) 패턴으로 알아서 glue 코드를 생성해 줄 것이다.

Emscripten을 사용하는 C/C++

아래 옵션을 설정하면 Emscripten가 생성해주는 자바스크립트 glue를 일반적인 스크립트 대신, 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

이 옵션을 쓰면 생성되는 glue가 new URL('...', import.meta.url)패턴을 알아서 사용해준다. 따라서 번들러는 관련된 WebAssembly 파일을 자동으로 찾을 수 있다.

WebAssembly thread를 사용하려면, 옵션으로 -pthread 플래그를 추가하면 된다.

$ 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

Rust의 WebAssembly 주요 툴체인인 wasm-pack은 다양한 형태로 파일을 생성할 수 있는 모드가 있다.

기본적으로, WebAssembly ESM 통합 제안에 따라 자바스크립트 모듈로 생성된다. 글 작성 시점으로 해당 제안은 아직 실험단계이고, 생성되는 파일은 Webpack으로 번들되는 경우에만 동작한다.

반면에, wasm-pack에 --target-web 플래그를 추가하면, 브라우저에서 동작하는 ES6 모듈을 생성한다.

$ wasm-pack build --target web

생성되는 파일은 new URL('...', import.meta.url) 패턴을 사용하고, Wasm 파일도 번들러가 잘 찾을 수 있다. 여러분이 WebAssembly 스레드를 Rust에서 사용하려 한다면, 조금 더 어려운 과정이 기다리고 있다. 해당 내용은 가이드의 해당 장을 참고하면 된다. 짧게 설명해보자면, 특정 스레드 API를 사용하는 대신에 wasm-bindgen-rayon 어댑터를 통해서 Rayon을 사용해서 브라우저에 웹 워커를 spawn(역: 하위 작업으로 새로운 워커를 생성하는 일.) 해야 한다.

앞으로 출시될 기능들

import.meta.resolve

아주 믿음직스러운 import.meta.resolve(...) 호출이 장차 위 역할을 대신해줄 가능성이 있다. 추가적인 파라미터 없이 현재 모듈과 연관된 식별자들을 보다 직접적으로 확인할 수 있을 것이다.

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

import와 같은 모듈 탐색 시스템을 거치므로, import 지도와 커스텀 resolver와 더 잘 통합될 것이다. URL처럼 런타임 API에 의존하지 않는 정적 문법이므로, 번들러도 더 확실하게 식별할 수 있을 것이다.

import.meta.resolve는 이미 Node.js의 실험 기능이지만, 웹에서 어떻게 동작할 것인지는 아직 해결되지 않은 문제다.

Import 단언(Assertion)

Import 단언은 ECMA스크립트 모듈이 아닌 다른 타입을 가져올 수 있는 새로운 기능이다. 아직은 JSON 타입으로 국한되어있다.

foo.json:

{ "answer": 42 }

main.mjs:

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

이 경우는 번들러를 통해서 사용해야 하며, 현재 사용 중인 new URL 패턴의 사용 사례를 대체할 수는 있지만, import 단언은 케이스 마다 다르게 추가된다. 지금으로서는 JSON만 가능하고, CSS 모듈은 곧 지원될 예정이다. 다른 에셋은 더 공통적인 접근법이 필요한 상태다.

v8.dev 기능 설명에서 이 기능을 더 자세히 찾아볼 수 있다.

결론

위 처럼, 자바스크립트 파일이 아닌 리소스를 웹으로 로드하는 방법은 다양하다. 하지만 각각 모두 다양한 단점이 있고, 다양한 툴체인 모두에서 사용할 수 없다. 앞으로 출시될 기능 제안에서는 특수한 문법을 사용해서 그런 에셋을 불러올 수 있겠지만, 아직은 갈 길이 멀다.

아직까지는 브라우저와 다양한 번들러, 그리고 WebAssembly 툴체인에서 동작하는 new URL('...', import.meta.url) 패턴이 가장 유력한 접근법이다.