Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Deno의 Node.js 호환 기능

Node.js를 만든 Ryan Dahl이 Deno도 만들었지만 둘은 다른 프로젝트라서 완전히 호환되지는 않는 런타임이다. 그런데도 JavaScript 풍부한 생태계(브라우저, Node.js 포함)를 무시할 수는 없으므로 Deno 1.15부터 Node.js 호환성 모드를 추가하기 시작했다. Deno가 브라우저에서 사용하는 Web Platform API를 따르고 있고 Node.js처럼 V8 엔진을 이용하기 때문에 상당수의 Node.js 코드는 Deno에서도 동작하지만 두 런타임의 차이가 있기 때문에 호환성 문제가 있다.

  • Node.js는 그 발전과정의 흐름을 보면 CommonJS와 ES Modules를 모두 지원한다.(Node.js가 만들어질 때는 ES Modules가 없었다.) 하지만 Deno는 ES Modules만 지원하기 때문에 CommonJS(require)로 작성된 코드는 Deno에서는 기본적으로 동작하지 않는다.
  • Node.js가 내장 모듈을 많이 제공하고 있기 때문에 이러한 모듈은 Deno와는 다르다. Deno는 웹 표준을 따르려고 하고 있으며 브라우저 외의 기능은 전역 변수 Deno에서 API를 제공하고 있다.
  • Node.js는 package.json이 꼭 필요하지만, Deno는 별도의 메타데이터 파일을 필요로 하지 않는다.
  • Node.js는 자신만의 모듈 처리 알고리즘을 가지고 있어서 상대 경로나 절대 경로를 사용하지 않고 모듈의 이름을 지정하는 베어 지정자(bare specifier)를 지원한다. 그리고 package.json을 검사해서 이름을 확인하고 node_modules 폴더에서 모듈을 찾는다. 하지만 Deno는 브라우저와 같은 방식으로 모듈을 처리하기 때문에 확장자를 포함한 전체 이름으로 임포트하고 원격 파일을 임포트한다면 웹서버가 처리 후 올바른 미디어 타입을 제공하면 이에 따라 Deno가 처리하게 된다.
  • Node.js는 HTTP로 원격 임포트를 지원하지 않고 npm같은 패키지 매니저로 서드파티 모듈을 로컬에 설치해야 하지만 Deno는 HTTP로 원격 임포트(datablob URL도 지원한다)를 지원하고 원격 코드를 로컬에 캐시하는데 이는 브라우저의 동작과 비슷하다.

이러한 차이점이 있지만 Deno는 Node.js 생태계와 호환되기 위해 다양한 기능을 제공하고 있다. 물론 호환이 전혀 되지 않은 부분도 있다

  • Node.js에는 애드온 시스템이 있는데 Deno는 이를 지원하지 않고 앞으로도 지원할 계획이 없다. 사용할 Node.js 코드가 네이티브 애드온을 사용한다면 Deno에서는 사용할 수 없다.
  • Node.js에서는 Deno의 지원범위와는 다른 VM같은 내장 모듈도 있는데 이러한 모듈을 Deno에서 쉽게 폴리필 할 수 없다.

std/node

Deno는 Node.js의 내장 모듈을 지원하기 위해 표준 라이브러리 std/node를 제공하고 있다. 100% 대체하는 것은 아니므로 std/node의 문서를 참고해야 하지만 해당 문서는 최신 버전과 좀 안 맞는 부분이 있어서 표준 라이브러리를 참고해서 봐야 한다. 이 std/node를 이용해서 Node.js의 내장 모듈을 대체할 수 있다.

Node.js에서 HTTP를 실행하는 다음 코드를 살펴보자.

// nodejs-server.js
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello world');
});

이 코드는 CommonJS 형식이라 requirestd/nodemodule을 사용해서 폴리필할 수 있다. 아래 코드처럼 require를 정의하면 Deno에서 이 코드를 그래도 실행할 수 있다.

// nodejs-server.js
import { createRequire } from "https://deno.land/std@0.157.0/node/module.ts";
const require = createRequire(import.meta.url);

// 여기서 부터 nodejs http 서버
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello world');
});

server.listen(8000);

