모듈

모듈 (Modules)

JavaScript는 코드를 모듈 단위로 나눠서 관리하는 다양한 방식을 오랫동안 겪어 왔어요. 2012년부터 존재해 온 TypeScript도 그 많은 형식을 지원해 줬는데, 시간이 지나면서 커뮤니티와 JavaScript 스펙은 결국 ES Modules(혹은 ES6 모듈)이라는 하나의 형식으로 수렴했죠. 여러분은 아마 이걸 import/export 문법으로 더 친숙하게 알고 있을 거예요.

ES Modules는 2015년에 JavaScript 스펙에 추가됐고, 2020년이 되면 대부분의 웹 브라우저와 JavaScript 런타임에서 두루 지원하게 됐어요.

이 핸드북에서는 ES Modules와 그 전신으로 널리 쓰였던 CommonJS의 module.exports = 문법을 함께 다룰게요. 다른 모듈 패턴에 대한 자세한 설명은 Modules reference page에서 찾아볼 수 있어요.

출처: TypeScript 공식문서

본문

JavaScript 모듈이 정의되는 방식

ECMAScript 2015에서와 마찬가지로, TypeScript에서도 파일 최상위에 importexport가 있는 파일은 모듈로 간주해요. 반대로 최상위에 import·export 선언이 하나도 없는 파일은 스크립트로 취급되는데, 이 경우 그 내용이 전역 스코프에서 사용 가능해져요(그래서 모듈에서도 접근할 수 있죠).

모듈은 전역 스코프가 아니라 자기만의 스코프 안에서 실행돼요. 즉, 모듈 안에서 선언한 변수·함수·클래스 같은 것들은 어떤 export 형식으로든 명시적으로 내보내지 않는 이상 모듈 밖에서는 보이지 않아요. 반대로 다른 모듈에서 내보낸 변수·함수·클래스·인터페이스 등을 사용하려면 어떤 import 형식으로든 가져와야 해요.

모듈이 아닌 것들 (Non-modules)

본격적으로 들어가기 전에, TypeScript가 어떤 걸 모듈로 보는지 이해해 두는 게 중요해요. JavaScript 스펙은 import 선언, export, 최상위 await가 하나도 없는 JavaScript 파일을 모듈이 아니라 스크립트로 간주한다고 명시해요.

스크립트 파일 안에서는 변수와 타입이 공유된 전역 스코프에 선언된 것으로 봐요. 그리고 여러 입력 파일을 하나의 출력 파일로 합치려면 outFile 컴파일러 옵션을 쓰거나, HTML에서 여러 <script> 태그로 (올바른 순서대로!) 불러온다고 가정해요.

지금 importexport가 하나도 없는데 모듈로 취급받고 싶은 파일이 있다면, 다음 줄만 추가해 주면 돼요:

export {};

이렇게 하면 아무것도 내보내지 않는 모듈로 바뀌어요. 이 문법은 module target이 무엇이든 상관없이 동작해요.

TypeScript에서의 모듈

TypeScript에서 모듈 기반 코드를 작성할 때 고려할 큰 세 가지가 있어요.

  • 문법(Syntax): 무언가를 import·export할 때 어떤 문법을 쓸까요?
  • 모듈 해석(Module Resolution): 모듈 이름(혹은 경로)과 디스크 상의 파일은 어떤 관계일까요?
  • 모듈 출력 대상(Module Output Target): 내보낸 JavaScript 모듈은 어떤 모습이어야 할까요?

ES Module 문법

한 파일에서 export default로 기본 export(주요 내보내기)를 선언할 수 있어요.

// @filename: hello.ts
export default function helloWorld() {
  console.log("Hello, world!");
}

이건 이렇게 import해요.

import helloWorld from "./hello.js";

helloWorld();

기본 export 외에도, default를 빼고 export를 쓰면 변수와 함수를 여러 개 내보낼 수 있어요.

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;

export class RandomNumberGenerator {}

export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

이것들은 다른 파일에서 import 문법으로 이렇게 사용해요.

