본문으로 건너뛰기
Advertisement

3.3 interface vs type

TypeScript를 배우다 보면 가장 먼저 혼란을 겪는 주제 중 하나가 "interface와 type 중 무엇을 써야 하나?"다. 두 도구는 많은 경우 서로 대체 가능하지만, 핵심적인 차이점이 있고 상황에 따라 하나가 더 적합하다.

이 장에서는 차이점을 기술적으로 비교하고, 실무 기준의 의사결정 가이드라인을 제시한다.


핵심 차이점 총정리

기능interfacetype
객체 구조 정의가능가능
원시 타입 별칭불가type Name = string
유니온 타입불가type A = B | C
인터섹션 타입extends로 유사하게type A = B & C
리터럴 타입불가type Dir = "left" | "right"
선언 병합가능 (같은 이름 중복 선언)불가 (오류 발생)
확장 방법extends 키워드& 인터섹션
재귀 타입제한적자연스럽게 가능
클래스 implements가능가능 (객체 타입인 경우)
튜플/배열 타입제한적type Pair = [string, number]
조건부 타입불가type A = T extends U ? X : Y
mapped 타입불가type A = { [K in keyof T]: ... }
에러 메시지 가독성인터페이스 이름 표시구조 전체 인라인 표시 경우 있음

확장 방식 차이

interface: extends 키워드

interface Animal {
name: string;
sound(): string;
}

interface Pet extends Animal {
owner: string;
vaccinated: boolean;
}

interface ServiceAnimal extends Animal {
serviceType: "guide" | "therapy" | "search-rescue";
certificationId: string;
}

// 다중 확장
interface SpecialPet extends Pet, ServiceAnimal {
specialTrait: string;
}

extends는 확장 시점에 타입 호환성 검사를 수행한다. 부모 인터페이스와 충돌하는 프로퍼티를 정의하면 즉시 오류가 발생한다.

interface Base {
value: string;
}

// interface Derived extends Base {
// value: number; // 오류: 'string'과 호환되지 않는 'number'
// }

type: & 인터섹션

type Animal = {
name: string;
sound(): string;
};

type Pet = Animal & {
owner: string;
vaccinated: boolean;
};

// 충돌 감지 시점이 다름
type Base = { value: string };
type Derived = Base & { value: number };
// Derived.value는 string & number = never
// 오류가 즉시 발생하지 않고 사용 시점에서 never로 감지됨

실무 차이점: extends는 확장 시점에 호환성을 검사하므로 더 빠른 오류 감지가 가능하다. &는 더 유연하지만 충돌 감지가 늦을 수 있다.


선언 병합: interface만 가능

이것이 두 도구의 가장 큰 기술적 차이다.

interface 선언 병합

// 여러 파일이나 같은 파일에서 같은 이름으로 선언 가능
interface UserConfig {
theme: "light" | "dark";
}

interface UserConfig {
language: "ko" | "en" | "ja";
}

interface UserConfig {
notifications: boolean;
}

// 자동으로 합쳐짐
const config: UserConfig = {
theme: "dark",
language: "ko",
notifications: true,
};

type은 병합 불가

type UserSettings = { theme: "light" | "dark" };
// type UserSettings = { language: string }; // 오류: 중복 식별자

선언 병합의 대표 활용: 라이브러리 타입 보강(Module Augmentation)

// 외부 라이브러리의 타입을 건드리지 않고 확장
// types/express-session/index.d.ts

import "express-session";

declare module "express-session" {
interface SessionData {
userId?: string;
cartItems?: string[];
lastVisited?: Date;
}
}

// 이후 session.userId로 타입 안전하게 접근 가능
import { Request } from "express";
function getCart(req: Request): string[] {
return req.session.cartItems ?? [];
}

재귀 타입

재귀 타입은 자기 자신을 참조하는 타입이다. type이 더 자연스럽게 표현된다.

type으로 재귀 타입

// JSON 값 타입 (재귀)
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };

const data: JsonValue = {
name: "Alice",
age: 30,
tags: ["ts", "dev"],
address: {
city: "Seoul",
zip: "12345",
},
};

