Skip to Content
WebJavaScriptECMAScript17. 종합 점검과 확장하기

이번 편의 결과물: todo --help로 전체 명령을 확인하고 node --test가 모두 통과합니다. README에 사용법을 정리하고 확장 아이디어를 설계합니다. · 다루는 개념: 전체 명령 통합 점검, README 작성, 확장 아이디어 설계

이 편에서 만드는 파일

todo-cli/ ├── src/ │ └── index.js (~ --help 추가) └── README.md (+)

todo-cli 정본 전체 파일 목록

01편부터 16편까지 만든 todo-cli의 최종 구조입니다.

todo-cli/ ├── package.json ├── README.md ├── data/ │ └── todos.json ├── src/ │ ├── index.js │ ├── errors.js │ ├── cli/ │ │ └── parseArgs.js │ ├── commands/ │ │ ├── add.js │ │ ├── list.js │ │ ├── done.js │ │ ├── remove.js │ │ ├── search.js │ │ ├── stats.js │ │ ├── export.js │ │ └── import.js │ ├── models/ │ │ ├── Todo.js │ │ └── TodoStore.js │ ├── storage/ │ │ └── fileStore.js │ └── utils/ │ └── date.js └── test/ └── todo-store.test.js
만든 것
01package.json, src/index.js(뼈대)
02process.argv 파싱
03–04메모리 배열 기반 add/list, 필터·정렬
05Todo/TodoStore 클래스로 리팩터링
06models/, store/(→ models/TodoStore.js), cli/로 모듈 분리
07이벤트 루프 개념(실습 파일 없음)
08storage/fileStore.js, 비동기 저장·로드
09TodoStore가 이터러블, list --page 제너레이터
10태그를 Map/Set으로 관리, tags 명령
11commands/search.js(정규식)
12utils/date.js(D-데이 표시), commands/stats.js
13commands/export.js, commands/import.js, TodoStore.replace
14errors.js, commands/done.js/remove.js, TodoStore.remove, 사용자용 에러 메시지
15test/todo-store.test.js
16TodoStore 최신 문법 리팩터링
17--help, README.md

핵심 파일 최종 코드

package.json

{ "name": "todo-cli", "version": "0.1.0", "description": "터미널에서 쓰는 할 일 관리 CLI", "type": "module", "main": "src/index.js", "bin": { "todo": "./src/index.js" }, "scripts": { "start": "node src/index.js", "test": "node --test" } }

src/models/Todo.js

// src/models/Todo.js export const DIRTY = Symbol('dirty'); export class Todo { constructor({ id, title, done = false, tags = [], due = null, createdAt = new Date().toISOString() }) { this.id = id; this.title = title; this.done = done; this.tags = Array.from(new Set(tags)); this.due = due; this.createdAt = createdAt; this[DIRTY] = false; } static fromObject(obj) { return new Todo(obj); } toJSON() { return { id: this.id, title: this.title, done: this.done, tags: this.tags, due: this.due, createdAt: this.createdAt }; } }

src/models/TodoStore.js

