본문 바로가기

ES 모듈 시스템

export와 import로 파일마다 스코프를 나누고 공개할 값과 타입을 정하는 ES 모듈의 문법을 익히고, 타입 전용 가져오기와 부수 효과 가져오기, Node.js에서 직접 실행하는 NodeNext 구성, 모듈 경로와 TypeScript 6.0의 출력 형식을 다룹니다.

지금까지의 예제 파일에는 import나 export가 없었습니다. 이런 파일은 스크립트(script)로 취급되어 모든 선언이 하나의 전역 스코프를 함께 쓰므로, 두 파일에 같은 이름의 변수가 있으면 서로 부딪힙니다. 그래서 2장부터는 글마다 include를 그 글의 파일로 바꾸어 검사했습니다.

파일에 import나 export가 하나라도 있으면 그 파일은 모듈(module)이 되어 자기만의 스코프를 가지고, 내보낸 것만 다른 파일에서 쓸 수 있습니다. 이 글은 자바스크립트의 표준 모듈인 ES 모듈(ECMAScript modules)의 내보내기와 가져오기를 익히고, Node.js에서 직접 실행하는 구성으로 결과를 확인한 뒤, 모듈 경로와 출력 형식을 정리합니다.

app.ts는 mathUtils.ts의 PI add Calculator와 validation.ts의 isValidEmail을 가져오고 main.ts는 userService.ts의 기본 클래스를 MyUserService로 가져온다.

TYPE / RUNTIME / CONTRACT

가져오는 파일에서 공개 API를 내보내는 파일로 읽는다

app.ts는 이름 있는 내보내기를 사용하고 main.ts는 기본 내보내기의 로컬 이름을 선택합니다. 화살표는 가져오는 파일 → 내보내는 파일입니다.

가져오는 파일에서 공개 API를 내보내는 파일로 읽는다app.ts는 mathUtils.ts의 PI add Calculator와 validation.ts의 isValidEmail을 가져오고 main.ts는 userService.ts의 기본 클래스를 MyUserService로 가져온다.named import이름에 별칭default importapp.ts이름 있는 값 사용mathUtils.tsPI · addCalculatorvalidation.tscheckEmail로 별칭main.tsMyUserService로컬 이름 선택userService.tsdefaultUserService
가져오는 파일에서 공개 API를 내보내는 파일로 읽는다app.ts는 mathUtils.ts의 PI add Calculator와 validation.ts의 isValidEmail을 가져오고 main.ts는 userService.ts의 기본 클래스를 MyUserService로 가져온다.named이름에 별칭defaultapp.ts이름 있는 값 사용mathUtils.tsPI · addCalculatorvalidation.tscheckEmail로 별칭main.tsMyUserService로컬 이름 선택userService.tsdefaultUserService

화살표는 사용하는 파일에서 내보내는 파일로 향하는 의존성입니다. named·별칭·default 연결은 원래 예제의 공개 API를 나타내며 함수 호출의 실행 순서 그림은 아닙니다.

NAMED

이름으로 가져온다

PI는 3.14159, add(5,3)은 8, Calculator(10).multiply(4)는 40입니다. 중괄호 안의 이름이 공개 API에 대응합니다.

ALIAS

가져올 이름과 로컬 이름

isValidEmail as checkEmail은 같은 함수에 로컬 이름을 붙입니다. checkEmail("[email protected]")은 true입니다.

DEFAULT

기본 값의 로컬 이름을 선택

UserService를 MyUserService로 가져옵니다. 생성한 service는 Alice를 addUser하고 getUsers로 저장한 목록을 반환합니다.

NAMESPACE

값과 타입의 존재를 구분

import * as Math로 Math.PI, Math.add, Math.Calculator를 사용합니다. Point와 ID는 타입 위치에서 참조하며 런타임 속성으로 생성되지 않습니다.

각 예제는 별도의 import 방식을 보여줍니다. 공개 API와 파일 스코프를 구분하고, 값 의존성의 순환이 생기면 초기화 순서도 확인하세요.

위 그림의 화살표는 가져오는 파일에서 내보내는 파일로 향합니다. app.ts는 mathUtils.ts와 validation.ts가 이름을 붙여 내보낸 값을 가져오고, main.ts는 userService.ts의 기본 내보내기를 자기가 정한 이름으로 가져옵니다.

