빌드 시스템 없이 프론트엔드 JavaScript 라이브러리 가져오기
원문은 Julia Evans님이 에 게재했습니다. 이 블로그 구독하기
나는 JavaScript를 빌드 시스템 없이 쓰는 걸 좋아하는데, 어제도 백만 번째로 빌드 시스템 없이 코드에서 JavaScript 라이브러리를 가져와야 하는 문제에 부딪혔다. 라이브러리 설정 가이드가 빌드 시스템을 쓴다는 전제로 되어 있어서 가져오는 방법을 알아내는 데 정말 한참이 걸렸다.
다행히 이제는 이런 상황을 헤쳐나가는 법을 대부분 익혀서, 라이브러리를 성공적으로 쓰거나 너무 어렵다고 판단되면 다른 라이브러리로 갈아타기도 한다. 그래서 몇 년 전에 있었으면 좋았을 JavaScript 라이브러리 가져오기 가이드를 여기에 정리해 봤다.
여기서는 프론트엔드에서 JavaScript 라이브러리를 쓰는 경우만, 그것도 빌드 시스템 없이 쓰는 경우만 다룰 것이다.
이 글에서 다룰 내용은 다음과 같다:
- 라이브러리가 제공할 수 있는 세 가지 주요 JavaScript 파일 유형 (ES Modules, 전역 변수를 쓰는 ‘클래식’ 유형, 그리고 CommonJS)
- JavaScript 라이브러리가 빌드에 어떤 유형의 파일을 포함하고 있는지 확인하는 방법
- 각 유형의 파일을 코드에서 가져오는 방법
JavaScript 파일의 세 가지 종류
라이브러리가 제공할 수 있는 JavaScript 파일에는 기본적으로 세 가지 유형이 있다:
- 전역 변수를 정의하는 ‘클래식’ 유형의 파일. 그냥
<script src>로 불러오면 바로 동작하는 종류다. 구할 수 있으면 가장 편하지만 항상 있는 건 아니다 - ES 모듈 (다른 파일에 의존할 수도 있고 아닐 수도 있다. 뒤에서 설명하겠다)
- ‘CommonJS’ 모듈. Node용이며, 빌드 시스템 없이는 브라우저에서 전혀 쓸 수 없다.
‘클래식’ 유형에 더 좋은 이름이 있는지 모르겠지만 그냥 ‘클래식’이라고 부르겠다. ‘AMD’라는 유형도 있지만 2024년 기준으로 얼마나 의미가 있는지는 잘 모르겠다.
이제 세 가지 파일 유형을 알았으니, 라이브러리가 실제로 어떤 것을 제공하는지 알아보는 방법을 이야기해 보자!
파일은 어디에 있을까: NPM 빌드
모든 JavaScript 라이브러리는 NPM에 업로드하는 빌드를 가지고 있다. (처음의 나처럼) “Julia! 핵심은 Node로 라이브러리를 빌드하지 않는 건데! 왜 NPM 이야기를 하는 거지?”라고 생각할지도 모른다.
하지만 https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js 같은 CDN 링크를 쓰고 있다면, 그것도 여전히 NPM 빌드를 쓰는 것이다! CDN에 있는 모든 파일은 원래 NPM에서 온 것이다.
그래서 나는 Node로 라이브러리를 빌드할 생각이 전혀 없더라도 가끔 npm install로 라이브러리를 설치한다. 그냥 임시 폴더를 새로 만들고 거기서 npm install을 한 뒤 끝나면 지워버린다. 파일 시스템에서 NPM 빌드에 들어 있는 파일들을 직접 뒤져볼 수 있는 게 좋다. 그러면 라이브러리가 빌드에서 실제로 제공하는 모든 것을 빠짐없이 보고 있다는 확신이 들고, CDN이 뭔가를 숨기고 있는 건 아닌지 걱정하지 않아도 되기 때문이다.
그러니 몇 개의 라이브러리를 npm install해서 빌드에서 어떤 유형의 JavaScript 파일을 제공하는지 알아보자!
예제 라이브러리 1: chart.js
먼저 그래프 라이브러리인 Chart.js 내부를 살펴보자.
$ cd /tmp/whatever
$ npm install chart.js
$ cd node_modules/chart.js/dist
$ ls *.*js
chart.cjs chart.js chart.umd.js helpers.cjs helpers.js
이 라이브러리는 기본적으로 세 가지 옵션이 있는 것 같다:
옵션 1: chart.cjs. .cjs 확장자는 이 파일이 Node에서 쓰는 CommonJS 파일이라는 뜻이다. 즉, 어떤 빌드 단계 없이는 브라우저에서 직접 쓸 수 없다는 의미다.
옵션 2: chart.js. .js 확장자만으로는 어떤 종류의 파일인지 알 수 없지만, 파일을 열어 보면 import '@kurkle/color';가 보인다. 이는 ES 모듈이라는 즉각적인 신호다. import ... 구문은 ES 모듈 문법이다.
옵션 3: chart.umd.js. ‘UMD’는 ‘Universal Module Definition’의 약자로, 이 파일을 기본 <script src>나 CommonJS, 혹은 내가 잘 이해하지 못하는 AMD라는 세 번째 방식으로 쓸 수 있다는 뜻인 것 같다.
UMD 파일을 사용하는 방법
나는 Chart.js를 쓸 때 옵션 3을 선택했다. 코드에 이것만 추가하면 됐다:
<script src="./chart.umd.js"> </script>
그러면 전역 Chart 변수로 라이브러리를 쓸 수 있었다. 더 쉬울 수가 없다. 나는 NPM을 쓰거나 CDN이 다운되는 것을 걱정하지 않아도 되도록 chart.umd.js를 그냥 Git 저장소에 복사해 넣었다.
빌드 파일이 항상 dist 디렉터리에 있는 것은 아니다
많은 라이브러리가 빌드를 dist 디렉터리에 넣지만, 항상 그런 것은 아니다! 빌드 파일의 위치는 라이브러리의 package.json에 지정되어 있다.
예를 들어 Chart.js의 package.json에서 발췌한 내용은 다음과 같다.
"jsdelivr": "./dist/chart.umd.js",
"unpkg": "./dist/chart.umd.js",
"main": "./dist/chart.cjs",
"module": "./dist/chart.js",
내 생각에 이건 ES 모듈(module)을 쓰고 싶다면 dist/chart.js를 써야 하고, jsDelivr와 unpkg CDN은 ./dist/chart.umd.js를 써야 한다는 뜻인 것 같다. main은 아마 Node용인 것 같다.
chart.js의 package.json에는 "type": "module"이라는 항목도 있는데, 이 문서에 따르면 Node가 기본적으로 파일을 ES 모듈로 처리하도록 한다는 뜻이다. 정확히 어떤 파일이 ES 모듈이고 어떤 파일이 아닌지까지는 알려주지 않지만, 어쨌든 그 안에 뭔가 ES 모듈이 있다는 것은 알 수 있다.
예제 라이브러리 2: @atcute/oauth-browser-client
@atcute/oauth-browser-client는 브라우저에서 OAuth로 Bluesky에 로그인하기 위한 라이브러리다.
이 라이브러리가 빌드에서 어떤 종류의 JavaScript 파일을 제공하는지 살펴보자!
$ npm install @atcute/oauth-browser-client
$ cd node_modules/@atcute/oauth-browser-client/dist
$ ls *js
constants.js dpop.js environment.js errors.js index.js resolvers.js
여기서 그나마 진입점이 될 만한 파일은 index.js뿐인 것 같고, 내용은 대략 이렇게 생겼다:
export { configureOAuth } from './environment.js';
export * from './errors.js';
export * from './resolvers.js';
이 export 구문은 이 파일이 ES 모듈이라는 뜻이다. 즉, 빌드 단계 없이도 브라우저에서 쓸 수 있다는 의미다! 어떻게 하는지 살펴보자.
importmap으로 ES 모듈을 사용하는 방법
ES 모듈을 쓰는 것은 그냥 <script src="whatever.js">를 추가하는 것만큼 쉽지 않다. 대신 ES 모듈에 의존성이 있는 경우(@atcute/oauth-browser-client처럼) 단계는 다음과 같다:
- HTML에 import map을 설정한다
import { configureOAuth } from '@atcute/oauth-browser-client';같은 import 문을 JS 코드에 넣는다- HTML에 JS 코드를
<script type="module" src="YOURSCRIPT.js"></script>형태로 포함한다
그냥 import { BrowserOAuthClient } from "./oauth-client-browser.js" 같은 식으로 하지 않고 import map이 필요한 이유는, 모듈 내부에 import {something} from @atcute/client 같은 import 문이 더 있고, 브라우저에게 @atcute/client와 그 밖의 모든 의존성에 대한 코드를 어디서 가져와야 하는지 알려줘야 하기 때문이다.
내가 @atcute/oauth-browser-client에 사용한 importmap은 이렇게 생겼다:
<script type="importmap">
{
"imports": {
"nanoid": "./node_modules/nanoid/bin/dist/index.js",
"nanoid/non-secure": "./node_modules/nanoid/non-secure/index.js",
"nanoid/url-alphabet": "./node_modules/nanoid/url-alphabet/dist/index.js",
"@atcute/oauth-browser-client": "./node_modules/@atcute/oauth-browser-client/dist/index.js",
"@atcute/client": "./node_modules/@atcute/client/dist/index.js",
"@atcute/client/utils/did": "./node_modules/@atcute/client/dist/utils/did.js"
}
}
</script>
이 import map을 동작하게 만드는 건 꽤 까다롭다. 자동으로 생성해 주는 도구가 분명 있을 것 같은데 아직 찾지 못했다. esbuild의 metafile을 이용해 importmap을 자동으로 생성하는 스크립트를 작성하는 건 분명 가능하지만, 아직 해보지는 않았고 더 좋은 방법이 있을지도 모른다.
나는 어제 github.com/jvns/bsky-oauth-example을 동작시키려고 importmap을 설정했는데, 그 저장소에 예제 코드가 있다.
또 누군가 Simon Willison의 download-esm을 알려줬는데, ES 모듈을 다운로드해서 import가 JS 파일을 직접 가리키도록 다시 작성해 주기 때문에 importmap이 필요 없게 해준다. 아직 써보진 않았지만 아주 좋은 아이디어 같다.
importmap의 문제점: 파일이 너무 많다
다만 브라우저에서 importmap을 쓰면서 몇 가지 문제에 부딪히기도 했다. 사이트를 로드하는 데 수십 개의 JavaScript 파일을 다운로드해야 했는데, 개발 환경의 웹서버가 어째선지 이를 따라가지 못했다. 파일 로드가 무작위로 실패하는 일이 계속 발생했고, 페이지를 새로고침하며 이번에는 성공하길 바라야 했다.
사이트를 프로덕션에 배포하고 나서는 더 이상 문제가 되지 않았으니, 아마 로컬 개발 환경의 문제였던 것 같다.
그리고 ES 모듈 전반에 대해 약간 귀찮은 점 중 하나는 쓰려면 웹서버를 실행하고 있어야 한다는 것이다. 분명 타당한 이유가 있겠지만, 웹서버를 켜지 않고 그냥 index.html 파일을 여는 편이 더 쉬운 건 사실이다.
‘파일이 너무 많다’는 부분 때문에 솔직히 이런 식으로 importmap과 함께 ES 모듈을 쓰는 건 내게는 그다지 매력적이지 않다고 생각한다. 하지만 가능하다는 걸 알아두는 건 좋다.
importmap 없이 ES 모듈을 사용하는 방법
ES 모듈에 의존성이 없다면 훨씬 더 쉽다. importmap이 필요 없다! 그냥 이렇게 하면 된다:
- HTML에
<script type="module" src="YOURCODE.js"></script>를 넣는다.type="module"이 중요하다. YOURCODE.js에import {whatever} from "https://example.com/whatever.js"를 넣는다
대안: esbuild 사용하기
importmap을 쓰고 싶지 않다면 esbuild 같은 빌드 시스템을 쓸 수도 있다. 그 방법에 대해서는 Some notes on using esbuild에서 이야기한 적이 있지만, 이 글은 빌드 시스템을 완전히 피하는 방법에 대한 것이므로 여기서는 그 옵션에 대해 다루지 않겠다. 그래도 나는 여전히 esbuild를 좋아하고, 이 경우에도 좋은 선택지라고 생각한다.
importmap의 브라우저 지원은 어떨까?
CanIUse에 따르면 importmap은 ‘Baseline 2023: 주요 브라우저에서 새롭게 사용 가능’이므로, 내 감각으로는 2024년 기준으로 아직은 조금 이른 느낌이다. 나 자신과 열두 명 정도가 쓰는 재미있는 실험용 코드라면 importmap을 쓰겠지만, 코드를 더 널리 쓰이게 하고 싶다면 대신 esbuild를 쓸 것 같다.
예제 라이브러리 3: @atproto/oauth-client-browser
마지막 예제 라이브러리를 하나 더 살펴보자! 이건 @atcute/oauth-browser-client와는 다른 Bluesky 인증 라이브러리다.
$ npm install @atproto/oauth-client-browser
$ cd node_modules/@atproto/oauth-client-browser/dist
$ ls *js
browser-oauth-client.js browser-oauth-database.js browser-runtime-implementation.js errors.js index.js indexed-db-store.js util.js
여기서도 실질적인 후보 파일은 index.js뿐인 것 같다. 하지만 앞선 예제 라이브러리와는 상황이 다르다! index.js를 살펴보자:
index.js 안에는 이런 내용이 잔뜩 들어 있다:
__exportStar(require("@atproto/oauth-client"), exports);
__exportStar(require("./browser-oauth-client.js"), exports);
__exportStar(require("./errors.js"), exports);
var util_js_1 = require("./util.js");
이 require() 구문은 CommonJS 문법으로, 이 파일을 브라우저에서 전혀 쓸 수 없으며 어떤 형태로든 빌드 단계가 필요하다는 뜻이다. ESBuild도 통하지 않는다.
또 이 라이브러리의 package.json에는 "type": "commonjs"라고 적혀 있는데, 이것도 CommonJS라는 걸 알 수 있는 또 다른 단서다.
esm.sh로 CommonJS 모듈을 사용하는 방법
원래 나는 빌드 시스템을 배우지 않고는 CommonJS 모듈을 쓰는 게 불가능하다고 생각했는데, Bluesky에서 누군가 esm.sh을 알려줬다! 무엇이든 ES 모듈로 변환해 주는 CDN이다. skypack.dev도 비슷한 일을 하는데, 둘의 차이가 뭔지는 잘 모르겠다. 다만 한쪽이 안 될 때 다른 쪽을 시도해 보면 된다고 말하는 사람도 있었다.
@atproto/oauth-client-browser의 경우 사용법은 꽤 간단해 보였는데, HTML에 이것만 넣으면 됐다:
<script type="module" src="script.js"> </script>
그리고 script.js에는 이것을 넣는다.
import { BrowserOAuthClient } from "https://esm.sh/@atproto/[email protected]"
잘 동작하는 것 같고, 멋지다! 물론 이건 여전히 일종의 빌드 시스템을 쓰는 셈이다. 다만 빌드를 내가 아니라 esm.sh가 대신 실행해 주는 것이다. 이 접근 방식에 대해 내가 주로 우려하는 점은 다음과 같다:
- CDN이 영원히 동작할 거라고는 별로 믿지 않는다. 보통은 의존성을 저장소에 복사해 넣어두는 걸 좋아하는데, 그래야 나중에 어떤 이유로든 사라지는 일을 방지할 수 있기 때문이다.
- CDN이 보안 침해를 당했다는 이야기를 들은 적이 있어서 좀 무섭다.
- esm.sh가 정확히 뭘 하는지 잘 모르겠다.
esbuild도 CommonJS 모듈을 ES 모듈로 변환할 수 있다
또 esbuild를 사용해서도 CommonJS 모듈을 ES 모듈로 변환할 수 있다는 걸 알게 됐는데, 몇 가지 제약이 있다. import { BrowserOAuthClient } from 구문이 동작하지 않는다. 이에 대한 github 이슈가 있다.
esbuild 방식이 esm.sh 방식보다 내게는 아마 더 매력적일 것 같다. 이미 내 컴퓨터에 있는 도구라 더 신뢰가 가기 때문이다. 아직 많이 실험해 보진 않았다.
세 가지 파일 유형 요약
우리가 마주할 수 있는 세 가지 JS 파일 유형과 각각의 사용 방법, 그리고 식별 방법을 요약하면 다음과 같다.
별로 도움이 안 되게도 .js나 .min.js 확장자는 이 세 가지 중 어떤 것이든 될 수 있다. 그래서 파일이 something.js라면 정체가 뭔지 알아내기 위해 좀 더 탐정처럼 파봐야 한다.
- ‘클래식’ JS 파일
- 사용 방법:
<script src="whatever.js"></script> - 식별 방법:
- 웹사이트 설정 가이드에 ‘CDN으로 사용하세요!’ 같은 크고 친절한 배너가 있다
.umd.js확장자- 그냥
<script src=...태그에 넣어 보고 동작하는지 확인해 본다
- 사용 방법:
- ES 모듈
- 사용 방법:
- 의존성이 없다면 코드에서 바로
import {whatever} from "./my-module.js"한다 - 의존성이 있다면 importmap을 만들고
import {whatever} from "my-module"한다- 또는 download-esm을 사용해 importmap이 필요 없게 만든다
- esbuild나 다른 ES 모듈 번들러를 사용한다
- 의존성이 없다면 코드에서 바로
- 식별 방법:
import나export문을 찾는다. (module.exports = ...는 CommonJS다).mjs확장자- 아마도
package.json의"type": "module"(다만 정확히 어떤 파일을 가리키는지는 나도 잘 모르겠다)
- 사용 방법:
- CommonJS 모듈
- 사용 방법:
- https://esm.sh을 사용해 ES 모듈로 변환한다. 예:
https://esm.sh/@atproto/[email protected] - 어떻게든 빌드를 사용한다 (??)
- https://esm.sh을 사용해 ES 모듈로 변환한다. 예:
- 식별 방법:
- 코드에서
require()나module.exports = ...를 찾는다 .cjs확장자- 아마도
package.json의"type": "commonjs"(다만 정확히 어떤 파일을 가리키는지는 나도 잘 모르겠다)
- 코드에서
- 사용 방법:
ES 모듈이 표준화되어 있어서 정말 좋다
내 관점에서 CommonJS 모듈과 ES 모듈의 가장 큰 차이는 ES 모듈이 실제로 표준이라는 점이다. 그래서 훨씬 더 자신 있게 쓸 수 있다. 브라우저는 웹 표준에 대해 영원히 하위 호환성을 보장하니까, 오늘 ES 모듈로 코드를 작성하면 15년 후에도 똑같이 동작할 거라고 확신할 수 있다.
또 esbuild 같은 툴을 쓰는 것에 대해서도 마음이 놓인다. 설령 esbuild 프로젝트가 중단되더라도 표준을 구현하고 있는 것이니, 나중에 대체할 수 있는 비슷한 도구가 분명 또 나올 것 같기 때문이다.
JS 커뮤니티는 정말 멋진 도구들을 많이 만들어 왔다
이런 이야기를 할 때면 ‘난 JavaScript가 너무 싫어!!! 최악이야!!!’ 같은 반응을 많이 듣는다. 하지만 내 경험상 JavaScript에는 훌륭한 도구가 정말 많다(어제 알게 된 https://esm.sh도 멋져 보인다! 나는 esbuild를 좋아한다!). 그리고 동작 방식을 배우는 데 시간을 들이면 그런 도구들을 활용해 삶을 훨씬 더 편하게 만들 수 있다.
그러니 이 글의 목표는 절대 JavaScript에 대해 불평하는 것이 아니라, 풍경을 이해해서 툴링을 나에게 맞는 방식으로 활용하는 것이다.
아직 남아 있는 질문들
아직 남아 있는 질문들은 다음과 같다. 답을 알게 되면 글에 추가하겠다.
- 로컬에 설정해 둔 ES 모듈에 대해 importmap을 자동으로 생성해 주는 도구가 있을까? (있다고 한다: jspm)
- https://esm.sh이 하는 것처럼 내 컴퓨터에서 CommonJS 모듈을 ES 모듈로 변환하려면 어떻게 해야 할까? (esbuild가 어느 정도 할 수 있다고는 하는데, named export가 동작하지 않는다)
- 사람들이 보통 CommonJS 모듈을 일반 JS 코드로 빌드할 때, 실제로 어떤 코드가 그 일을 하는 걸까? 물론 webpack, rollup, esbuild 같은 도구가 있지만, 그 도구들은 모두 자체 JS 파서/정적 분석을 구현하고 있는 걸까? 세상에 JS 파서는 몇 개나 있는 걸까?
- ES 모듈을 단일 파일(예:
atcute-client.js)로 번들하면서도, 브라우저에서 여전히 그 파일로부터 여러 다른 경로(예:@atcute/client/lexicons와@atcute/client모두)를 import할 수 있는 방법이 있을까?
등장한 모든 도구
이 글에서 이야기한 모든 도구의 목록은 다음과 같다:
- Simon Willison의 download-esm - ES 모듈을 다운로드해서 import가 JS 파일을 직접 가리키도록 변환해 importmap이 필요 없게 해준다
- https://esm.sh/와 skypack.dev
- esbuild
- JSPM은 importmap을 생성할 수 있다
이 글을 쓰면서, 보통은 프로젝트를 업데이트할 때마다 실행하는 빌드를 갖고 싶지 않지만, 프로젝트를 설정할 때 단 한 번만 실행하고 의존성 버전을 업데이트할 때를 제외하고는 다시는 실행하지 않는 빌드 단계(download-esm 같은 것을 이용해)는 기꺼이 가질 수도 있겠다는 생각이 들었다.
이게 전부다!
Marco Rogers에게 감사드린다. 이 글에 담긴 많은 것을 그에게서 배웠다. 이 글에는 아마 틀린 부분이 있을 텐데, 알려주시면 정말 감사하겠다. Bluesky나 Mastodon으로 알려 달라!
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기