// src/models/TodoStore.js import { Todo } from './Todo.js'; export class TodoStore { #todos; constructor(todos = []) { this.#todos = todos; } static fromArray(rawList) { return new TodoStore(rawList.map((raw) => Todo.fromObject(raw))); } add(title, { tags = [], due } = {}) { const id = this.#nextId(); const todo = new Todo({ id, title, tags, due: due ?? null }); this.#todos.push(todo); return todo; } list({ filter, sort } = {}) { const filterFn = TodoStore.#makeStatusFilter(filter); const compareFn = TodoStore.#makeComparator(sort); return this.#todos.filter(filterFn).toSorted(compareFn); } findById(id) { return this.#todos.find((todo) => todo.id === id); } replace(todo) { const index = this.#todos.findIndex((item) => item.id === todo.id); if (index === -1) { this.#todos.push(todo); } else { this.#todos[index] = todo; } } remove(id) { const index = this.#todos.findIndex((todo) => todo.id === id); if (index === -1) return false; this.#todos.splice(index, 1); return true; } tagCounts() { const flattened = this.#todos.flatMap((todo) => todo.tags.map((tag) => ({ tag }))); const grouped = Object.groupBy(flattened, (entry) => entry.tag); const counts = new Map(); for (const [tag, entries] of Object.entries(grouped)) { counts.set(tag, entries.length); } return counts; } findLastDone() { return this.#todos.findLast((todo) => todo.done); } allTagsUnion() { return this.#todos.reduce((acc, todo) => acc.union(new Set(todo.tags)), new Set()); } toArray() { return [...this.#todos]; } [Symbol.iterator]() { return this.#todos[Symbol.iterator](); } #nextId() { const last = this.#todos[this.#todos.length - 1]; return last ? last.id + 1 : 1; } static #makeStatusFilter(status) { if (status === 'done') return (todo) => todo.done; if (status === 'pending') return (todo) => !todo.done; return () => true; } static #makeComparator(sortKey) { if (sortKey === 'due') { return (a, b) => { if (a.due === null && b.due === null) return 0; if (a.due === null) return 1; if (b.due === null) return -1; return a.due.localeCompare(b.due); }; } if (sortKey === 'title') { return (a, b) => a.title.localeCompare(b.title); } return (a, b) => a.createdAt.localeCompare(b.createdAt); } }

src/storage/fileStore.js

// src/storage/fileStore.js import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const DATA_PATH = fileURLToPath(new URL('../../data/todos.json', import.meta.url)); export async function loadTodos() { try { const raw = await readFile(DATA_PATH, 'utf-8'); return JSON.parse(raw); } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } export async function saveTodos(todos) { const json = JSON.stringify(todos, null, 2); await writeFile(DATA_PATH, json, 'utf-8'); }

src/index.js

#!/usr/bin/env node // src/index.js import { parseArgs } from './cli/parseArgs.js'; import { TodoStore } from './models/TodoStore.js'; import { loadTodos, saveTodos } from './storage/fileStore.js'; import { runAdd } from './commands/add.js'; import { runList } from './commands/list.js'; import { runDone } from './commands/done.js'; import { runRemove } from './commands/remove.js'; import { runTags } from './commands/tags.js'; import { runSearch } from './commands/search.js'; import { runStats } from './commands/stats.js'; import { runExport } from './commands/export.js'; import { runImport } from './commands/import.js'; import { TodoNotFoundError, InvalidArgumentError, StorageError } from './errors.js'; function printHelp() { console.log(`todo-cli 사용법: todo add "제목" [--tag a,b] [--due 2026-12-31] todo list [--filter done|pending] [--sort due|title|created] [--page N] todo done <id> todo remove <id> todo tags todo search <패턴> todo stats todo export [파일 경로] todo import <파일 경로>`); } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help') { printHelp(); return; } const { command, positional, flags } = parseArgs(args); const rawTodos = await loadTodos(); const store = TodoStore.fromArray(rawTodos); try { if (command === 'add') { await runAdd(store, positional, flags); await saveTodos(store.toArray().map((todo) => todo.toJSON())); } else if (command === 'list') { runList(store, flags); } else if (command === 'tags') { runTags(store); } else if (command === 'search') { runSearch(store, positional); } else if (command === 'stats') { runStats(store); } else if (command === 'export') { await runExport(store, positional); } else if (command === 'import') { await runImport(store, positional); await saveTodos(store.toArray().map((todo) => todo.toJSON())); } else if (command === 'done') { runDone(store, positional); await saveTodos(store.toArray().map((todo) => todo.toJSON())); } else if (command === 'remove') { runRemove(store, positional); await saveTodos(store.toArray().map((todo) => todo.toJSON())); } else { console.log('알 수 없는 명령입니다:', command); printHelp(); } } catch (error) { if (error instanceof TodoNotFoundError || error instanceof InvalidArgumentError) { console.error(`오류: ${error.message}`); process.exitCode = 1; return; } if (error instanceof StorageError) { console.error(`저장소 오류: ${error.message}`); if (error.cause) console.error(`원인: ${error.cause.message}`); process.exitCode = 1; return; } console.error('예상하지 못한 오류가 발생했습니다.'); console.error(error.stack); process.exitCode = 1; } } main();

