🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

TypeScript 모듈 시스템 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 10. 30. · 15 Views

TypeScript 모듈 시스템 완벽 가이드

TypeScript의 모듈 시스템을 처음부터 끝까지 완벽하게 이해하고, 실무에서 바로 활용할 수 있는 실용적인 가이드입니다. ES 모듈, CommonJS, 네임스페이스부터 고급 패턴까지 다룹니다.


목차

  1. ES 모듈 기본 - Import와 Export의 모든 것
  2. CommonJS와의 상호 운용 - require와 import 함께 사용하기
  3. Module Resolution - TypeScript가 모듈을 찾는 방법
  4. Namespace - 레거시 코드와의 만남
  5. Dynamic Import - 코드 스플리팅과 Lazy Loading
  6. Type-Only Import/Export - 컴파일 최적화
  7. Barrel Exports와 Re-exporting - 깔끔한 API 설계
  8. Declaration Merging - 타입 확장의 마법
  9. Ambient Modules - 타입 없는 라이브러리 다루기
  10. Triple-Slash Directives - 의존성과 설정 지시

1. ES 모듈 기본 - Import와 Export의 모든 것

시작하며

여러분이 TypeScript로 프로젝트를 시작하면서 "어떻게 하면 코드를 깔끔하게 분리할 수 있을까?"라고 고민해본 적 있나요? 수백 줄의 코드가 하나의 파일에 몰려 있으면 유지보수가 정말 어려워집니다.

실무에서는 코드를 기능별로 분리하고, 필요한 부분만 가져와서 사용하는 것이 필수입니다. 이렇게 하지 않으면 코드가 스파게티처럼 얽히고, 버그를 찾기도 힘들어집니다.

바로 이럴 때 필요한 것이 ES 모듈 시스템입니다. Import와 Export를 사용하면 코드를 논리적으로 분리하고, 재사용 가능한 컴포넌트로 만들 수 있습니다.

개요

간단히 말해서, ES 모듈은 JavaScript와 TypeScript에서 코드를 여러 파일로 나누고 관리하는 표준 방식입니다. 왜 모듈 시스템이 필요할까요?

실무에서는 수천, 수만 줄의 코드를 다룹니다. 예를 들어, 사용자 인증, 데이터 처리, UI 컴포넌트가 모두 한 파일에 있다면 어떻게 될까요?

코드를 찾기도 어렵고, 팀원과 협업할 때 충돌도 자주 발생합니다. 기존에는 전역 스코프에 모든 것을 선언했다면, 이제는 각 파일이 독립적인 스코프를 가지고 명시적으로 export/import합니다.

ES 모듈의 핵심 특징은 세 가지입니다: 첫째, 각 파일은 독립적인 스코프를 가집니다. 둘째, 명시적으로 export한 것만 외부에서 접근 가능합니다.

셋째, import 시 정적 분석이 가능해 트리 쉐이킹(사용하지 않는 코드 제거)이 가능합니다. 이러한 특징들이 코드의 안정성과 번들 크기 최적화에 매우 중요합니다.

코드 예제

// utils.ts - 유틸리티 함수들을 export
export function calculateTax(price: number, rate: number): number {
  // 세금 계산 로직
  return price * rate;
}

export const TAX_RATE = 0.1; // named export로 상수 내보내기

export default class PaymentProcessor {
  // default export로 클래스 내보내기
  process(amount: number): void {
    console.log(`Processing payment: $${amount}`);
  }
}

// main.ts - 필요한 것만 선택적으로 import
import PaymentProcessor, { calculateTax, TAX_RATE } from './utils';

const processor = new PaymentProcessor();
const totalAmount = calculateTax(100, TAX_RATE);
processor.process(totalAmount);

설명

이것이 하는 일: ES 모듈 시스템은 코드를 파일 단위로 분리하고, 필요한 부분만 선택적으로 가져와 사용할 수 있게 해줍니다. 첫 번째로, utils.ts 파일에서 export 키워드를 사용해 함수, 상수, 클래스를 외부에 공개합니다.

calculateTaxTAX_RATE는 named export로 내보내므로, import할 때 정확한 이름을 사용해야 합니다. 왜 이렇게 할까요?

여러 개를 동시에 export하고, 각각을 명확하게 식별하기 위해서입니다. 그 다음으로, PaymentProcessor 클래스는 default export로 내보냅니다.

한 파일에는 하나의 default export만 가능하며, import할 때 원하는 이름으로 가져올 수 있습니다. 내부적으로 TypeScript 컴파일러는 이를 CommonJS나 ES6 모듈 형식으로 변환하며, 타입 체크도 함께 수행합니다.

main.ts에서는 중괄호 {}를 사용해 named export를 가져오고, default export는 중괄호 없이 가져옵니다. 한 줄로 두 가지를 동시에 import할 수 있어 편리합니다.

마지막으로, 가져온 함수와 클래스를 사용해 실제 비즈니스 로직을 실행합니다. 여러분이 이 코드를 사용하면 코드베이스가 깔끔하게 정리되고, 각 파일의 역할이 명확해집니다.

또한 사용하지 않는 export는 번들링 시 자동으로 제거되어 최종 파일 크기가 줄어들고, 타입스크립트가 import 경로와 타입을 체크해주므로 실수를 사전에 방지할 수 있습니다.

실전 팁

💡 Named export는 여러 개를 내보낼 때, default export는 파일의 주요 기능 하나를 내보낼 때 사용하세요. 혼용도 가능하지만, 일관성 있게 사용하는 것이 중요합니다.

💡 import * as Utils from './utils' 형태로 모든 named export를 하나의 객체로 가져올 수 있습니다. 하지만 트리 쉐이킹이 제대로 작동하지 않을 수 있으니, 정말 필요한 것만 선택적으로 import하세요.

💡 상대 경로(./, ../)를 사용할 때는 확장자를 생략할 수 있지만, tsconfig.json의 moduleResolution 설정에 따라 다르게 동작할 수 있습니다. Node.js 환경에서는 .js 확장자를 명시하는 것이 안전합니다.

💡 순환 참조(circular dependency)를 조심하세요. A 파일이 B를 import하고, B가 다시 A를 import하면 런타임 에러가 발생할 수 있습니다. 의존성 그래프를 단방향으로 유지하세요.

💡 barrel exports 패턴(index.ts에서 여러 모듈을 재export)을 사용하면 import 경로를 간결하게 만들 수 있습니다. 예: import { A, B, C } from './components'


2. CommonJS와의 상호 운용 - require와 import 함께 사용하기

시작하며

여러분이 Node.js 프로젝트를 TypeScript로 마이그레이션하면서 "기존 CommonJS 라이브러리를 어떻게 사용하지?"라고 막막했던 경험이 있나요? 많은 npm 패키지들이 아직 CommonJS 형식으로 배포되고 있습니다.

이런 상황은 실제로 매우 흔합니다. 특히 오래된 라이브러리나 Node.js 전용 패키지들은 CommonJS를 사용하는데, 여러분의 TypeScript 프로젝트는 ES 모듈을 사용한다면 호환성 문제가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 CommonJS와 ES 모듈의 상호 운용성을 이해하는 것입니다. TypeScript는 두 시스템을 자연스럽게 연결해주는 다양한 방법을 제공합니다.

