JavaScript로 규모가 커지는 프로젝트를 다루다 보면 타입 오류로 인한 런타임 에러를 자주 경험하게 됩니다. TypeScript는 JavaScript에 정적 타입을 더한 언어로, 코드 작성 단계에서 오류를 미리 잡아주고 IDE 자동 완성을 강력하게 지원합니다.
왜 TypeScript인가?
TypeScript를 사용하면 얻을 수 있는 이점은 다음과 같습니다.
- 컴파일 타임 오류 검출 — 실행 전에 타입 오류를 잡습니다.
- IDE 자동완성 — 객체의 속성과 메서드가 자동으로 제안됩니다.
- 코드 문서화 — 타입 자체가 코드의 문서가 됩니다.
- 리팩터링 안전성 — 변수명이나 구조를 바꿀 때 영향 범위를 정확히 알 수 있습니다.
기본 타입
// 기본 타입 선언
let name: string = '홍길동';
let age: number = 30;
let isActive: boolean = true;
// 배열
let fruits: string[] = ['사과', '바나나'];
let scores: Array<number> = [100, 95, 88];
// 튜플 — 길이와 각 요소 타입이 고정된 배열
let point: [number, number] = [10, 20];
// any — 타입 검사를 건너뜀 (남용 금지)
let anything: any = '문자열이었다가';
anything = 42; // 숫자로 바뀌어도 오류 없음
// unknown — any보다 안전한 대안
let input: unknown = getUserInput();
if (typeof input === 'string') {
console.log(input.toUpperCase()); // 타입 검사 후 사용
}
인터페이스와 타입 별칭
객체의 구조를 정의할 때 interface와 type을 사용합니다.
// Interface
interface User {
id: number;
name: string;
email: string;
age?: number; // 선택적 속성 (Optional)
readonly createdAt: Date; // 읽기 전용
}
// Type alias
type Point = {
x: number;
y: number;
};
// 유니온 타입
type Status = 'pending' | 'active' | 'inactive';
// 함수 타입
function greet(user: User): string {
return `안녕하세요, ${user.name}님!`;
}
// 화살표 함수
const add = (a: number, b: number): number => a + b;
제네릭(Generics)
제네릭을 사용하면 타입을 파라미터처럼 사용할 수 있어, 다양한 타입에 재사용 가능한 코드를 작성할 수 있습니다.
// 제네릭 함수
function identity<T>(arg: T): T {
return arg;
}
identity<string>('hello'); // 'hello'
identity<number>(42); // 42
// 제네릭 인터페이스
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// 사용 예시
const userResponse: ApiResponse<User> = {
data: { id: 1, name: '홍길동', email: 'hong@example.com', createdAt: new Date() },
status: 200,
message: 'success'
};
유틸리티 타입
TypeScript는 기존 타입을 변환하는 유틸리티 타입을 내장합니다.
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Partial — 모든 속성을 선택적으로
type UserUpdate = Partial<User>;
// Pick — 특정 속성만 선택
type UserPublic = Pick<User, 'id' | 'name' | 'email'>;
// Omit — 특정 속성만 제외
type UserWithoutPassword = Omit<User, 'password'>;
// Readonly — 모든 속성을 읽기 전용으로
type ReadonlyUser = Readonly<User>;
// Record — 키-값 맵 타입
type UserMap = Record<string, User>;
TypeScript는 JavaScript의 상위집합으로, 기존 JS 코드를 그대로 사용하면서 점진적으로 타입을 추가할 수 있습니다.
any 사용을 최소화하고, 인터페이스와 제네릭을 적극 활용하면 유지보수하기 쉬운 코드를 작성할 수 있습니다.
타입 가드 (Type Guards)
유니온 타입을 사용할 때 런타임에서 타입을 좁혀주는 타입 가드가 필요합니다. typeof, instanceof, 사용자 정의 타입 가드를 활용하면 안전한 타입 처리가 가능합니다.
// typeof 가드
function processValue(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase(); // string 메서드 안전 사용
}
return value.toFixed(2); // number 메서드 안전 사용
}
// instanceof 가드
function handleError(error: Error | string) {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error(error);
}
}
// 사용자 정의 타입 가드 (is 키워드)
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function isCat(animal: Cat | Dog): animal is Cat {
return (animal as Cat).meow !== undefined;
}
function makeSound(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // Cat으로 좁혀짐
} else {
animal.bark(); // Dog으로 좁혀짐
}
}
tsconfig.json 핵심 설정
TypeScript 프로젝트의 동작 방식은 tsconfig.json으로 제어합니다. 실무에서 자주 사용하는 핵심 옵션입니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
"strict": true는 noImplicitAny, strictNullChecks, strictFunctionTypes 등 7가지 엄격 검사를 한꺼번에 활성화합니다. 신규 프로젝트에서는 처음부터 켜두는 것을 강력 권장합니다.
조건부 타입 (Conditional Types)
TypeScript의 조건부 타입은 입력 타입에 따라 다른 타입을 반환합니다. 타입 레벨의 if-else라고 이해하면 됩니다.
// T가 string이면 string[], 아니면 never
type StringArray<T> = T extends string ? string[] : never;
type A = StringArray<string>; // string[]
type B = StringArray<number>; // never
// 함수의 반환 타입 추출
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() { return { id: 1, name: '홍길동' }; }
type UserType = ReturnType<typeof getUser>;
// 결과: { id: number; name: string }
클래스와 접근 제한자
TypeScript의 클래스는 public, private, protected, readonly 접근 제한자를 지원합니다. 생성자 매개변수 앞에 접근 제한자를 붙이면 자동으로 속성이 생성됩니다.
class BankAccount {
constructor(
private readonly id: string,
private owner: string,
private balance: number = 0
) {}
deposit(amount: number): void {
if (amount <= 0) throw new Error('입금 금액은 양수여야 합니다');
this.balance += amount;
}
withdraw(amount: number): void {
if (amount > this.balance) throw new Error('잔액이 부족합니다');
this.balance -= amount;
}
get currentBalance(): number {
return this.balance;
}
}
const account = new BankAccount('ACC001', '홍길동', 10000);
account.deposit(5000);
console.log(account.currentBalance); // 15000
// account.balance; // 오류! private 속성에 외부 접근 불가
TypeScript 점진적 마이그레이션 전략
기존 JavaScript 프로젝트를 TypeScript로 전환할 때는 단계적 접근이 현실적입니다.
- 1단계 —
allowJs: true로 JS와 TS 공존 허용, 빌드 확인 - 2단계 — 새 파일은 모두
.ts로 작성, 기존 파일은 유지 - 3단계 — 기존
.js파일을 하나씩.ts로 변환, 타입 오류가 많으면@ts-ignore주석으로 임시 처리 - 4단계 —
any타입 제거,strict: true활성화
핵심 비즈니스 로직이 담긴 파일부터 우선 변환하면 타입 안전성의 이점을 빠르게 체감할 수 있습니다. 테스트 파일, 설정 파일은 마지막에 변환해도 됩니다.
TypeScript를 실무에 도입하면 달라지는 것들
TypeScript를 처음 도입할 때는 생산성이 일시적으로 떨어지는 것처럼 느껴질 수 있습니다. 타입 오류를 고치는 데 시간이 걸리고, 간단한 코드도 타입 선언 때문에 길어지기 때문입니다. 하지만 프로젝트가 일정 규모 이상으로 성장하면 TypeScript의 진가가 드러납니다.
가장 큰 변화는 리팩터링에 대한 자신감입니다. 함수 이름을 바꾸거나 인터페이스를 수정할 때, TypeScript 컴파일러가 영향을 받는 모든 곳을 즉시 알려줍니다. JavaScript였다면 런타임에서 터지는 에러를 개발 단계에서 잡을 수 있습니다. 특히 팀 규모가 커지고 코드베이스가 복잡해질수록 이 이점이 더 두드러집니다.
두 번째 변화는 협업 품질 향상입니다. 타입이 곧 문서가 됩니다. 새로운 팀원이 합류했을 때, 함수의 매개변수와 반환 타입만 봐도 어떤 데이터를 주고받는지 파악할 수 있습니다. IDE 자동 완성도 훨씬 정확해져서 API를 일일이 문서에서 찾을 필요가 없어집니다.
세 번째로는 버그 발생 빈도 감소입니다. "Cannot read property of undefined" 같은 전형적인 런타임 에러의 상당 부분이 TypeScript 도입만으로 사라집니다. 2022년 Airbnb의 사례 연구에 따르면, TypeScript 도입 후 프론트엔드 버그의 약 38%를 TypeScript 에러로 사전에 방지할 수 있었다고 보고했습니다.
물론 TypeScript도 만능은 아닙니다. 외부 라이브러리의 타입 정의가 부정확한 경우, 복잡한 타입 추론으로 빌드 속도가 느려지는 경우도 있습니다. 그러나 중장기적으로 유지보수할 프로젝트라면 TypeScript 도입의 이점이 비용을 크게 상회합니다. 오늘날 대부분의 국내외 주요 프론트엔드 프로젝트가 TypeScript를 표준으로 채택하고 있는 이유입니다.