JavaScript package 101 - Module

HYUNGU, KANG·2023년 5월 16일
2

JS-Package101

목록 보기
1/1
post-thumbnail

JavaScript(이하 JS) 생태계는 빠르게 발전하고 있고, 그에 따라서 익혀야 할 지식들과 스펙들은 점점 더 늘어나고 있습니다.
JS를 사용한다면 필수로 익혀야하는 Package Manager 인 NPM(그리고 패키지)를 둘러싼 지식들에 대해서 글로 적어보려 합니다.

Module: module resolution (bundler, package.json), module system (esm/cjs)
Versioning: tilde, caret, semver
Package manager: yarn, pnpm, npm, corepack
Dependency management: node_modules, hoisting, symlink, pnp
Depedencies: dependencies, devDependencies, peerDependencies


Module resolution (module resolve)

일반적으로 JS 에서 모듈은 다음과 같이 사용할 수 있습니다.

const moduleA = require('module-a');
const myModule = require('../myModule');

myModule 은 상대 경로로 접근하는 자신의 모듈인것을 알 수 있고 module-a 는 절대 경로로 접근하는 일반적으로 node_modules 에 존재하는 패키지를 의미한다는것을 알 수 있습니다. 즉 일반적으로 myModule 은 내부 종속성, module-a 는 외부 종속성을 의미합니다.

그렇다면 실제로 코드를 해석할 때, 모듈이 포함하고 있는 코드를 어떻게 가져올까요?

Node.js, TypeScript 및 웹팩과 같은 번들러들은 소스코드를 해석할 때, 모듈을 import 하는 코드를 해석하는것을 module resolution 이라고 합니다.
즉 결과물에서 어떤 파일이 import 되는지는 module resoution 에 따라서 달라집니다.


import moduleA from './moduleA.js';

일반적인 코드를 예시로 들어보겠습니다.
모듈의 경로와 확장자가 명확하면 해당 위치에 존재하는 해당 확장자의 파일을 가져오려 시도합니다.

import moduleA from './moduleA';

하지만 모듈의 경로만 지정이 돼 있다면, 아래처럼 몇가지 추가적인 시도가 필요합니다.

  1. ./moduleA.js 파일이 해당 위치에 있는지를 확인합니다.
  2. ./moduleA.js 파일이 없다면, ./moduleA 폴더에 package.json 파일이 있다면 package.json 에 명시된 파일을 읽어옵니다.
  3. ./moduleA/package.json 파일이 없다면, ./moduleA/index.js 파일이 있는지 확인하고 가져오려 시도합니다.

대~충 위의 내용을 한줄로 정리하면.. import 한 경로가 상대적인지 절대적인지, 디렉토리인지 파일인지에 따라서 처리가 나뉘고, 각 케이스에 맞게 모듈을 가져오는 처리들이 내부 로직과 설정들에 의해서 계산되고 최종적으로 모듈을 가져온다. 입니다.


모듈을 배포하는 입장에서 이를 잘 지원해주기 위해서는 모듈의 package.jsonmain, module, types, exports 필드 등을 작성하는법을 잘 알고있으면 되고, 또한 배포하려는 플랫폼에서 사용되는 번들러가 내가 작성하려는 package.json 필드의 스펙을 지원하는지도 알아두어야 합니다.

이상적인 모듈 환경을 위해서는 모듈을 배포하는 입장에서 최신 스펙에 대한 지원과 업데이트를
*그렇지 않은 경우 발생할 수 있는 문제: https://github.com/facebook/react/issues/20235

번들러를 개발하는 입장에선 최신 스펙에 대한 지원과 업데이트가 지속적으로 이루어져야 합니다.
*그렇지 않은 경우 발생할 수 있는 문제: https://github.com/facebook/metro/issues/670

요즘에는 exports 필드 하나로도 모듈 시스템 뿐만 아니라, 플랫폼에 맞춰서까지 다 정의를 할 수 있으니 문서를 살펴보시는것을 추천합니다.

이는 Firebase(JS) 나 React(Client/Server) 같이 배포 환경이 다양한 패키지들에서도 자주 사용되니, 왜 같은 경로의 모듈을 import 하는데, 이 환경에선 되고 저 환경에선 어떤 함수가 없다고 에러가 발생하는지 궁금하셨던 분들은 꼭 알아두시길 바랍니다.