개요

간단히 말해서, CommonJS는 Node.js에서 사용하는 전통적인 모듈 시스템으로, require()module.exports를 사용합니다. 왜 CommonJS를 알아야 할까요?

실무에서는 ES 모듈과 CommonJS가 혼재된 환경이 매우 많습니다. 예를 들어, Express.js 같은 인기 프레임워크나 많은 유틸리티 라이브러리들이 여전히 CommonJS를 사용하므로, 이를 TypeScript 프로젝트에서 올바르게 import하는 방법을 알아야 합니다.

기존에는 const express = require('express')처럼 사용했다면, TypeScript에서는 import express from 'express' 또는 import * as express from 'express'로 사용할 수 있습니다. CommonJS와 ES 모듈의 주요 차이점은 세 가지입니다: 첫째, CommonJS는 동적 로딩(런타임)이 가능하고 ES 모듈은 정적(컴파일 타임)입니다.

둘째, CommonJS는 동기적으로 로드되고 ES 모듈은 비동기적입니다. 셋째, CommonJS는 module.exports가 객체 전체를 대체하지만, ES 모듈은 명시적인 export 구문을 사용합니다.

이러한 차이를 이해하면 호환성 문제를 쉽게 해결할 수 있습니다.

코드 예제

// legacy-module.js (CommonJS 형식)
module.exports = {
  connect: function(url) {
    return `Connected to ${url}`;
  },
  disconnect: function() {
    return 'Disconnected';
  }
};

// modern-code.ts (TypeScript에서 CommonJS 모듈 사용)
// 방법 1: default import (esModuleInterop: true 필요)
import database from './legacy-module';

// 방법 2: namespace import (항상 안전)
import * as db from './legacy-module';

// 타입 정의를 추가하면 더욱 안전
interface DatabaseModule {
  connect(url: string): string;
  disconnect(): void;
}

const typedDb = db as DatabaseModule;
console.log(typedDb.connect('mongodb://localhost'));

설명

이것이 하는 일: CommonJS 모듈을 TypeScript 프로젝트에서 안전하게 사용하고, 타입 안정성까지 확보합니다. 첫 번째로, legacy-module.js는 전형적인 CommonJS 패턴으로 작성되었습니다.

module.exports에 객체를 할당하여 여러 함수를 export합니다. 왜 이 방식을 사용할까요?

CommonJS는 ES6 이전의 표준으로, 많은 기존 코드가 이 형태로 작성되어 있기 때문입니다. 그 다음으로, modern-code.ts에서는 두 가지 방법으로 import합니다.

import database from은 default import처럼 보이지만, 실제로는 TypeScript가 module.exports 전체를 default export로 간주합니다. 이는 esModuleInterop: true 설정이 있어야 동작합니다.

내부적으로 TypeScript는 CommonJS 모듈을 ES 모듈로 변환하는 헬퍼 코드를 생성합니다. import * as db from은 더 안전한 방법입니다.

이는 tsconfig.json 설정에 관계없이 항상 동작하며, 모든 export를 하나의 네임스페이스 객체로 가져옵니다. 마지막으로, 타입 단언(type assertion)을 사용해 DatabaseModule 인터페이스를 적용하면, TypeScript가 함수 시그니처와 반환 타입을 체크해줍니다.

여러분이 이 코드를 사용하면 레거시 코드와 최신 TypeScript 코드를 함께 사용할 수 있습니다. 기존 npm 패키지를 그대로 활용하면서도 타입 안정성을 유지할 수 있고, 프로젝트를 단계적으로 마이그레이션할 때 매우 유용합니다.

tsconfig.json의 allowSyntheticDefaultImportsesModuleInterop 옵션을 활성화하면 호환성이 크게 향상됩니다.

실전 팁

💡 tsconfig.json에서 esModuleInterop: trueallowSyntheticDefaultImports: true를 설정하면 CommonJS 모듈을 더 자연스럽게 import할 수 있습니다. 거의 모든 현대 프로젝트에서 이 옵션을 사용합니다.

💡 타입 정의 파일(@types 패키지)이 없는 CommonJS 라이브러리는 declare module 구문으로 직접 타입을 정의하세요. 예: declare module 'old-library' { export function doSomething(): void; }

💡 동적 import(const module = await import('./module'))를 사용하면 런타임에 조건부로 모듈을 로드할 수 있습니다. 코드 스플리팅이나 lazy loading에 유용합니다.

💡 CommonJS 모듈이 default export를 제공하지 않으면 import * as 형태를 사용하세요. import pkg from 'package'가 undefined를 반환한다면 이것이 원인일 가능성이 높습니다.

💡 빌드 타겟에 따라 모듈 시스템이 달라집니다. tsconfig.json의 module 옵션을 'commonjs'로 설정하면 TypeScript가 CommonJS로 컴파일하고, 'esnext'로 설정하면 ES 모듈로 유지합니다.


3. Module Resolution - TypeScript가 모듈을 찾는 방법

시작하며

여러분이 import { User } from 'models/User'라고 작성했는데 "Cannot find module" 에러가 발생한 적 있나요? 분명 파일은 존재하는데 TypeScript가 찾지 못하는 상황은 정말 답답합니다.

이런 문제는 모듈 해석(Module Resolution) 설정을 제대로 이해하지 못해서 발생합니다. TypeScript는 import 경로를 실제 파일 경로로 변환하는 복잡한 알고리즘을 사용하며, 프로젝트 구조에 따라 적절한 전략을 선택해야 합니다.

바로 이럴 때 필요한 것이 Module Resolution 전략을 이해하는 것입니다. Classic과 Node 두 가지 방식이 있으며, Path Mapping을 활용하면 import 경로를 훨씬 깔끔하게 만들 수 있습니다.

개요

간단히 말해서, Module Resolution은 TypeScript가 import 문의 모듈 이름을 실제 파일 경로로 변환하는 과정입니다. 왜 이것이 중요할까요?

실무 프로젝트는 복잡한 폴더 구조를 가집니다. 예를 들어, src/features/auth/components/LoginForm.tsx에서 src/shared/utils/validation.ts를 import한다면, 상대 경로는 ../../../shared/utils/validation처럼 매우 길어집니다.

이는 읽기 어렵고 오류가 발생하기 쉽습니다. 기존에는 상대 경로(../../..)로만 import했다면, 이제는 절대 경로(@/shared/utils/validation)를 사용할 수 있습니다.

Module Resolution의 핵심 개념은 세 가지입니다: 첫째, Node 전략은 node_modules를 포함해 Node.js 방식으로 찾습니다. 둘째, baseUrl과 paths를 설정하면 커스텀 경로 매핑이 가능합니다.

셋째, TypeScript는 .ts, .tsx, .d.ts 확장자를 자동으로 시도합니다. 이를 제대로 설정하면 import 경로가 간결해지고 리팩토링이 쉬워집니다.

코드 예제

