이번 편의 결과물: 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| 편 | 만든 것 |
|---|---|
| 01 | package.json, src/index.js(뼈대) |
| 02 | process.argv 파싱 |
| 03–04 | 메모리 배열 기반 add/list, 필터·정렬 |
| 05 | Todo/TodoStore 클래스로 리팩터링 |
| 06 | models/, store/(→ models/TodoStore.js), cli/로 모듈 분리 |
| 07 | 이벤트 루프 개념(실습 파일 없음) |
| 08 | storage/fileStore.js, 비동기 저장·로드 |
| 09 | TodoStore가 이터러블, list --page 제너레이터 |
| 10 | 태그를 Map/Set으로 관리, tags 명령 |
| 11 | commands/search.js(정규식) |
| 12 | utils/date.js(D-데이 표시), commands/stats.js |
| 13 | commands/export.js, commands/import.js, TodoStore.replace |
| 14 | errors.js, commands/done.js/remove.js, TodoStore.remove, 사용자용 에러 메시지 |
| 15 | test/todo-store.test.js |
| 16 | TodoStore 최신 문법 리팩터링 |
| 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 --help2. 전체 테스트 실행
node --test3. README 확인
README.md를 열어 명령 표와 실행 방법이 실제 동작과 일치하는지 하나씩 대조합니다.
--help출력에 9개 명령(add,list,done,remove,tags,search,stats,export,import)이 모두 보입니다.node --test결과가# fail 0입니다.
직접 해보기
todo edit <id> "새 제목"명령을 새로 설계해봅니다.TodoStore에findById가 이미 있으므로 찾은 항목의title만 바꾸고,index.js에else if분기와saveTodos호출을 추가하면 됩니다.data/todos.json대신 사용자 홈 디렉터리(os.homedir())에 데이터를 저장하도록src/storage/fileStore.js의DATA_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