// 트리 구조
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};

const tree: TreeNode<string> = {
value: "root",
children: [
{
value: "branch-1",
children: [
{ value: "leaf-1-1", children: [] },
{ value: "leaf-1-2", children: [] },
],
},
{ value: "branch-2", children: [] },
],
};

interface로 재귀 타입 (제한적)

// interface로도 가능하지만 직접 재귀는 조금 더 장황함
interface TreeNodeInterface<T> {
value: T;
children: TreeNodeInterface<T>[];
}

// JSON 같은 복잡한 재귀 유니온은 interface로 표현하기 어렵다
// interface JsonValue { ... } 로는 | null | boolean ... 유니온을 표현할 수 없음

언제 interface? 언제 type? — 실무 기준 가이드라인

interface를 선택해야 할 때

1. 공개 API의 객체 구조를 정의할 때

라이브러리나 모듈의 공개 인터페이스는 interface로 정의한다. 사용자가 선언 병합으로 확장할 수 있어야 하기 때문이다.

// 라이브러리 코드
export interface PluginConfig {
name: string;
version: string;
}

// 사용자 코드에서 확장 가능
declare module "my-library" {
interface PluginConfig {
customField: string;
}
}

2. 클래스의 계약(contract)을 정의할 때

interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<boolean>;
}

class UserRepository implements Repository<User> {
// 모든 메서드를 구현하지 않으면 컴파일 오류
async findById(id: string): Promise<User | null> { ... }
async findAll(): Promise<User[]> { ... }
async save(user: User): Promise<User> { ... }
async delete(id: string): Promise<boolean> { ... }
}

3. 객체 지향 계층 구조를 표현할 때

interface Shape {
area(): number;
perimeter(): number;
}

interface TwoDShape extends Shape {
x: number;
y: number;
}

interface ThreeDShape extends Shape {
volume(): number;
}

type을 선택해야 할 때

1. 유니온이나 인터섹션 타입을 정의할 때

// interface로는 불가
type Status = "active" | "inactive" | "pending";
type ID = string | number;
type Result<T> = { success: true; data: T } | { success: false; error: string };

2. 원시 타입 또는 튜플에 이름을 붙일 때

type Timestamp = number;       // Unix 타임스탬프
type RGB = [number, number, number];
type HSL = [number, number, number];
type Pair<A, B> = [A, B];

3. 유틸리티 타입, 조건부 타입, mapped 타입을 조합할 때

// 조건부 타입 — interface로 불가
type NonNullable2<T> = T extends null | undefined ? never : T;

// Mapped 타입
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};

// 복잡한 조합
type ApiEndpoints = {
[K in keyof UserService as `/${Lowercase<string & K>}`]: UserService[K];
};

4. 재귀 타입이 필요할 때

type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

클래스에서의 사용: implements는 둘 다 가능

클래스는 interface와 객체 형태의 type 모두를 implements로 사용할 수 있다.

interface Printable {
print(): void;
}

type Saveable = {
save(): Promise<void>;
};

// 둘 다 implements 가능
class Document implements Printable, Saveable {
print(): void {
console.log("문서 출력");
}

async save(): Promise<void> {
console.log("문서 저장");
}
}

단, 유니온 타입은 implements할 수 없다.

type StringOrNumber = string | number;
// class MyClass implements StringOrNumber {} // 오류: 유니온은 implements 불가

실전 예제: 라이브러리 타입 확장(interface), 복잡한 유니온(type)

라이브러리 타입 확장 (interface 활용)

// Axios 인터셉터에 커스텀 메타데이터 추가
// types/axios/index.d.ts
import "axios";

declare module "axios" {
interface AxiosRequestConfig {
_retryCount?: number;
_startTime?: number;
skipAuth?: boolean;
cacheKey?: string;
}

interface AxiosResponse {
duration?: number; // 응답 시간 (ms)
}
}

// 실제 사용 코드
import axios from "axios";

axios.interceptors.request.use((config) => {
config._startTime = Date.now();
config._retryCount = 0;
return config;
});

