타입스크립트로 할 일 관리 시스템 만들기 — 문제와 풀이

타입스크립트를 어느 정도 배웠다면 이런 의문이 들죠. "이거 자바스크립트로 짜도 되는 거 아닌가?" 동작은 같습니다. 차이는 오타 하나, 잘못된 값 하나 때문에 디버깅에 한 시간을 쓰느냐 마느냐에서 갈립니다.
이 글에서는 종합 과제를 하나 풀어봅니다. 콘솔에서 돌아가는 할 일 관리 시스템을 만들면서 유니온 타입, 제네릭, 판별 유니온, Partial/Record 같은 유틸리티 타입을 한 번에 써봅니다. 코드는 전부 직접 돌려서 결과까지 확인했고, 일부러 타입 오류를 내서 컴파일러가 잡아주는 모습도 같이 보여드립니다.
시작하기 전에
- 사전 요구사항: Node.js 18 이상, 터미널 기본 사용법
- 예상 시간: 처음부터 직접 따라 친다면 한 시간 정도
- 사용 도구:
tsc(타입 검사),tsx(타입스크립트 직접 실행). 둘 다npx로 받아 쓰니 따로 설치할 게 없습니다.
과제: 무엇을 만들까
요구사항이 꽤 많습니다. 하나씩 살펴보겠습니다.
- 상태와 우선순위는 정해진 값만 들어가야 합니다. 상태는
'pending' | 'inProgress' | 'done', 우선순위는'low' | 'medium' | 'high'. Todo인터페이스에는id,title,description?,status,priority,tags,createdAt,dueDate?가 들어갑니다.- 제네릭 인터페이스
Repository<T>를 정의합니다.add,getAll,getById,update(부분 업데이트는Partial<T>),delete,filter. TodoRepository가Repository<Todo>를 구현하고,add호출 때마다 id를 자동 증가시킵니다.getByStatus,getByPriority,getByTag,getOverdue,countByStatus(): Record<TodoStatus, number>같은 도메인 메서드를 추가합니다.- 제네릭 정렬 함수
sortBy<T, K extends keyof T>— 원본을 수정하지 않고, 잘못된 키를 넘기면 컴파일이 막혀야 합니다. printTodo,getStatusLabel,getPriorityLabel. 라벨 변환은switch로.- 판별 유니온
ActionResult<T>와safeGetById— 성공이면data, 실패면message. - 메인에서 다섯 개 추가 → 출력 → 통계 → 필터링 → 정렬 → 업데이트 → 실패 조회까지 시연.
힌트도 함께 제시됩니다. 그중 핵심만 추려보면 이렇습니다.
Partial<Todo>는Todo의 모든 속성을 선택적으로 만든 타입입니다.update가 일부 필드만 받게 해줍니다.Record<TodoStatus, number>는{ pending: number; inProgress: number; done: number }과 같습니다.sortBy에서 비교 대상은a[key],b[key]. 제약은K extends keyof T로.getOverdue는Date를getTime()으로 비교하면 안전합니다.
자, 이제 풀이로 들어갑니다.
1단계: 프로젝트 만들기
빈 폴더 하나면 됩니다. tsx, typescript 모두 npx로 받아 쓸 거라 별도 설치 없이 바로 시작합니다.
mkdir typescript_todo_app cd typescript_todo_app
tsconfig.json 한 개를 둡니다. strict 모드는 켜둡니다 — 안 켜면 타입스크립트의 절반은 없는 거나 마찬가지입니다.
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noEmit": true }, "include": ["*.ts"] }
확인하기: 폴더 안에 tsconfig.json 파일이 있고, npx --yes -p typescript tsc --version 명령이 버전 번호를 출력하면 됩니다.
2단계: 기본 타입과 인터페이스
먼저 todo.ts 파일을 만들고, 가장 단순한 것부터 채워 넣습니다. 유니온 타입은 한 줄이면 끝납니다.
type TodoStatus = 'pending' | 'inProgress' | 'done'; type Priority = 'low' | 'medium' | 'high'; interface Todo { id: number; title: string; description?: string; status: TodoStatus; priority: Priority; tags: string[]; createdAt: Date; dueDate?: Date; }
여기서 ?는 "있어도 되고 없어도 되는" 속성이라는 뜻입니다. description이 없으면 그 필드는 그냥 undefined가 됩니다.
확인하기: 같은 파일 끝에 잠깐 다음 줄을 추가해보세요.
const t: Todo = { id: 1, title: '연습', status: 'todo', priority: 'low', tags: [], createdAt: new Date() };
status: 'todo' 부분에서 빨간 줄이 떠야 정상입니다. 확인했으면 그 줄은 지웁니다.
3단계: 제네릭 저장소 인터페이스
Repository<T>는 어떤 자료든 공통으로 가질 수 있는 CRUD 인터페이스입니다. T라고 쓴 자리에 나중에 Todo를 끼워 넣는 식이죠.
interface Repository<T> { add(item: T): void; getAll(): T[]; getById(id: number): T | undefined; update(id: number, updates: Partial<T>): boolean; delete(id: number): boolean; filter(predicate: (item: T) => boolean): T[]; }
update의 두 번째 매개변수가 Partial<T>인 게 핵심입니다. 제목만 바꾸고 싶을 때 { title: '새 제목' }만 넘겨도 받아줍니다. 매번 객체 전체를 새로 만들 필요가 없죠.
4단계: TodoRepository 구현
본격적인 구현에 들어가기 전에 클래스와 인터페이스가 어떻게 묶이는지 한 장으로 정리해두면 따라가기 편합니다.

