TypeScript 실전 베스트 프랙티스
TypeScript를 실무 프로젝트에서 효과적으로 사용하기 위한 핵심 패턴과 모범 사례를 정리합니다. 타입 안전성을 높이고 유지보수성을 개선하는 방법을 알아봅니다.
TypeScript 실전 베스트 프랙티스
TypeScript는 JavaScript에 정적 타입 시스템을 추가한 언어로, 대규모 프로젝트에서 코드의 안정성과 개발 생산성을 크게 향상시킵니다. 이 글에서는 실무에서 바로 적용할 수 있는 TypeScript 베스트 프랙티스를 체계적으로 정리합니다.
엄격한 타입 설정으로 시작하기
TypeScript 프로젝트를 시작할 때 가장 먼저 해야 할 일은 tsconfig.json에서 엄격 모드를 활성화하는 것입니다. 처음부터 엄격하게 설정하면 나중에 수정할 코드가 줄어듭니다.
권장 tsconfig.json 설정
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
strict: true는 다음 옵션들을 모두 활성화합니다.
strictNullChecks: null과 undefined를 명시적으로 처리해야 합니다.strictFunctionTypes: 함수 타입의 매개변수를 엄격히 검사합니다.strictBindCallApply: bind, call, apply의 인자 타입을 검사합니다.noImplicitAny: 암시적 any 타입을 허용하지 않습니다.noImplicitThis: this의 타입이 불명확한 경우 에러를 발생시킵니다.
any 타입 사용 피하기
any 타입은 TypeScript의 타입 시스템을 완전히 우회합니다. 가능한 한 사용을 피하고, 대안을 찾아야 합니다.
any 대신 사용할 수 있는 타입들
// 나쁜 예: any 사용
function processData(data: any): any {
return data.value;
}
// 좋은 예 1: unknown 사용 (타입 좁히기 필요)
function processData(data: unknown): string {
if (typeof data === 'object' && data !== null && 'value' in data) {
return String((data as { value: unknown }).value);
}
throw new Error('Invalid data format');
}
// 좋은 예 2: 제네릭 사용
function processData<T extends { value: string }>(data: T): string {
return data.value;
}
// 좋은 예 3: 구체적인 타입 정의
interface DataPayload {
value: string;
timestamp: number;
}
function processData(data: DataPayload): string {
return data.value;
}
unknown 타입은 any와 비슷하게 모든 값을 할당할 수 있지만, 사용하기 전에 반드시 타입을 좁혀야 합니다. 이것이 훨씬 안전한 접근 방식입니다.
유니온 타입과 판별 유니온 활용
유니온 타입은 TypeScript의 가장 강력한 기능 중 하나입니다. 특히 판별 유니온(Discriminated Union)은 복잡한 상태를 타입 안전하게 다룰 때 매우 유용합니다.
판별 유니온 패턴
// API 응답 상태를 판별 유니온으로 모델링
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number };
function handleResponse<T>(response: ApiResponse<T>) {
switch (response.status) {
case 'loading':
console.log('로딩 중...');
break;
case 'success':
// TypeScript가 여기서 data 속성이 있음을 알고 있음
console.log('성공:', response.data);
break;
case 'error':
// TypeScript가 여기서 error와 code 속성이 있음을 알고 있음
console.error(`에러 ${response.code}: ${response.error}`);
break;
}
}
상태 머신 모델링
type ConnectionState =
| { state: 'disconnected' }
| { state: 'connecting'; attempt: number }
| { state: 'connected'; sessionId: string }
| { state: 'disconnecting'; reason: string };
function getStatusMessage(conn: ConnectionState): string {
switch (conn.state) {
case 'disconnected':
return '연결이 해제되었습니다.';
case 'connecting':
return `연결 시도 중... (${conn.attempt}번째)`;
case 'connected':
return `연결됨 (세션: ${conn.sessionId})`;
case 'disconnecting':
return `연결 해제 중: ${conn.reason}`;
}
}
제네릭을 효과적으로 사용하기
제네릭은 재사용 가능한 컴포넌트를 만들 때 필수적입니다. 하지만 과도하게 복잡한 제네릭은 오히려 코드 가독성을 해칠 수 있으므로 적절한 수준을 유지해야 합니다.
제네릭 활용 패턴
// 제네릭 함수: 배열에서 특정 조건의 첫 번째 요소 찾기
function findFirst<T>(
items: T[],
predicate: (item: T) => boolean
): T | undefined {
return items.find(predicate);
}
// 제네릭 인터페이스: 페이지네이션 응답
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
// 제네릭 제약 조건: id 속성이 있는 타입으로 제한
function findById<T extends { id: string | number }>(
items: T[],
id: T['id']
): T | undefined {
return items.find(item => item.id === id);
}
// 제네릭 유틸리티: 객체의 특정 키만 선택
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
제네릭 사용 시 주의사항
제네릭을 사용할 때 몇 가지 원칙을 지키면 좋습니다.
- 필요한 만큼만 사용하세요: 제네릭 타입 파라미터가 한 번만 사용된다면 제네릭이 불필요할 수 있습니다.
- 의미 있는 이름을 사용하세요:
T대신TItem,TResponse같은 서술적 이름이 복잡한 경우 더 명확합니다. - 제약 조건을 적극 활용하세요:
extends를 통해 타입 파라미터의 범위를 제한하면 안전성이 높아집니다.
타입 좁히기(Type Narrowing) 마스터하기
TypeScript의 타입 좁히기는 넓은 타입을 더 구체적인 타입으로 좁혀나가는 과정입니다. 이를 잘 활용하면 타입 단언(as) 없이도 안전한 코드를 작성할 수 있습니다.
다양한 타입 좁히기 기법
// typeof 가드
function formatValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase();
}
if (typeof value === 'number') {
return value.toFixed(2);
}
return value ? '예' : '아니오';
}// instanceof 가드 function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } if (typeof error === 'string') { return error; } return '알 수 없는 에러가 발생했습니다.'; }
// 사용자 정의 타입 가드 interface Fish { swim: () => void; }
interface Bird { fly: () => void; }
function isFish(animal: Fish | Bird): animal is Fish { return 'swim' in animal; }
function move(animal: Fish | Bird) { if (isFish(animal)) { animal.swim(); // Fish로 좁혀짐 } else { animal.fly(); // Bird로 좁혀짐 } }
## 유틸리티 타입 활용
TypeScript는 다양한 내장 유틸리티 타입을 제공합니다. 이를 잘 활용하면 새로운 타입을 쉽게 만들 수 있습니다.
### 자주 사용하는 유틸리티 타입
interface User { id: number; name: string; email: string; password: string; createdAt: Date; updatedAt: Date; }
// Partial: 모든 속성을 선택적으로 type UpdateUserDto = Partial
// Pick: 특정 속성만 선택 type UserSummary = Pick
// Omit: 특정 속성 제외 type PublicUser = Omit
// Record: 키-값 맵 정의 type UserRoles = Record
// Required: 모든 속성을 필수로 type RequiredConfig = Required
// Readonly: 모든 속성을 읽기 전용으로 type FrozenUser = Readonly
// ReturnType: 함수의 반환 타입 추출 type ApiResult = ReturnType
### 커스텀 유틸리티 타입 만들기
// DeepPartial: 중첩 객체도 모두 선택적으로 type DeepPartial
// NonNullableFields: 모든 필드에서 null/undefined 제거 type NonNullableFields
// 조건부 타입: 특정 조건에 따라 타입 결정 type IsString
## 에러 처리 패턴
TypeScript에서 에러를 타입 안전하게 처리하는 패턴을 알아봅니다.
### Result 패턴
type Result
function divide(a: number, b: number): Result
const result = divide(10, 0); if (result.ok) { console.log('결과:', result.value); } else { console.error('에러:', result.error); }
## 타입 단언보다 타입 가드를 선호하기
타입 단언(`as`)은 TypeScript 컴파일러의 타입 검사를 무시하는 것이므로, 가능하면 타입 가드를 사용하여 안전하게 타입을 좁히는 것이 좋습니다.
// 나쁜 예: 타입 단언 남용 const input = document.getElementById('username') as HTMLInputElement; input.value = 'test'; // getElementById가 null을 반환할 수 있음
// 좋은 예: 타입 가드 사용 const input = document.getElementById('username'); if (input instanceof HTMLInputElement) { input.value = 'test'; } else { throw new Error('username 입력 필드를 찾을 수 없습니다.'); }
## const assertions와 as const
`as const`를 사용하면 리터럴 타입으로 좁혀지고, 객체와 배열이 읽기 전용이 됩니다. 설정값이나 상수 정의에 매우 유용합니다.
// as const 없이 const COLORS = { primary: '#3B82F6', secondary: '#10B981', }; // 타입: { primary: string; secondary: string }
// as const 사용 const COLORS = { primary: '#3B82F6', secondary: '#10B981', } as const; // 타입: { readonly primary: "#3B82F6"; readonly secondary: "#10B981" }
// 튜플에서의 활용 const ENDPOINTS = ['/api/users', '/api/posts', '/api/comments'] as const; type Endpoint = typeof ENDPOINTS[number]; // "/api/users" | "/api/posts" | "/api/comments"
## 마무리
TypeScript를 효과적으로 사용하기 위한 핵심은 타입 시스템을 적극적으로 활용하는 것입니다. `any`를 피하고, 제네릭과 유틸리티 타입을 활용하며, 판별 유니온으로 복잡한 상태를 모델링하세요. 타입 좁히기를 통해 런타임 안전성을 높이고, 엄격한 설정으로 잠재적 버그를 사전에 차단하세요.
좋은 TypeScript 코드는 타입 자체가 문서 역할을 합니다. 타입 정의를 보면 코드의 의도와 동작을 파악할 수 있어야 합니다. 이러한 원칙을 지키면서 꾸준히 TypeScript를 사용하다 보면, 더 견고하고 유지보수하기 쉬운 코드를 작성할 수 있게 될 것입니다.관련 게시글
JavaScript 비동기 프로그래밍 완벽 가이드
JavaScript의 비동기 프로그래밍을 콜백부터 Promise, async/await, 이벤트 루프, 에러 처리, 동시성 패턴까지 단계별로 완벽하게 정리합니다.
웹 성능 최적화 완벽 가이드: Core Web Vitals
Core Web Vitals를 중심으로 웹 성능을 측정하고 개선하는 방법을 알아봅니다. LCP, FID, CLS 지표와 실전 최적화 기법을 다룹니다.
Tailwind CSS 실전 활용법: 모던 웹 스타일링
Tailwind CSS를 활용한 효율적인 웹 스타일링 방법을 알아봅니다. 유틸리티 퍼스트 철학부터 반응형 디자인, 다크 모드, 컴포넌트 패턴까지 다룹니다.