iBetter Books
수정

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)  );}

isValidAgeisValidUser가 타입 가드(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 옵션을 켜면서 코드 품질을 높이는 과정을 다룹니다.