내보내기

선언 앞에 export를 붙이면 그 이름을 다른 파일에 공개합니다. 변수와 함수, 클래스 같은 값뿐 아니라 인터페이스와 타입 별칭도 내보낼 수 있으며, 이렇게 이름을 붙여 내보내는 것을 이름 있는 내보내기(named export)라고 합니다.

mathUtils.ts
export const PI = 3.14159;

export function add(a: number, b: number): number {
  return a + b;
}

export class Calculator {
  constructor(public value: number) {}
  multiply(factor: number): number {
    return this.value * factor;
  }
}

export interface Point {
  x: number;
  y: number;
}

export type ID = string | number;

선언마다 export를 붙이는 대신, 파일의 끝에서 export { 이름, 이름 }으로 한꺼번에 내보낼 수도 있습니다. 어느 쪽이든 결과는 같으므로, 공개할 이름의 목록을 한곳에서 보고 싶을 때 이 방법을 씁니다.

validation.ts
function isValidEmail(email: string): boolean {
  return email.includes("@") && email.includes(".");
}

function isValidPassword(password: string): boolean {
  return password.length >= 8;
}

export { isValidEmail, isValidPassword };

여기의 이메일 검사는 @와 .가 들어 있는지만 보는 학습용 조건이어서, 실제 이메일 주소가 올바른지 판정하지는 못합니다.

모듈이 대표하는 값이 하나라면 export default로 기본 내보내기(default export)를 할 수 있습니다. 기본 내보내기는 모듈마다 하나뿐이고, 가져오는 쪽이 이름을 자유롭게 정합니다.

userService.ts
interface User {
  id: number;
  name: string;
}

export default class UserService {
  private users: User[] = [];

  addUser(user: User): void {
    this.users.push(user);
    console.log(`User ${user.name} added.`);
  }

  getUsers(): User[] {
    return this.users;
  }
}

PI와 add, Calculator는 실행할 때도 있는 값이지만, Point와 ID는 타입 검사에만 쓰이는 선언이어서 컴파일한 자바스크립트에는 남지 않습니다. 같은 파일에서 내보냈더라도 값과 타입은 이렇게 성격이 다르며, 가져오는 쪽에서도 그 차이를 표시할 수 있습니다.

가져오기

이름 있는 내보내기는 중괄호 안에 같은 이름을 적어 가져오고, as로 이 파일에서 쓸 이름을 바꿀 수 있습니다. 기본 내보내기는 중괄호 없이 원하는 이름으로 가져옵니다.

app.ts
import { PI, add, Calculator } from "./mathUtils";
import { isValidEmail as checkEmail } from "./validation";
import MyUserService from "./userService";

console.log(PI, add(5, 3), new Calculator(10).multiply(4));
console.log(checkEmail("[email protected]"));

const service = new MyUserService();
service.addUser({ id: 1, name: "Alice" });
3.14159 8 40
true
User Alice added.

MyUserService는 userService.ts가 기본으로 내보낸 UserService 클래스를 이 파일에서 부르는 이름입니다. 이 파일의 경로에는 확장자가 없는데, 확장자를 생략할 수 있는지는 뒤에서 볼 모듈 해석 방식에 따라 다르며, 이 형태는 번들러를 쓰는 환경을 전제로 합니다.

import * as 이름은 모듈이 내보낸 값을 모두 한 객체의 속성으로 가져오는 네임스페이스 가져오기(namespace import)입니다. 이 객체에는 실행 중에 있는 값만 담기므로, 인터페이스와 타입 별칭은 MathUtils.Point처럼 타입의 자리에서만 쓸 수 있습니다.

타입만 가져올 때는 import type { Point, ID } from "./mathUtils";처럼 import type을 쓰거나, import { add, type Point } from "./mathUtils";처럼 이름 앞에 type을 붙입니다. import type으로 적은 가져오기는 컴파일할 때 문장 전체가 지워지므로, 실행 중에 그 파일을 불러오지 않습니다. 이름 앞에 type을 붙이면 그 이름만 지워지므로, 위의 예처럼 add 같은 값을 함께 가져오면 그 파일은 실행 중에도 불러옵니다.

