모듈 시스템 - 타입스크립트에서 ESM 사용하기

2025. 1. 2. 21:56Nodejs

반응형

ECMAScript Modules(ESM)은 JavaScript의 표준 모듈 시스템으로, import와 export 구문을 통해 코드를 모듈화할 수 있도록 해줍니다. Node.js 12부터 ESM이 안정화되었고, TypeScript에서도 ESM을 원활하게 사용할 수 있도록 여러 설정이 필요합니다. 본 글에서는 TypeScript에서 ESM을 사용하는 방법을 소개하고, 주요 설정과 사용 방법을 예시를 통해 설명하겠습니다.

 

1. ESM이란?

ECMAScript Modules(ESM)은 JavaScript의 표준 모듈 시스템으로, import와 export 구문을 통해 코드를 모듈화할 수 있도록 해줍니다. 이는 require()와 module.exports를 사용하는 CommonJS와 다릅니다. ESM은 최신 JavaScript에서 권장하는 모듈 시스템으로, 호환성과 트리 쉐이킹(tree shaking), 최적화 등의 장점이 있습니다.

TypeScript에서도 ESM을 원활하게 사용할 수 있도록 여러 설정이 필요합니다. 본 글에서는 TypeScript에서 ESM을 사용하는 방법을 소개하고, 주요 설정과 사용 방법을 예시를 통해 설명하겠습니다.

2. 타입스크립트에서 ESM 사용을 위한 설정

기본적으로 타입스크립트는 CommonJS 모듈 시스템을 사용하기 때문에, ESM을 사용하려면 package.json과 tsconfig.json에 적절한 설정을 추가해야 합니다.

package.json 수정

먼저, package.json에 type 프로퍼티를 module로 설정합니다. 이는 Node.js가 해당 프로젝트에서 ESM을 사용하도록 명시하기 하기 위함입니다.

{
  ...
  "type": "module"
}

tsconfig.json 수정

다음으로, tsconfig.json에 ESM과 관련된 설정을 추가합니다. ESM과 관련된 주요 옵션은 아래와 같습니다.

  • "module": 타입스크립트를 컴파일할 때 사용하는 모듈 시스템을 지정합니다. 즉, 어떤 형식으로 모듈을 변환할 것인지 결정합니다. ESM을 사용하려면 Node16, NodeNext, ES6, ES2015, ES2020, ES2022 중 하나로 설정합니다.
  • "moduleResolution": 타입스크립트가 다른 모듈을 해석하는 방식을 지정합니다. 즉, 타입스크립트가 import 구문을 사용하여 다른 파일이나 패키지를 어떻게 찾을지 결정하는 방식입니다. ESM을 사용하려면 node, node16, nodenext 중 하나로 설정합니다.
  • "allowSyntheticDefaultImports": CommonJS 모듈을 ESM 스타일로 가져오는 방법을 제어합니다. 이 옵션을 설정하면 module.exports로 내보낸 객체를 ESM의 default export처럼 처리할 수 있습니다. 하지만 이 옵션은 단순히 문법적으로만 허용하기 때문에 런타임 시 문제가 발생할 수 있습니다.
// allowSyntheticDefaultImports: false
import * as moment from 'moment'; // correct: * as 방식으로 가져오는 것은 정상적
console.log(moment.utc().format('YYYY-MM-DD'));

import moment from 'moment'; // incorrect: CommonJS 모듈을 default import로 가져올 수 없음
console.log(moment().format('YYYY-MM-DD'));