import { pi, phi, absolute } from "./maths.js";

console.log(pi);
const absPhi = absolute(phi);

추가적인 import 문법

import는 import { old as new } 같은 형식으로 이름을 바꿔 가져올 수 있어요.

import { pi as π } from "./maths.js";

console.log(π);

위 문법들을 하나의 import 문 안에 섞어 쓸 수도 있어요.

// @filename: maths.ts
export const pi = 3.14;
export default class RandomNumberGenerator {}

// @filename: app.ts
import RandomNumberGenerator, { pi as π } from "./maths.js";

RandomNumberGenerator;
console.log(π);

내보낸 객체 전부를 가져다가 * as name 으로 단일 네임스페이스에 담을 수도 있어요.

// @filename: app.ts
import * as math from "./maths.js";

console.log(math.pi);
const positivePhi = math.absolute(math.phi);

파일을 import하되 현재 모듈에 어떤 변수도 넣지 않으려면 import "./file" 을 쓰면 돼요.

// @filename: app.ts
import "./maths.js";

console.log("3.14");

이 경우 import는 하는 일이 없어요. 하지만 maths.ts의 코드 전체는 평가되기 때문에, 다른 객체에 영향을 주는 **부수 효과(side-effects)**가 일어날 수 있어요.

TypeScript 고유의 ES Module 문법

타입도 JavaScript 값과 똑같은 문법으로 export·import할 수 있어요.

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };

export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}

// @filename: app.ts
import { Cat, Dog } from "./animal.js";

type Animals = Cat | Dog;

TypeScript는 타입의 import를 선언하기 위한 두 가지 개념으로 import 문법을 확장했어요.

import type

타입만 import할 수 있는 import 문이에요.

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => "fluffy";

// @filename: valid.ts
import type { Cat, Dog } from "./animal.js";
export type Animals = Cat | Dog;

// @filename: app.ts
import type { createCatName } from "./animal.js";
const name = createCatName();

마지막 app.ts처럼 import type으로 가져온 건 타입으로만 써야지, 값으로는 사용할 수 없어요. createCatName()처럼 값을 호출하려 하면 'createCatName' cannot be used as a value because it was imported using 'import type' 오류가 나요.

인라인 type import

TypeScript 4.5부터는 개별 import 앞에 type을 붙여 그 참조가 타입임을 표시할 수도 있어요.

// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";

export type Animals = Cat | Dog;
const name = createCatName();

import type과 인라인 type을 함께 쓰면, Babel·swc·esbuild 같은 non-TypeScript 트랜스파일러가 어떤 import를 안전하게 제거해도 되는지 알게 돼요.

CommonJS 동작을 따르는 ES Module 문법

TypeScript에는 CommonJS·AMD의 require에 직접 대응하는 ES Module 문법이 있어요. ES Module로 하는 import는 대부분 그 환경의 require와 같지만, 이 문법을 쓰면 TypeScript 파일과 CommonJS 출력이 1:1로 일치한다는 걸 보장할 수 있어요.

import fs = require("fs");
const code = fs.readFileSync("hello.ts", "utf8");

이 문법에 대해 더 자세히 알고 싶다면 modules reference page에서 확인해 보세요.

CommonJS 문법

CommonJS는 npm에 올라온 대부분의 모듈이 쓰는 형식이에요. 위에서 ES Module 문법으로 작성하고 있더라도, CommonJS 문법이 어떻게 동작하는지 짧게라도 이해해 두면 디버깅이 한결 수월해져요.

내보내기 (Exporting)

식별자는 전역 객체인 moduleexports 속성에 값을 설정해서 내보내요.

function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};

이런 파일들은 require 문으로 import할 수 있어요.

const maths = require("./maths");
maths.pi;

JavaScript의 구조 분해(destructuring) 기능을 쓰면 조금 더 간결하게 가져올 수도 있어요.

const { squareTwo } = require("./maths");
squareTwo;

CommonJS와 ES Modules 상호운용 (interop)