왼쪽이 제네릭 인터페이스 Repository<T>, 오른쪽이 그걸 Todo로 구체화해서 구현한 TodoRepository입니다. 인터페이스에 정의된 6개 메서드는 의무적으로 구현하고, 도메인 전용 메서드(getByStatus, getOverdue 등)는 구현 클래스에만 따로 추가합니다.
이제 인터페이스를 실제 클래스로 만듭니다. id는 1부터 자동 증가하게 하고, 외부에서 잘못 건드리지 못하도록 내부 상태는 private로 둡니다.
class TodoRepository implements Repository<Todo> { private items: Todo[] = []; private nextId: number = 1; add(item: Todo): void { const withId: Todo = { ...item, id: this.nextId++ }; this.items.push(withId); } getAll(): Todo[] { return [...this.items]; } getById(id: number): Todo | undefined { return this.items.find((todo) => todo.id === id); } update(id: number, updates: Partial<Todo>): boolean { const index = this.items.findIndex((todo) => todo.id === id); if (index === -1) return false; this.items[index] = { ...this.items[index], ...updates, id: this.items[index].id }; return true; } delete(id: number): boolean { const index = this.items.findIndex((todo) => todo.id === id); if (index === -1) return false; this.items.splice(index, 1); return true; } filter(predicate: (item: Todo) => boolean): Todo[] { return this.items.filter(predicate); }
작은 디테일 두 개를 짚어둡니다.
getAll이[...this.items]로 복사본을 돌려줍니다. 안 그러면 외부에서 받은 배열을 그대로 수정해버려 내부 상태가 어긋날 수 있어요.update에서 마지막에id: this.items[index].id를 한 번 더 적었습니다. 누가 실수로update(3, { id: 999 })같은 걸 보내도 id는 안 바뀌게 막아둔 겁니다.
5단계: 도메인 메서드
Repository에 없는, 할 일에만 의미 있는 메서드들을 같은 클래스에 이어서 추가합니다.
getByStatus(status: TodoStatus): Todo[] { return this.items.filter((todo) => todo.status === status); } getByPriority(priority: Priority): Todo[] { return this.items.filter((todo) => todo.priority === priority); } getByTag(tag: string): Todo[] { return this.items.filter((todo) => todo.tags.includes(tag)); } getOverdue(): Todo[] { const now = new Date(); return this.items.filter( (todo) => todo.dueDate !== undefined && todo.dueDate.getTime() < now.getTime() && todo.status !== 'done', ); } countByStatus(): Record<TodoStatus, number> { const counts: Record<TodoStatus, number> = { pending: 0, inProgress: 0, done: 0 }; for (const todo of this.items) { counts[todo.status]++; } return counts; } }
countByStatus의 반환 타입을 Record<TodoStatus, number>로 박아두면 나중에 누가 TodoStatus에 'archived' 같은 값을 추가했을 때 이 메서드에서 컴파일 오류가 납니다. 초기값 객체에 키가 빠졌다고 알려주는 거죠. 없으면 런타임에서야 발견했을 종류의 버그입니다.
6단계: 제네릭 정렬 함수
sortBy는 Todo뿐 아니라 다른 객체 배열에도 그대로 쓸 수 있어야 합니다.
function sortBy<T, K extends keyof T>(arr: T[], key: K, direction: 'asc' | 'desc'): T[] { const copy = [...arr]; copy.sort((a, b) => { const av = a[key]; const bv = b[key]; if (av < bv) return direction === 'asc' ? -1 : 1; if (av > bv) return direction === 'asc' ? 1 : -1; return 0; }); return copy; }
K extends keyof T가 핵심입니다. 두 번째 인자는 첫 번째 인자 요소의 키 중 하나여야만 합니다. 오타를 내면 그 자리에서 빨간 줄이 뜹니다.
sortBy(todos, 'priority', 'asc'); // 통과 sortBy(todos, 'priorityy', 'asc'); // 컴파일 오류
7단계: 출력과 라벨 변환
switch로 짜는 김에 한 단계 욕심을 더 내봅니다. never 타입으로 케이스 누락을 막는 패턴입니다.
function getStatusLabel(status: TodoStatus): string { switch (status) { case 'pending': return '대기'; case 'inProgress': return '진행 중'; case 'done': return '완료'; default: { const _exhaustive: never = status; return _exhaustive; } } } function getPriorityLabel(priority: Priority): string { switch (priority) { case 'low': return '낮음'; case 'medium': return '보통'; case 'high': return '높음'; default: { const _exhaustive: never = priority; return _exhaustive; } } }
default 분기에 never를 끼워 넣은 이유는 이렇습니다. 나중에 누가 TodoStatus에 새 값을 추가하면, case에 빠진 그 값이 default로 흘러들어옵니다. 그 순간 status가 더 이상 never가 아니게 되어 컴파일 오류가 납니다. 케이스 누락을 컴파일 시점에 잡아주는 안전장치인 셈이죠.
printTodo는 평범합니다. 선택적 속성은 undefined 체크 후에 출력합니다.
function formatDate(d: Date): string { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } function printTodo(todo: Todo): void { console.log(`[#${todo.id}] ${todo.title}`); console.log(` 상태 : ${getStatusLabel(todo.status)}`); console.log(` 우선순위: ${getPriorityLabel(todo.priority)}`); console.log(` 태그 : ${todo.tags.length > 0 ? todo.tags.join(', ') : '(없음)'}`); console.log(` 생성일 : ${formatDate(todo.createdAt)}`); if (todo.description !== undefined) { console.log(` 설명 : ${todo.description}`); } if (todo.dueDate !== undefined) { console.log(` 마감일 : ${formatDate(todo.dueDate)}`); } console.log(''); }
8단계: 판별 유니온으로 결과 표현
ActionResult<T>는 성공/실패 두 가지 가능성을 한 타입에 묶어둡니다. status 필드 하나로 분기를 만들고, 분기마다 들어 있는 필드가 다릅니다.
type ActionResult<T> = | { status: 'success'; data: T } | { status: 'error'; message: string }; function safeGetById(repo: TodoRepository, id: number): ActionResult<Todo> { const found = repo.getById(id); if (found === undefined) { return { status: 'error', message: '해당 id의 할 일을 찾을 수 없습니다' }; } return { status: 'success', data: found }; }
쓰는 쪽이 깔끔해지는 게 진짜 이득입니다. 그림으로 보면 흐름이 더 분명합니다.