// allowSyntheticDefaultImports: true
import moment from 'moment'; // correct: default import가 가능 (문법적으로 허용)
console.log(moment().format('YYYY-MM-DD'));
"module": "ESNext", "moduleResolution": "node"로 설정하면 에러가 발생하는데 "module": "NodeNext", "moduleResolution": "nodenext"로 설정하면 에러가 발생하지 않습니다. "nodenext"는 Node.js 18.x 이상에서 사용하는 최신 ESM 처리 방식으로, module.exports를 default export처럼 취급할 수 있지만, "node"는 Node.js 12.x 이상에서 사용하는 전통적인 모듈 해석 방식으로 module.exports를 default export로 취급하지 않기 때문에 발생하는 에러라고 합니다.
  • "esModuleInterop": CommonJS 모듈을 ESM 스타일로 가져오는 방법을 제어합니다. 이 옵션을 설정하면 module.exports로 내보낸 객체를 ESM의 default export처럼 처리할 수 있습니다. allowSyntheticDefaultImports와 달리 이 옵션은 컴파일된 자바스크립트 코드에 default export를 지원하기 위한 코드가 추가됩니다. 또한, esModuleInterop을 활성화하면 allowSyntheticDefaultImports도 자동으로 활성화됩니다.
// math.js
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b
}

// main.ts

// esModuleInterop: false
import * as math from './math';
console.log(math.add(1, 2));

// esModuleInterop: true
import math from './math';
console.log(math.add(1, 2));

 

ESM 관련 설정을 포함한 최종적인 tsconfig.json은 아래와 같습니다. 참고로 제가 사용한 Node.js 버전은 v20.x 입니다.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

 

3. Path Alias 적용하기

Path Alias는 tsconfig.json의 paths 옵션을 사용하여 상대 경로 대신 짧고 직관적인 경로로 모듈을 가져올 수 있도록 도와주는 기능입니다. CommonJS 환경에서 Path Alias를 적용하려면 tsconfig.json에 paths 옵션을 설정한 후, ts-node로 실행 시 tsconfig-paths/register를 추가해야 합니다.

 

[path alias 적용 전]

// common/lib/math.js
export const math = (a, b) => a + b;

// service/foo/bar.service.js
import math from '../../common/lib/math'
$ ts-node main.ts

 

[path alias 적용 후]

// tsconfig.json
{
  ...,
  paths: {
    "@/common/*": ["common/*"]
  }
}

// main.js
import math from '@/common/lib/math';
$ ts-node -r tsconfig-paths/register main.ts

 

tsconfig-paths는 tsconfig.json의 paths 설정을 읽어서 Node.js 런타임 환경에서 로드 가능한 실제 경로로 변환하는 역할을 수행합니다. 하지만 tsconfig-paths는 CommonJS 환경에서 사용하도록 설계되었기 때문에 ESM에서는 바로 사용할 수 없습니다.

따라서 ESM 환경에서 Path Alias를 사용하려면, ts-node/esm, tsconfig-path와 함께 경로를 매핑해주는 Custom Loader를 작성해야 합니다. Custom Loader는 ESM의 모듈 로딩 과정을 커스터마이즈하여 Path Alias를 런타임에서도 적용할 수 있도록 돕습니다.

 

예제 코드는 위와 동일합니다. 달라진 점은 ESM에서는 확장자를 명시해야 되기 때문에 math -> math.js로 수정하였습니다.

// tsconfig.json
{
  ...,
  paths: {
    "@/common/*": ["common/*"]
  }
}

// common/lib/math.js
export const math = (a, b) => a + b;

// main.js
import math from '@/common/lib/math.js';

 

이제 Custom Loader를 작성합니다. tsconfig-paths는 tsconfig.json의 baseUrl과 paths 설정을 로드하고 createMatchPath를 통해 Path Alias를 실제 파일 경로로 매핑하는 함수를 생성하는 역할을 합니다. ts-node/esm은 모듈 경로 탐색 시 타입스크립트 파일도 탐색하기 위해서 사용합니다. 기본적으로 제공되는 resolve(코드에서는 nextResolve) 함수는 타입스크립트 파일을 지원하지 않습니다.

// path-alias-loader.js
import { resolve as resolveTs } from "ts-node/esm";
import * as tsconfigPaths from "tsconfig-paths";
import { pathToFileURL } from "url";

const { absoluteBaseUrl, paths } = tsconfigPaths.loadConfig();
const matchPath = tsconfigPaths.createMatchPath(absoluteBaseUrl, paths);