CommonJS와 ES Modules 사이에는 **기본 import(default import)**와 모듈 네임스페이스 객체 import의 구분에서 서로 맞지 않는 부분이 있어요. TypeScript는 이 두 제약 세트 사이의 마찰을 줄여 주는 컴파일러 플래그로 esModuleInterop을 제공해요.

TypeScript의 모듈 해석 옵션

모듈 해석(module resolution)이란, importrequire 문의 문자열을 보고 그 문자열이 가리키는 파일이 무엇인지 결정하는 과정을 말해요.

TypeScript에는 ClassicNode 두 가지 해석 전략이 있어요. Classic은 컴파일러 옵션 modulecommonjs가 아닐 때의 기본값으로, 하위 호환을 위해 포함된 전략이에요. Node 전략은 Node.js가 CommonJS 모드에서 동작하는 방식을 재현하되, 그 위에 .ts.d.ts에 대한 추가 검사가 들어 있어요.

TypeScript 안에서 모듈 전략에 영향을 주는 TSConfig 플래그는 여러 개 있어요: moduleResolution, baseUrl, paths, rootDirs.

이 전략들이 정확히 어떻게 동작하는지 자세히 보고 싶다면 Module Resolution reference page를 확인해 보세요.

TypeScript의 모듈 출력 옵션

내보낸 JavaScript 출력에 영향을 주는 옵션은 두 가지예요.

  • target: 어떤 JS 기능을 다운레벨링(더 오래된 런타임에서 돌 수 있게 변환)할지, 어떤 건 그대로 둘지를 정해요.
  • module: 모듈끼리 서로 상호작용할 때 어떤 코드를 쓸지 정해요.

target은 TypeScript 코드를 실행할 JavaScript 런타임에 어떤 기능이 있는지에 따라 정해져요. 즉, 지원하는 가장 오래된 웹 브라우저, 실행할 Node.js의 가장 낮은 버전, 또는 Electron처럼 런타임이 가진 고유한 제약 같은 것에서 결정돼요.

모듈 간의 모든 통신은 **모듈 로더(module loader)**를 통해 일어나요. 이때 컴파일러 옵션 module이 어떤 로더를 쓸지 결정해요. 런타임에서 모듈 로더는 모듈을 실행하기 전에 그 모듈의 모든 의존성을 찾아내고 실행하는 역할을 해요.

예를 들어, ES Module 문법을 쓰는 아래 TypeScript 파일에서 module 옵션 몇 가지가 만들어 내는 결과를 볼게요.

import { valueOfPi } from "./constants.js";

export const twoPi = valueOfPi * 2;

ES2020

import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;

CommonJS

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

UMD

(function (factory) {
    if (typeof module === "object" && typeof module.exports === "object") {
        var v = factory(require, exports);
        if (v !== undefined) module.exports = v;
    }
    else if (typeof define === "function" && define.amd) {
        define(["require", "exports", "./constants.js"], factory);
    }
})(function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.twoPi = void 0;
    const constants_js_1 = require("./constants.js");
    exports.twoPi = constants_js_1.valueOfPi * 2;
});

ES2020이 사실 원래 index.ts와 똑같다는 점을 눈여겨보세요. 사용할 수 있는 모든 옵션과 그때 어떤 JavaScript가 출력되는지는 TSConfig Reference의 module에서 확인할 수 있어요.

TypeScript 네임스페이스 (namespaces)

TypeScript에는 ES Modules 표준보다 **먼저 나온 자체 모듈 형식인 namespaces**가 있어요. 이 문법은 복잡한 정의 파일을 만들 때 쓸모 있는 기능이 많아서, DefinitelyTyped에서 여전히 활발히 쓰이고 있어요. deprecated는 아니지만, namespaces의 대부분 기능이 ES Modules에 있기 때문에 JavaScript의 방향에 맞춰 ES Modules를 쓰는 걸 권장해요. namespaces에 대해 더 알고 싶다면 the namespaces reference page에서 확인해 보세요.

더 알아보기