if (result.status === 'success') 한 줄로 분기하면, 그 블록 안에서 타입스크립트는 result가 성공 케이스라는 걸 알아채서 data 필드를 안전하게 꺼낼 수 있게 해줍니다. 반대쪽 else 블록에서는 message만 꺼낼 수 있고요. status 필드 하나가 분기 키 역할을 하는 패턴이라 이걸 "판별 유니온"이라고 부릅니다.
const result = safeGetById(repo, 999); if (result.status === 'success') { // 이 블록 안에서는 result.data 가 Todo 로 좁혀짐 (좁힘 narrowing) console.log(result.data.title); } else { // 여기서는 result.message 만 접근 가능 console.log(result.message); }
result.data를 그냥 쓰려고 하면 컴파일러가 막습니다 — status 분기 없이는 어느 쪽인지 모르니까요. 이게 타입 좁히기입니다.
9단계: 메인 시나리오
이제 전부 이어 붙이면 끝입니다. 한 군데 신경 써야 할 부분이 있는데, 우선순위 정렬입니다. 문자열 그대로 정렬하면 알파벳 순이라 high < low < medium 순서가 나와 의도랑 어긋납니다. 매핑 테이블로 숫자 가중치를 붙여서 정렬합니다.
function main(): void { const repo = new TodoRepository(); repo.add({ id: 0, title: '타입스크립트 공부하기', description: '제네릭 챕터까지 끝낸다', status: 'inProgress', priority: 'high', tags: ['공부', 'typescript'], createdAt: new Date('2026-04-20'), dueDate: new Date('2026-04-30'), }); repo.add({ id: 0, title: '장보기', status: 'pending', priority: 'low', tags: ['생활'], createdAt: new Date('2026-04-22'), }); repo.add({ id: 0, title: '병원 예약', description: '치과 정기검진', status: 'pending', priority: 'medium', tags: ['건강', '예약'], createdAt: new Date('2026-04-15'), dueDate: new Date('2026-04-10'), // 과거 → overdue }); repo.add({ id: 0, title: '블로그 글 발행', status: 'done', priority: 'medium', tags: ['공부', '블로그'], createdAt: new Date('2026-04-18'), dueDate: new Date('2026-04-21'), }); repo.add({ id: 0, title: '운동 계획 세우기', description: '주 3회 헬스장 가기', status: 'pending', priority: 'high', tags: ['건강'], createdAt: new Date('2026-04-23'), dueDate: new Date('2026-05-01'), }); console.log('===== 전체 할 일 목록 ====='); for (const todo of repo.getAll()) printTodo(todo); console.log('===== 상태별 개수 ====='); console.log(repo.countByStatus()); console.log(''); console.log('===== 우선순위 high 만 ====='); for (const todo of repo.getByPriority('high')) printTodo(todo); console.log('===== 태그가 "건강"인 할 일 ====='); for (const todo of repo.getByTag('건강')) printTodo(todo); console.log('===== 기한이 지난 할 일 ====='); for (const todo of repo.getOverdue()) printTodo(todo); console.log('===== 우선순위 내림차순 정렬 ====='); const priorityWeight: Record<Priority, number> = { low: 1, medium: 2, high: 3 }; const withWeight = repo.getAll().map((t) => ({ ...t, _w: priorityWeight[t.priority] })); for (const t of sortBy(withWeight, '_w', 'desc')) printTodo(t); console.log('===== id=2 상태를 done 으로 업데이트 ====='); const ok = repo.update(2, { status: 'done' }); console.log(`update 결과: ${ok}`); const updated = repo.getById(2); if (updated) printTodo(updated); console.log('===== safeGetById 로 없는 id 조회 ====='); const result = safeGetById(repo, 999); if (result.status === 'success') { console.log('찾았습니다:'); printTodo(result.data); } else { console.log(`오류: ${result.message}`); } } main();
한 파일에 전부 담았더니 280줄 정도 나옵니다. 본질적인 로직은 그중 절반쯤이고 나머지는 출력 포맷팅과 시나리오 데이터입니다.
테스트하기 (1) — 실제로 돌려보기
tsx로 그냥 돌립니다. 별도 빌드 없이 곧장 실행됩니다.
npx --yes tsx todo.ts
제가 직접 돌렸을 때 나온 출력입니다(앞뒤만 발췌).
===== 전체 할 일 목록 ===== [#1] 타입스크립트 공부하기 상태 : 진행 중 우선순위: 높음 태그 : 공부, typescript 생성일 : 2026-04-20 설명 : 제네릭 챕터까지 끝낸다 마감일 : 2026-04-30 [#2] 장보기 상태 : 대기 우선순위: 낮음 태그 : 생활 생성일 : 2026-04-22 ... ===== 상태별 개수 ===== { pending: 3, inProgress: 1, done: 1 } ===== 기한이 지난 할 일 ===== [#3] 병원 예약 상태 : 대기 우선순위: 보통 태그 : 건강, 예약 생성일 : 2026-04-15 설명 : 치과 정기검진 마감일 : 2026-04-10 ===== id=2 상태를 done 으로 업데이트 ===== update 결과: true [#2] 장보기 상태 : 완료 ... ===== safeGetById 로 없는 id 조회 ===== 오류: 해당 id의 할 일을 찾을 수 없습니다
눈여겨봐야 할 곳은 이렇습니다.
- 다섯 개가 전부 나옵니다.
- 상태별 개수
pending: 3, inProgress: 1, done: 1을 모두 더하면 전체 개수인 5와 일치합니다. - 기한이 지난 할 일에
dueDate: 2026-04-10인 "병원 예약"만 잡힙니다(오늘이 2026-04-23이니까요). update(2, ...)호출 뒤 #2의 상태가 "완료"로 바뀝니다.safeGetById(999)가 실패 메시지를 반환합니다.
테스트하기 (2) — 타입 검사
런타임이 아니라 컴파일 타임에서 잘 막아주는지 따로 확인합니다.
npx --yes -p typescript tsc --noEmit
아무 출력 없이 끝나면 통과입니다. 진짜로 에러를 잡는지 확인하려면 일부러 실수가 있는 파일을 하나 만들어서 돌려보세요.
// type_check_demo.ts (의도적으로 오류를 내는 파일) type TodoStatus = 'pending' | 'inProgress' | 'done'; interface Todo { id: number; title: string; status: TodoStatus; } function setStatus(t: Todo, s: TodoStatus): void { t.status = s; } const myTodo: Todo = { id: 1, title: '예시', status: 'pending' }; setStatus(myTodo, 'doing'); // 1) 상태 오타 console.log(myTodo.titles); // 2) 속성 오타 function sortBy<T, K extends keyof T>(arr: T[], key: K, direction: 'asc' | 'desc'): T[] { return [...arr].sort((a, b) => (a[key] < b[key] ? -1 : 1) * (direction === 'asc' ? 1 : -1)); } sortBy([myTodo], 'priorityy', 'asc'); // 3) 키 오타
npx --yes -p typescript tsc --noEmit --strict type_check_demo.ts
실제로 돌리면 이렇게 세 줄이 나옵니다.
type_check_demo.ts(13,19): error TS2345: Argument of type '"doing"' is not assignable to parameter of type 'TodoStatus'. type_check_demo.ts(14,20): error TS2551: Property 'titles' does not exist on type 'Todo'. Did you mean 'title'? type_check_demo.ts(19,18): error TS2345: Argument of type '"priorityy"' is not assignable to parameter of type 'keyof Todo'.
자바스크립트로 짰다면 1번은 잘못된 상태가 그대로 저장되고, 2번은 undefined가 출력되고, 3번은 정렬이 이상해지거나 원본 순서 그대로 끝났을 겁니다. 버그 세 개가 코드를 돌리기 전에 잡힙니다. 타입스크립트가 본전을 뽑는 지점이 바로 여기입니다.
마무리
여기까지 따라오셨다면 실력을 더 키워볼 수 있는 도전 과제 몇 가지를 남겨두고 글을 마치겠습니다.
save/load를 추가해보기. JSON으로 직렬화해서 파일에 저장하고 다시 불러오는 기능.Date는 그대로 직렬화되지 않아서 불러올 때new Date(...)로 복원해야 한다는 함정이 있습니다.- 태그도 유니온으로 좁혀보기.
tags: string[]대신tags: ('공부' | '건강' | '생활' | ...)[]같은 식으로 묶어두면 태그 오타도 컴파일에서 잡힙니다. 값의 종류가 적을 땐 꽤 쓸모 있습니다. countByPriority추가하기. 반환 타입을Record<Priority, number>로 두면 자연스럽습니다. 5단계 코드를 거의 그대로 베껴 써도 됩니다.sortBy에 비교 함수를 받게 하기. 우선순위 가중치 매핑을 함수 밖으로 빼내려면compare?: (a: T[K], b: T[K]) => number같은 옵션 매개변수가 필요합니다.
타입스크립트는 코드를 더 빠르게 짜게 해주는 도구가 아닙니다. 틀린 코드를 더 빨리 알아차리게 해주는 도구입니다. 작은 프로젝트에서는 그 차이가 잘 안 느껴지기도 합니다. 그래도 한 번 손에 익으면 자바스크립트로 돌아갈 때마다 "이 줄에서 분명 뭔가 어긋날 것 같은데" 하는 불안이 슬며시 따라옵니다. 그 불안이 곧 타입스크립트의 가치입니다.
문제 해결
npx tsx ...가 권한 질문을 띄우거나 멈춘 듯하다: 처음 한 번은tsx패키지를 받느라 시간이 걸립니다.--yes플래그를 붙이면 자동으로 승인됩니다.tsc가Cannot find module류의 오류를 낸다:tsconfig.json이 같은 폴더에 있는지, 안의"include": ["*.ts"]가 맞는지 확인하세요.new Date('2026-04-10')이 시간대 때문에 어긋난다: ISO 형식이라 UTC 자정으로 해석됩니다. 마감 비교 정도엔 영향이 거의 없지만, 시각이 중요한 곳에서는new Date(2026, 3, 10)같은 로컬 생성자가 안전합니다.switch의default에서never할당 오류가 난다: 오류가 아니라 의도한 동작입니다. 케이스가 덜 채워졌다는 신호죠. 빠진case를 채우면 사라집니다.






댓글
댓글을 작성하려면 이 필요합니다.