[ComitChu 개발기] 비슷하지만 완전히 다르다 – TypeScript 선언문 심층 비교

Suyo·2025년 8월 1일
0

ComitChu

목록 보기
3/5

TypeScript는 개발자에게 강력한 타입 시스템을 제공하지만, 겉으로 보기에 비슷한 여러 선언문들 때문에 혼동을 느끼기 쉽다. 특히 React와 같은 프레임워크를 사용하다 보면, 이러한 미묘한 차이를 제대로 이해하지 못해 런타임 오류, 타입 누락, 심지어는 불필요한 번들링 이슈를 겪기도 한다.

이 글에서는 혼동하기 쉬운 TypeScript의 핵심 선언문들을 코드 예시와 실제 동작 결과를 중심으로 비교하고, 각각의 선언이 가지는 실용적인 의미를 깊이 있게 분석한다.


1. interface vs type

interfacetype은 객체의 형태를 정의할 때 가장 많이 사용되는 문법이다. 둘은 많은 경우에 비슷하게 동작하지만, 몇 가지 결정적인 차이점을 가지고 있다.

구분interfacetype
확장 방법extends 키워드 사용& (intersection) 사용
선언 병합가능 (Declaration Merging)불가능
표현력객체 구조 중심유니언, 튜플, 조건부 타입 등 다양한 표현 가능

코드 예시 1: 확장과 병합

// 인터페이스 확장 (extends)
interface Animal {
  name: string;
}
interface Dog extends Animal {
  breed: string;
}
// 최종 Dog 타입: { name: string; breed: string }

// 타입 별칭 확장 (&)
type AnimalT = { name: string };
type DogT = AnimalT & { breed: string };
// 최종 DogT 타입: { name: string; breed: string }

두 방식 모두 객체를 확장하는 데 사용되지만, interface의 가장 강력한 기능은 선언 병합(Declaration Merging)이다. 동일한 이름의 interface를 여러 번 선언하면 TypeScript가 이를 하나로 합쳐준다.

// 선언 병합 예시
interface Config {
  url: string;
}
interface Config {
  timeout: number;
}
// 최종 Config 타입: { url: string; timeout: number }
// -> 이 기능은 라이브러리에서 타입 확장을 위해 유용하게 사용된다.

type ConfigT = { url: string };
// type ConfigT = { timeout: number }; // 오류 발생: 중복 선언 불가

코드 예시 2: 다양한 표현력
type은 인터페이스로 표현하기 어려운 복합적인 타입들을 조합할 수 있다는 장점이 있다.

// 유니언 타입
type Theme = 'light' | 'dark';

// 튜플 타입
type Coords = [number, number];

// 제네릭과 조건부 타입
type FilteredArray<T> = T extends (infer U)[] ? U : T;
type StringArray = FilteredArray<string[]>; // string

2. import vs import type

이 둘의 가장 큰 차이점은 컴파일 이후 JavaScript 코드에 남는지 여부이다. import는 값과 타입을 모두 가져올 수 있어 런타임에 필요한 모듈을 불러올 때 사용하며, import type은 순수하게 타입 정보만 불러오기 때문에 컴파일 시점에 완전히 제거된다.

코드 예시

// types.ts
export type User = { name: string; age: number; };

// utils.ts
export function getUser(name: string) {
  return { name, age: 30 };
}

// main.ts
import { getUser } from "./utils"; // 런타임에 필요한 '값'
import type { User } from "./types"; // 런타임에 필요 없는 '타입'

const user: User = getUser("Alice");
console.log(user);

결과 분석
getUser 함수는 실제로 코드를 실행하는 데 필요한 값(value)이다. 따라서 import { getUser } 문은 컴파일된 JavaScript 결과물에 그대로 남아 있게 된다.

반면, User는 단순히 user 변수의 타입을 정의하기 위한 타입(type) 정보일 뿐이다. TypeScript는 타입 체크를 마친 후 이 정보를 더 이상 필요로 하지 않기 때문에, import type { User } 문은 컴파일 시점에 완전히 사라진다.

// 컴파일된 JS 결과 (main.js)
import { getUser } from "./utils"; // getUser는 그대로 남음
const user = getUser("Alice"); // User 타입 정보는 사라짐
console.log(user);

import type을 사용하면 런타임에 불필요한 코드를 제거하여 번들 크기를 최적화하고, 타입 전용 모듈을 명확히 구분해 코드 가독성을 높일 수 있다.


3. export vs export type

이 두 선언 역시 import와 마찬가지로 컴파일 이후 JS 결과물에 남는지 여부가 다르다. export는 값 또는 타입을 내보내며, export type은 오직 타입만 내보낸다.

코드 예시

// types.ts
export type User = { name: string };
export const VERSION = "1.0.0";

// main.ts
import type { User } from "./types";
import { VERSION } from "./types";

결과 분석
위 코드를 컴파일하면 VERSION은 JavaScript 코드에 남아있지만, User 타입은 완전히 사라진다.

// 컴파일된 JS 결과 (main.js)
import { VERSION } from "./types";

export type은 특히 타입만 내보내는 라이브러리나 모듈에서 불필요한 번들링을 막고, 의존성 관계를 깔끔하게 유지하는 데 효과적이다.