{
  "exports": {
    "types": "./dist/index.d.ts",
    "browser": "./dist/index.browser.js",
    "react-native": "./dist/index.native.js",
    "server": "./dist/index.server.js",
    "require": "./dist/index.cjs",
    "import": "./dist/index.mjs",
  }
}

Node.js 공식문서 - https://nodejs.org/api/packages.html#package-entry-points
firebase package.json - https://github.com/firebase/firebase-js-sdk/blob/master/packages/auth/package.json#L13-L71
react package.json - https://github.com/facebook/react/blob/main/packages/react/package.json#L23-L26

리액트의 경우 RSC 지원을 위해서 서버에서 사용되는 react 의 경우
"react-server": "./react.shared-subset.js" 형태로 간소화된 subset 파일을 export 해주고 있습니다.
이는 서버에서 RSC를 핸들링할 때 import {} from "react"; 형태의 React module import 가 react.shared-subset.js 파일에서 import 되도록 매핑을 해줍니다.

모듈이 import 될 때 "react-server" conditional exports 를 사용하려면 설정이 필요한데, server-components-demo 에서는 앱을 실행할 때 node.js 의 conditions 옵션을 사용해서 설정하는것을 확인할 수 있습니다.

번외로, 사용하는 쪽에서 import 를 매핑할 수 있는 방법도 이야기가 나오고 있습니다.
https://github.com/WICG/import-maps


일단 현재 단계에서는 ts/webpack 등의 도구들에서도 표준인 node.js 의 스펙에 맞춰서 지원을 하고, 컴파일(트랜스파일)이나 번들링에서의 추가적인 기능들을 지원하기 위해서 추가 옵션을 지원한다고 간단하게 이해를 하셔도 좋습니다.

우리가 컴파일러(트랜스파일러)/번들러를 설정하면서 자주 마주치는 resolver 를 통한 path alias 혹은 파일 확장자를 통한 분기처리 등이 대표적인 추가 옵션의 예가 될 수 있습니다.

// Path alias

// import MyComponent from '../../components/MyComponent';
import MyComponent from '@components/myModule';
// Resolve extensions

// ../module.web.js
// ../module.native.js
// ../module.ios.js
// ../module.android.js
import MyModule from '../module';

이처럼 모듈을 해석할때는 모듈 경로, 확장자, package.json 을 비롯한 여러가지 설정들에 영향을 받습니다.

간단한 지식 정도는 이 글을 통해서 얻을 수 있겠지만, 실제로 module resolution 이 각 도구에서 어떻게 동작하는지 이해하려면 아래 링크들을 참고하시는게 좋습니다.


Module system (module type/format, module loader)

모듈의 경로(위치)를 해석하기 위한게 Module resolution 이라면, 모듈을 해석하기 위해 필요한것은 Module system 입니다.
모듈 안에서 어떤것들이 export 되고 있는지를 알 수 있어야, 모듈을 사용하고 있는데서도 import 가 가능하니까요.
CJS,ESM,AMD,UMD 키워드 들에 대해서는 한번씩 스쳐가면서라도 들어보셨을거라고 생각합니다. 이것들이 모두 모듈 시스템의 이름입니다.

Module system 에는 module format(type) 이 있고, 이를 처리하는 module loader 가 있습니다.
CJS 에서는 require/module.exports 키워드를 사용하는 포멧을 가지고 있고
ESM 에서는 import/export 키워드를 사용하는 포멧을 가지고 있습니다.

Others (AMD,UMD)

과거에는 모듈 시스템에 대한 표준이 없었기때문에 표준이 정립되는 과정에서 나타났던 모듈 시스템들 입니다.
히스토리가 궁금하신 분들은 찾아보셔도 좋습니다.

CJS

CJS (CommonJS)는 JS 모듈 시스템 중 하나로, Node.js 환경에서 주로 사용됩니다.
CJS는 동기적으로 모듈을 로드하고 내보내는 방식을 채택하여 모듈의 종속성 관리와 코드 재사용을 용이하게 합니다.

CJS 모듈은 require 함수를 사용하여 다른 모듈을 로드하고, module.exports 객체를 통해 모듈의 내보낼 대상을 정의합니다.
다른 모듈을 가져오기 위해 require 함수에 모듈 식별자를 전달하고, 해당 모듈에서 내보낸 객체를 반환받아 사용할 수 있습니다.

예를 들어, 다음과 같이 모듈을 정의하고 내보내는 방식으로 CJS 모듈을 사용할 수 있습니다:

// 모듈 정의
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// 모듈 내보내기
module.exports = {
  add,
  subtract
};

