이번 편의 결과물: todo-cli를 실행하면 data/todos.json에서 목록을 불러오고, add 실행 후 같은 파일에 저장합니다. 파일이 없어도 오류 없이 빈 목록으로 시작합니다. · 다루는 개념: fs/promises(readFile/writeFile), async/await, try/catch 에러 처리
이 편에서 만드는 파일
todo-cli/
├── data/
│ └── todos.json (+, 시드 데이터)
└── src/
├── index.js (~)
├── models/
│ └── TodoStore.js (~)
└── storage/
└── fileStore.js (+)개념 정리
fs/promises
Node.js의 node:fs 모듈은 콜백 기반 API가 기본이지만, node:fs/promises는 같은 기능을 Promise로 감싸 제공합니다. async/await와 함께 쓰면 콜백 중첩 없이 순서대로 읽을 수 있습니다.
| 함수 | 역할 |
|---|---|
readFile(path, 'utf-8') | 파일 내용을 문자열로 읽음(Promise 반환) |
writeFile(path, data, 'utf-8') | 문자열을 파일에 씀(기존 내용 덮어씀) |
async/await와 07편의 관계
await는 Promise가 처리(완료 또는 실패)될 때까지 그 줄에서 함수 실행을 멈춥니다. 07편에서 본 것처럼 이 대기는 콜스택을 막지 않고, 대기하는 동안 다른 동기 코드가 먼저 실행될 수 있습니다. async 함수는 항상 Promise를 반환합니다.
async function loadData() {
const raw = await readFile('data.json', 'utf-8'); // 여기서 대기
return JSON.parse(raw);
}try/catch로 실패 다루기
파일이 없거나 JSON 형식이 깨졌을 때 프로그램이 스택 트레이스를 그대로 뿜어내면 사용자가 알아보기 어렵습니다. try/catch로 감싸 상황별로 다르게 처리합니다.
| 에러 상황 | error.code | 처리 방법 |
|---|---|---|
| 파일이 아직 없음(첫 실행) | ENOENT | 빈 배열로 시작(정상 상황) |
| JSON 형식이 깨짐 | 없음(SyntaxError) | 사용자에게 파일이 손상되었다고 안내(14편에서 더 다룸) |
| 그 외 파일 시스템 오류 | 다양함 | 다시 던져서 프로그램을 중단 |
error.code === 'ENOENT'만 정상 상황으로 처리하고, 나머지는 throw로 다시 던져야 진짜 문제를 숨기지 않습니다.
실습
1. data 폴더와 시드 파일 만들기
// data/todos.json
[
{
"id": 1,
"title": "ECMAScript 03편 실습 끝내기",
"done": true,
"tags": ["study"],
"due": "2026-09-18",
"createdAt": "2026-09-15T09:00:00+09:00"
},
{
"id": 2,
"title": "포트폴리오 프로젝트 카드 6개 채우기",
"done": false,
"tags": ["portfolio", "html"],
"due": "2026-09-20",
"createdAt": "2026-09-15T10:30:00+09:00"
},
{
"id": 3,
"title": "todo-cli에 검색 명령 추가",
"done": false,
"tags": ["study", "node"],
"due": "2026-09-22",
"createdAt": "2026-09-16T08:10:00+09:00"
},
{
"id": 4,
"title": "운동 30분",
"done": false,
"tags": ["health"],
"due": null,
"createdAt": "2026-09-16T07:00:00+09:00"
},
{
"id": 5,
"title": "독서 기록 앱 별점 기능",
"done": true,
"tags": ["react"],
"due": "2026-09-14",
"createdAt": "2026-09-12T20:00:00+09:00"
}
]2. 코드 작성
// 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');
}DATA_PATH는 fileStore.js 파일 위치를 기준으로 계산합니다. import.meta.url은 현재 모듈 파일의 URL이고, new URL('../../data/todos.json', ...)으로 상대 경로를 절대 경로로 바꾼 뒤 fileURLToPath로 파일 시스템 경로 문자열로 변환합니다. 이렇게 하면 어느 폴더에서 node를 실행하든 항상 같은 data/todos.json을 가리킵니다.
// src/models/TodoStore.js
import { Todo } from './Todo.js';
export class TodoStore {
#todos = [];
#nextId = 1;
static fromData(dataList) {
const store = new TodoStore();
for (const item of dataList) {
store.#todos.push(Object.assign(Object.create(Todo.prototype), item));
}
store.#nextId = store.#todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
return store;
}
toData() {
return this.#todos.map((todo) => ({ ...todo }));
}
add(title, options) {
const todo = new Todo(this.#nextId, title, options);
this.#todos.push(todo);
this.#nextId += 1;
return todo;
}
list({ filter, sort } = {}) {
const filterFn = TodoStore.#makeStatusFilter(filter);
const compareFn = TodoStore.#makeComparator(sort);
return this.#todos.filter(filterFn).sort(compareFn);
}
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);
};
}
return (a, b) => a.createdAt.localeCompare(b.createdAt);
}
}fromData는 저장된 순수 객체({ id, title, ... })를 Object.assign(Object.create(Todo.prototype), item)으로 감싸 Todo.prototype의 메서드(toDisplayLine)를 쓸 수 있게 만듭니다. JSON.parse가 만든 객체는 일반 객체일 뿐 Todo 인스턴스가 아니기 때문에 이 과정이 필요합니다. toData는 반대로 저장 직전에 클래스 인스턴스를 순수 객체 배열로 펼칩니다.
// src/index.js
import { parseArgs } from './cli/parseArgs.js';
import { TodoStore } from './models/TodoStore.js';
import { runAdd } from './commands/add.js';
import { runList } from './commands/list.js';
import { loadTodos, saveTodos } from './storage/fileStore.js';
async function main() {
const savedData = await loadTodos();
const store = TodoStore.fromData(savedData);
const argv = process.argv.slice(2);
const { command, positional, flags } = parseArgs(argv);
if (command === 'add') {
runAdd(store, positional, flags);
await saveTodos(store.toData());
} else if (command === 'list') {
runList(store, flags);
} else {
console.log('알 수 없는 명령입니다:', command);
}
}
main().catch((error) => {
console.error('실행 중 오류가 발생했습니다:', error.message);
process.exitCode = 1;
});main 함수 전체를 async로 선언해 안에서 await를 쓸 수 있게 합니다. add일 때만 saveTodos를 호출해 목록이 바뀐 경우에만 파일에 씁니다. main() 호출 뒤 .catch(...)를 붙여, main 안에서 처리하지 못하고 새어 나온 에러를 사용자용 메시지로 바꿔 출력합니다.
3. 실행
node src/index.js listnode src/index.js add "새 할일" --tag study --due 2026-09-25확인
- 첫
list실행 시data/todos.json의 5개 항목이 출력됩니다. add실행 후data/todos.json을 다시 열어 새 항목이 추가되어 있는지 확인합니다.data/todos.json을 삭제한 뒤node src/index.js list를 실행해도 오류 없이 빈 목록(출력 없음)으로 시작하는지 확인합니다. 삭제했다면 실습 1단계 내용으로 다시 만들어 둡니다.
직접 해보기
data/todos.json을 열어[다음에 아무 글자나 하나 추가해 일부러 JSON을 깨뜨린 뒤 실행해 어떤 에러 메시지가 나오는지 관찰하고 원래대로 되돌립니다.saveTodos호출 앞에console.log('저장 시작');, 뒤에console.log('저장 완료');를 추가해await가 실제로 저장이 끝날 때까지 다음 줄 실행을 멈추는지 확인합니다.
정답 보기
JSON을 깨뜨리면 JSON.parse(raw)에서 SyntaxError: Unexpected token ... in JSON이 발생하고, 이 에러는 error.code가 없으므로 catch 블록에서 throw error로 다시 던져집니다. 결국 main().catch(...)가 이를 잡아 “실행 중 오류가 발생했습니다: Unexpected token …”을 출력합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
await를 썼는데 SyntaxError: await is only valid in async functions | await를 async 함수 밖에서 사용 | await를 쓰는 함수 앞에 반드시 async를 붙인다 |
| 저장이 안 된 것처럼 보임 | saveTodos 호출에 await를 빠뜨려 프로그램이 먼저 종료됨 | await saveTodos(...)처럼 반드시 기다린다 |
| 파일이 없을 때도 프로그램이 죽음 | ENOENT 코드를 확인하지 않고 모든 에러를 다시 던짐 | error.code === 'ENOENT'일 때만 빈 배열을 반환하도록 분기한다 |