// tsconfig.json - 모듈 해석 설정
{
  "compilerOptions": {
    "moduleResolution": "node",
    "baseUrl": "./src",
    "paths": {
      "@/components/*": ["components/*"],
      "@/utils/*": ["shared/utils/*"],
      "@/models/*": ["models/*"],
      "@/*": ["*"]
    },
    "typeRoots": ["./node_modules/@types", "./src/types"]
  }
}

// 실제 사용 예시 - src/features/auth/LoginPage.tsx
import { Button } from '@/components/Button'; // 절대 경로
import { validateEmail } from '@/utils/validation'; // 깔끔한 경로
import { User } from '@/models/User'; // 명확한 의도

// 상대 경로를 사용했다면
// import { Button } from '../../components/Button';
// import { validateEmail } from '../../../shared/utils/validation';

설명

이것이 하는 일: TypeScript가 복잡한 프로젝트 구조에서 모듈을 정확하게 찾고, 개발자에게 깔끔한 import 경로를 제공합니다. 첫 번째로, moduleResolution: "node"는 Node.js의 모듈 해석 알고리즘을 사용하라는 뜻입니다.

이는 node_modules 폴더를 탐색하고, package.json의 main/types 필드를 읽고, index 파일을 자동으로 찾는 등의 동작을 포함합니다. 왜 이것이 기본일까요?

대부분의 TypeScript 프로젝트가 npm 생태계를 사용하기 때문입니다. 그 다음으로, baseUrl: "./src"는 모든 non-relative import의 기준점을 설정합니다.

이제 import { X } from 'components/X'라고 쓰면 TypeScript는 ./src/components/X를 찾습니다. 내부적으로 컴파일러는 baseUrl을 기준으로 경로를 재계산합니다.