다른 파일에서 이 모듈을 사용하기 위해서는 다음과 같이 require 함수를 사용하여 모듈을 가져올 수 있습니다:

// 모듈 가져오기
const math = require('./math');

// 가져온 모듈 사용
console.log(math.add(5, 3)); // 8
console.log(math.subtract(5, 3)); // 2

CJS 모듈은 동기적으로 모듈을 로드하기 때문에, require 함수를 사용하는 부분이 동기적으로 처리될 때까지 코드 실행이 차단됩니다.
만약 모듈 파일에 10초 정도의 수행시간이 걸리는 로직이 있다면, 모듈이 import 되는 순간에 해당 로직이 동기적으로 10초 동안 실행됩니다.
(만약 해당 로직이 무언가를 조작하거나 오염시키는 행위를 한다면 import 하는 과정에서 실행환경에 영향을 끼치므로, 해당 모듈은 side effect가 있다고 표현합니다. 이는 번들러가 모듈을 트리 쉐이킹 하는데도 영향을 미칩니다.)

CJS는 모듈 파일의 확장자를 .cjs로 사용하거나, package.json 파일의 "type": "commonjs" 설정을 통해 사용할 수 있습니다.

ESM

ESM (ECMAScript Modules)은 JS 표준 모듈 시스템입니다.
ESM은 ES6부터 도입되었으며, 네이티브로 모듈을 정의하고 가져오는 기능을 제공합니다.
ES 모듈은 주로 웹 브라우저와 Node.js에서 사용되며, 최신 JS 개발에서는 가장 권장되는 모듈 시스템입니다.

ESM은 import 및 export 키워드를 사용하여 모듈을 정의하고 가져옵니다. 다른 모듈에서 해당 모듈을 가져올 때 import 키워드를 사용합니다.

예를 들어, 다음과 같이 모듈을 정의하고 가져오는 방식으로 ESM을 사용할 수 있습니다:

// 모듈 정의 (math.js)
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// 모듈 가져오기 (index.js)
import { add, subtract } from './math';

console.log(add(5, 3)); // 8
console.log(subtract(5, 3)); // 2

ESM은 비동기적으로 모듈을 로드합니다. 즉, 모듈이 필요한 시점에 모듈을 로드하고 실행하는것도 가능합니다.
이는 웹 브라우저 환경에서도 동작하기 때문에, 네트워크에서 모듈을 가져와 사용하는 동적인 로딩이 가능합니다.

ESM은 모듈 파일의 확장자를 .mjs로 사용하거나, package.json 파일의 "type": "module" 설정을 통해 사용할 수 있습니다. 브라우저에서는 <script type="module">을 사용하여 ESM을 사용할 수 있습니다.


모듈 공급자는 모듈을 Module system 에 맞는 포멧으로 작성 및 공급해야 하고, 사용하는 쪽에서는 올바른 Module loader 를 통해서 해당 모듈을 처리해야 합니다.

만약 CJS/ESM 모두를 지원하는 모듈이라고 가정할 때, 사용하는 쪽의 모듈 시스템이 CJS 라면 모듈 공급자는 CJS 포멧으로 작성된 모듈을 제공해야 하고 moduleResolution 에 의해 CJS 포멧으로 작성된 모듈이 사용될 수 있도록 파일 경로를 설정하거나 package.json 에 설정 해줘야 합니다.

패키지의 메인 모듈 시스템 없이 단일 엔트리파일에서 모든걸 export 하거나

{
  "name": "my-module",
  "main": "./dist/cjs/index",
  "module": "./dist/esm/index"
}

혹은 패키지의 메인 모듈 시스템을 정의하고 확장자(cjs/mjs)를 명시해서 export 해도 된다.
다만 이 경우엔, 번들러에서 확장자로 파일을 분기처리 할 때 문제가 될 수 있다.

