들어가며⌗

최근에 typescript로 간단한 테스트 프로젝트를 설정할 일이 있었습니다. 폴더를 만들고 간단한 테스트를 위해서 기존 프로젝트에서 코드를 조금씩 복사해서 테스트 프로젝트를 구성하던 중 처음 보는 lint 오류를 발견했습니다. 타입 정보로만 사용되는 import에 type을 붙여라! 는 거였죠.

최대한 간단하게 테스트를 해보려던 저는 머리가 복잡해졌죠. 아니 이게 도대체 뭔데 프로젝트 여기저기서 난리가 난걸까 하고 말이죠. 그래서 일단 eslint의 룰을 해제하고 작업을 이어갔습니다.

그리고 나중에 시간이 좀 생겼을 때 관련해서 찾아보니 타입 전용 import, export 그리고 import 생략(elision)에 대해 알게 되었죠.

import 생략은 typescript가 코드를 js로 컴파일 및 변환하는 과정에서 타입 정보로만 사용되는 import를 자동으로 제거한다는 것입니다. 생각해보면, js에서는 딱히 타입을 체크하지 않으니 typescript에서 타입을 체크하기 위해 사용하는 정보가 필요없겠구나 싶긴 했지만 그렇다면 왜 굳이 import에 이게 타입을 위한 import인지 적어야 할까 하는 생각이 들었습니다.

그래서 좀 알아본 뒤, 나름 이해한 내용에 대해 잊어먹지 않기 위해 이 글을 적게 되었죠.

타입 전용(type only) import & export 란?⌗

타입 전용 import & export는 컴파일 타임에 타입을 체크하기 위한 용도로 사용되는 import와 실제 런타임에서 사용되는 import 값들을 구분하기 위해 사용됩니다. 기존 import, export에 type을 붙여서 import type, export type 으로 사용합니다.

// 타입 전용 import
import type { User, Status } from "./types";

// 타입 전용 export
export type { User, Status };

// 혼합된 import (TypeScript 4.5+)
import { type User, api, config } from "./module";

// 타입 전용 import로 가져온 타입을 다시 타입 전용 export로 내보내기
export type { User } from "./user";

여기서 가장 중요한 점은 import type으로 가져온 타입 관련 코드는 컴파일 과정에서 완전히 사라진다는 겁니다. 다음과 같이 테스트해볼 수 있습니다.

우선 ex1/types.ts 파일에 간단한 타입을 하나 작성합니다.

export interface User {
  name: string;
  age: number;
}

그리고 ex1/main.ts 파일에서 위에서 작성한 타입을 사용합니다.

import { type User } from "./types";

// 여기서 User는 컴파일 타임의 타입 체크 용도로만 사용되고 있습니다.
const user: User = {
  name: "John",
  age: 30,
};

console.log(user);

이제 npx tsc 명령어를 통해 컴파일을 하고 dist/type-only-import/ex1/main.js 파일을 확인해보면 다음과 같이 타입 정보로만 사용되는 import가 제거된 것을 확인할 수 있습니다.

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// 여기서 User는 컴파일 타임의 타입 체크 용도로만 사용되고 있습니다.
const user = {
  name: "John",
  age: 30,
};
console.log(user);

왜 이게 중요한가요?⌗

큰 프로젝트를 빌드하다보면, 모듈 간의 의존성이 복잡해집니다. 어디서 뭘 export하고 import 하는지가 명확하지 않고 외부 라이브러리 까지 복잡하게 얽히면 파악하기 더 어려워집니다. 그리고 빌드 및 번들링과정을 거치면 파일 크기가 커지고 빌드도 오래걸리며, 앱이 실행될 때 덩치가 큰 파일을 로드해야 하니까 메모리 사용도 늘어납니다.

그런데 타입 전용 import & export를 사용하면 컴파일 타임에 타입 정보로만 사용되는 import를 제거하고, 런타임에는 필요한 값들만 남기기 때문에 빌드 시간과 메모리 사용량을 줄일 수 있습니다.

게다가 이해하기 힘든 순환 참조, 사이드 이펙트 등의 오류도 방지할 수 있습니다.

순환 참조 오류⌗

// src/ex2/a.ts
import { B } from "./b";

export class A {
  b: B;
  constructor() {
    this.b = new B();
    console.log("A created");
  }
}