export function resolve(specifier, context, nextResolve) {
  let match = matchPath(specifier);

  if (specifier.endsWith(".js")) {
    const trimmed = specifier.substring(0, specifier.length - 3);
    const matchedPath = matchPath(trimmed) && matchPath(trimmed);
    match = matchedPath ? `${matchedPath}.js` : undefined;
  }

  return match
    ? resolveTs(pathToFileURL(`${match}`).href, context, nextResolve)
    : resolveTs(specifier, context, nextResolve);
}

export { load } from "ts-node/esm";

 

 

이제 작성된 Custom Loader를 node로 실행 시 전달하면 Path Alias를 적용할 수 있습니다. 

$ node --loader ./path-alias-loader.js main.js

 

위와 같이 코드를 작성하는 과정이 번거롭다면, ts-paths-esm-loader 라이브러리를 설치하여 간편하게 사용할 수 있습니다. 사실 위의 코드는 해당 라이브러리의 코드를 가져온 것 입니다.

$ node --loader ts-paths-esm-loader main.js

 

4. 빌드 시 Path Alias 적용하기

Path Alias를 적용한 상태에서 타입스크립트를 빌드하면, Path Alias 설정이 실제 모듈 경로로 변환되지 않는다. 이로 인해 실행 시 모듈을 찾을 수 없다는 에러가 발생합니다. 이를 해결하는 방법은 여러가지가 있습니다.

 

첫 번째 방법은 위에서 만든 Custom Loader를 사용하는 것입니다. 하지만 이 방법은 권장되지 않습니다. 실행 시 추가적인 Path Alias 변환 과정이 필요하기 때문입니다. 일반적으로 서버 배포 과정은 빌드, 배포, 실행의 세 단계로 나뉘는데, 이 방법은 실행 단계에서 불필요한 오버헤드와 성능 저하를 유발할 수 있습니다.

$ node --loader ./path-alias-loader.js dist/index.js

 

두 번째 방법은 tsc-alias와 같은 라이브러리를 통해 빌드 단계에서 경로를 변환합니다. 아래와 같이 빌드 스크립트를 작성하고 실행해보면 빌드된 파일에서 경로가 변환된 것을 확인할 수 있습니다. ts-patch + typescript-transformer-paths를 사용하는 방법도 있는데 예제에서는 다루지 않겠습니다.

// package.json
{
  "scripts": {
    "build": "tsc && tsc alias",
    "start": "node dist/index.js"
  }
}
tsc-alias는 tsconfig.json의 paths 설정을 기반으로, 빌드된 JavaScript 파일의 Path Alias를 실제 파일 경로로 변환해 주는 도구입니다. 이 과정은 타입스크립트의 컴파일 결과를 후처리(Post-Processing)하는 방식으로 동작합니다.

 

5. 후기

오랫동안 CommonJS를 사용해온 저로써는 ESM으로의 전환이 쉽지 않습니다. 당연하게 사용하던 Directory Import와 Path Alias를 ESM 환경에서 적용하기 위해 수많은 글을 찾아보고 낯선 개념들을 이해해야 했습니다. 검색 과정에서 ESM 전환을 포기하거나, 커뮤니티에서 관련 레퍼런스를 쉽게 찾기 어렵다는 이유로 전환을 미룬다는 글들을 자주 접하기도 했습니다. 저 역시 Path Alias를 적용하는 동안 비슷한 생각을 많이 했습니다.

그럼에도 이번 경험을 통해 Custom Loader에 대한 개념과 적용 방식을 익히게 되었고, 시간을 투자할 가치가 있었다고 느꼈습니다. 또한, 개발 과정에서 ESM과 관련된 에러에 대한 두려움이 한층 줄어들었다는 점도 큰 수확이었습니다. 아직 업무에 적용하기엔 제 경험이 너무 부족한 터라 개인적으로 사이드 프로젝트에 적용하며 경험을 쌓은 뒤에 업무에 활용해볼 계획입니다

반응형