paths 객체는 더욱 강력한 매핑을 제공합니다. @/components/*components/*로 매핑하면, @/components/Button./src/components/Button으로 변환됩니다.

와일드카드(*)는 나머지 경로를 그대로 유지합니다. 마지막으로, typeRoots는 타입 정의 파일을 찾을 위치를 지정합니다.

여러분이 이 설정을 사용하면 import 문이 훨씬 읽기 쉬워지고, 파일을 이동할 때 import 경로를 수정할 필요가 줄어듭니다. IDE의 자동 완성도 더 잘 작동하며, 팀원들이 프로젝트 구조를 빠르게 파악할 수 있습니다.

하지만 주의할 점은 번들러(Webpack, Vite 등)도 같은 경로 매핑을 이해하도록 설정해야 한다는 것입니다.

실전 팁

💡 @/ 접두사는 관례적으로 프로젝트 루트를 의미합니다. 팀 내에서 일관된 규칙을 정하고, README에 문서화하세요.

💡 Webpack을 사용한다면 tsconfig-paths-webpack-plugin을, Vite를 사용한다면 vite-tsconfig-paths를 설치해 tsconfig.json의 paths를 자동으로 반영하세요.

💡 모노레포(monorepo) 환경에서는 각 패키지마다 tsconfig.json을 두고, references 옵션으로 연결하세요. 이렇게 하면 패키지 간 의존성을 명확하게 관리할 수 있습니다.

💡 moduleResolution: "bundler"(TypeScript 5.0+)는 최신 번들러 환경에 최적화된 옵션입니다. Vite, esbuild 등을 사용한다면 이 옵션을 고려해보세요.

💡 경로 매핑이 작동하지 않으면 tsc --traceResolution으로 디버깅하세요. TypeScript가 어떤 경로를 시도하는지 자세히 볼 수 있습니다.


4. Namespace - 레거시 코드와의 만남

시작하며

여러분이 오래된 TypeScript 코드베이스를 유지보수하면서 namespace 키워드를 본 적 있나요? "이게 뭐지?

모듈이랑 뭐가 다르지?"라는 의문이 들었을 겁니다. Namespace는 ES 모듈이 표준화되기 전에 TypeScript가 사용하던 내부 모듈 시스템입니다.

요즘은 권장되지 않지만, 여전히 많은 레거시 프로젝트와 타입 정의 파일에서 사용되므로 이해하고 있어야 합니다. 바로 이럴 때 필요한 것이 Namespace의 개념과 언제 사용해야 하는지(또는 사용하지 말아야 하는지)를 아는 것입니다.

특히 글로벌 라이브러리의 타입 정의를 작성할 때 유용합니다.

개요

간단히 말해서, Namespace는 관련된 코드를 논리적으로 그룹화하고, 전역 스코프의 오염을 방지하는 TypeScript의 구조입니다. 왜 아직도 배워야 할까요?

첫째, 많은 타입 정의 파일(@types 패키지)이 namespace를 사용합니다. 예를 들어, jQuery, Google Maps API 같은 글로벌 라이브러리의 타입을 정의할 때 namespace가 자주 등장합니다.

둘째, 레거시 프로젝트를 마이그레이션할 때 namespace를 모듈로 변환해야 하는 경우가 있습니다. 기존에는 모든 것을 전역 스코프에 선언했다면, namespace를 사용하면 계층적으로 구조화할 수 있습니다.

Namespace의 핵심 특징은 세 가지입니다: 첫째, 중첩이 가능해 깊은 계층 구조를 만들 수 있습니다. 둘째, export 키워드로 공개 API를 제어합니다.

셋째, 여러 파일에 걸쳐 같은 namespace를 확장할 수 있습니다(declaration merging). 하지만 현대 프로젝트에서는 ES 모듈을 사용하는 것이 더 낫다는 점을 기억하세요.

코드 예제

// legacy-namespace.ts
namespace Utils {
  // export하지 않으면 외부에서 접근 불가
  const SECRET_KEY = 'internal-only';

  // export한 것만 Utils.formatDate로 접근 가능
  export function formatDate(date: Date): string {
    return date.toISOString().split('T')[0];
  }

  // 중첩된 namespace
  export namespace Validation {
    export function isEmail(email: string): boolean {
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    }
  }
}

// 사용 예시
const formatted = Utils.formatDate(new Date());
const valid = Utils.Validation.isEmail('test@example.com');

// 같은 namespace를 다른 파일에서 확장 가능
namespace Utils {
  export function parseJSON(json: string): any {
    return JSON.parse(json);
  }
}

설명

이것이 하는 일: 관련 함수와 타입을 하나의 이름 아래 그룹화하고, 전역 네임스페이스 충돌을 방지합니다. 첫 번째로, namespace Utils는 새로운 스코프를 생성합니다.

내부의 SECRET_KEY는 export하지 않았으므로 namespace 외부에서 접근할 수 없습니다. 왜 이렇게 할까요?

구현 세부사항을 숨기고 공개 API만 노출하기 위해서입니다. 이는 캡슐화의 기본 원칙입니다.

그 다음으로, export function formatDate는 namespace의 공개 멤버가 됩니다. 외부에서는 Utils.formatDate()처럼 접근합니다.

내부적으로 TypeScript는 이를 즉시 실행 함수(IIFE) 패턴으로 컴파일하여 스코프를 격리합니다. namespace Validation은 중첩된 namespace입니다.

이를 통해 Utils.Validation.isEmail처럼 계층적 구조를 만들 수 있습니다. 마지막으로, declaration merging이라는 특별한 기능이 있습니다.

같은 이름의 namespace를 여러 번 선언하면 TypeScript가 자동으로 병합합니다. 이는 여러 파일에 걸쳐 기능을 추가할 때 유용하지만, 예측하기 어려운 동작을 만들 수 있습니다.

여러분이 레거시 코드를 유지보수하거나 글로벌 라이브러리의 타입을 정의할 때 namespace를 만날 것입니다. 하지만 새 프로젝트에서는 ES 모듈을 사용하세요.

모듈은 명시적 의존성, 트리 쉐이킹, 번들러 지원 등에서 훨씬 우수합니다. namespace를 모듈로 변환하려면 각 namespace를 별도 파일로 분리하고 export/import를 사용하면 됩니다.

실전 팁

💡 새 프로젝트에서는 절대 namespace를 사용하지 마세요. ES 모듈이 모든 면에서 우수합니다. TypeScript 공식 문서도 모듈 사용을 권장합니다.

💡 글로벌 라이브러리(브라우저 스크립트 태그로 로드되는)의 타입 정의를 작성할 때는 declare global 블록과 함께 namespace를 사용할 수 있습니다.

💡 namespace를 모듈로 마이그레이션할 때는 각 중첩 namespace를 별도 파일로 만들고, 부모 namespace는 barrel export(index.ts)로 대체하세요.

💡 /// <reference path="..." /> 구문은 namespace 파일을 연결하는 오래된 방법입니다. 이것도 피하고 import를 사용하세요.

💡 타입 정의 파일(.d.ts)에서는 namespace가 여전히 유용할 수 있습니다. 특히 복잡한 글로벌 API를 구조화할 때 사용됩니다.


5. Dynamic Import - 코드 스플리팅과 Lazy Loading

시작하며

여러분의 웹 애플리케이션이 처음 로드될 때 너무 느리다는 피드백을 받은 적 있나요? 사용자가 첫 화면을 보기까지 10초나 걸린다면 대부분 이탈할 것입니다.

이 문제는 모든 코드를 한 번에 로드하기 때문에 발생합니다. 사용자가 당장 필요하지 않은 기능(예: 관리자 페이지, 설정 모달)까지 처음부터 다운로드하면 초기 로딩 시간이 길어집니다.

바로 이럴 때 필요한 것이 Dynamic Import입니다. 필요한 시점에만 코드를 로드하여 초기 번들 크기를 줄이고, 사용자 경험을 크게 개선할 수 있습니다.

개요

간단히 말해서, Dynamic Import는 런타임에 조건부로 모듈을 로드하는 ES 표준 기능으로, import() 함수 형태를 사용합니다. 왜 이것이 게임 체인저일까요?

실무에서는 애플리케이션이 수백 개의 컴포넌트와 라이브러리를 사용합니다. 예를 들어, 차트 라이브러리(Chart.js)는 200KB가 넘지만, 사용자가 대시보드 페이지를 방문할 때만 필요합니다.

나머지 페이지에서는 전혀 사용하지 않는데 처음부터 로드하는 것은 낭비입니다. 기존에는 모든 import를 파일 상단에 정적으로 선언했다면, 이제는 필요한 시점에 await import()로 동적으로 로드할 수 있습니다.

Dynamic Import의 핵심 특징은 세 가지입니다: 첫째, Promise를 반환하므로 비동기적으로 처리됩니다. 둘째, 번들러가 자동으로 코드를 분할(code splitting)합니다.

셋째, 조건문 안에서 사용할 수 있어 필요 시에만 로드 가능합니다. 이는 초기 로딩 속도, 사용자 경험, 비용 절감(데이터 사용량)에 직접적인 영향을 미칩니다.

코드 예제

// 기존 정적 import (항상 로드됨)
// import { Chart } from 'chart.js'; // ❌ 200KB가 항상 로드됨

// Dynamic Import 사용 예시
async function showDashboard() {
  // 로딩 인디케이터 표시
  const spinner = document.getElementById('loading');
  spinner!.style.display = 'block';

  try {
    // 필요한 순간에만 모듈 로드 (code splitting 발생)
    const { Chart } = await import('chart.js');

    // 타입 안정성도 유지됨
    const chart = new Chart(ctx, {
      type: 'bar',
      data: chartData
    });

    spinner!.style.display = 'none';
  } catch (error) {
    console.error('Failed to load chart library:', error);
  }
}

// React에서의 사용 예시
const AdminPanel = React.lazy(() => import('./AdminPanel'));

// 조건부 로딩
if (user.isAdmin) {
  const module = await import('./admin-tools');
  module.initialize();
}

설명

이것이 하는 일: 애플리케이션을 여러 청크(chunk)로 분할하고, 사용자가 실제로 필요로 할 때만 각 청크를 다운로드합니다. 첫 번째로, await import('chart.js')는 Promise를 반환하는 함수 호출입니다.

이는 네트워크 요청을 통해 별도의 JavaScript 파일을 다운로드합니다. 왜 비동기일까요?

파일 다운로드는 시간이 걸리므로, 다른 코드 실행을 블록하지 않기 위해서입니다. TypeScript는 import된 모듈의 타입을 자동으로 추론하므로 타입 안정성도 유지됩니다.

그 다음으로, 번들러(Webpack, Vite 등)가 dynamic import를 발견하면 자동으로 해당 코드를 별도 파일로 분리합니다. 예를 들어, chart.js와 관련 코드는 chunk-123.js 같은 이름의 별도 파일이 되고, 사용자가 showDashboard()를 호출할 때만 다운로드됩니다.

내부적으로는 <script> 태그를 동적으로 생성하거나 fetch()를 사용합니다. React의 React.lazy()는 dynamic import를 컴포넌트 레벨로 추상화한 것입니다.

<Suspense> 컴포넌트와 함께 사용하면 로딩 중 fallback UI를 표시할 수 있습니다. 마지막으로, 조건부 로딩 예시처럼 if 문 안에서도 사용할 수 있어, 특정 사용자나 상황에서만 코드를 로드하는 것이 가능합니다.

여러분이 이 기법을 사용하면 초기 JavaScript 번들 크기가 50-70% 줄어드는 것을 볼 수 있습니다. 이는 First Contentful Paint(FCP)와 Time to Interactive(TTI) 같은 성능 지표를 크게 개선하고, 특히 모바일 사용자에게 중요합니다.

SEO에도 긍정적인 영향을 미치며, 사용하지 않는 코드에 대한 데이터 비용도 절약됩니다.

실전 팁

💡 Route-based code splitting을 우선 적용하세요. 각 페이지/라우트를 별도 번들로 분리하는 것이 가장 효과적입니다. React Router나 Next.js는 이를 쉽게 지원합니다.

💡 너무 작은 모듈을 dynamic import하면 HTTP 요청 오버헤드가 이득보다 클 수 있습니다. 최소 50KB 이상의 모듈에 적용하세요.

💡 Preloading과 Prefetching을 활용하세요. Webpack의 magic comments(import(/* webpackPreload: true */ './module'))로 브라우저에 힌트를 줄 수 있습니다.

💡 에러 처리를 반드시 추가하세요. 네트워크 실패, 파일 누락 등의 상황을 try-catch로 처리하고 사용자에게 적절한 피드백을 제공하세요.