// src/ex2/b.ts
import { A } from "./a";

export class B {
  a: A;
  constructor() {
    this.a = new A();
    console.log("B created");
  }
}

// src/ex2/main.ts
import { A } from "./a";

const a = new A();

위 예제를 빌드 후 node dist/type-only-import/ex2/main.js 명령어를 통해 실행해보면 다음과 같은 오류가 발생합니다.

/Users/onlifecoding/src/test/typescript-studies/dist/type-only-import/ex2/b.js:7
        this.a = new a_1.A();
        ^

RangeError: Maximum call stack size exceeded
    at new B (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/b.js:7:9)
    at new A (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/a.js:7:18)
    at new B (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/b.js:7:18)
    at new A (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/a.js:7:18)
    at new B (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/b.js:7:18)
    at new A (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/a.js:7:18)
    at new B (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/b.js:7:18)
    at new A (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/a.js:7:18)
    at new B (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/b.js:7:18)
    at new A (/Users/onlifecoding/src/test/typescript-studies/dist/ex2/a.js:7:18)

Node.js v22.14.0

클래스 A, B가 각각 타입으로 상대방을 참조하고 있기 때문에 순환참조 오류가 발생합니다. 위 코드에서 타입 전용 import를 사용해볼까요?

// src/ex2/a.ts
import type { B } from "./b";

export class A {
  b: B | undefined;
  constructor() {
    //this.b = new B(); <- 값이 아닌 타입으로만 사용하도록 변경 했습니다.
    console.log("A created");
  }
}

// src/ex2/b.ts
import type { A } from "./a";

export class B {
  a: A | undefined;
  constructor() {
    //this.a = new A(); <- 값이 아닌 타입으로만 사용하도록 변경 했습니다.
    console.log("B created");
  }
}

이제 다시 빌드 후 실행하면 오류가 발생하지 않습니다.

사이드 이펙트 오류⌗

타입 전용 import는 컴파일 과정에서 완전 제거 되기 때문에 import로 인한 예상하지 못한 동작을 방지할 수 있습니다.

// analytics.ts
console.log("Analytics module loaded!"); // 모듈이 로드될 때 실행될 console.log

export interface TrackingEvent {
  name: string;
  data: Record<string, any>;
}

// main.ts
// 타입 전용 import는 console.log가 실행되지 않습니다.
import type { TrackingEvent } from "./analytics";

// 타입과 사이드 이펙트 모두 필요할 때는 따로 명시해줘야 합니다.
import type { TrackingEvent } from "./analytics";
import "./analytics"; // 사이드 이펙트 발생

타입 전용 import & export 사용 시 주의사항⌗

코드 가독성을 위한 구조화⌗

다음과 같이 같은 파일에서 import하더라도 타입 전용 import와 런타임에 값으로 사용되는 import를 명확하게 구분하는 게 좋습니다.

// 타입 전용 import
import type { ComponentProps } from "./components";

// 런타임에 사용되는 import
import { Button, Modal } from "./components";

isolatedModules 옵션 사용시 type 전용 export 사용⌗

Babel, SWC등의 빌드 툴을 사용하는 경우 전체 프로젝트를 한 번에 분석하지 않기 때문에 각 파일이 독립적으로 transpile 될 수 있도록 isolatedModules 옵션을 사용합니다. 이 옵션을 사용할 때는 type 전용 export를 사용해서 타입 정보를 내보내야 합니다.

// 잘못된 방식 - isolatedModules에서 컴파일 에러
export { User } from "./user";
export { Post } from "./post";

// 올바른 방식
export type { User } from "./user";
export type { Post } from "./post";

verbatimModuleSyntax 옵션 사용⌗

tsconfig.json에서 verbatimModuleSyntax 옵션을 사용하면 다음과 같이 타입 전용 import, export의 사용 규칙이 명확해집니다.

  • 타입 전용 import, export는 빌드 과정에서 완전히 제거됩니다.
  • type 키워드가 사용되지 않은 import, export는 빌드 후에도 유지됩니다.
  • typescript가 import에 대해서 타입으로 사용되는지 아닌지 검사하지 않도록 합니다. 따라서 typescript가 자동으로 수행하던 import 생략(elision)을 수행하지 않습니다.

참고 자료⌗