모듈의 값은 쓰지 않고 그 모듈을 실행하는 것만 필요하다면, import "./polyfills";처럼 이름 없이 경로만 적습니다. 이것을 부수 효과 가져오기(side-effect import)라고 하며, 가져온 모듈의 최상위 코드가 가져온 파일의 코드보다 먼저 실행됩니다. TypeScript 6.0에서는 noUncheckedSideEffectImports가 기본으로 켜져 있어서, 이렇게 가져오는 경로의 파일을 찾지 못하면 진단이 나옵니다.

Node.js에서 직접 실행하기

이제 여러 파일이 서로 가져오는 프로젝트를 Node.js의 ES 모듈로 실행해 봅니다. 새 폴더 ts-modules를 만들고, 앞의 mathUtils.ts를 src/mathUtils.ts에 그대로 저장한 뒤 나머지 파일을 더합니다.

package.json
{
  "private": true,
  "type": "module"
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src"]
}

"type": "module"은 이 폴더의 .js 파일을 ES 모듈로 실행하라는 뜻이고, "module": "nodenext"는 Node.js가 모듈을 찾고 실행하는 규칙대로 검사하고 출력하라는 뜻입니다. verbatimModuleSyntax는 적은 import와 export를 그대로 출력하고, 타입만 가져오는 곳에는 반드시 type을 표시하게 합니다.

src/polyfills.ts
console.log("Polyfills loaded!");
export {};
src/demo.ts
import "./polyfills.js";
import { add, Calculator } from "./mathUtils.js";
import * as mathValues from "./mathUtils.js";
import type { Point, ID } from "./mathUtils.js";

const point: Point = { x: 2, y: 3 };
const id: ID = "P7";

console.log(add(point.x, point.y));
console.log(new Calculator(10).multiply(3));
console.log(Object.keys(mathValues).join(","));
console.log(id);
tsc -p .
node dist/demo.js
Polyfills loaded!
5
30
Calculator,PI,add
P7

polyfills.ts의 export {};는 내보내는 것이 없는 파일도 모듈로 읽히게 하는 줄로, 이 폴더는 "type": "module"이라 이미 모듈로 다루어지지만 다른 설정에서도 스크립트로 읽히지 않도록 붙여 두었습니다. 가져온 순서대로 polyfills.js가 먼저 실행되어 첫 줄이 나오고, 이어서 demo.js의 코드가 실행됩니다. 네임스페이스 객체에는 값인 Calculator, PI, add만 있고 Point와 ID는 없으며, ES 모듈의 네임스페이스 객체는 키를 문자 코드의 순서로 정렬해 두므로 대문자로 시작하는 이름이 먼저 나옵니다.

경로에 .ts가 아닌 .js를 적은 점이 중요합니다. Node.js는 ES 모듈의 상대 경로에 확장자를 요구하고, 실제로 불러오는 것은 컴파일한 .js 파일이기 때문입니다. 컴파일러는 이 .js 경로를 보고 그에 대응하는 .ts 소스를 찾아 검사하며, 확장자를 빼고 "./mathUtils"로 적으면 nodenext에서는 진단이 나옵니다.

더 깊이: 가져오는 경로에 .ts를 그대로 적는 설정

Node.js의 타입 제거 실행이나 Deno처럼 .ts 파일을 컴파일 없이 바로 실행하는 도구는, 실제로 있는 파일인 "./mathUtils.ts"를 경로에 적어야 그 파일을 찾습니다. Bun은 .js로 적은 경로에서도 같은 이름의 .ts 파일을 찾아 주지만, 이 두 도구는 그렇지 않습니다. 반면 tsc로 출력해 실행하는 코드는 출력 파일인 .js를 적어야 하므로, 같은 소스를 두 방식으로 함께 쓰기가 어려웠습니다. 타입스크립트는 .ts 경로를 허용하는 allowImportingTsExtensions를 먼저 두었지만, 경로 자체를 바꾸어 출력하지는 않았습니다.

