Ch 02. 단계별 전환 실습
전략을 세웠으니 이제 실제로 코드를 바꿔봅니다. 이번 챕터는 이론이 아니라 실습입니다. 작은 JavaScript 프로젝트를 직접 TypeScript로 전환하면서 각 단계에서 어떤 일이 일어나는지 경험합니다.
실습 프로젝트 준비
다음과 같은 구조의 간단한 JavaScript 프로젝트를 가정합니다. 사용자 데이터를 처리하는 유틸리티 모음입니다.
src/
├── utils/
│ ├── formatters.js
│ └── validators.js
├── api/
│ └── userApi.js
└── index.js
각 파일의 초기 상태입니다.
// 새 파일: src/utils/formatters.jsfunction formatUserName(user) { return `${user.firstName} ${user.lastName}`;}function formatDate(dateStr) { const date = new Date(dateStr); return date.toLocaleDateString('ko-KR');}function formatPhoneNumber(phone) { return phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');}module.exports = { formatUserName, formatDate, formatPhoneNumber };
// 새 파일: src/utils/validators.jsfunction isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}function isValidAge(age) { return typeof age === 'number' && age >= 0 && age <= 150;}function isValidUser(user) { return user && user.firstName && user.lastName && isValidEmail(user.email);}module.exports = { isValidEmail, isValidAge, isValidUser };
// 새 파일: src/api/userApi.jsconst { isValidUser } = require('../utils/validators');async function fetchUser(id) { const response = await fetch(`https://api.example.com/users/${id}`); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json();}async function createUser(userData) { if (!isValidUser(userData)) { throw new Error('Invalid user data'); } const response = await fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData), }); return response.json();}module.exports = { fetchUser, createUser };
// 새 파일: src/index.jsconst { formatUserName } = require('./utils/formatters');const { fetchUser } = require('./api/userApi');async function main() { const user = await fetchUser(1); console.log(formatUserName(user));}main().catch(console.error);
1단계: TypeScript 설정 추가
먼저 TypeScript를 설치하고 tsconfig.json을 만듭니다.
npm install --save-dev typescript @types/nodenpx tsc --init
그리고 tsconfig.json을 마이그레이션에 맞게 수정합니다.
// 수정: tsconfig.json{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "allowJs": true, "checkJs": false, "strict": false, "noImplicitAny": false, "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true }, "include": ["src"], "exclude": ["node_modules", "dist"]}
이 시점에서 npx tsc를 실행하면 에러 없이 컴파일됩니다. .js 파일들이 그대로 dist/로 복사됩니다. 여기가 시작점입니다.
2단계: 타입 정의 파일 먼저 만들기
파일을 바꾸기 전에 타입부터 선언합니다. 프로젝트 전체에서 공유하는 데이터 구조를 types.ts 파일로 뽑아냅니다.
// 새 파일: src/types.tsexport interface User { id: number; firstName: string; lastName: string; email: string; age?: number; phone?: string; createdAt?: string;}export interface ApiResponse<T> { data: T; status: number; message?: string;}
타입 파일을 먼저 만드는 이유가 있습니다. 개별 파일을 전환할 때 "이 값의 타입이 뭐지?" 고민하는 시간을 줄여줍니다. 또한 팀 전체가 데이터 구조에 대해 합의하는 시간을 갖게 됩니다.
3단계: 의존성이 없는 파일부터 전환
formatters.js는 외부 의존성이 없는 순수 함수 모음입니다. 여기서 시작합니다.
파일 이름을 바꿉니다.
mv src/utils/formatters.js src/utils/formatters.ts
그리고 타입을 추가합니다.
// 수정: src/utils/formatters.tsimport { User } from '../types';export function formatUserName(user: User): string { return `${user.firstName} ${user.lastName}`;}export function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString('ko-KR');}export function formatPhoneNumber(phone: string): string { return phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');}
module.exports 대신 export를 씁니다. require 대신 import를 씁니다. 각 함수에 매개변수 타입과 반환 타입을 추가합니다.
npx tsc --noEmit으로 타입 검사만 실행합니다. 컴파일 결과물 없이 타입 에러만 확인하는 명령입니다.
4단계: validators.js 전환
다음은 validators.js입니다.
mv src/utils/validators.js src/utils/validators.ts
// 수정: src/utils/validators.tsimport { User } from '../types';export function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}export function isValidAge(age: unknown): age is number { return typeof age === 'number' && age >= 0 && age <= 150;}export function isValidUser(user: unknown): user is User { if (!user || typeof user !== 'object') return false; const u = user as Record<string, unknown>; return ( typeof u.firstName === 'string' && typeof u.lastName === 'string' && typeof u.email === 'string' && isValidEmail(u.email) );}
isValidAge와 isValidUser가 타입 가드(type guard) 형태로 바뀌었습니다. 반환 타입을 boolean 대신 age is number, user is User로 선언하면 조건문 안에서 타입이 좁혀집니다.
5단계: API 레이어 전환
userApi.js는 외부 API를 호출하고 validators를 사용합니다.
mv src/api/userApi.js src/api/userApi.ts
// 수정: src/api/userApi.tsimport { User, ApiResponse } from '../types';import { isValidUser } from '../utils/validators';export async function fetchUser(id: number): Promise<User> { const response = await fetch(`https://api.example.com/users/${id}`); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const data = await response.json() as User; return data;}export async function createUser(userData: unknown): Promise<ApiResponse<User>> { if (!isValidUser(userData)) { throw new Error('Invalid user data'); } const response = await fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData), }); const data = await response.json() as ApiResponse<User>; return data;}
response.json()의 반환 타입은 Promise<any>입니다. as User로 타입을 단언합니다. 이상적으로는 런타임에서 검증해야 하지만, 전환 초기에는 as를 허용합니다. 나중에 Zod 같은 검증 라이브러리로 강화할 수 있습니다.
6단계: 진입점 전환
마지막으로 index.js를 바꿉니다.
mv src/index.js src/index.ts
// 수정: src/index.tsimport { formatUserName } from './utils/formatters';import { fetchUser } from './api/userApi';async function main(): Promise<void> { const user = await fetchUser(1); console.log(formatUserName(user));}main().catch(console.error);
암묵적 any 처리
전환 과정에서 자주 만나는 상황이 있습니다. 타입을 알 수 없는 값입니다.
// 이런 코드가 있다고 가정합니다function processData(data) { // 매개변수 data에 any가 암묵적으로 붙습니다 return data.map(item => item.value);}
noImplicitAny: false 상태에서는 이 코드가 통과합니다. 하지만 noImplicitAny: true로 바꾸면 에러가 납니다.
이때 선택지는 세 가지입니다.
첫 번째는 타입을 명시하는 것입니다.
interface DataItem { value: unknown;}function processData(data: DataItem[]): unknown[] { return data.map(item => item.value);}
두 번째는 제네릭을 쓰는 것입니다.
function processData<T extends { value: unknown }>(data: T[]): unknown[] { return data.map(item => item.value);}
세 번째는 임시로 any를 명시하는 것입니다.
// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction processData(data: any[]): any[] { return data.map(item => item.value);}
세 번째는 미루기입니다. 전환 속도를 위해 허용하되, 주석으로 표시하고 나중에 개선합니다.
전환 후 빌드 확인
모든 파일 전환이 끝나면 전체 빌드를 확인합니다.
npx tsc
에러 없이 dist/ 폴더가 생기면 성공입니다. 기존 테스트가 있다면 실행해서 동작이 동일한지 확인합니다.
전환이 완료된 시점에서 allowJs: false로 바꿔도 됩니다. .js 파일이 없으니 영향이 없습니다. 다만 굳이 바꾸지 않아도 됩니다.
다음 챕터에서는 strict 옵션을 켜면서 코드 품질을 높이는 과정을 다룹니다.