TypeScript 독학 가이드 10 - 실전 프로젝트

learning by Seven Fingers Studio 18분
TypeScript실전프로젝트ReactTodo앱웹개발

이론만 배우면 재미없죠. 실제로 뭔가 만들어봐야 실력이 늘어요. 오늘은 TypeScript로 Todo 앱을 만들어보고, React와 함께 쓰는 방법까지 알아볼게요. 제가 실무에서 쓰는 팁도 함께 공유합니다!

Todo 앱 만들기 (순수 TypeScript)

먼저 Node.js 환경에서 간단한 Todo 앱을 만들어볼게요.

프로젝트 설정

mkdir todo-app
cd todo-app
npm init -y
npm install -g typescript
tsc --init

타입 정의하기

types.ts 파일:

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: Date;
}

export type TodoInput = Omit<Todo, "id" | "createdAt">;

export type FilterType = "all" | "active" | "completed";

포인트: Omit으로 입력용 타입을 간단히 만들었어요.

TodoManager 클래스

todoManager.ts 파일:

import { Todo, TodoInput, FilterType } from "./types";

export class TodoManager {
  private todos: Todo[] = [];
  private nextId: number = 1;

  addTodo(input: TodoInput): Todo {
    const newTodo: Todo = {
      id: this.nextId++,
      ...input,
      createdAt: new Date()
    };
    this.todos.push(newTodo);
    return newTodo;
  }

  getTodos(filter: FilterType = "all"): Todo[] {
    switch (filter) {
      case "active":
        return this.todos.filter(todo => !todo.completed);
      case "completed":
        return this.todos.filter(todo => todo.completed);
      default:
        return this.todos;
    }
  }

  toggleTodo(id: number): boolean {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      return true;
    }
    return false;
  }

  deleteTodo(id: number): boolean {
    const index = this.todos.findIndex(t => t.id === id);
    if (index !== -1) {
      this.todos.splice(index, 1);
      return true;
    }
    return false;
  }

  updateTodo(id: number, updates: Partial<TodoInput>): boolean {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      Object.assign(todo, updates);
      return true;
    }
    return false;
  }
}

포인트: Partial로 일부 속성만 업데이트 가능하게 했어요.

실행 코드

index.ts 파일:

import { TodoManager } from "./todoManager";

const manager = new TodoManager();

// Todo 추가
manager.addTodo({ title: "TypeScript 공부하기", completed: false });
manager.addTodo({ title: "운동하기", completed: false });
manager.addTodo({ title: "저녁 먹기", completed: true });

// 모든 Todo 조회
console.log("=== 모든 할 일 ===");
manager.getTodos().forEach(todo => {
  console.log(`${todo.id}. [${todo.completed ? "✓" : " "}] ${todo.title}`);
});

// 할 일 완료 처리
manager.toggleTodo(1);

// 활성 Todo만 조회
console.log("\n=== 미완료 할 일 ===");
manager.getTodos("active").forEach(todo => {
  console.log(`${todo.id}. ${todo.title}`);
});

// 완료된 Todo만 조회
console.log("\n=== 완료된 할 일 ===");
manager.getTodos("completed").forEach(todo => {
  console.log(`${todo.id}. ${todo.title}`);
});

실행하기

tsc
node index.js

실행 결과:

✓ 컴파일 성공
=== 모든 할 일 ===
1. [✓] TypeScript 공부하기
2. [ ] 운동하기
3. [✓] 저녁 먹기
=== 미완료 할 일 ===
2. 운동하기
=== 완료된 할 일 ===
1. TypeScript 공부하기
3. 저녁 먹기

타입 안전한 Todo 앱이 완성되었습니다!

React + TypeScript 시작하기

실무에서는 React와 함께 쓰는 경우가 많아요. 프로젝트 생성부터 알아봅시다.

Vite로 프로젝트 생성

npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
npm run dev

실행 결과:

✓ 프로젝트 생성 완료
VITE v5.0.0 ready in 234 ms
Local: http://localhost:5173/

React + TypeScript 프로젝트가 준비되었습니다

컴포넌트 타입 정의

Todo.tsx:

import { FC } from "react";

interface TodoProps {
  id: number;
  title: string;
  completed: boolean;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}

export const Todo: FC<TodoProps> = ({
  id,
  title,
  completed,
  onToggle,
  onDelete
}) => {
  return (
    <div style={{ padding: "10px", borderBottom: "1px solid #ccc" }}>
      <input
        type="checkbox"
        checked={completed}
        onChange={() => onToggle(id)}
      />
      <span style={{ textDecoration: completed ? "line-through" : "none" }}>
        {title}
      </span>
      <button onClick={() => onDelete(id)}>삭제</button>
    </div>
  );
};

useState 타입 지정

App.tsx:

import { useState } from "react";
import { Todo } from "./Todo";

interface TodoItem {
  id: number;
  title: string;
  completed: boolean;
}

