Skip to Content
WebJavaScriptECMAScript06. ESM으로 모듈 분리하기

이번 편의 결과물: 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.jsadd 명령 처리
src/commands/list.jslist 명령 처리
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.jsTodo.jsimport하고, index.jsTodoStore.js와 두 명령 파일을 import합니다. add.jslist.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를 열어 classfilter/sort 같은 구현 코드가 더 이상 보이지 않고, import문과 명령 분기만 남았는지 확인합니다.

직접 해보기

  1. src/commands/list.js를 열어 runList 맨 위에 console.log('실행:', 'list');를 추가하고, src/commands/add.js에도 같은 방식으로 로그를 추가합니다. 두 파일이 서로의 코드를 몰라도 독립적으로 수정 가능한지 확인합니다.
  2. src/models/Todo.js에서 export를 지우고 실행해 어떤 오류가 나는지 관찰한 뒤 다시 되돌립니다.

정답 보기

export를 지우면 TodoStore.jsimport { 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공통으로 쓰는 부분을 별도 파일로 뽑아 양쪽이 그 파일만 참조하게 한다

확인 문제

문제 14지선다
ESM에서 export한 클래스를 다른 파일에서 쓰려면?
문제 24지선다
Node.js ESM에서 상대 경로 import 시 지켜야 할 규칙은?
문제 34지선다
src/index.js를 명령 처리 함수 호출부만 남기고 클래스 로직을 models/, commands/로 옮긴 이유는?
문제 44지선다
한 파일에서 여러 항목을 이름 있는 내보내기로 export할 때의 특징은?

참고 자료

Last updated on