Skip to Content
WebJavaScriptECMAScript05. 프로토타입과 class로 Todo, TodoStore 만들기

이번 편의 결과물: todos 배열과 낱개 함수로 되어 있던 04편 코드를 Todo, TodoStore 클래스로 리팩터링합니다. add, list --filter, list --sort 동작은 04편과 동일합니다. · 다루는 개념: 프로토타입 체인, class 문법(필드·메서드·private 필드), 캡슐화

이 편에서 만드는 파일

todo-cli/ └── src/ └── index.js (~)

개념 정리

프로토타입 체인

JavaScript 객체는 자신에게 없는 프로퍼티를 찾을 때 자신의 프로토타입(prototype)을 따라 올라갑니다. 이 연결을 프로토타입 체인이라고 합니다. class 문법이 없던 시절에는 함수와 prototype 객체로 이 체인을 직접 구성했습니다.

function Todo(id, title) { this.id = id; this.title = title; this.done = false; } Todo.prototype.toDisplayLine = function () { const mark = this.done ? '[x]' : '[ ]'; return `${mark} #${this.id} ${this.title}`; }; const todo = new Todo(1, '리포트 제출'); todo.toDisplayLine(); // '[ ] #1 리포트 제출'

new Todo(...)로 만든 객체는 자신에게 id, title, done만 갖고 있습니다. toDisplayLineTodo.prototype에만 있지만, todo.toDisplayLine()을 호출하면 자신에게 없으니 프로토타입 체인을 따라 Todo.prototype에서 찾아 실행합니다. Object.getPrototypeOf(todo) === Todo.prototypetrue입니다.

class는 프로토타입 위의 문법

class 문법은 이 프로토타입 체인을 자동으로 구성해 주는 표기법(문법적 설탕)입니다. 위 코드와 아래 코드는 동작이 같습니다.

class Todo { constructor(id, title) { this.id = id; this.title = title; this.done = false; } toDisplayLine() { const mark = this.done ? '[x]' : '[ ]'; return `${mark} #${this.id} ${this.title}`; } }

toDisplayLine처럼 클래스 본문에 적은 메서드는 인스턴스가 아니라 Todo.prototype에 정의됩니다. 인스턴스마다 메서드 복사본이 생기지 않아 메모리를 아낍니다.

private 필드로 캡슐화

#으로 시작하는 필드는 클래스 밖에서 접근할 수 없는 private 필드입니다. TodoStore가 관리하는 목록 배열을 외부에서 직접 바꾸지 못하게 막을 때 씁니다.

표기접근 범위
this.title클래스 안팎 어디서나 읽고 쓸 수 있음(public)
this.#todos같은 클래스 안의 메서드에서만 접근 가능(private)
static #makeComparator인스턴스가 아니라 클래스 자체에 속한 private 메서드

store.#todos를 클래스 밖에서 쓰면 문법 오류가 납니다. 목록을 읽거나 바꾸려면 반드시 store.add(...), store.list(...) 같은 공개 메서드를 거쳐야 합니다. 이것이 캡슐화입니다.

실습

1. src/index.js 열기

04편의 todos 배열, addTodo, makeStatusFilter, makeComparator, printList 함수를 Todo, TodoStore 클래스로 옮깁니다.

2. 코드 작성

// src/index.js import { parseArgs } from './cli/parseArgs.js'; 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})`; } } 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); } } function printList(list) { list.forEach((todo) => console.log(todo.toDisplayLine())); } 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') { const [title] = positional; const created = store.add(title, flags); console.log('추가됨:', created.toDisplayLine()); printList(store.list()); } else if (command === 'list') { printList(store.list(flags)); } else { console.log('알 수 없는 명령입니다:', command); }

store.#todos, TodoStore.#makeStatusFilter, TodoStore.#makeComparator는 모두 TodoStore 클래스 밖에서 접근할 수 없습니다. index.js의 나머지 코드는 store.add(...)store.list(...)만 호출합니다. done 두 번째 완료 항목이 이번 편부터 시작 시 미리 세 개 채워지는 이유는 08편(파일 저장)까지는 저장 기능이 없어서, 실행할 때마다 같은 예시 데이터로 시작하기 때문입니다.

3. 실행

node src/index.js list --filter pending
node src/index.js add "새 할일" --tag study

확인

  • list --filter pending 결과가 04편과 같은 두 항목인지 확인합니다.
[ ] #1 리포트 제출 (태그: work, 마감: 2026-09-20) [ ] #3 todo-cli에 검색 명령 추가 (태그: study, 마감: 2026-09-22)
  • Todo.prototype.toDisplayLine이 실제로 프로토타입에 있는지 확인하려면 파일 맨 아래에 console.log(Object.getPrototypeOf(store.list()[0]) === Todo.prototype);을 추가해 실행하고, true가 나오는지 봅니다(확인 후 삭제해도 됩니다).

직접 해보기

  1. Todo 클래스에 get summary() 게터를 추가해 todo.summary"제목 (완료)" 또는 "제목 (미완료)" 문자열을 바로 읽을 수 있게 해봅니다.
  2. TodoStoreget size() 게터를 추가해 store.size로 전체 할 일 개수를 읽을 수 있게 해봅니다. #todos.length를 그대로 반환하면 됩니다.

정답 보기

// Todo 클래스 안 get summary() { return `${this.title} (${this.done ? '완료' : '미완료'})`; } // TodoStore 클래스 안 get size() { return this.#todos.length; }

자주 하는 실수

증상원인고치는 법
SyntaxError: Private field '#todos' must be declared in an enclosing classprivate 필드를 클래스 밖에서 참조#으로 시작하는 필드는 항상 같은 클래스 안 메서드에서만 쓴다
thisundefined로 나옴클래스 메서드를 일반 함수처럼 따로 떼어 호출(const fn = store.list; fn())메서드는 항상 store.list()처럼 인스턴스에 붙여서 호출한다
new 없이 Todo(1, '제목')을 호출해 오류 발생class는 반드시 new로 생성해야 함class로 만든 생성자는 new Todo(...)처럼 항상 new를 붙인다

확인 문제

문제 14지선다
프로토타입 체인에 대한 설명으로 옳은 것은?
문제 24지선다
class 문법과 프로토타입 기반 함수 생성자의 관계로 옳은 것은?
문제 34지선다
private 필드(#todos)를 클래스 밖에서 직접 store.#todos로 접근하면?
문제 44지선다
TodoStore에서 add와 list를 public 메서드로, #todos를 private 필드로 설계한 이유로 가장 적절한 것은?

참고 자료

Last updated on