💡 번들 분석 도구(webpack-bundle-analyzer, vite-plugin-visualizer)로 어떤 모듈이 큰지 파악하고, 우선순위를 정해 dynamic import를 적용하세요.


6. Type-Only Import/Export - 컴파일 최적화

시작하며

여러분이 TypeScript 프로젝트를 빌드했는데 번들 크기가 예상보다 크거나, 순환 참조 에러가 발생한 적 있나요? 타입만 import했는데 런타임 코드에 영향을 주는 경우가 있습니다.

이 문제는 TypeScript가 어떤 import가 타입용인지 값용인지 명확히 구분하지 못할 때 발생합니다. 컴파일러는 안전을 위해 모든 import를 런타임 코드에 포함시킬 수 있고, 이는 불필요한 의존성을 만듭니다.

바로 이럴 때 필요한 것이 Type-Only Import/Export입니다. import typeexport type 구문을 사용하면 컴파일러에게 명확한 의도를 전달하고, 더 효율적인 코드를 생성할 수 있습니다.

개요

간단히 말해서, Type-Only Import는 타입 정보만 가져오고 런타임 JavaScript 코드에는 포함되지 않도록 보장하는 명시적 구문입니다. 왜 이것이 중요할까요?

실무에서는 인터페이스와 타입을 많이 공유합니다. 예를 들어, User 타입을 여러 파일에서 import하는데, 이것은 런타임에 필요 없습니다.

하지만 일반 import를 사용하면 번들러가 혼동할 수 있고, 특히 enum이나 class처럼 타입이면서 값인 경우 문제가 됩니다. 기존에는 import { User } from './types'처럼 일반 import를 사용했다면, 이제는 import type { User } from './types'로 의도를 명확히 할 수 있습니다.

Type-Only Import의 핵심 이점은 세 가지입니다: 첫째, 컴파일 후 JavaScript에서 완전히 제거되어 번들 크기가 줄어듭니다. 둘째, 순환 참조 문제를 피할 수 있습니다(타입은 순환 참조가 허용됨).

셋째, 코드 리뷰 시 어떤 import가 타입용인지 한눈에 알 수 있습니다. 이는 프로젝트가 커질수록 더욱 중요해집니다.

코드 예제

// types.ts - 타입과 값을 함께 export
export interface User {
  id: number;
  name: string;
}

export class UserService {
  getUser(id: number): User {
    return { id, name: 'John' };
  }
}

export type UserRole = 'admin' | 'user' | 'guest';

// main.ts - Type-Only Import 사용
// ✅ 타입만 필요한 경우 (컴파일 후 제거됨)
import type { User, UserRole } from './types';

// ✅ 런타임 값이 필요한 경우
import { UserService } from './types';

// ❌ 혼합 사용 (권장하지 않음)
// import { User, UserService } from './types';

function displayUser(user: User): void {
  // User는 타입으로만 사용됨
  console.log(user.name);
}

const service = new UserService(); // 값으로 사용
const role: UserRole = 'admin'; // 타입으로만 사용

설명

이것이 하는 일: TypeScript 컴파일러에게 특정 import가 타입 체크용이며 런타임 코드 생성에서 제외해야 함을 명시적으로 알립니다. 첫 번째로, types.ts에서 interface Usertype UserRole은 순수 타입이고, class UserService는 타입이면서 값입니다.

왜 이 구분이 중요할까요? TypeScript는 컴파일 시 인터페이스와 타입은 완전히 지우지만, 클래스는 JavaScript 코드로 변환되기 때문입니다.

그 다음으로, main.ts에서 import type { User, UserRole }은 TypeScript에게 "이 두 가지는 절대 런타임에 사용하지 않겠다"는 보증입니다. 만약 실수로 const u = User 같은 코드를 작성하면 컴파일 에러가 발생합니다.

내부적으로 TypeScript는 이러한 import를 AST(Abstract Syntax Tree)에서 완전히 제거하며, 생성된 JavaScript 파일에는 흔적도 남지 않습니다. UserService는 일반 import로 가져옵니다.

왜냐하면 new UserService() 같은 런타임 코드에서 실제로 사용되기 때문입니다. 만약 혼합 import를 사용하면(import { User, UserService }) TypeScript는 안전을 위해 전체를 런타임 코드에 포함시킬 수 있고, 이는 트리 쉐이킹을 방해합니다.

여러분이 이 패턴을 사용하면 번들 크기가 5-15% 줄어들 수 있습니다(타입 정의가 많은 프로젝트에서). 또한 isolatedModules: true 옵션(Babel, esbuild와의 호환성)을 사용할 때 발생할 수 있는 문제를 예방하고, 순환 참조 에러를 디버깅하기도 쉬워집니다.

ESLint의 @typescript-eslint/consistent-type-imports 규칙으로 자동화할 수 있습니다.

실전 팁

💡 ESLint에서 @typescript-eslint/consistent-type-imports 규칙을 활성화하면 일반 import를 자동으로 type-only import로 변환하는 auto-fix를 제공합니다.

💡 export type { User } from './types' 형태로 타입만 재export할 수도 있습니다. Barrel exports에서 유용합니다.

💡 import type을 사용하면 순환 참조 문제를 우아하게 해결할 수 있습니다. A와 B 파일이 서로의 타입만 참조한다면 순환 import가 허용됩니다.

💡 TypeScript 5.0+에서는 import { type User, UserService }처럼 개별 import에 type 키워드를 붙일 수 있습니다. 혼합 import가 필요할 때 유용합니다.

💡 enum은 조심하세요. 기본적으로 JavaScript 객체로 컴파일되므로 런타임 값입니다. const enum을 사용하면 인라인되어 type-only import가 가능합니다.


7. Barrel Exports와 Re-exporting - 깔끔한 API 설계

시작하며

여러분이 컴포넌트 라이브러리를 만들면서 "사용자가 수십 개의 서로 다른 경로에서 import해야 하나?"라고 고민한 적 있나요? import { Button } from '@mylib/components/Button', import { Input } from '@mylib/components/Input'처럼 길고 반복적인 import 문은 사용자 경험을 해칩니다.

이런 문제는 내부 구조가 외부 API에 그대로 노출될 때 발생합니다. 폴더 구조를 리팩토링하면 모든 사용자 코드가 깨질 수 있고, 어떤 컴포넌트가 공개 API인지 명확하지 않습니다.

바로 이럴 때 필요한 것이 Barrel Exports입니다. index.ts 파일에서 여러 모듈을 재export하여 단일 진입점을 제공하면, 사용자는 import { Button, Input } from '@mylib/components'처럼 간결하게 사용할 수 있습니다.

개요

간단히 말해서, Barrel Export는 여러 모듈을 하나의 파일(보통 index.ts)에서 재export하여 단일 진입점을 제공하는 패턴입니다. 왜 이것이 라이브러리 설계의 핵심일까요?

실무에서 좋은 API는 간결하고 일관성 있어야 합니다. 예를 들어, React는 import { useState, useEffect } from 'react'처럼 단일 경로에서 모든 것을 제공합니다.

lodash는 import { map, filter } from 'lodash'로 수백 개의 함수를 제공하죠. 이는 barrel export 패턴 덕분입니다.

