이번 편의 결과물: src/index.js 하나에 있던 코드를 src/models/Todo.js, src/models/TodoStore.js, src/commands/add.js, src/commands/list.js로 나눕니다. 실행 결과는 05편과 동일합니다. · 다루는 개념: import/export, 모듈 스코프, 파일 단위 책임 분리
이 편에서 만드는 파일
todo-cli/
└── src/
├── index.js (~)
├── models/
│ ├── Todo.js (+)
│ └── TodoStore.js (+)
└── commands/
├── add.js (+)
└── list.js (+)개념 정리
export와 import
ESM(ECMAScript Modules)에서는 파일마다 독립된 스코프를 가지며, export로 명시한 것만 다른 파일에서 import로 가져다 쓸 수 있습니다.
| 문법 | 형태 | 가져오는 쪽 |
|---|---|---|
| 이름 있는 내보내기 | export class Todo { ... } | import { Todo } from './Todo.js'; |
| 이름 있는 내보내기(여러 개) | export { a, b }; | import { a, b } from './file.js'; |
| 기본 내보내기 | export default class TodoStore { ... } | import TodoStore from './TodoStore.js'; |
이 프로젝트는 한 파일에서 여러 개를 내보낼 일이 거의 없어 이름 있는 내보내기로 통일합니다. 기본 내보내기는 파일마다 하나뿐이라 가져오는 이름을 자유롭게 바꿀 수 있지만, 이름을 통일해 두면 어느 파일에서 왔는지 검색하기 쉽습니다.
모듈 스코프와 상대 경로
각 .js 파일은 자신만의 스코프를 갖습니다. Todo.js에서 선언한 변수는 export하지 않는 한 다른 파일에서 보이지 않습니다. Node.js의 ESM은 파일 경로를 확장자까지 포함해 정확히 씁니다.
// 틀림: 확장자 생략(브라우저 번들러 관례, Node ESM에서는 오류)
import { Todo } from './Todo';
// 맞음
import { Todo } from './Todo.js';책임에 따라 파일 나누기
지금까지 src/index.js 한 파일이 클래스 정의, 명령 처리, 진입점 역할을 모두 맡고 있었습니다. 이번 편부터 역할별로 나눕니다.
| 파일 | 책임 |
|---|---|
src/models/Todo.js | 할 일 하나의 데이터와 표시 형식 |
src/models/TodoStore.js | 할 일 목록의 저장·필터·정렬 로직 |
src/commands/add.js | add 명령 처리 |
src/commands/list.js | list 명령 처리 |
src/index.js | 인자 파싱 후 알맞은 명령 함수 호출(진입점) |
src/index.js는 이제 어떤 클래스 내부 로직도 직접 알 필요가 없습니다. 명령 이름에 맞는 함수를 호출하기만 합니다.
실습
1. 폴더 만들기
src/models/, src/commands/ 폴더를 만듭니다.
2. 코드 작성
// src/models/Todo.js
export class Todo {
constructor(id, title, { tag, due } = {}) {
this.id = id;
this.title = title;
this.done = false;
this.tags = tag ? [tag] : [];
this.due = due ?? null;
this.createdAt = new Date().toISOString();
}
toDisplayLine() {
const mark = this.done ? '[x]' : '[ ]';
const tagText = this.tags.length > 0 ? this.tags.join(', ') : '없음';
const dueText = this.due ?? '없음';
return `${mark} #${this.id} ${this.title} (태그: ${tagText}, 마감: ${dueText})`;
}
}// src/models/TodoStore.js
import { Todo } from './Todo.js';
export class TodoStore {
#todos = [];
#nextId = 1;
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);
}
}// src/commands/add.js
export function runAdd(store, positional, flags) {
const [title] = positional;
const created = store.add(title, flags);
console.log('추가됨:', created.toDisplayLine());
store.list().forEach((todo) => console.log(todo.toDisplayLine()));
}// src/commands/list.js
export function runList(store, flags) {
store.list(flags).forEach((todo) => console.log(todo.toDisplayLine()));
}// 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';
const store = new TodoStore();
store.add('리포트 제출', { tag: 'work', due: '2026-09-20' });
store.add('운동하기', { tag: 'health' });
store.add('todo-cli에 검색 명령 추가', { tag: 'study', due: '2026-09-22' });
const argv = process.argv.slice(2);
const { command, positional, flags } = parseArgs(argv);
if (command === 'add') {
runAdd(store, positional, flags);
} else if (command === 'list') {
runList(store, flags);
} else {
console.log('알 수 없는 명령입니다:', command);
}TodoStore.js가 Todo.js를 import하고, index.js는 TodoStore.js와 두 명령 파일을 import합니다. add.js와 list.js는 서로를 몰라도 되고, Todo 클래스가 어떻게 생겼는지도 몰라도 됩니다. store가 넘겨주는 Todo 인스턴스의 toDisplayLine()만 호출하면 됩니다.
3. 실행
node src/index.js list --sort due확인
- 05편과 같은 출력이 나오는지 확인합니다.
[ ] #1 리포트 제출 (태그: work, 마감: 2026-09-20)
[ ] #3 todo-cli에 검색 명령 추가 (태그: study, 마감: 2026-09-22)
[x] #2 운동하기 (태그: health, 마감: 없음)src/index.js를 열어class나filter/sort같은 구현 코드가 더 이상 보이지 않고,import문과 명령 분기만 남았는지 확인합니다.
직접 해보기
src/commands/list.js를 열어runList맨 위에console.log('실행:', 'list');를 추가하고,src/commands/add.js에도 같은 방식으로 로그를 추가합니다. 두 파일이 서로의 코드를 몰라도 독립적으로 수정 가능한지 확인합니다.src/models/Todo.js에서export를 지우고 실행해 어떤 오류가 나는지 관찰한 뒤 다시 되돌립니다.
정답 보기
export를 지우면 TodoStore.js의 import { Todo } from './Todo.js';가 Todo를 찾지 못해 SyntaxError: The requested module './Todo.js' does not provide an export named 'Todo'가 발생합니다. export는 파일 간 경계를 만드는 핵심 키워드입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
Cannot find module './Todo' | 상대 경로에 확장자(.js)를 생략함 | Node ESM은 확장자를 반드시 포함해야 한다 |
does not provide an export named 'Todo' | 클래스 선언 앞에 export를 빠뜨림 | export class Todo 형태로 선언한다 |
순환 참조로 값이 undefined로 들어옴 | A가 B를, B가 다시 A를 import함 | 공통으로 쓰는 부분을 별도 파일로 뽑아 양쪽이 그 파일만 참조하게 한다 |