TypeScript 5.x 실전 타입 패턴 — 유지보수 비용을 줄이는 방법
프로젝트가 커질수록 타입스크립트의 any가 전염병처럼 퍼진다. 6개월 전만 해도 깨끗했던 코드가, 지금은 어디서 any가 터져 나올지 모른다. 나는 지난 18개월간 50만 줄 규모의 TypeScript 프로젝트를 유지보수하면서 배운 패턴들을 공유한다.
문제: any가 전염병처럼 퍼진다
any를 쓰는 이유는 대부분 이렇다:
"지금은 시간이 없다. 나중에 고치자."
하지만 "나중에"는 절대 오지 않는다. 이 any는 다음 개발자에게 영향을 준다. 그 개발자도 any를 전파한다. 몇 개월 후 코드베이스는 any로 가득 찬다.
우리 팀의 실제 사례:
- Month 1: 5개의 any
- Month 3: 42개의 any
- Month 6: 187개의 any
- Month 12: any 개수를 세는 게 의미 없어짐 (어디서나 나타남)
any를 줄이려면 처음부터 좋은 타입 패턴을 써야 한다.
패턴 1: Discriminated Union으로 상태 관리하기
많은 API 응답 타입은 이렇게 정의된다:
interface ApiResponse {
success: boolean;
data?: any;
error?: any;
}
이건 최악이다. success가 true일 때 data는 항상 존재하고, false일 때 error가 존재한다. 하지만 타입스크립트는 이걸 알 수 없다.
Discriminated Union을 쓰자:
interface SuccessResponse<T> {
success: true;
data: T;
}
interface ErrorResponse {
success: false;
error: {
code: string;
message: string;
};
}
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// 사용처에서:
function handleResponse<T>(response: ApiResponse<T>) {
if (response.success) {
// 여기서 response.data는 안전하게 T 타입
console.log(response.data);
} else {
// 여기서 response.error는 안전하게 에러 객체
console.error(response.error.message);
}
}
차이는 다음과 같다:
RuntimeError: Cannot read property 'data' of undefined
vs
TypeScript compile error: Property 'data' does not exist on type 'ErrorResponse'
전자는 프로덕션에서 터진다. 후자는 개발 중에 잡힌다.
패턴 2: Template Literal Types로 문자열 상수 강제
이벤트 이름이나 상태 문자열에 any를 쓰는 경우가 많다:
// ❌ 나쁨
const eventName = 'user_login'; // 문자열은 아무거나 가능
emit(eventName);
// 2개월 후
const eventName = 'user-login'; // 오타, 이제 이벤트가 안 들음
emit(eventName);
Template Literal Types로 정확한 형식을 강제하자:
type EventName =
| `user:${string}:${string}`
| `session:${string}`
| `payment:${string}:success`
| `payment:${string}:failed`;
const emit = (name: EventName) => { /* ... */ };
emit('user:login:success'); // ✅ OK
emit('user:logout:success'); // ✅ OK
emit('payment:123:success'); // ✅ OK
emit('payment:123:failed'); // ✅ OK
emit('user-login-success'); // ❌ Type error
API 상태 코드도 마찬가지:
type HttpStatus =
| `2${number}${number}` // 2xx 성공
| `3${number}${number}` // 3xx 리다이렉트
| `4${number}${number}` // 4xx 클라이언트 에러
| `5${number}${number}`; // 5xx 서버 에러
const statusCode: HttpStatus = 200; // ✅ OK
const statusCode2: HttpStatus = 201; // ✅ OK
const statusCode3: HttpStatus = 600; // ❌ Type error
패턴 3: satisfies 연산자로 타입 검증
TypeScript 4.9에서 추가된 satisfies는 "이 값이 이 타입을 만족하는가?"를 체크한다:
interface Config {
api: {
baseUrl: string;
timeout: number;
};
logging: {
level: 'debug' | 'info' | 'warn' | 'error';
};
}
const config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
logging: {
level: 'verbose', // ❌ Type error with satisfies
},
} satisfies Config;
satisfies를 안 쓰면?
// ❌ 타입 에러를 못 잡음
const config: Config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
logging: {
level: 'verbose',
},
};
또는 아예 타입을 정의 안 했으면?
// ❌ 완전한 어둠
const config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
logging: {
level: 'verbose',
},
};
satisfies는 자동 완성도 제공한다. VS Code가 여정 중에 값의 형태를 안다.
패턴 4: Conditional Types로 복잡한 타입 관계 표현
API 응답이 입력에 따라 달라지는 경우가 있다:
type ApiResponse<T> =
T extends { includeMetadata: true }
? { data: string; metadata: Record<string, unknown> }
: { data: string };
const response1 = null as ApiResponse<{ includeMetadata: true }>;
// response1.metadata는 안전하게 접근 가능
const response2 = null as ApiResponse<{ includeMetadata: false }>;
// response2.metadata는 타입 에러
이 패턴은 React의 제네릭 컴포넌트에서도 유용하다:
interface TableProps<T extends boolean = false> {
data: string[];
selectable: T;
onSelectionChange?: T extends true
? (selected: string[]) => void
: never;
}
// selectable={true}를 넘기면 onSelectionChange가 필수
// selectable={false}를 넘기면 onSelectionChange는 제공하면 타입 에러
패턴 5: 제너릭 제약(Generic Constraints)으로 타입 범위 좁히기
제너릭은 강력하지만 너무 열려있다:
// ❌ 너무 넓음
function getValue<T>(obj: T, key: string): any {
return obj[key];
}
// ✅ 더 좋음
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'John', age: 30 };
const name = getValue(user, 'name'); // string 타입으로 추론됨
const age = getValue(user, 'age'); // number 타입으로 추론됨
getValue(user, 'email'); // ❌ Type error, 'email'은 user에 없음
API 클라이언트에도 적용할 수 있다:
interface ApiMethods {
'GET /users': { response: User[] };
'POST /users': { request: User; response: User };
'DELETE /users/:id': { response: { success: boolean } };
}
function apiCall<T extends keyof ApiMethods>(
method: T,
data?: ApiMethods[T]['request']
): ApiMethods[T]['response'] {
// 구현...
return null as any;
}
// 사용처
const users = apiCall('GET /users'); // User[] 타입
apiCall('POST /users', { name: 'John' }); // User 타입 반환
// apiCall('GET /users', { ... }); // ❌ Type error, POST가 아님
패턴 6: infer로 타입 추출하기
Promise의 내부 타입을 뽑아내는 경우:
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // Promise<number>
배열의 요소 타입을 뽑아내기:
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type StringArray = ArrayElement<string[]>; // string
type NumberArray = ArrayElement<number[]>; // number
이 패턴을 쓰면 any를 완전히 제거할 수 있다.
실전 팁: 타입 가드(Type Guard) 함수
런타임에 타입을 확인하는 함수를 만들자:
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'email' in obj &&
typeof obj.name === 'string' &&
typeof obj.email === 'string'
);
}
function processData(data: unknown) {
if (isUser(data)) {
// 여기서 data는 안전하게 User 타입
console.log(data.name);
}
}
팀 규칙: any를 제거하자
우리 팀의 코드 리뷰 규칙:
1. any를 쓴 코드는 머지 불가
2. any가 필요하면 // @ts-ignore 대신 as 타입으로 최소화
3. any가 있는 함수는 주석으로 이유를 명시
4. 월 1회 any 제거 리팩토링 스프린트
이 규칙 덕분에 우리 프로젝트의 any 밀도는 오히려 매달 0.5% 감소하고 있다.
결론
TypeScript 5.x는 충분히 강력하다. any를 줄이는 건 거래가 아니라 투자다. 초기에 타입을 잘 정의하면, 3개월 후에는 리팩토링 비용이 절반 이하로 줄어든다.
오랜 개발 경험에서 배운 게 있다면: 컴파일 타임 에러 하나는 프로덕션 런타임 버그 열 개보다 낫다.