기존에는 각 파일의 정확한 경로를 알아야 했다면, 이제는 패키지나 폴더 이름만 알면 됩니다. Barrel Export의 핵심 이점은 세 가지입니다: 첫째, import 경로가 간결해지고 일관성 있습니다.

둘째, 내부 구조 변경이 외부 API에 영향을 주지 않습니다(캡슐화). 셋째, 공개 API가 명확해져 문서화가 쉽습니다.

단점도 있습니다: 모든 것을 한 곳에서 export하면 트리 쉐이킹이 덜 효율적일 수 있고, 초기 파싱 시간이 늘어날 수 있습니다.

코드 예제

// components/Button/Button.tsx
export function Button(props: ButtonProps) {
  return <button {...props} />;
}

// components/Input/Input.tsx
export function Input(props: InputProps) {
  return <input {...props} />;
}

// components/Card/Card.tsx
export function Card(props: CardProps) {
  return <div className="card" {...props} />;
}

// components/index.ts - Barrel Export 파일
// 방법 1: 개별 재export (권장 - 트리 쉐이킹 최적)
export { Button } from './Button/Button';
export { Input } from './Input/Input';
export { Card } from './Card/Card';

// 방법 2: 전체 재export (간결하지만 비추천)
// export * from './Button/Button';

// 사용 예시 - 깔끔한 단일 import
import { Button, Input, Card } from './components';
// import { Button } from './components/Button/Button'; (이전 방식)

설명

이것이 하는 일: 복잡한 내부 폴더 구조를 추상화하고, 사용자에게 간결하고 안정적인 API를 제공합니다. 첫 번째로, 각 컴포넌트는 자체 폴더에 위치하며 독립적으로 개발됩니다.

Button, Input, Card는 각각 별도 파일에 있고, 테스트 파일, 스타일 파일도 함께 있을 수 있습니다. 왜 이렇게 구조화할까요?

관련 파일을 가까이 두면 유지보수가 쉽고, 컴포넌트를 독립적으로 이해할 수 있기 때문입니다. 그 다음으로, components/index.ts 파일이 모든 컴포넌트를 재export합니다.

export { Button } from './Button/Button' 구문은 Button을 import하고 즉시 export하는 단축 표현입니다. 내부적으로 TypeScript는 타입 정보도 함께 재export하므로, 사용자는 타입 안정성을 유지합니다.

방법 1(export { Button })과 방법 2(export *)의 차이는 중요합니다. 방법 1은 명시적으로 어떤 것을 공개하는지 보여주고, 번들러가 사용되지 않는 것을 더 잘 제거합니다.

방법 2는 모든 export를 자동으로 재export하므로 간결하지만, 의도하지 않은 것까지 공개될 수 있고 트리 쉐이킹이 덜 효과적입니다. 여러분이 이 패턴을 사용하면 라이브러리 사용자가 훨씬 행복해집니다.

import 문이 짧고 일관성 있으며, 자동 완성도 잘 작동합니다. 내부 폴더 구조를 변경하거나 파일을 이동해도 index.ts만 업데이트하면 되므로, breaking change 없이 리팩토링할 수 있습니다.

하지만 대규모 프로젝트에서는 너무 많은 것을 한 barrel에 넣지 말고, 카테고리별로 나누세요(예: components/forms/index.ts, components/layout/index.ts).

실전 팁

💡 Barrel 파일이 너무 커지면(50개 이상의 export) 카테고리별로 분리하세요. 예: components/forms/index.ts, components/feedback/index.ts 등으로 나누고, 최상위 index.ts에서 다시 재export합니다.

💡 export *는 이름 충돌을 일으킬 수 있습니다. 두 파일에서 같은 이름을 export하면 에러가 발생하므로, 명시적 export를 선호하세요.

💡 TypeScript 프로젝트에서 export type도 barrel에서 재export할 수 있습니다. export type { ButtonProps } from './Button/Button'로 타입만 공개하세요.

💡 개발 중에는 barrel이 불편할 수 있습니다. 파일을 수정할 때마다 index.ts도 업데이트해야 하므로, 스크립트나 IDE 플러그인으로 자동화하세요.

💡 Next.js나 Vite 같은 최신 번들러는 barrel export를 최적화합니다. 하지만 Webpack 4 이하에서는 성능 문제가 있을 수 있으니, 벤치마크를 확인하세요.


8. Declaration Merging - 타입 확장의 마법

시작하며

여러분이 타사 라이브러리를 사용하면서 "이 타입에 내 커스텀 속성을 추가하고 싶은데"라고 생각한 적 있나요? 예를 들어, Express의 Request 객체에 인증된 사용자 정보를 추가하고 싶지만, 타입이 허용하지 않습니다.

이런 상황은 실무에서 매우 자주 발생합니다. 외부 라이브러리의 타입을 수정할 수 없으니, 타입 단언(as any)을 남발하게 되고, 결국 타입 안정성을 잃게 됩니다.

바로 이럴 때 필요한 것이 Declaration Merging입니다. TypeScript의 강력한 기능으로, 같은 이름의 인터페이스나 namespace를 여러 번 선언하면 자동으로 병합됩니다.

개요

간단히 말해서, Declaration Merging은 같은 이름의 타입 선언을 여러 곳에서 하면 TypeScript가 자동으로 하나로 합치는 기능입니다. 왜 이것이 게임 체인저일까요?

실무에서는 기존 라이브러리를 확장하거나, 글로벌 객체에 속성을 추가하거나, 모듈을 점진적으로 확장해야 하는 경우가 많습니다. 예를 들어, Express 미들웨어에서 req.user를 사용하려면 Request 타입에 user 속성이 있어야 하는데, 기본 타입 정의에는 없습니다.

기존에는 타입 단언으로 우회했다면, 이제는 Declaration Merging으로 안전하게 확장할 수 있습니다. Declaration Merging의 핵심 원리는 세 가지입니다: 첫째, 인터페이스는 항상 병합됩니다(type alias는 안 됨).

둘째, 같은 속성이 있으면 타입이 호환되어야 합니다. 셋째, namespace, enum, class도 특정 조건에서 병합 가능합니다.

이 기능은 타입 안정성을 유지하면서 유연성을 제공하는 TypeScript의 독특한 강점입니다.

코드 예제

// express.d.ts - Express 타입 확장
import 'express';

// 기존 Express 모듈의 타입을 확장
declare module 'express' {
  // Request 인터페이스에 새 속성 추가
  interface Request {
    user?: {
      id: string;
      email: string;
      role: 'admin' | 'user';
    };
  }
}

// middleware.ts - 확장된 타입 사용
import { Request, Response, NextFunction } from 'express';

function authMiddleware(req: Request, res: Response, next: NextFunction) {
  // ✅ TypeScript가 req.user를 인식함
  req.user = {
    id: '123',
    email: 'user@example.com',
    role: 'admin'
  };
  next();
}

// route.ts - 타입 안정성 유지
app.get('/profile', (req, res) => {
  // ✅ 자동 완성과 타입 체크 작동
  if (req.user?.role === 'admin') {
    res.json({ message: 'Admin access' });
  }
});

설명