{
  "name": "my-module",
  "type": "module"
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    "require": "./dist/index.cjs",
    "import": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

이렇게 제공된 CJS 모듈은 CJS module loader 에 의해서 처리되어야 합니다.
그렇다면, CJS 환경에서 ESM 모듈을 로드를 시도하는식으로 사용중인 모듈 시스템과 다른 포멧의 모듈을 사용하면 어떻게 될까요?
시도를 해본다면 아래와 같은 에러들을 만나게 될겁니다.

SyntaxError: Unexpected token import
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
ReferenceError: require is not defined
ReferenceError: module is not defined in ES module scope
ReferenceError: exports is not defined in ES module scope

다른 모듈 시스템에서 사용되는 키워드들을 인식하지 못하므로 코드를 해석하는데 에러가 발생합니다.

이런 케이스는 주로 아래와 같은 상황에 많이 겪게 됩니다.

현재 프로젝트의 package.jsontype 필드의 값에 따라서 현재 프로젝트의 모듈 시스템이 결정됩니다.
현재 프로젝트가 "type": "module" 로 설정되면 모든 js 파일들을 esm module 로 인식하고 처리하게 됩니다.

이 경우에 프로젝트 내에서 간단한 처리를 하기 위해서 만들어놓은 scripts 들 혹은 eslint 나 babel 설정을 위한 config 파일들(.eslintrc.js, .babel.config.js) 또한 esm module 로 처리가 되면서 에러가 발생하는 경우가 있습니다.

이런 경우엔 확장자를 .cjs 로 명시하거나, 스크립트 파일과 가장 가까운 위치의 package.json 파일의 type 필드에 모듈 타입을 "commonjs" 로 지정하여 cjs module 로 처리되도록 하거나, config 파일의 경우 모듈 시스템에 영향을 받지 않는 js 가 아닌 다른 파일 형태(json) 로 변경하는 방법이 있습니다.


예외적으로 몇몇 경우에서는 다른 환경의 모듈을 로드할 수 있습니다.

// ESM 환경에서 CJS 모듈 로드
import CJSModule from './module.cjs';
// CJS 환경에서 ESM 모듈 로드
async function loadESMModule() {
  const ESMModule = await import('./module.mjs');
}

해당 파일이 특정 모듈 명시하기 위해서 반드시 .cjs 혹은 .mjs 확장자를 사용하거나, 가장 가까운 위치의 package.json 파일에 type 을 올바르게 지정하여야 올바른 module loader 에 의해 처리됩니다.
그렇지 않고 .js 확장자를 가지고 있다면, CJS 모듈임에도 불구하고 ESM 모듈로 처리가 되면서 에러가 발생합니다.
이런식으로 특정 파일이 특정 모듈 시스템에 의해서 처리가 되어야 함을 명시적이고 엄격하게 나타내려면 ESM 은 .mjs CJS 는 .cjs 로 확장자를 지정하면 됩니다.


우리는 이제 모듈 시스템간 호환에도 제약이 있단것을 알게 됐습니다.

그렇다면 모든 모듈이 CJS/ESM 을 동시에 지원하지 않고 한쪽 환경만 지원할 수도 있을텐데
우리는 어떻게 복잡한 앱 혹은 패키지를 개발하면서 이러한 문제에서 신경을 덜쓰고 개발을 할 수 있는 것일까요?

바로 정답은 compile(transpiling) 에 있습니다.
어플리케이션을 개발할때는 실시간으로 소스코드를 변환시키고, 모듈을 배포할때는 변환된 결과물만을 배포합니다.
어떤 포멧으로 작성됐던간에 변환을 할 때, 실행 가능하게만 변환을 시켜주기만 한다면 실행에 문제가 없는것입니다.

babel 에서는 babel-plugin-transform-modules-commonjs 플러그인을 통해서 ESM 포멧을 CJS 로 변경하는 방법을 제공하고 있습니다.
https://babeljs.io/docs/babel-plugin-transform-modules-commonjs

TypeScript 에서는 tsconfig.jsonmodule 필드를 통해서 컴파일 결과물의 모듈 타입을 변경하는 방법을 제공하고 있습니다.
https://www.typescriptlang.org/tsconfig/#module

실행이 되기 전에 코드를 변환해주는 해주는 테크닉은 모듈간의 호환 뿐만 아니라, 최신 문법으로 작성된 코드들이 ES5 만을 지원하는 환경에서도 정상적으로 동작을 하게끔 도와주는데도 주로 사용되고 있습니다.


이렇게 모듈에 대해서 간략하게 알아보았습니다.
모듈 시스템에 관하여 더 자세하게 알고싶다면 아래의 링크들 또한 참고해보시는것을 추천합니다.

https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1 (ko: https://roseline.oopy.io/dev/translation-why-cjs-and-esm-cannot-get-along)
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
https://github.com/tc39/proposal-top-level-await
https://github.com/tc39/proposal-dynamic-import
https://github.com/nodejs/modules
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import
https://toss.tech/article/commonjs-esm-exports-field

profile
JavaScript, TypeScript and React-Native

0개의 댓글