axios.interceptors.response.use((response) => {
if (response.config._startTime) {
response.duration = Date.now() - response.config._startTime;
}
return response;
});

복잡한 유니온 상태 관리 (type 활용)

// Redux-like 액션 타입
type UserAction =
| { type: "USER_FETCH_START" }
| { type: "USER_FETCH_SUCCESS"; payload: User }
| { type: "USER_FETCH_ERROR"; error: string }
| { type: "USER_UPDATE"; payload: Partial<User> }
| { type: "USER_DELETE"; id: string }
| { type: "USER_LOGOUT" };

type AuthAction =
| { type: "AUTH_LOGIN"; payload: { token: string; user: User } }
| { type: "AUTH_LOGOUT" }
| { type: "AUTH_TOKEN_REFRESH"; token: string };

type AppAction = UserAction | AuthAction;

// 리듀서: 모든 케이스 강제 처리
function userReducer(state: User | null, action: UserAction): User | null {
switch (action.type) {
case "USER_FETCH_SUCCESS":
return action.payload;
case "USER_UPDATE":
return state ? { ...state, ...action.payload } : null;
case "USER_DELETE":
case "USER_LOGOUT":
return null;
case "USER_FETCH_START":
case "USER_FETCH_ERROR":
return state;
}
}

고수 팁: 팀 컨벤션 설정과 ESLint 규칙

TypeScript 공식 권장사항

TypeScript 팀의 공식 입장: "어떤 것을 사용해도 괜찮지만, 일관성이 중요하다." 다만 interface를 기본으로 사용하고, type이 필요한 경우에만 사용하는 것을 권장한다(TypeScript 코드베이스 자체도 이 방식을 따른다).

ESLint 규칙으로 컨벤션 강제

// .eslintrc.json 또는 eslint.config.js
{
"rules": {
// "interface-over-type": interface를 기본으로 강제
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],

// 또는 type을 기본으로 강제
"@typescript-eslint/consistent-type-definitions": ["error", "type"]
}
}

프로젝트별 추천 전략

오픈소스 라이브러리 / SDK 개발:

  • 공개 타입은 interface (사용자가 확장 가능해야 함)
  • 내부 구현 타입은 type 자유롭게 사용

일반 애플리케이션 개발:

  • 도메인 모델(User, Order 등)은 interface
  • 유틸리티·유니온·상태 타입은 type

팀 내 혼란을 줄이는 실용적 규칙:

// 규칙 1: 선언 병합이 필요한 상황에서만 interface 필수
// 규칙 2: 유니온·인터섹션·리터럴은 type 필수
// 규칙 3: 그 외에는 팀이 합의한 하나로 통일

// tsconfig.json에 strict 모드 활성화로 타입 안전성 확보
// tsconfig.json
{
"compilerOptions": {
"strict": true, // 엄격 타입 검사
"noImplicitAny": true, // any 암시적 사용 금지
"strictNullChecks": true, // null/undefined 엄격 검사
"noUncheckedIndexedAccess": true, // 인덱스 접근 undefined 포함
"exactOptionalPropertyTypes": true // optional 프로퍼티 엄격 처리
}
}

정리 표

상황권장 도구이유
객체 구조 정의 (일반)interface가독성, 확장 용이
공개 API 타입interface선언 병합으로 사용자 확장 가능
클래스 계약interfaceimplements 의도 명확
유니온 타입typeinterface로 표현 불가
인터섹션/믹스인type 또는 interface extends상황에 따라 선택
원시 타입 별칭typeinterface로 불가
튜플 타입typeinterface로 표현 어색
조건부/mapped 타입typeinterface로 불가
재귀 타입type더 자연스러운 표현
라이브러리 타입 보강interface (선언 병합)type으로 불가

다음 장에서는...

3.4 인덱스 시그니처와 Record에서는 동적 키를 가진 객체를 타입 안전하게 다루는 방법을 살펴본다. [key: string]: T 인덱스 시그니처와 Record<K, V> 유틸리티 타입의 차이, 그리고 noUncheckedIndexedAccess 컴파일러 옵션으로 런타임 오류를 컴파일 타임에 잡는 방법을 다룬다.

Advertisement