이것이 하는 일: 기존 타입 정의를 수정하지 않고, 타입 안정성을 유지하면서 새로운 속성이나 메서드를 추가합니다. 첫 번째로, declare module 'express' 구문은 "이미 존재하는 express 모듈의 타입을 확장하겠다"는 의미입니다.

import 'express'를 먼저 해야 할까요? 원본 타입 정의를 먼저 로드해야 그것을 확장할 수 있기 때문입니다.

이는 ambient module declaration이라고 불립니다. 그 다음으로, interface Request를 다시 선언합니다.

TypeScript는 이미 express 패키지에 Request 인터페이스가 있음을 알고 있습니다. 같은 이름의 인터페이스를 발견하면, 자동으로 두 정의를 병합합니다.

내부적으로 컴파일러는 모든 Request 선언을 모아서 하나의 통합된 인터페이스를 만듭니다. user? 속성은 optional로 정의되었습니다.

왜냐하면 인증되지 않은 요청에서는 존재하지 않기 때문입니다. 마지막으로, authMiddleware에서 req.user에 값을 할당하고, route 핸들러에서 사용할 때 TypeScript가 완벽한 타입 체크와 자동 완성을 제공합니다.

req.user?.role처럼 optional chaining도 정확하게 작동합니다. 여러분이 이 기법을 사용하면 타입 단언(as any, as unknown as)을 제거하고, 런타임 에러를 컴파일 타임에 잡을 수 있습니다.

팀원들도 req.user가 어떤 타입인지 정확히 알 수 있고, 리팩토링 시 영향받는 모든 곳을 TypeScript가 알려줍니다. 이는 대규모 프로젝트에서 타입 안정성을 유지하는 핵심 패턴입니다.

실전 팁

💡 type 대신 interface를 사용하세요. type alias는 병합되지 않습니다. 확장 가능성이 필요하면 항상 interface를 선택하세요.

💡 전역 타입을 확장할 때는 global.d.ts 같은 별도 파일을 만들고 declare global { } 블록을 사용하세요. 예: Window 객체에 속성 추가.

💡 같은 속성을 다른 타입으로 재선언하면 에러가 발생합니다. 병합 시 타입 호환성을 유지해야 합니다.

💡 Third-party 라이브러리 확장은 @types 폴더나 별도 *.d.ts 파일에 작성하고, tsconfig.json의 typeRootstypes에 포함되도록 하세요.

💡 namespace와 interface를 함께 사용하면 복잡한 API를 모델링할 수 있습니다. jQuery 같은 함수이면서 객체인 라이브러리의 타입 정의에서 볼 수 있습니다.


9. Ambient Modules - 타입 없는 라이브러리 다루기

시작하며

여러분이 npm에서 유용한 라이브러리를 찾았는데 TypeScript 타입 정의가 없어서 포기한 적 있나요? npm install old-library를 했더니 "Could not find a declaration file" 에러가 발생하고, @types/old-library도 존재하지 않습니다.

이런 상황은 특히 오래되거나 작은 패키지에서 자주 발생합니다. 타입이 없으면 모든 것이 any가 되어 TypeScript의 장점을 잃게 되고, 실수로 잘못된 인자를 전달해도 컴파일러가 잡아주지 못합니다.

바로 이럴 때 필요한 것이 Ambient Module Declaration입니다. .d.ts 파일에 직접 타입을 정의하면, 타입 없는 JavaScript 라이브러리도 타입 안정성을 가질 수 있습니다.

개요

간단히 말해서, Ambient Module은 JavaScript로 작성된 외부 모듈의 타입 정보를 TypeScript에 알려주는 선언 파일입니다. 왜 이것을 배워야 할까요?

실무에서는 수천 개의 npm 패키지를 사용합니다. 모든 패키지가 TypeScript 타입을 제공하지는 않으며, 특히 레거시 패키지나 특정 도메인(하드웨어 제어, 오래된 UI 라이브러리 등)에서 자주 발생합니다.

예를 들어, 회사 내부 JavaScript 유틸리티 라이브러리를 TypeScript 프로젝트에서 사용해야 할 때 타입 정의를 직접 작성해야 합니다. 기존에는 any로 타입 체크를 포기했다면, 이제는 최소한의 타입 정의로 안정성을 확보할 수 있습니다.

Ambient Module의 핵심 개념은 세 가지입니다: 첫째, declare module 구문으로 모듈의 존재를 알립니다. 둘째, 구현 코드는 없고 타입 시그니처만 작성합니다.

셋째, .d.ts 파일은 컴파일되지 않고 타입 체크에만 사용됩니다. 이는 점진적 타이핑(gradual typing)의 핵심 개념으로, 완벽한 타입이 없어도 일부라도 정의하면 큰 도움이 됩니다.

코드 예제

// old-library.d.ts - 타입이 없는 라이브러리의 타입 정의
declare module 'old-library' {
  // 함수 시그니처 정의
  export function processData(input: string): string;

  // 객체 구조 정의
  export interface Config {
    timeout: number;
    retries?: number;
  }

  // 클래스 정의
  export class DataProcessor {
    constructor(config: Config);
    process(data: string): Promise<string>;
    reset(): void;
  }

  // 상수 정의
  export const VERSION: string;

  // default export
  export default function initialize(apiKey: string): void;
}

// main.ts - 타입 안정성을 가지고 사용
import oldLib, { processData, DataProcessor, Config } from 'old-library';

// ✅ 타입 체크 작동
const config: Config = { timeout: 5000 };
const processor = new DataProcessor(config);

// ✅ 잘못된 인자는 에러
// processor.process(123); // ❌ Error: Argument of type 'number'

설명

이것이 하는 일: TypeScript 컴파일러에게 외부 JavaScript 모듈의 API 구조를 알려주어, 타입 체크와 자동 완성을 가능하게 합니다. 첫 번째로, declare module 'old-library' 구문은 "이 이름의 모듈이 런타임에 존재할 것"이라고 선언합니다.

declare 키워드를 쓸까요? 이는 구현이 다른 곳에 있고, 여기서는 타입 정보만 제공한다는 의미입니다.

TypeScript는 이 선언을 믿고, import 시 에러를 내지 않습니다. 그 다음으로, 모듈 내부의 export들을 정의합니다.

export function processData는 함수의 타입 시그니처만 작성합니다(구현 코드 없음). 인터페이스와 클래스도 같은 방식입니다.

내부적으로 TypeScript는 이러한 선언을 타입 체크 시 참조하지만, JavaScript 코드 생성에는 영향을 주지 않습니다. Config 인터페이스에서 retries?는 optional property입니다.

실제 라이브러리의 동작을 관찰해서 어떤 속성이 필수이고 선택적인지 파악하여 정의해야 합니다. 마지막으로, main.ts에서 이 타입 정의를 사용하면, TypeScript는 마치 원래부터 타입이 있었던 것처럼 완벽하게 체크합니다.

여러분이 이 기법을 사용하면 타사 라이브러리를 안전하게 통합할 수 있습니다. 처음에는 기본적인 타입(any 대신 구체적 타입)만 정의하고, 사용하면서 점진적으로 정확하게 개선할 수 있습니다.