4. enum vs const enum vs as const

상수를 정의할 때 가장 많이 혼동되는 3가지 방식이다. 핵심은 런타임에 객체가 생성되는지타입 리터럴로 고정되는지이다.

구분enumconst enumas const
런타임 객체존재함 (객체 생성)존재하지 않음존재하지 않음
JS 결과물객체 코드가 남음값만 인라인 처리됨값만 인라인 처리됨
주요 용도논리적 그룹의 상수경량화된 상수타입 리터럴로 고정

코드 예시 + 결과

// enum.ts
export enum Status {
  LOADING = "loading",
  DONE = "done",
}

export const enum FastStatus { // const enum은 런타임 객체 미생성
  LOADING = "loading",
  DONE = "done",
}

export const statusArray = ["loading", "done"] as const; // 타입 리터럴로 고정
export type StatusLiteral = typeof statusArray[number]; // "loading" | "done"

위 코드를 컴파일하면, enum Status는 런타임에 사용할 수 있도록 객체 코드가 생성된다.

// 컴파일된 JS 결과 (enum.js)
var Status;
(function (Status) {
  Status["LOADING"] = "loading";
  Status["DONE"] = "done";
})(Status || (Status = {}));

반면, const enumas const는 런타임에 아무것도 남기지 않는다. 사용되는 지점에 해당 값만 그대로 삽입(inline)된다.

// 사용 예시
console.log(Status.LOADING);
console.log(FastStatus.DONE); // const enum은 값만 삽입됨
const myStatus: StatusLiteral = "loading";

const enum은 성능 최적화에 유리하며, as const는 값 자체를 타입으로 고정시켜 더욱 엄격한 타입 체크를 가능하게 한다.


5. declare 키워드

declare는 TypeScript에게 "이런 형태의 모듈/변수가 어딘가에 존재하니, 타입만 믿고 사용해도 돼"라고 알려주는 역할을 한다. 실제 구현 코드가 아니기 때문에 JavaScript로 컴파일되지 않는다.

주로 타입스크립트가 기본적으로 인식하지 못하는 모듈이나 전역 변수에 대한 타입을 정의할 때 사용된다.

예시: CSS 모듈 / SVG 임포트
React 프로젝트에서 CSS 모듈이나 SVG 파일을 import 할 때 타입 오류가 발생하는 경우가 많다. 이때 declare를 사용해 해당 파일의 타입을 선언해 주면 된다.

// global.d.ts
declare module "*.module.css" {
  const classes: { [key: string]: string };
  export default classes;
}

declare module "*.svg" {
  const content: string;
  export default content;
}

사용 예시

// Button.module.css
.button {
  background-color: blue;
}

// Button.tsx
import styles from "./Button.module.css";
import Icon from "./icon.svg";

// 이제 .css와 .svg 파일을 import해도 타입 오류가 발생하지 않는다.
// styles는 string key를 가진 객체로, Icon은 string으로 인식된다.
console.log(styles.button); // 예: "_button_xyz123"
console.log(Icon);          // 예: "/static/media/icon.svg"

declare 덕분에 TypeScript는 .css.svg 파일의 존재와 타입을 인식하고, 개발자는 자동완성 같은 편의 기능을 온전히 누릴 수 있게 된다.


6. 실용적인 선택 기준 가이드

지금까지 살펴본 내용을 바탕으로, 실제 프로젝트에서 어떤 선언을 선택해야 할지 고민될 때 참고할 수 있는 가이드를 제시한다.

언제 interface를 쓰고 언제 type을 쓸까?

  • interface:
    • 객체 구조를 정의할 때
    • 컴포넌트의 Props 타입을 정의할 때 (가독성 좋음)
    • 확장이 자주 필요한 경우 (e.g., extends를 통한 상속)
    • 선언 병합 기능이 필요한 경우 (라이브러리 타입 선언 등)
  • type:
    • interface로 표현하기 어려운 복합 타입 (유니언, 튜플 등)을 만들 때
    • 간단한 타입 별칭을 만들 때
    • as const를 통해 타입 리터럴을 정의할 때

import vs import type

  • 가져올 대상이 타입만이라면 import type을 명시적으로 사용한다.
  • 번들 최적화가 중요하거나, 순환 참조 우려가 있는 경우에도 import type을 권장한다.

export vs export type

  • 값과 타입을 모두 외부에 공개해야 할 때는 export를 사용한다.
  • 타입 정보만 외부에 공개하고 싶을 때는 export type을 사용한다.
  • 라이브러리를 제작할 때 이 둘을 명확히 구분하면 의존성 관리가 훨씬 수월해진다.

마무리


이번 글에서 다룬 TypeScript의 선언문들은 겉으로 보기엔 유사하지만, 실제 동작 방식과 목적에 있어 큰 차이를 보인다. 각 선언이 가지는 고유한 특성을 명확히 이해하고 적재적소에 활용한다면, 코드의 가독성은 물론 성능과 유지보수성까지 크게 향상시킬 수 있을 것이다. 이 내용이 여러분의 TypeScript 여정에 실질적인 도움이 되기를 바란다.

profile
Mee-

0개의 댓글