이는 std.node에 폴리필이 있어서 npm 모듈(require를 포함해서)을 처리해 준다. 앞에서 말한 제약 사항 외에는 괜찮긴 하지만 실제로 동작하는 건 모듈별로 봐야 한다.

아직 공식 문서--compat 호환성 모드라 동작 방식에 대한 설명은 어디까지가 지금과 맞는지 확실치 않다.

std.node에는 Node.js의 내장 모듈을 대체하는 모듈이 포함되어 있다. 100% 호환되는 것은 아니고 부분적으로 구현된 모듈이 있기 때문에 문서를 참고해 봐야 한다.

CommonJS의 require를 사용해야 하는 경우에는 ``std.nodemodule을 사용할 수 있다. 아래처럼require`를 정의한 뒤에 Node.js의 HTTP 서버 코드를 그대로 사용할 수 있다.

// nodejs-server.js
import { createRequire } from "https://deno.land/std@0.157.0/node/module.ts";
const require = createRequire(import.meta.url);

// 여기서 부터 nodejs http 서버
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello world');
});

server.listen(8000);

아래처럼 이 파일을 실행하면 잘 실행되는 것을 알 수 있다. 여기서 http 모듈은 당연히 Node.js가 아니라 std/nodehttp 모듈이다.

$ deno run --allow-net --allow-env nodejs-server.js

제대로 서버가 실행되었는지 터미널을 하나 더 열어서 요청을 보내보면 응답이 잘 오는 것을 볼 수 있다.

$ curl http://localhost:8000
hello world


npm 지원

Deno 1.15부터 Node.js용으로 만들어진 모듈과 호환성을 제공하기 위해 --compat 플래그를 제공했지만 1.25.0에서 실험적으로 npm 지원을 추가하면서 1.25.2 버전에서 --compat 플래그를 제거했다. 그래서 아직 공식 문서에도 --compat이 나와 있지만 Deno 최신 버전(이 글을 쓰는 시점에서는 1.25.4)을 쓴다면 무시해도 좋고 npm 지원도 실험적인 지원이므로 나중에는 동작이 달라질 수도 있다.

npm 지원은 모듈을 사용할 때 npm:<package-name>[@<version-requirement>][/<binary-name>]같은 형식으로 앞에 npm: 지정자를 붙여주면 이를 Deno가 처리해 준다. 이 지정자는 현재 deno run, deno test, deno bench에서 동작하고 아직 타입검사는 하지 않는다. 지금은 npm을 쓸 때 환경변수와 파일시스템 읽기 기능이 필요한데 나중에는 필요없게 될 것이라고 한다.

간단한 예제를 살펴보자. 아래에서 cowsay 모듈을 Deno에서 사용하기 위해 npm:cowsay@1.5.0를 실행했다.(이 예제들은 1.25.0 릴리스 공지에 있는 예제이다.)

$ deno run --unstable --allow-env --allow-read npm:cowsay@1.5.0 Hello there!
Download https://registry.npmjs.org/cowsay
Download https://registry.npmjs.org/get-stdin
Download https://registry.npmjs.org/string-width
Download https://registry.npmjs.org/strip-final-newline
Download https://registry.npmjs.org/yargs
...중략...
Download https://deno.land/std@0.157.0/node/module_all.ts
Download https://deno.land/std@0.157.0/node/_http_agent.mjs
...중략...
Download https://deno.land/std@0.157.0/node/_crypto/crypto_browserify/asn1.js/encoders/der.js
Download https://deno.land/std@0.157.0/node/_crypto/crypto_browserify/asn1.js/encoders/pem.js
 ______________
< Hello there! >
 --------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

자연스럽게 npm 모듈을 Deno에서 사용한 것을 볼 수 있다. 아직 실험적 기능이므로 --unstable 플래그가 필요하다. 지정하지 않으면 error: Unstable use of npm specifiers. The --unstable flag must be provided. 오류가 발생한다. 일부러 캐시를 초기화하고 다운로드받는 로그도 넣어놨는데 Deno의 표준 라이브러리뿐 아니라 https://registry.npmjs.org에서 필요한 모듈을 알아서 받아오는 것을 볼 수 있다.

이번엔 Express 서버를 실행해 보자.

// npm-express.ts
import express from "npm:express";
const app = express();

app.get("/", function (req, res) {
  res.send("Hello World");
});

app.listen(3000);
console.log("listening on http://localhost:3000/");

Express 서버에서 많이 보던 코드 그대로다. 이를 실행해보자.

$ deno run --unstable --allow-read \
  --allow-env --allow-net npm-express.ts
listening on http://localhost:3000/

터미널을 새로 열어서 요청을 보내보면 응답을 잘 주는 것을 볼 수 있다.

$ curl http://localhost:3000
Hello World

npm 지원 기능은 Node.js에서 사용할 때와 유사하게 자연스럽게 동작하는 것을 알 수 있다. 이는 기존 Deno에서 아쉬웠던 부분을 크게 해결할 것으로 생각한다. 물론 npm 모듈이라고 하더라도 앞에서 얘기한 Deno와 호환되지 않는 코드는 제대로 실행되지 않을 것이므로 사용할 때 확인해 보고 사용해야 한다.

CDN을 이용한 모듈

Node.js 생태계가 브라우저 JavaScript 생태계도 같이 지원하고 있으므로 Deno도 이를 이용할 수 있다. 이는 정확히 Node.js 호환을 위한 기능은 아니지만, 브라우저에서 ES Modules를 이용하기 위해 CDN을 통해 제공하는 레지스트리가 있고 Deno는 원격 HTTP 모듈을 지원하므로 이러한 CDN을 통해서 Deno에서도 npm 레지스트리의 모듈을 사용할 수 있다.

deno.land/x는 Deno의 공개 레지스트리로 Node.js의 npm 레지스트리라고 생각하면 된다. Deno 모듈을 등록해서 사용할 수 있지만 아직 생태계가 크지 않아서 각 모듈이 잘 관리되고 있다고 하기는 어려운 상황이다.

대신 Deno를 지원하는 CDN이 있어서 Node.js용으로 작성된 모듈을 Deno에서 사용할 수 있도록 지원해 주고 있다. 이러한 CDN은 다음과 같은 지원을 해준다.(모든 CDN이 아래 기능을 다 지원하는 것은 아니다.)

  • npm에 배포된 방식과 상관없이 ES Modules 형식으로 제공한다.
  • Node.js의 모듈 처리 로직은 CDN이 대신 모든 의존성을 해결해서 제공한다.
  • 모듈의 타입 정의를 Deno에 알려주어 타입 검사를 할 수 있게 해준다.
  • Node.js 내장 모듈을 CDN이 폴리필해주어 Node.js 내장 모듈도 Deno에서 동작하게 해준다.
  • CDN이 npm처럼 버전 처리를 해주어 URL에서 버전을 지정할 수 있게 해준다.

esm.sh

esm.sh는 Deno를 위해 설계된 CDN으로 npm 패키지를 ES Modules 번들로 접근하게 해준다. esm.sh는 esbuild를 사용해서 npm 패키지를 ES Modules로 변환해서 Deno 코드에서 임포트할 수 있다.

예를 들어 어서트 라이브러리인 chai를 사용한다고 하면 https://esm.sh/chai를 주소창에 입력하면 다음과 같이 최신 버전으로 리다이렉트가 된다.

esm.js의 모듈 안내 페이지

이 주소를 아래와 같이 사용하면 chai를 마치 Deno용으로 만들어진 모듈처럼 사용할 수 있다.

// esmsh.ts
import * as chai from "https://esm.sh/v95/chai@4.3.6/es2022/chai.js";

const expect = chai.expect;

const foo = 'foo';
expect(foo).to.equal('bar');

chaiNODE_ENV 환경 변수에 접근하기 때문에 --allow-env 플래그를 주어서 실행하면 잘 실행되는 것을 알 수 있다.

$ deno run --allow-env esmsh.ts
error: Uncaught AssertionError: expected 'foo' to equal 'bar'
expect(foo).to.equal('bar');
               ^
    at file:///Users/outsider/deno-example/esmsh.ts:6:16

물론 위처럼 아주 구체적으로 적지 않고 import * as chai from "https://esm.sh/chai@4.3.6";같은 식으로 적는 것도 가능하다. 이외에도 많은 기능이 있긴 한데 esm.sh 사이트를 참고하면 안내되어 있다. esm.shstd/node를 사용해서 Node의 내장 모듈을 교체하므로 Deno가 가진 제약 사항도 동일하게 적용된다.

Skypack

Skypack도 npm 레지스트리에 올라온 모듈을 Deno를 위해 지원해 준다. 2020년 초에 Deno를 지원하기 시작했고 21년 초에도 Deno 호환성을 개선했고 타입 정보를 위한 ?dts 쿼리 스트링도 이때 추가되었다.

esm.sh는 모듈 검색도 어렵고 사이트도 개인이 만들었나 싶어질 정도로 설명도 불친절하고 정보 찾기도 어려웠는데 Skypack은 사이트가 훨씬 깔끔하게 되어 있다. 모듈 검색도 할 수 있고 사용법과 함께 README와 통계(이건 npm에서 긁어 온 것 같다.)도 나와 있다.

Skypack으로도 chai를 사용해보자.

// skypack.ts
import * as chai from "https://cdn.skypack.dev/chai";

const expect = chai.expect;

const foo = 'foo';
expect(foo).to.equal('bar');

모듈을 가져오는 주소 빼고는 esm.sh의 코드와 다른 부분은 없다. 하지만 이를 실행하면 아래와 같이 오류가 난다.

$ deno run skypack.ts
[Package Error] "chai@v4.3.6" could not be built.
[1/5] Verifying package is valid…
[2/5] Installing dependencies from npm…
[3/5] Building package using esinstall…
Running esinstall...
chai/karma.sauce.js
   Import "./test/auth/index" could not be resolved from file.
/tmp/cdn/_jxKTOH1FZOrZ5Cu9nlya/node_modules/chai/test/auth/index?commonjs-external
   Module "/tmp/cdn/_jxKTOH1FZOrZ5Cu9nlya/node_modules/chai/test/auth/index" could not be resolved by Skypack (Is it installed?).
Install failed for one of chai, chai/chai, chai/index, chai/karma.conf, chai/karma.sauce, chai/register-assert, chai/register-expect, chai/register-should, chai/sauce.browsers.
Install failed for one of chai, chai/chai, chai/index, chai/karma.conf, chai/karma.sauce, chai/register-assert, chai/register-expect, chai/register-should, chai/sauce.browsers.
error: Uncaught Error: [Package Error] "chai@v4.3.6" could not be built.
throw new Error("[Package Error] \"chai@v4.3.6\" could not be built. ");
      ^
    at https://cdn.skypack.dev/error/build:chai@v4.3.6-jxKTOH1FZOrZ5Cu9nlya:22:7

Deno 지원 처리를 하면서 빌드 오류가 발생하는데 정확한 이유는 모르겠다. CDN을 다양하게 사용해 보면서 처리 방식을 좀 더 공부해봐야 할 것 같다.

Skypack의 동작을 확인하기 위해 debug 모듈을 사용해 보자.

// skypack-debug.ts
import debug from "https://cdn.skypack.dev/debug";

const log = debug('mylog');

debug.enable('mylog');

log('hello world');

debug는 애플리케이션에서 디버그 로그를 선택적으로 출력할 수 있게 하는 모듈이다. 원래는 DEBUG 환경변수로 제어하는데 ESM으로 빌드되면서 그렇게 된 건지 Deno에서는 환경변수를 읽지 않아서(브라우저 용이 된 게 아닐까 추측해 본다.) 코드로 활성화했다. 실행하면 아래처럼 로그가 출력된다.

$ deno run skypack-debug.ts
mylog hello world +0ms

이 두 CDN 외에도 UNPKGJSPM도 공식 문서에서는 추천하고 있는데 이는 Deno를 위해서 뭔가 해준다기보다는 브라우저에서 동작하는 npm 패키지가 ES Modules로 배포되었다면 Deno에서도 동작할 가능성이 아주 높기 때문에 이를 권장해 주는 것으로 보인다.

Deno를 지원하는 CDN 서비스의 동작을 아직 다 몰라서이기도 하지만 npm: 지정자를 지원하면서 쉽게 사용할 수 있기 때문에 전보다 CDN의 활용도는 좀 떨어지지 않나 생각한다. 하지만 아직 npm 지원은 실험적인 기능임을 염두에 두고 사용해야 할 것 같다.

2022/09/26 04:05 2022/09/26 04:05