DefinitelyTyped(@types 패키지)에 기여할 수도 있고, 회사 내부 패키지의 타입을 관리하는 데에도 유용합니다. 중요한 것은 완벽한 타입을 한 번에 작성하려 하지 말고, 실제 사용 패턴을 반영해 점진적으로 개선하는 것입니다.

실전 팁

💡 타입을 정확히 모르겠다면 일단 any로 두고 나중에 개선하세요. unknown을 사용하면 더 안전합니다(사용 전에 타입 가드 필요).

💡 declare module 내부에서 import type을 사용할 수 있습니다. 다른 타입을 재사용하면 중복을 줄일 수 있습니다.

💡 와일드카드 모듈 선언(declare module '*.css')로 특정 확장자 파일을 모듈로 인식시킬 수 있습니다. CSS 모듈, 이미지 import 등에 유용합니다.

💡 라이브러리 문서가 부족하면 런타임에 console.log로 객체 구조를 확인하고, 그에 맞춰 타입을 정의하세요. 브라우저 개발자 도구가 도움이 됩니다.

💡 타입 정의를 DefinitelyTyped에 기여하면 전 세계 개발자가 혜택을 받습니다. @types 패키지 생성 가이드를 참고하세요.


10. Triple-Slash Directives - 의존성과 설정 지시

시작하며

여러분이 타입 정의 파일을 작성하면서 "다른 타입 정의를 어떻게 참조하지?"라고 막막했던 적 있나요? 일반 import 문을 .d.ts 파일에서 사용하면 때때로 예상치 못한 동작이 발생합니다.

이런 문제는 특히 글로벌 타입 정의나 복잡한 타입 의존성 구조에서 발생합니다. TypeScript 컴파일러에게 특정 파일이나 라이브러리를 먼저 로드하라고 지시할 방법이 필요합니다.

바로 이럴 때 필요한 것이 Triple-Slash Directives입니다. /// <reference /> 형태의 특수 주석으로, 타입 의존성과 컴파일러 옵션을 파일 레벨에서 제어할 수 있습니다.

개요

간단히 말해서, Triple-Slash Directives는 파일의 최상단에 작성하는 특수 주석으로, TypeScript 컴파일러에게 메타 정보를 전달합니다. 왜 아직도 사용될까요?

현대 TypeScript에서는 import 문이 대부분의 경우 더 나은 선택이지만, 세 가지 상황에서는 여전히 필요합니다. 첫째, 글로벌 타입 정의 파일에서 다른 타입을 참조할 때(import를 쓰면 글로벌이 아니게 됨).

둘째, 타입 라이브러리(@types 패키지)를 명시적으로 포함시킬 때. 셋째, 파일별로 컴파일러 옵션을 오버라이드할 때입니다.

기존에는 tsconfig.json의 files 배열로 의존성을 관리했다면, 이제는 파일 내에서 직접 명시할 수 있습니다. Triple-Slash Directives의 핵심 유형은 네 가지입니다: 첫째, path는 다른 타입 파일을 참조합니다.

둘째, types는 @types 패키지를 포함합니다. 셋째, lib은 내장 타입 라이브러리를 추가합니다.

넷째, no-default-lib는 기본 라이브러리를 제외합니다. 이들은 모듈 시스템 이전의 레거시 기능이지만, 특정 상황에서는 여전히 최선의 도구입니다.

코드 예제

// global-types.d.ts - 글로벌 타입 정의 파일
/// <reference types="node" /> // Node.js 타입을 포함
/// <reference lib="dom" /> // DOM 타입 추가
/// <reference path="./custom-types.d.ts" /> // 다른 타입 파일 참조

// ❌ import를 사용하면 이 파일이 모듈이 되어 글로벌이 아니게 됨
// import { SomeType } from './types';

// ✅ 글로벌 네임스페이스에 타입 추가
declare global {
  interface Window {
    myGlobalFunction(): void;
  }

  // NodeJS의 ProcessEnv에 커스텀 환경 변수 추가
  namespace NodeJS {
    interface ProcessEnv {
      CUSTOM_API_KEY: string;
      FEATURE_FLAG: 'true' | 'false';
    }
  }
}

// custom-types.d.ts - 참조되는 파일
interface CustomConfig {
  apiUrl: string;
}

// app.ts - 타입 사용
// ✅ 글로벌 타입이 자동으로 적용됨
window.myGlobalFunction(); // 타입 체크 통과
const apiKey = process.env.CUSTOM_API_KEY; // string 타입

설명

이것이 하는 일: TypeScript 컴파일러에게 컴파일 전에 특정 타입 파일이나 라이브러리를 로드하라고 명령합니다. 첫 번째로, /// <reference types="node" />는 "@types/node" 패키지를 명시적으로 포함시킵니다.

왜 이게 필요할까요? tsconfig.json에서 types 옵션을 사용하면 자동 포함이 비활성화되므로, 필요한 타입을 명시해야 합니다.

컴파일러는 이 지시문을 보고 node_modules/@types/node를 먼저 로드합니다. 그 다음으로, /// <reference lib="dom" />은 브라우저 DOM 타입을 추가합니다.

lib 옵션은 tsconfig.json의 lib 배열을 보완합니다. 내부적으로 TypeScript는 내장 타입 정의 파일(lib.dom.d.ts)을 포함시킵니다.

/// <reference path="./custom-types.d.ts" />는 상대 경로로 다른 타입 파일을 참조합니다. 이는 의존성 순서를 명확히 하는데, TypeScript가 custom-types.d.ts를 먼저 파싱하도록 보장합니다.

마지막으로, declare global 블록은 글로벌 네임스페이스를 확장합니다. 이 파일에 import가 없으므로 전체가 글로벌 스코프에 적용됩니다.

여러분이 이것을 사용하는 경우는 제한적이어야 합니다. 대부분의 상황에서는 ES6 import를 사용하세요.

하지만 글로벌 타입 정의, 복잡한 타입 라이브러리 설정, 또는 특수한 컴파일 환경에서는 triple-slash directives가 유일한 해결책일 수 있습니다. 특히 Node.js 환경 변수 타입이나 브라우저 전역 객체 확장은 이 방법이 표준 패턴입니다.

실전 팁

💡 Triple-slash directives는 반드시 파일의 최상단에 있어야 합니다. 주석이나 코드 위에 있으면 무시됩니다.

💡 현대 프로젝트에서는 가능한 한 피하세요. import 문이 더 명확하고 도구 지원도 좋습니다. tsconfig.json의 types 옵션으로 대부분 해결 가능합니다.

💡 /// <reference no-default-lib="true" />는 기본 라이브러리(lib.d.ts)를 제외합니다. 매우 특수한 환경(임베디드, WebAssembly 등)에서만 사용됩니다.

💡 path 지시문은 상대 경로만 허용합니다. 절대 경로나 node_modules는 types 지시문을 사용하세요.

💡 타입 정의 파일(.d.ts)에서만 의미가 있습니다. 일반 .ts 파일에서는 대부분 무시되거나 에러를 발생시킵니다.


#TypeScript#Module#Export#Import#ES6

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.

AWS Bedrock 인용과 출처 표시 완벽 가이드

AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.

이전3/3
다음