TypeScript 5.7에 들어온 rewriteRelativeImportExtensions는 이 간격을 메웁니다. 경로가 ./나 ../로 시작하는 상대 경로이고 .ts, .tsx, .mts, .cts로 끝나면, 출력할 때 각각 .js, .jsx, .mjs, .cjs로 바꿔 씁니다. 다만 릴리스 노트는 상대 경로만 단순하게 바꾼다고 밝히므로, paths의 별칭이나 package.json의 imports와 exports를 거치는 경로는 바뀌지 않습니다. 실행 중에 계산하는 동적 import()의 경로는 컴파일러가 __rewriteRelativeImportExtension이라는 도우미 함수로 감싸 출력하므로, 실행할 때 그 값이 .ts로 끝나는 상대 경로이면 그때 바뀝니다. Node.js의 TypeScript 문서도 타입 제거 실행과 함께 쓰는 권장 설정에 이 옵션을 넣어 둡니다.

모듈 경로

가져오기의 경로는 세 가지 모양이 있습니다. ./나 ../로 시작하는 상대 경로는 지금 파일의 위치에서 다른 파일을 찾고, react처럼 이름만 적은 경로는 node_modules에 설치된 패키지를 찾습니다. 나머지 하나는 tsconfig.json의 paths로 정한 별칭입니다.

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@utils/*": ["./src/utils/*"],
      "@models/*": ["./src/models/*"]
    }
  }
}

이렇게 정하면 import { formatMoney } from "@utils/formatter";가 src/utils/formatter.ts를 가리키는 것으로 검사됩니다. 예전에는 paths와 함께 baseUrl을 적었지만, TypeScript 6.0에서 baseUrl은 폐기 예정으로 지정되어 진단이 나오므로, 위처럼 paths의 각 경로에 기준 위치를 직접 적습니다. 그리고 paths는 컴파일러의 검사만 돕고 출력한 import의 경로를 바꾸지 않으므로, 같은 별칭을 번들러나 실행 환경도 알고 있어야 합니다. 별칭이 실제로 어떻게 풀리는지는 모듈 해석 전략에서 자세히 봅니다.

출력하는 모듈의 형식

module 옵션은 타입스크립트가 모듈의 코드를 어떤 형식으로 출력할지와, 어떤 실행 환경의 규칙으로 검사할지를 정합니다. Node.js에서 실행할 코드는 nodenext를, 번들러가 다시 묶을 코드는 esnext나 preserve를 주로 쓰고, commonjs는 다음 글에서 볼 CommonJS 형식으로 출력합니다.

예전에는 AMD와 UMD, SystemJS 같은 형식도 출력할 수 있었지만, TypeScript 6.0에서 이 값들은 폐기 예정으로 지정되어 설정하면 진단이 나오고, 7.0에서는 지원하지 않습니다. 오래된 프로젝트에서 이 설정을 만나면, 번들러로 출력을 맡기거나 위의 형식 가운데 하나로 옮기는 것을 먼저 검토합니다. 모듈 옵션의 전체 규칙은 공식 모듈 참조에 있습니다.

실제 값 가져오기와 import type의 소거, polyfills의 부수 효과, 컴파일러와 실행 환경의 경로 해석을 구분한다.

TYPE / RUNTIME / CONTRACT

가져온 값, 타입, 실행 효과를 구분한다

값을 쓰는 import와 타입 전용 import, 실행만 필요한 import는 역할이 다릅니다. TypeScript의 해석과 실제 JavaScript 로딩도 각각 확인합니다.

VALUE

가져온 값을 사용한다

app.ts는 PI, add, Calculator를 사용해 3.14159, 8, 40을 출력합니다. 이 코드에서 사용하는 값은 JavaScript의 연결에 남습니다.

TYPE ONLY · 보충

import type은 실행 연결에서 빠진다

Point와 ID는 타입 위치에서 사용합니다. import type 또는 export type으로 표시한 연결은 출력 JavaScript에서 제거됩니다.

SIDE EFFECT

바인딩 없이 모듈을 평가한다

import "./polyfills"는 모듈의 실행 효과를 가져옵니다. 본문에서는 "Polyfills loaded!"가 "Application started."보다 먼저 출력됩니다.

RESOLUTION

타입 검사와 실행 경로를 맞춘다

Node.js ESM의 상대 경로에는 실행 파일 확장자가 필요합니다. 본문의 확장자 생략은 이를 지원하는 해석 환경을 전제로 합니다. paths는 출력 import를 다시 쓰지 않습니다.

module과 moduleResolution은 실제 실행 환경에 맞춰 선택합니다. 일반 import의 제거 여부는 사용 방식과 컴파일 설정에도 영향을 받으므로 타입 전용 의도를 명시하세요.

위 그림은 이 글의 가져오기를 역할에 따라 나눈 것입니다. 값을 쓰는 가져오기는 출력에 남아 실행 중에 연결되고, import type은 출력에서 지워지며, 부수 효과 가져오기는 이름 없이 모듈을 실행합니다. 그리고 경로를 찾는 일은 타입을 검사하는 컴파일러와 실제로 파일을 불러오는 실행 환경이 각각 하므로, 둘이 같은 파일을 가리키는지 함께 확인해야 합니다.

정리

  • import나 export가 있는 파일은 모듈이 되어 자기만의 스코프를 가지며, 내보낸 이름만 다른 파일에서 쓸 수 있습니다.
  • 이름 있는 내보내기는 같은 이름으로 가져오고 as로 바꿀 수 있으며, 기본 내보내기는 모듈마다 하나이고 가져오는 쪽이 이름을 정합니다.
  • 네임스페이스 가져오기의 객체에는 값만 담기고, import type과 type 표시는 출력에서 지워지며, 부수 효과 가져오기는 모듈을 실행만 합니다.
  • Node.js의 ES 모듈에서는 상대 경로에 출력 파일의 확장자 .js를 적고, 컴파일러가 그에 대응하는 .ts를 찾아 검사합니다.
  • TypeScript 6.0에서 baseUrl과 AMD, UMD, SystemJS 출력은 폐기 예정이며, paths는 출력의 경로를 바꾸지 않습니다.

연습 문제

1. 값을 바꾼 실행

src/demo.ts의 point를 { x: 4, y: 6 }으로, 계산기를 new Calculator(5).multiply(4)로 바꾸면 출력의 둘째와 셋째 줄은 무엇이 되나요? console.log(mathValues.Point);를 더하면 어떻게 되나요?

해설 보기

둘째 줄은 4와 6을 더한 10, 셋째 줄은 5에 4를 곱한 20입니다. Point는 인터페이스여서 네임스페이스 객체의 값으로 존재하지 않으므로, 값의 자리에 쓴 mathValues.Point는 진단됩니다. 값을 내보내는 목록은 바꾸지 않았으므로 네 번째 줄의 Calculator,PI,add는 그대로입니다.

2. 확장자를 뺀 경로

src/demo.ts의 "./mathUtils.js"를 "./mathUtils"로 바꾸고 tsc -p .을 실행하면 어떻게 되나요? 같은 코드를 번들러로 묶는 프로젝트에서는 어떤가요?

해설 보기

nodenext로 검사하는 ES 모듈에서는 상대 경로에 확장자가 필요하므로, 확장자를 뺀 가져오기는 진단됩니다. 실제로 Node.js도 확장자가 없는 상대 경로를 ES 모듈에서 찾지 않으므로, 이 진단은 실행 중의 실패를 미리 알려 주는 것입니다. 번들러로 묶는 프로젝트에서 moduleResolution을 bundler로 두면, 번들러가 확장자를 채워 찾으므로 확장자를 생략해도 됩니다.

3. 타입 표시를 빠뜨렸을 때

verbatimModuleSyntax가 켜진 상태에서 import type { Point, ID }를 import { Point, ID }로 바꾸면 무엇이 달라지나요?

해설 보기

verbatimModuleSyntax는 적은 import를 그대로 출력하므로, 타입만 가져오는데 type을 표시하지 않으면 출력된 코드가 실행 중에 없는 Point와 ID를 가져오려 하게 됩니다. 그래서 컴파일러는 그런 가져오기에 type을 붙이라는 진단을 냅니다. 1장에서 본 Node.js의 타입 제거 실행도 같은 이유로 타입만 가져올 때 type을 요구합니다.

다음 글 CommonJS와의 상호 운용성에서는 require와 module.exports를 쓰는 CommonJS 모듈과 ES 모듈이 서로를 가져올 때 어떤 일이 일어나는지 다룹니다.