import와 export
레고 블록을 생각해봅시다. 혼자 만드는 작은 작품이라면 블록을 한 바구니에 다 담아도 됩니다. 그런데 여러 사람이 함께 만드는 큰 작품이라면 어떨까요. A는 성벽을 만들고, B는 성탑을 만들고, C는 해자를 만듭니다. 각자 완성한 부품을 합쳐서 하나의 성을 완성합니다. 이때 "B가 만든 성탑"을 A가 가져다 붙이려면 건네주는 방법이 필요합니다.
TypeScript 모듈이 하는 일이 바로 이것입니다. 파일에서 만든 함수, 타입, 클래스를 다른 파일에 건네주고, 또 다른 파일에서 가져다 씁니다.
왜 파일을 나눠야 할까
한 파일에 모든 코드를 넣으면 어떻게 될까요.
// 새 파일: everything.ts// 타입 정의 50줄// 유틸리티 함수 100줄// User 관련 로직 200줄// Product 관련 로직 200줄// Order 관련 로직 200줄// ... 총 1000줄
User 관련 버그를 고치려면 1000줄 중 어딘가를 찾아야 합니다. 팀원이 동시에 같은 파일을 수정하면 충돌이 납니다. 테스트를 작성하려면 불필요한 것까지 로드해야 합니다.
파일을 나누면 각 파일이 하나의 책임을 집니다. user.ts는 사용자 관련 것만, product.ts는 상품 관련 것만 다룹니다. 찾기도 쉽고, 수정하기도 쉽고, 테스트하기도 쉽습니다.
named export — 이름으로 내보내기
가장 기본적인 방법입니다. 선언 앞에 export 키워드를 붙입니다.
// 새 파일: math.tsexport function add(a: number, b: number): number { return a + b;}export function subtract(a: number, b: number): number { return a - b;}export const PI = 3.14159;export type Point = { x: number; y: number;};
export가 붙은 것은 다른 파일에서 가져갈 수 있습니다. 붙지 않은 것은 이 파일 안에서만 씁니다. 이것이 "캡슐화"입니다. 외부에 보여줄 것만 골라서 내보냅니다.
named import — 이름으로 가져오기
내보낸 것을 가져올 때는 중괄호를 씁니다.
// 새 파일: app.tsimport { add, subtract, PI } from "./math";console.log(add(3, 4)); // 7console.log(subtract(10, 3)); // 7console.log(PI); // 3.14159
중괄호 안에 가져올 이름을 나열합니다. 파일 경로는 .ts 확장자를 생략합니다. TypeScript 컴파일러가 알아서 찾아줍니다.
필요한 것만 가져오는 것이 원칙입니다. math.ts에 함수가 열 개 있어도 쓸 것만 가져오면 됩니다.
이름을 바꿔서 가져오기
같은 이름이 충돌할 때는 as로 이름을 바꿉니다.
// 새 파일: rename-import.tsimport { add as mathAdd, PI as mathPI } from "./math";// 다른 파일에서 add라는 이름을 이미 쓰고 있을 때function add(a: string, b: string): string { return a + b;}console.log(add("hello", " world")); // "hello world"console.log(mathAdd(3, 4)); // 7console.log(mathPI); // 3.14159
모두 가져오기
*로 파일에서 내보내는 모든 것을 한꺼번에 가져올 수 있습니다.
// 새 파일: all-import.tsimport * as MathUtils from "./math";console.log(MathUtils.add(3, 4)); // 7console.log(MathUtils.PI); // 3.14159
MathUtils라는 "네임스페이스 객체"를 만들어서 그 안에 담습니다. 어디서 온 것인지 명확히 보여서 좋지만, 사용할 것만 명시적으로 가져오는 방식이 더 선호됩니다.
default export — 대표 선수 내보내기
파일에서 "이게 핵심이야"라고 지정하는 방식입니다. 파일당 하나만 쓸 수 있습니다.
// 새 파일: user.tsexport interface User { id: number; name: string; email: string;}export default class UserService { private users: User[] = []; addUser(user: User): void { this.users.push(user); } findById(id: number): User | undefined { return this.users.find((u) => u.id === id); } getAll(): User[] { return [...this.users]; }}
UserService 클래스가 이 파일의 "대표"입니다.
default import — 대표 가져오기
default export를 가져올 때는 중괄호 없이, 아무 이름이나 붙입니다.
// 새 파일: main.tsimport UserService from "./user";import { User } from "./user";const service = new UserService();const newUser: User = { id: 1, name: "김철수", email: "[email protected]" };service.addUser(newUser);const found = service.findById(1);console.log(found?.name); // 김철수
UserService라는 이름을 쓰지 않고 MyService나 Service로 가져와도 됩니다. default export는 이름이 없기 때문입니다. 그러나 혼란을 막기 위해 원본 이름과 같게 쓰는 것이 관례입니다.
named export vs default export
어느 것을 쓸지 헷갈리는 경우가 많습니다. 실무에서 자주 쓰는 기준을 정리하면 이렇습니다.
| 상황 | 권장 방식 |
|---|---|
| 파일에서 여러 것을 내보낼 때 | named export |
| 유틸리티 함수 모음 | named export |
| 타입/인터페이스 정의 | named export |
| 파일 하나에 클래스/컴포넌트 하나 | default export |
| React 컴포넌트 | default export (관례) |
둘을 섞어 쓰는 것도 완전히 유효합니다. user.ts 예제처럼 User 인터페이스는 named로, UserService 클래스는 default로 내보낸 것이 그 예입니다.
re-export — 다시 내보내기
다른 파일에서 가져온 것을 "통과시켜" 다시 내보낼 수 있습니다.
// 새 파일: types/index.tsexport { User } from "../user";export type { Point } from "../math";
types/index.ts가 여러 타입의 "중계 허브"가 됩니다. 사용하는 쪽에서는 각 파일 경로를 알 필요 없이 types/index.ts 하나만 바라보면 됩니다.
// 새 파일: consumer.ts// 각 파일에서 따로 가져오는 대신// import { User } from "./user";// import { Point } from "./math";// 한 곳에서 한꺼번에 가져옵니다import { User, Point } from "./types";
이 패턴을 "배럴 파일"이라고 부릅니다.
배럴(barrel) 파일 패턴
배럴은 여러 물건을 담는 통입니다. index.ts라는 통 하나에 여러 파일의 내보내기를 모아둡니다.
폴더 구조가 이렇다고 합시다.
utils/
├── array.ts
├── string.ts
├── number.ts
└── index.ts ← 배럴 파일
// 새 파일: utils/array.tsexport function chunk<T>(arr: T[], size: number): T[][] { const result: T[][] = []; for (let i = 0; i < arr.length; i += size) { result.push(arr.slice(i, i + size)); } return result;}export function unique<T>(arr: T[]): T[] { return [...new Set(arr)];}
// 새 파일: utils/string.tsexport function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1);}export function truncate(str: string, maxLength: number): string { if (str.length <= maxLength) return str; return str.slice(0, maxLength) + "...";}
// 새 파일: utils/number.tsexport function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max);}export function formatCurrency(amount: number): string { return amount.toLocaleString("ko-KR") + "원";}
배럴 파일이 이 모두를 한데 모읍니다.
// 새 파일: utils/index.tsexport * from "./array";export * from "./string";export * from "./number";
이제 사용하는 쪽은 경로 하나로 모든 유틸리티를 가져올 수 있습니다.
// 새 파일: feature.tsimport { chunk, unique, capitalize, clamp, formatCurrency } from "./utils";const numbers = [1, 2, 3, 4, 5, 6, 7, 8];console.log(chunk(numbers, 3)); // [[1, 2, 3], [4, 5, 6], [7, 8]]console.log(unique([1, 1, 2, 3, 3])); // [1, 2, 3]console.log(capitalize("hello")); // Helloconsole.log(truncate("긴 문장", 5)); // ...은 없지만 동작 확인console.log(clamp(15, 0, 10)); // 10console.log(formatCurrency(50000)); // 50,000원
배럴 파일 덕분에 utils/array, utils/string, utils/number 경로를 각각 외울 필요가 없습니다. utils만 알면 됩니다.
import type — 타입만 가져오기
TypeScript 3.8부터 추가된 문법입니다. 타입만 가져올 때 명시적으로 표시합니다.
// 새 파일: typed-import.tsimport type { User } from "./user";import type { Point } from "./math";// 이 파일은 User와 Point를 타입으로만 씁니다.// 런타임에는 이 import가 완전히 사라집니다.function greet(user: User): string { return `안녕하세요, ${user.name}님`;}function distance(a: Point, b: Point): number { return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);}
import type을 쓰면 두 가지 이점이 있습니다. 첫째, 이 import가 런타임 값이 아닌 타입만을 위한 것임을 명확히 표시합니다. 둘째, 번들러가 타입 전용 import를 쉽게 제거하여 빌드 결과물을 최적화할 수 있습니다.
규모가 큰 프로젝트에서는 import type을 일관되게 사용하는 것이 좋습니다. tsconfig.json의 verbatimModuleSyntax 옵션을 켜면 타입 import에 import type을 강제할 수도 있습니다.
경로 문법 정리
import 경로를 작성할 때 몇 가지 패턴이 있습니다.
// 상대 경로 — 현재 파일 기준import { add } from "./math"; // 같은 폴더import { User } from "../user"; // 부모 폴더import { chunk } from "../utils/array"; // 특정 파일// 패키지 — node_modules에서import express from "express";import { z } from "zod";
상대 경로는 ./나 ../으로 시작합니다. 패키지 경로는 그냥 패키지 이름으로 시작합니다. TypeScript가 node_modules에서 찾아줍니다.
tsconfig.json에 paths 옵션을 설정하면 @/utils/array 같은 별칭 경로도 쓸 수 있습니다. 이 내용은 다음 챕터에서 살펴봅니다.
레고 블록을 나눠서 만들고 합치는 방법을 배웠습니다. export로 블록을 건네주고, import로 가져다 씁니다. re-export와 배럴 파일로 여러 파일을 하나의 입구로 묶을 수 있습니다. import type으로 타입 전용 import를 명시할 수 있습니다.
다음 챕터에서는 TypeScript 컴파일러에게 "이렇게 동작해"라고 지시하는 tsconfig.json을 살펴봅니다.