이전 편들의 if/else if 체인 구조를 그대로 유지하면서, --help 처리와 printHelp()만 새로 추가했습니다.

README.md 작성

프로젝트 루트에 사용법을 정리합니다.

<!-- README.md --> # todo-cli Node.js 22 기반 명령행 할 일 관리 CLI입니다. ## 설치와 실행 \`\`\` npm install node src/index.js --help \`\`\` ## 명령 | 명령 | 설명 | |------|------| | `todo add "제목" [--tag a,b] [--due YYYY-MM-DD]` | 할 일 추가 | | `todo list [--filter done\|pending] [--sort due\|title\|created] [--page N]` | 목록 조회 | | `todo done <id>` | 완료 처리 | | `todo remove <id>` | 삭제 | | `todo tags` | 태그별 개수 집계 | | `todo search <패턴>` | 제목 정규식 검색 | | `todo stats` | 완료율·태그별 통계 | | `todo export [경로]` / `todo import <경로>` | 백업·복원 | ## 테스트 \`\`\` node --test \`\`\`

확인

1. 전체 명령 점검

node src/index.js --help

2. 전체 테스트 실행

node --test

3. README 확인

README.md를 열어 명령 표와 실행 방법이 실제 동작과 일치하는지 하나씩 대조합니다.

  • --help 출력에 9개 명령(add, list, done, remove, tags, search, stats, export, import)이 모두 보입니다.
  • node --test 결과가 # fail 0입니다.

직접 해보기

  1. todo edit <id> "새 제목" 명령을 새로 설계해봅니다. TodoStorefindById가 이미 있으므로 찾은 항목의 title만 바꾸고, index.jselse if 분기와 saveTodos 호출을 추가하면 됩니다.
  2. data/todos.json 대신 사용자 홈 디렉터리(os.homedir())에 데이터를 저장하도록 src/storage/fileStore.jsDATA_PATH 계산 방식을 바꿔봅니다. 여러 폴더에서 todo 명령을 실행해도 같은 목록을 보게 됩니다.

정답 보기(1번)

// src/commands/edit.js import { TodoNotFoundError, InvalidArgumentError } from '../errors.js'; export function runEdit(store, positional) { const [idText, ...titleParts] = positional; const id = Number(idText); const newTitle = titleParts.join(' '); if (!newTitle) { throw new InvalidArgumentError('새 제목이 필요합니다: todo edit <id> "새 제목"'); } const todo = store.findById(id); if (!todo) { throw new TodoNotFoundError(id); } todo.title = newTitle; console.log(`수정됨: #${todo.id} ${todo.title}`); }

index.js에는 } else if (command === 'edit') { runEdit(store, positional); await saveTodos(...); }처럼 기존 분기와 같은 모양으로 추가합니다.

자주 하는 실수

증상원인고치는 법
--help가 다른 명령과 충돌parseArgs--help를 플래그로만 처리main() 맨 앞에서 args[0] === '--help'를 먼저 확인
새 명령 추가 후 데이터가 저장 안 됨상태를 바꾸는 명령인데 saveTodos 호출을 빠뜨림목록을 바꾸는 분기 끝에는 항상 await saveTodos(...)를 추가한다
README와 실제 동작이 어긋남기능 추가 후 문서를 안 고침기능을 추가할 때마다 README 명령 표도 함께 수정

확인 문제

문제 14지선다
index.js에서 명령마다 처리 후 saveTodos를 호출하는 기준은?
문제 24지선다
TodoStore가 Symbol.iterator를 구현해서 얻는 이점은?
문제 34지선다
README.md를 프로젝트에 두는 목적으로 가장 알맞은 것은?
문제 44지선다
새 명령(예: edit)을 추가할 때 함께 고려해야 할 것으로 옳지 않은 것은?

참고 자료

Last updated on