3.1 인터페이스
TypeScript에서 인터페이스(interface)는 객체의 구조(shape)를 정의하는 계약서다. "이 객체는 반드시 이런 프로퍼티들을 가져야 한다"는 규칙을 컴파일러에게 알려준다. 런타임에는 완전히 사라지는 컴파일 타임 전용 구조물이지만, 코드 자동완성·타입 검사·문서화 역할을 동시에 수행하는 TypeScript의 핵심 도구다.
이 장에서는 인터페이스의 기본 문법부터 optional/readonly 프로퍼티, 확장(extends), 선언 병합, 함수·인덱서블 타입까지 실무에서 자주 마주치는 모든 패턴을 다룬다.
인터페이스 기본 문법
interface 키워드 뒤에 이름을 쓰고, 중괄호 안에 프로퍼티와 타입을 나열한다.
interface User {
id: number;
name: string;
email: string;
}
이 인터페이스를 타입 어노테이션으로 사용하면 컴파일러가 구조를 검사한다.
const alice: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
// 오류: 'email' 프로퍼티가 없음
const bob: User = {
id: 2,
name: "Bob",
// email 누락 → 컴파일 오류
};
TypeScript는 구조적 타이핑(structural typing)을 사용하므로, 인터페이스에 정의된 프로퍼티를 모두 포함하면 명시적 선언 없이도 호환된다.
function greet(user: User): string {
return `안녕하세요, ${user.name}님!`;
}
// 인터페이스를 직접 명시하지 않아도 구조가 맞으면 통과
const charlie = { id: 3, name: "Charlie", email: "charlie@example.com", role: "admin" };
greet(charlie); // 추가 프로퍼티가 있어도 정상 (초과 프로퍼티 검사는 객체 리터럴 직접 전달 시에만 적용)
optional 프로퍼티
프로퍼티 이름 뒤에 ?를 붙이면 해당 프로퍼티는 있어도 되고 없어도 된다.
interface UserProfile {
id: number;
name: string;
bio?: string; // 선택 프로퍼티
avatarUrl?: string; // 선택 프로퍼티
}
const user1: UserProfile = { id: 1, name: "Alice" }; // 정상
const user2: UserProfile = { id: 2, name: "Bob", bio: "개발자" }; // 정상
optional 프로퍼티는 타입이 string | undefined가 된다. 접근할 때는 optional chaining이나 타입 가드를 사용한다.
function displayBio(profile: UserProfile): string {
// optional chaining: bio가 undefined면 "없음" 반환
return profile.bio ?? "소개글 없음";
}
function showAvatar(profile: UserProfile): void {
if (profile.avatarUrl !== undefined) {
console.log(`아바타: ${profile.avatarUrl}`);
}
}
readonly 프로퍼티
readonly 키워드를 붙이면 최초 할당 이후 변경이 불가하다. 주로 ID처럼 불변이어야 하는 값에 사용한다.
interface Point {
readonly x: number;
readonly y: number;
}
const origin: Point = { x: 0, y: 0 };
// origin.x = 10; // 오류: 읽기 전용 프로퍼티에 할당 불가
interface Config {
readonly apiKey: string;
readonly baseUrl: string;
timeout?: number; // 타임아웃은 변경 가능
}
const config: Config = {
apiKey: "secret-key-123",
baseUrl: "https://api.example.com",
timeout: 5000,
};
config.timeout = 10000; // 정상: timeout은 readonly가 아님
// config.apiKey = "new"; // 오류: apiKey는 readonly
readonly는 얕은(shallow) 불변성이다. 객체나 배열 내부의 중첩 프로퍼티까지는 보호하지 않는다.
interface AppState {
readonly users: User[];
}
const state: AppState = { users: [] };
// state.users = []; // 오류: users 자체는 변경 불가
state.users.push({ id: 1, name: "Alice", email: "a@b.com" }); // 정상: 배열 내부는 변경 가능
인터페이스 확장 (extends)
extends 키워드로 다른 인터페이스의 프로퍼티를 상속받는다. 코드 중복 없이 타입 계층 구조를 만들 수 있다.
단일 확장
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
const myDog: Dog = {
name: "초코",
age: 3,
breed: "골든 리트리버",
bark() {
console.log("멍멍!");
},
};
다중 확장
TypeScript 인터페이스는 여러 인터페이스를 동시에 확장할 수 있다.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
interface Loggable {
log(message: string): void;
logLevel: "info" | "warn" | "error";
}
interface AuditableEntity extends Serializable, Loggable {
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
class UserRecord implements AuditableEntity {
createdAt = new Date();
updatedAt = new Date();
createdBy = "system";
logLevel: "info" | "warn" | "error" = "info";
serialize(): string {
return JSON.stringify(this);
}
deserialize(data: string): void {
Object.assign(this, JSON.parse(data));
}
log(message: string): void {
console.log(`[${this.logLevel.toUpperCase()}] ${message}`);
}
}
확장 시 충돌하는 프로퍼티 이름이 있고 타입이 다르면 컴파일 오류가 발생한다.
interface A { value: string; }
interface B { value: number; }
// interface C extends A, B {} // 오류: 'value' 타입이 string과 number로 충돌
선언 병합 (Declaration Merging)
같은 이름의 interface를 여러 번 선언하면 TypeScript 컴파일러가 자동으로 합쳐준다. 이는 interface만의 독특한 특성이다.
interface Window {
title: string;
}
interface Window {
url: string;
}
// 두 선언이 자동으로 병합됨
// interface Window { title: string; url: string; }
const win: Window = {
title: "홈",
url: "https://example.com",
};
함수 오버로드 병합
같은 이름 인터페이스에 함수 시그니처를 추가할 때, 나중에 선언한 것이 먼저 검사된다(더 구체적인 오버로드를 나중에 추가하면 우선순위가 높아짐).
interface StringConverter {
convert(value: string): string;
}
interface StringConverter {
convert(value: number): string; // 오버로드 추가
}
// 병합 결과
// interface StringConverter {
// convert(value: number): string; // 나중 선언이 위로
// convert(value: string): string;
// }
함수 타입 인터페이스
인터페이스로 함수의 타입을 정의할 수 있다.
// 호출 시그니처(call signature)로 함수 타입 정의
interface Comparator {
(a: number, b: number): number;
}
const ascending: Comparator = (a, b) => a - b;
const descending: Comparator = (a, b) => b - a;
const numbers = [3, 1, 4, 1, 5, 9, 2, 6];
console.log([...numbers].sort(ascending)); // [1, 1, 2, 3, 4, 5, 6, 9]
console.log([...numbers].sort(descending)); // [9, 6, 5, 4, 3, 2, 1, 1]
프로퍼티와 호출 시그니처를 함께 정의할 수도 있다.
interface HttpClient {
baseUrl: string;
timeout: number;
(url: string, options?: RequestInit): Promise<Response>;
get(url: string): Promise<Response>;
post(url: string, body: unknown): Promise<Response>;
}
인덱서블 타입
[key: 타입]: 값타입 형태로 동적 키를 가진 객체의 구조를 정의한다.
문자열 인덱서
interface StringMap {
[key: string]: string;
}
const headers: StringMap = {
"Content-Type": "application/json",
"Authorization": "Bearer token123",
"X-Request-ID": "abc-123",
};
// 인덱서가 있으면 명시적 프로퍼티도 인덱서 타입과 호환되어야 한다
interface MixedMap {
[key: string]: string | number;
name: string; // 정상: string은 string | number에 포함
count: number; // 정상: number는 string | number에 포함
// active: boolean; // 오류: boolean은 string | number에 미포함
}
숫자 인덱서
배열 유사 객체에는 숫자 인덱서를 사용한다.
interface NumberIndexed {
[index: number]: string;
length: number;
}
const items: NumberIndexed = {
0: "첫 번째",
1: "두 번째",
2: "세 번째",
length: 3,
};
실전 예제: 사용자 관리 시스템
사용자(User), 관리자(Admin), 게스트(Guest) 타입을 인터페이스 계층으로 설계한다.
// 기본 엔티티 인터페이스
interface BaseEntity {
readonly id: string;
readonly createdAt: Date;
updatedAt: Date;
}
// 기본 사용자 인터페이스
interface User extends BaseEntity {
username: string;
email: string;
displayName: string;
avatarUrl?: string;
isActive: boolean;
}
// 권한 인터페이스
interface Permission {
resource: string;
actions: ("read" | "write" | "delete" | "admin")[];
}
// 관리자 인터페이스: User + 관리 권한
interface Admin extends User {
role: "superadmin" | "moderator";
permissions: Permission[];
managedDepartments: string[];
lastLoginAt?: Date;
canManageUser(targetUserId: string): boolean;
}
// 게스트 인터페이스: 제한된 접근
interface Guest {
readonly sessionId: string;
readonly expiresAt: Date;
allowedPages: string[];
referrerUrl?: string;
}
// 시스템 사용자 유니온 (다음 장에서 더 자세히 다룸)
type SystemUser = User | Admin | Guest;
// 유저 저장소 인터페이스
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findAll(filter?: Partial<User>): Promise<User[]>;
create(data: Omit<User, keyof BaseEntity>): Promise<User>;
update(id: string, data: Partial<Omit<User, "id" | "createdAt">>): Promise<User>;
delete(id: string): Promise<boolean>;
}
// 구현 예시
class InMemoryUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
}
async findAll(filter?: Partial<User>): Promise<User[]> {
const all = Array.from(this.users.values());
if (!filter) return all;
return all.filter((user) =>
Object.entries(filter).every(
([key, value]) => user[key as keyof User] === value
)
);
}
async create(data: Omit<User, keyof BaseEntity>): Promise<User> {
const now = new Date();
const user: User = {
...data,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
};
this.users.set(user.id, user);
return user;
}
async update(
id: string,
data: Partial<Omit<User, "id" | "createdAt">>
): Promise<User> {
const existing = this.users.get(id);
if (!existing) throw new Error(`사용자를 찾을 수 없음: ${id}`);
const updated: User = { ...existing, ...data, updatedAt: new Date() };
this.users.set(id, updated);
return updated;
}
async delete(id: string): Promise<boolean> {
return this.users.delete(id);
}
}
// 서비스 레이어
interface UserService {
getUser(id: string): Promise<User>;
registerUser(username: string, email: string, displayName: string): Promise<User>;
deactivateUser(id: string): Promise<void>;
promoteToAdmin(userId: string, role: Admin["role"]): Promise<Admin>;
}
고수 팁: 선언 병합으로 라이브러리 타입 확장
선언 병합의 가장 강력한 실무 활용은 서드파티 라이브러리의 타입을 런타임 수정 없이 확장하는 것이다.
Express Request 확장
// Express를 사용할 때 req.user 타입을 추가하는 방법
// src/types/express/index.d.ts 파일에 작성
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: "user" | "admin";
};
requestId?: string;
startTime?: number;
}
}
}
// 이후 Express 핸들러에서 타입 안전하게 사용 가능
import { Request, Response } from "express";
function authMiddleware(req: Request, res: Response, next: Function): void {
// req.user에 타입 안전하게 접근
if (!req.user) {
res.status(401).json({ error: "인증 필요" });
return;
}
next();
}
Window 객체 확장
// 브라우저 전역 객체에 커스텀 프로퍼티 추가
// src/types/global.d.ts
declare global {
interface Window {
__APP_CONFIG__: {
apiUrl: string;
version: string;
featureFlags: Record<string, boolean>;
};
__ANALYTICS__?: {
track(event: string, properties?: Record<string, unknown>): void;
};
}
}
// 사용 시 타입 안전하게 접근
const apiUrl = window.__APP_CONFIG__.apiUrl;
window.__ANALYTICS__?.track("page_view", { path: location.pathname });
환경 변수 타입 확장
// Node.js 환경 변수에 타입 추가
// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
JWT_SECRET: string;
PORT?: string;
REDIS_URL?: string;
}
}
}
// 사용 시
const port = parseInt(process.env.PORT ?? "3000", 10);
const dbUrl = process.env.DATABASE_URL; // string (undefined 아님)
병합 시 주의: interface만 병합 가능하고 type alias는 불가하다. 그리고 declare global 블록은 모듈 파일(export/import가 있는 파일) 안에서만 작동한다. 아무 export도 없는 파일에서는 declare global 없이 바로 interface 선언을 추가하면 전역으로 병합된다.
정리 표
| 기능 | 문법 | 설명 |
|---|---|---|
| 기본 선언 | interface Foo { prop: Type } | 객체 구조 정의 |
| optional | prop?: Type | 있어도 없어도 됨, Type | undefined |
| readonly | readonly prop: Type | 최초 할당 후 변경 불가 |
| 단일 확장 | interface B extends A | A의 모든 프로퍼티 상속 |
| 다중 확장 | interface C extends A, B | A, B 동시 상속 |
| 선언 병합 | 같은 이름 interface 중복 선언 | 자동으로 프로퍼티 합산 |
| 함수 타입 | (a: T, b: U): V | 호출 시그니처 |
| 인덱서 | [key: string]: T | 동적 키 객체 |
| 클래스 구현 | class Foo implements Bar | 인터페이스를 클래스로 구현 |
다음 장에서는...
3.2 타입 별칭에서는 type 키워드로 정의하는 타입 별칭을 다룬다. 인터페이스와 비슷해 보이지만 유니온(|), 인터섹션(&), 리터럴 타입 등 인터페이스로는 표현하기 어려운 복잡한 타입 구성이 가능하다. 두 도구의 차이를 이해하면 상황에 맞는 올바른 선택을 할 수 있다.