function App() {
  const [todos, setTodos] = useState<TodoItem[]>([]);
  const [input, setInput] = useState<string>("");

  const addTodo = () => {
    if (input.trim()) {
      setTodos([
        ...todos,
        { id: Date.now(), title: input, completed: false }
      ]);
      setInput("");
    }
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div style={{ padding: "20px", maxWidth: "600px", margin: "0 auto" }}>
      <h1>TypeScript Todo App</h1>
      <div>
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="할 일을 입력하세요"
        />
        <button onClick={addTodo}>추가</button>
      </div>
      <div style={{ marginTop: "20px" }}>
        {todos.map(todo => (
          <Todo
            key={todo.id}
            {...todo}
            onToggle={toggleTodo}
            onDelete={deleteTodo}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

포인트:

  • useState에 제네릭으로 타입 지정
  • 이벤트 핸들러 타입도 명확히 정의
  • 컴포넌트 props는 interface로 정의

이벤트 핸들러 타입

React에서 자주 쓰는 이벤트 타입들:

import { ChangeEvent, FormEvent, MouseEvent } from "react";

function MyComponent() {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("제출됨");
  };

  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    console.log("클릭됨");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
      <button onClick={handleClick}>제출</button>
    </form>
  );
}

Custom Hook 타입 정의

useTodos.ts:

import { useState, useCallback } from "react";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export const useTodos = () => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = useCallback((title: string) => {
    setTodos(prev => [
      ...prev,
      { id: Date.now(), title, completed: false }
    ]);
  }, []);

  const toggleTodo = useCallback((id: number) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []);

  const deleteTodo = useCallback((id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);

  return { todos, addTodo, toggleTodo, deleteTodo };
};

사용:

function App() {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
  // ...
}

API 호출 타입 정의

api.ts:

interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiResponse<T> {
  data: T;
  status: number;
  message?: string;
}

export async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const response = await fetch("https://api.example.com/users");
  const data = await response.json();
  return {
    data,
    status: response.status
  };
}

export async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const data = await response.json();
  return {
    data,
    status: response.status
  };
}

사용:

const { data: users } = await fetchUsers();
users.forEach(user => {
  console.log(user.name);  // 타입 안전!
});

타입 안전성 체크 팁

1. strict 모드 켜기

tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

2. ESLint + TypeScript 설정

npm install -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

.eslintrc.json:

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ]
}

3. 타입 가드 활용

function isUser(obj: any): obj is User {
  return obj && typeof obj.id === "number" && typeof obj.name === "string";
}

const data = await fetchData();
if (isUser(data)) {
  console.log(data.name);  // 안전하게 사용 가능
}

TypeScript 모범 사례

1. any 사용 최소화

❌ 나쁜 예:

function process(data: any) {
  return data.value;
}

✅ 좋은 예:

function process<T extends { value: unknown }>(data: T) {
  return data.value;
}

2. 명시적 타입 정의

❌ 나쁜 예:

const users = [];  // any[]

✅ 좋은 예:

const users: User[] = [];

3. 타입 재사용

❌ 나쁜 예:

function getUser(id: number): { id: number; name: string } {}
function updateUser(user: { id: number; name: string }) {}

✅ 좋은 예:

interface User {
  id: number;
  name: string;
}

function getUser(id: number): User {}
function updateUser(user: User) {}

4. 유니온 vs Enum

간단하면 유니온:

type Status = "pending" | "success" | "error";

복잡하면 Enum:

enum Status {
  Pending = "PENDING",
  Success = "SUCCESS",
  Error = "ERROR"
}

5. 타입 좁히기

function processValue(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase();  // string 메서드 사용 가능
  }
  return value.toFixed(2);  // number 메서드 사용 가능
}

실전 프로젝트 체크리스트

프로젝트 시작할 때 이것만은 꼭!

  • strict 모드 활성화
  • ESLint + TypeScript 설정
  • 공통 타입은 별도 파일로 분리
  • API 응답 타입 정의
  • 에러 처리 타입 정의
  • 유틸리티 타입 활용
  • any 사용 최소화
  • 제네릭으로 재사용성 높이기

마무리

TypeScript 독학 가이드 시리즈가 끝났어요! 지금까지 배운 내용:

  1. TypeScript 소개 - 왜 필요한지, JavaScript와의 차이
  2. 환경 설정 - Node.js, TypeScript 설치, 프로젝트 생성
  3. 기본 타입 - number, string, boolean, array, tuple, enum
  4. 함수와 타입 - 매개변수, 반환값, 오버로딩
  5. 인터페이스 - 객체 타입 정의, 확장
  6. 클래스 - 접근 제한자, 상속, 추상 클래스
  7. 타입 별칭과 유니온 - type, 유니온, 인터섹션
  8. 제네릭 - 재사용 가능한 타입 안전 코드
  9. 유틸리티 타입 - Partial, Pick, Omit 등
  10. 실전 프로젝트 - Todo 앱, React + TypeScript

이제 실제 프로젝트에 TypeScript를 적용해보세요. 처음엔 타입 에러가 귀찮아도, 한 달만 쓰면 TypeScript 없이는 못 코딩하게 될 거예요. 화이팅!


다음 글 보기

← 이전 글
TypeScript 독학 가이드 9 - 유틸리티 타입
← 블로그 목록으로