프로덕션 레벨 TypeScript 설계와 패턴
이 챕터는 고급 내용입니다. 처음 보시는 분은 가볍게 훑어보고, Chapter 1~11을 충분히 익힌 뒤 다시 돌아오세요!
Design Patterns Type Safety Best Practices Production
엄격한 tsconfig.json으로 시작하세요
{
"compilerOptions": {
// 엄격 모드 - 반드시 활성화! 타입 실수를 사전에 방지
"strict": true,
"noUncheckedIndexedAccess": true, // 배열/객체 접근 시 undefined 가능성 체크
"noImplicitOverride": true, // 부모 메서드 재정의 시 override 키워드 필수
"exactOptionalPropertyTypes": true, // 선택적 속성의 타입을 더 정확하게 체크
// 모듈 설정
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"isolatedModules": true,
// 출력 설정
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
// 코드 품질
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
// 경로 별칭
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@models/*": ["src/models/*"],
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
"strict": true 하나로 strictNullChecks, noImplicitAny, strictFunctionTypes 등 7개 옵션이 활성화됩니다.
// API 엔드포인트 스키마(구조)를 타입으로 미리 정의
// 각 URL별로 어떤 HTTP 메서드를 지원하고, 요청/응답 타입이 무엇인지 명시
interface ApiSchema {
'/users': {
GET: { response: User[]; query: { page?: number; limit?: number } };
POST: { response: User; body: CreateUserDto };
};
'/users/:id': {
GET: { response: User; params: { id: string } };
PUT: { response: User; params: { id: string }; body: UpdateUserDto };
DELETE: { response: void; params: { id: string } };
};
'/posts': {
GET: { response: Post[]; query: { authorId?: number } };
POST: { response: Post; body: CreatePostDto };
};
}
// 엔드포인트에서 사용 가능한 HTTP 메서드 추출
type ApiMethod<Path extends keyof ApiSchema> = keyof ApiSchema[Path];
// 특정 엔드포인트+메서드의 응답 타입 자동 추출
type ApiResponse<
Path extends keyof ApiSchema,
Method extends ApiMethod<Path>
> = ApiSchema[Path][Method] extends { response: infer R } ? R : never;
API 스키마를 한 곳에서 정의하면, 클라이언트 코드 전체에서 엔드포인트/메서드/요청/응답 타입이 자동으로 강제됩니다.
class TypedApiClient {
constructor(private baseUrl: string) {} // API 서버 주소 저장
// GET 요청 - Path에 따라 query 타입과 응답 타입이 자동 결정됨
async get<Path extends keyof ApiSchema>(
path: Path, // '/users' 같은 API 경로
options?: ApiSchema[Path] extends { GET: { query: infer Q } }
? { query?: Q } // 해당 경로의 GET 쿼리 파라미터 타입
: never
): Promise<ApiResponse<Path, 'GET'>> { // 해당 경로의 GET 응답 타입
const url = new URL(`${this.baseUrl}${path as string}`);
if (options && 'query' in options) {
const query = options.query as Record<string, string>;
Object.entries(query).forEach(([k, v]) =>
url.searchParams.set(k, String(v))
);
}
const res = await fetch(url.toString());
return res.json();
}
async post<Path extends keyof ApiSchema>(
path: Path,
body: ApiSchema[Path] extends { POST: { body: infer B } } ? B : never
): Promise<ApiResponse<Path, 'POST'>> {
const res = await fetch(`${this.baseUrl}${path as string}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
}
const api = new TypedApiClient('https://api.example.com');
// 자동 완성과 타입 검증이 작동!
const users = await api.get('/users', { query: { page: 1 } });
// ^? User[]
const newPost = await api.post('/posts', {
title: '새 글', // CreatePostDto 타입 강제
content: '내용',
});
// ^? Post
빌더 패턴이란: 복잡한 객체를 한 번에 만들지 않고, 단계별로 조립하여 생성하는 디자인 패턴입니다. 레고 블록을 하나씩 끼워 맞추듯, 메서드를 체이닝하여 객체를 완성합니다.
interface QueryConfig {
table: string;
fields: string[];
conditions: string[];
orderBy?: string;
limit?: number;
}
class QueryBuilder {
private config: Partial<QueryConfig> = {}; // 설정을 점진적으로 채움
from(table: string): this { // 1단계: 테이블 지정
this.config.table = table;
return this; // this를 반환하여 체이닝 가능
}
select(...fields: string[]): this { // 2단계: 조회할 필드 지정
this.config.fields = fields;
return this;
}
where(condition: string): this { // 3단계: 조건 추가 (여러 번 호출 가능)
if (!this.config.conditions) this.config.conditions = [];
this.config.conditions.push(condition);
return this;
}
orderBy(field: string): this { // 4단계: 정렬 기준 (선택사항)
this.config.orderBy = field;
return this;
}
limit(count: number): this { // 5단계: 결과 개수 제한 (선택사항)
this.config.limit = count;
return this;
}
build(): string { // 최종: 모은 설정으로 SQL 생성
const { table, fields, conditions, orderBy, limit } = this.config;
if (!table || !fields?.length) {
throw new Error('table과 fields는 필수입니다.');
}
let sql = `SELECT ${fields.join(', ')} FROM ${table}`;
if (conditions?.length) sql += ` WHERE ${conditions.join(' AND ')}`;
if (orderBy) sql += ` ORDER BY ${orderBy}`;
if (limit) sql += ` LIMIT ${limit}`;
return sql;
}
}
const query = new QueryBuilder()
.from('users')
.select('id', 'name', 'email')
.where('age > 18')
.where('active = true')
.orderBy('name')
.limit(10)
.build();
// SELECT id, name, email FROM users WHERE age > 18 AND active = true ORDER BY name LIMIT 10
상태 머신이란: 객체가 가질 수 있는 모든 상태와 상태 간 전이 규칙을 미리 정의하는 패턴입니다. 예를 들어 주문은 "대기 → 확정 → 배송 → 완료" 순서로만 진행되어야 합니다.
// 주문 상태를 discriminated union(구별된 유니온)으로 정의
// 각 상태마다 가지고 있는 데이터가 다릅니다
type OrderState =
| { status: 'pending'; createdAt: Date }
| { status: 'confirmed'; confirmedAt: Date; estimatedDelivery: Date }
| { status: 'shipped'; trackingNumber: string; shippedAt: Date }
| { status: 'delivered'; deliveredAt: Date; signature: string }
| { status: 'cancelled'; cancelledAt: Date; reason: string };
// 상태 전이 함수 - 유효한 전이만 허용 (pending에서만 confirm 가능)
function confirmOrder(
order: Extract<OrderState, { status: 'pending' }>
): Extract<OrderState, { status: 'confirmed' }> {
return {
status: 'confirmed',
confirmedAt: new Date(),
estimatedDelivery: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
};
}
function cancelOrder(
order: Extract<OrderState, { status: 'pending' | 'confirmed' }>,
reason: string
): Extract<OrderState, { status: 'cancelled' }> {
return {
status: 'cancelled',
cancelledAt: new Date(),
reason,
};
}
// 상태별 렌더링 - 완전한 패턴 매칭
function renderOrder(order: OrderState): string {
switch (order.status) {
case 'pending': return `주문 대기 중 (${order.createdAt})`;
case 'confirmed': return `확정됨. 배송 예정: ${order.estimatedDelivery}`;
case 'shipped': return `배송 중: ${order.trackingNumber}`;
case 'delivered': return `배송 완료. 서명: ${order.signature}`;
case 'cancelled': return `취소됨: ${order.reason}`;
} // exhaustive - 빠진 상태가 있으면 컴파일 에러!
}
// 이벤트 맵 정의 - 이벤트 이름과 전달할 데이터 타입을 연결
interface AppEvents {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; title: string };
'error': { code: number; message: string };
}
// 제네릭 이벤트 이미터 - 이벤트 이름에 맞는 데이터 타입을 자동 강제
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners = new Map<string, Set<Function>>(); // 이벤트별 리스너 저장
// 이벤트 구독 - 특정 이벤트 발생 시 실행할 함수 등록
on<E extends keyof Events>(
event: E, // 이벤트 이름
listener: (data: Events[E]) => void // 해당 이벤트의 데이터 타입에 맞는 콜백
): () => void {
const key = event as string;
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(listener);
// 구독 해제 함수 반환 (호출하면 리스너 제거)
return () => this.listeners.get(key)?.delete(listener);
}
// 이벤트 발행 - 등록된 모든 리스너에게 데이터 전달
emit<E extends keyof Events>(event: E, data: Events[E]): void {
const key = event as string;
this.listeners.get(key)?.forEach(listener => listener(data));
}
}
const emitter = new TypedEventEmitter<AppEvents>();
// 자동 완성: 이벤트 이름과 데이터 타입이 연결됨
emitter.on('user:login', (data) => {
console.log(data.userId); // OK: string
console.log(data.timestamp); // OK: Date
// console.log(data.name); // 컴파일 에러!
});
emitter.emit('post:created', {
postId: '123',
title: '새 글',
// content: '...' // 컴파일 에러! AppEvents에 없는 필드
});
리포지토리 패턴이란: 데이터를 저장하고 조회하는 로직을 하나의 인터페이스로 추상화하는 패턴입니다. 실제 저장소가 DB인지, 파일인지, API인지 상관없이 동일한 방식으로 데이터에 접근할 수 있습니다.
// 기본 엔티티 (모든 데이터가 공통으로 가지는 필드)
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// 제네릭 Repository 인터페이스 - 어떤 데이터 타입이든 CRUD 가능
interface Repository<T extends BaseEntity> {
findById(id: string): Promise<T | null>; // 하나 조회
findAll(filter?: Partial<T>): Promise<T[]>; // 목록 조회
create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>; // 생성
update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T>; // 수정
delete(id: string): Promise<boolean>; // 삭제
count(filter?: Partial<T>): Promise<number>; // 개수 조회
}
// 구체적 엔티티
interface User extends BaseEntity {
name: string;
email: string;
role: 'admin' | 'user';
}
// 구현
class UserRepository implements Repository<User> {
async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) {
// data는 { name: string; email: string; role: 'admin' | 'user' } 타입
const user: User = {
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
// DB에 저장하는 로직...
return user;
}
// ... 나머지 메서드 구현
}
예외 대신 타입으로 성공/실패를 표현하는 패턴
// Result 타입 정의 - 성공 또는 실패를 타입으로 표현
// try/catch 대신 반환값으로 에러를 전달하여, 에러 처리를 빼먹을 수 없게 함
type Result<T, E = Error> =
| { ok: true; value: T } // 성공: 값을 담고 있음
| { ok: false; error: E }; // 실패: 에러 정보를 담고 있음
// 성공 결과 생성 함수
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
// 실패 결과 생성 함수
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// 에러 타입 정의
type ValidationError = { field: string; message: string };
type NotFoundError = { entity: string; id: string };
type UserError = ValidationError | NotFoundError;
function parseAge(input: string): Result<number, ValidationError> {
const age = parseInt(input, 10);
if (isNaN(age)) {
return Err({ field: 'age', message: '숫자가 아닙니다' });
}
if (age < 0 || age > 150) {
return Err({ field: 'age', message: '유효한 나이가 아닙니다' });
}
return Ok(age);
}
// 사용
const result = parseAge('25');
if (result.ok) {
console.log(result.value); // number - 안전하게 접근
} else {
console.log(result.error.message); // string - 에러 정보
}
// Result에 변환/체이닝 메서드 추가
class ResultHelper {
// map: 성공 값을 변환 (실패면 그대로 통과)
static map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) return Ok(fn(result.value));
return result; // 실패면 에러를 그대로 전달
}
// flatMap: 성공 값으로 다음 Result를 생성 (체이닝)
static flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) return fn(result.value);
return result; // 실패면 에러를 그대로 전달
}
// unwrapOr: 성공이면 값, 실패면 기본값 반환
static unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.ok ? result.value : defaultValue;
}
}
// 실전 사용: 검증 체이닝
function validateUser(input: unknown): Result<User, ValidationError> {
const nameResult = validateName(input);
const emailResult = ResultHelper.flatMap(
nameResult,
(name) => validateEmail(input).ok
? Ok({ ...input, name } as User)
: validateEmail(input)
);
return emailResult;
}
// 에러가 throw되지 않음 - 컴파일러가 모든 경로를 강제
const userResult = validateUser(rawInput);
const userName = ResultHelper.unwrapOr(
ResultHelper.map(userResult, u => u.name),
'알 수 없는 사용자'
);
같은 string이나 number지만 의미적으로 구분되어야 하는 값을 타입으로 보호합니다. 예: UserId와 PostId는 둘 다 string이지만 절대 혼용하면 안 됩니다.
// Brand 유틸리티 타입 - 원래 타입에 "브랜드 태그"를 추가
type Brand<T, B> = T & { readonly __brand: B };
// 도메인 타입 정의 - 같은 string/number지만 서로 다른 "브랜드"
type UserId = Brand<string, 'UserId'>; // string이지만 UserId 전용
type PostId = Brand<string, 'PostId'>; // string이지만 PostId 전용
type Email = Brand<string, 'Email'>; // string이지만 Email 전용
type KRW = Brand<number, 'KRW'>; // number이지만 원화 전용
type USD = Brand<number, 'USD'>; // number이지만 달러 전용
// 생성 함수 (검증 포함) - 유효한 값만 브랜드 타입으로 변환
function createUserId(id: string): UserId {
if (!id.startsWith('user_')) throw new Error('Invalid user ID');
return id as UserId;
}
function createEmail(email: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error('Invalid email');
}
return email as Email;
}
function createKRW(amount: number): KRW {
return Math.round(amount) as KRW; // 원화는 정수
}
// 타입 시스템이 혼용을 방지!
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = createUserId('user_abc');
const postId = 'post_123' as PostId;
getUser(userId); // OK
getUser(postId); // 컴파일 에러! PostId는 UserId가 아님
getUser('abc'); // 컴파일 에러! string은 UserId가 아님
// 통화 혼용 방지
function payInKRW(amount: KRW): void { /* ... */ }
const priceKRW = createKRW(50000);
const priceUSD = 35.99 as USD;
payInKRW(priceKRW); // OK
payInKRW(priceUSD); // 컴파일 에러! USD는 KRW가 아님
Redux 스타일의 타입 안전한 액션/리듀서 패턴
// 액션 타입 정의 - "앱에서 발생할 수 있는 모든 행동" 목록
type Action =
| { type: 'SET_USER'; payload: User } // 사용자 설정
| { type: 'SET_LOADING'; payload: boolean } // 로딩 상태 변경
| { type: 'ADD_TODO'; payload: { text: string } } // 할 일 추가
| { type: 'TOGGLE_TODO'; payload: { id: number } } // 완료/미완료 토글
| { type: 'REMOVE_TODO'; payload: { id: number } }; // 할 일 삭제
// 상태 타입 - 앱의 전체 상태를 하나의 객체로 관리
interface AppState {
user: User | null;
loading: boolean;
todos: Array<{ id: number; text: string; done: boolean }>;
}
const initialState: AppState = {
user: null,
loading: false,
todos: [],
};
// 리듀서 - 모든 액션의 payload 타입이 자동 추론
function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
// ^? User (자동 추론)
case 'SET_LOADING':
return { ...state, loading: action.payload };
// ^? boolean
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload.text, // ^? { text: string }
done: false,
}],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload.id ? { ...t, done: !t.done } : t
),
};
case 'REMOVE_TODO':
return {
...state,
todos: state.todos.filter(t => t.id !== action.payload.id),
};
}
}
// 타입 안전한 액션 크리에이터 생성기
// 액션 타입 이름을 넣으면, 해당 payload 타입에 맞는 생성 함수를 반환
function createAction<T extends Action['type']>(type: T) {
return (
payload: Extract<Action, { type: T }>['payload'] // 해당 타입의 payload만 허용
): Extract<Action, { type: T }> => {
return { type, payload } as Extract<Action, { type: T }>;
};
}
// 액션 크리에이터 정의
const setUser = createAction('SET_USER');
const setLoading = createAction('SET_LOADING');
const addTodo = createAction('ADD_TODO');
const toggleTodo = createAction('TOGGLE_TODO');
// 사용 - payload 타입이 자동으로 검증됨!
const action1 = setUser({ id: '1', name: '홍길동', email: 'a@b.com' });
const action2 = setLoading(true);
const action3 = addTodo({ text: 'TypeScript 공부' });
const action4 = toggleTodo({ id: 1 });
// 타입 에러!
setLoading('true'); // boolean이어야 함
addTodo({ title: '...' }); // text 필드가 필요
toggleTodo({ id: '1' }); // number이어야 함
createSlice에서 내부적으로 사용되는 원리와 동일합니다.
// 자체 타입 포함
import axios from 'axios';
// package.json에
// "types": "./index.d.ts"
// 별도 @types 필요
import express from 'express';
// npm i -D @types/express
// 방법 1: declare module
declare module 'old-lib' {
export function doStuff(
input: string
): number;
}
// 방법 2: 임시 any 처리
declare module 'untyped-lib';
// 모든 import가 any
// 방법 3: 래퍼를 만들어 타입 안전성 확보
// wrappers/old-lib.ts
import oldLib from 'old-lib'; // any
interface OldLibApi {
process(data: string[]): Promise<ProcessResult>;
configure(options: OldLibOptions): void;
}
export function createOldLib(): OldLibApi {
return {
process: (data) => oldLib.process(data),
configure: (options) => oldLib.configure(options),
};
}
// 이제 앱 코드에서는 타입이 보장되는 래퍼만 사용
any는 경계(boundary)에만 두고, 내부 코드는 항상 타입 안전하게 유지하세요.
점진적으로 TypeScript를 도입하는 실전 전략
| 단계 | 작업 | 설정 |
|---|---|---|
| 1단계 | tsconfig 추가, allowJs 활성화 | "allowJs": true, "strict": false |
| 2단계 | .js를 .ts로 리네이밍 (에러 무시) | // @ts-ignore 또는 // @ts-expect-error |
| 3단계 | 핵심 모듈부터 타입 추가 | shared types, API 계층 우선 |
| 4단계 | strict 모드 점진적 활성화 | "strict": true, 옵션 하나씩 |
// 1단계 tsconfig.json
{
"compilerOptions": {
"allowJs": true, // JS 파일 허용
"checkJs": false, // JS 파일 타입 체크 안 함
"strict": false, // 느슨하게 시작
"noImplicitAny": false, // any 허용
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
// @ts-expect-error로 임시 무시한 곳은 TODO 주석과 함께 기록하세요. 나중에 검색하여 점진적으로 수정합니다.
// tsconfig.json - 빌드 속도 향상
{
"compilerOptions": {
"incremental": true, // 증분 컴파일 (변경된 부분만 다시 컴파일)
"tsBuildInfoFile": ".tsbuildinfo", // 빌드 캐시 파일 위치
"skipLibCheck": true, // .d.ts 체크 건너뛰기 (빌드 속도 향상)
"isolatedModules": true // 파일 단위 컴파일 가능 (번들러 호환)
}
}
| 패턴 | 비용 | 대안 |
|---|---|---|
enum (숫자/문자열) |
런타임 코드 생성 | as const 객체 또는 union 타입 |
namespace |
IIFE 래퍼 생성 | ES Modules |
과도한 class |
prototype 체인 오버헤드 | 인터페이스 + 함수 |
| 데코레이터 메타데이터 | reflect-metadata 런타임 | 필요할 때만 사용 |
interface, type, type assertion은 런타임 비용이 0입니다.
// 이 모든 타입 코드는 컴파일 후 완전히 사라집니다!
// 런타임 (Runtime, 프로그램 실행 시점) 비용: 0
interface Config {
apiUrl: string;
timeout: number;
retries: number;
}
type ReadonlyConfig = Readonly<Config>;
type PartialConfig = Partial<Config>;
type ConfigKeys = keyof Config;
function processConfig<T extends Config>(config: T): T {
return config; // 컴파일 후: function processConfig(config) { return config; }
}
// 반면, 이것들은 런타임 코드를 생성합니다
// enum -> 런타임 객체 생성
enum Direction { Up, Down, Left, Right }
// 대안: as const (런타임 비용 최소)
const Direction2 = {
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT',
} as const;
type Direction2 = typeof Direction2[keyof typeof Direction2];
// 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'
interface와 type을 최대한 활용하세요. 타입 시스템이 강력할수록, 런타임 검증 코드는 줄어듭니다.
이 챕터의 패턴들이 어렵게 느껴졌다면 정상입니다! Chapter 1~8의 기초가 탄탄해지면 자연스럽게 이해됩니다.
strict: true를 항상 사용하세요any를 피하고, unknown을 사용하세요