이번 편의 결과물: add --tag work,urgent로 여러 태그를 한 번에 붙이고(중복은 자동 제거), tags 명령으로 태그별 개수를 집계해 출력합니다. · 다루는 개념: Map 활용, Set으로 중복 제거, Symbol로 내부 전용 필드 은닉
이 편에서 만드는 파일
todo-cli/
└── src/
├── models/
│ ├── Todo.js (~)
│ └── TodoStore.js (~)
└── commands/
├── add.js (~)
└── tags.js (+)src/index.js에도 tags 명령 분기가 하나 추가됩니다.
개념 정리
Map — 키와 개수를 짝지어 저장
Map은 객체와 달리 어떤 값이든 키로 쓸 수 있고, 등록한 순서를 기억합니다. 태그별 개수를 셀 때처럼 “키 → 값” 쌍이 여러 개 필요할 때 적합합니다.
| 메서드/프로퍼티 | 역할 |
|---|---|
map.set(key, value) | 키-값 쌍 저장(있으면 덮어씀) |
map.get(key) | 키에 해당하는 값 조회(없으면 undefined) |
map.has(key) | 키 존재 여부 확인 |
map.size | 저장된 항목 개수 |
일반 객체({})로도 비슷하게 만들 수 있지만, 객체는 키가 문자열이나 Symbol로 강제 변환되고 toString 같은 상속된 프로퍼티와 섞일 위험이 있습니다. Map은 이런 문제 없이 순수하게 키-값 저장 용도로만 씁니다.
Set — 중복 없는 값 모음
Set은 같은 값을 한 번만 저장합니다. 태그처럼 “중복 없이 모아야 하는” 값에 적합합니다.
| 메서드 | 역할 |
|---|---|
set.add(value) | 값 추가(이미 있으면 무시) |
set.has(value) | 값 존재 여부 확인 |
Array.from(set) | Set을 배열로 변환 |
new Set(배열)로 배열의 중복을 한 번에 제거할 수 있습니다. Array.from(new Set(['a', 'b', 'a']))의 결과는 ['a', 'b']입니다.
Symbol — 충돌 없는 고유 키
Symbol()은 호출할 때마다 세상에 하나뿐인 고유한 값을 만듭니다. 객체의 프로퍼티 키로 쓰면 문자열 키와 절대 충돌하지 않고, JSON.stringify나 for...in, Object.keys에서 자동으로 제외됩니다.
| 특징 | 설명 |
|---|---|
| 고유성 | Symbol('id') !== Symbol('id') (설명 문자열이 같아도 다른 값) |
| 열거 제외 | Object.keys, for...in, JSON.stringify에 나타나지 않음 |
| 용도 | 저장 형식에 영향을 주면 안 되는 내부 전용 표시(마킹)에 적합 |
todo-cli에서는 새로 추가된 할 일을 아직 파일에 저장하지 않았다는 것을 표시하는 내부 전용 마크에 Symbol을 씁니다. 이 마크는 Todo 객체에 붙어 있어도 JSON.stringify(todo) 결과에는 나타나지 않습니다.
실습
1. src/models/Todo.js 열기
기존 필드는 그대로 두고, 태그 배열을 Set으로 정리하고 저장 여부를 표시하는 Symbol 키를 추가합니다.
2. 코드 작성
// 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 };
}
}Array.from(new Set(tags))는 tags 배열에 같은 태그가 두 번 들어와도 한 번만 남깁니다. DIRTY는 이 파일 밖에서 가져다 쓸 수 있도록 export한 Symbol 상수입니다. this[DIRTY] = false로 초기값을 표시해두고, 필요할 때 todo[DIRTY] = true로 바꿉니다. toJSON이 이름 있는 필드만 골라 반환하므로 DIRTY 마크는 애초에 저장 대상에 들어가지 않습니다.
이제 TodoStore에 태그 집계 메서드를 추가합니다.
// src/models/TodoStore.js
import { Todo, DIRTY } 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 });
todo[DIRTY] = true;
this.#todos.push(todo);
return todo;
}
list({ filter, sort } = {}) {
return this.#todos.filter(makeStatusFilter(filter)).sort(makeComparator(sort));
}
findById(id) {
return this.#todos.find((todo) => todo.id === id);
}
tagCounts() {
const counts = new Map();
for (const todo of this.#todos) {
for (const tag of todo.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}
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;
}
}
function makeStatusFilter(status) {
if (status === 'done') return (todo) => todo.done;
if (status === 'pending') return (todo) => !todo.done;
return () => true;
}
function 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);
}add의 두 번째 매개변수가 { tag, due }에서 { tags, due }로 바뀌었습니다. 여러 태그를 배열로 받도록 src/commands/add.js도 함께 고칩니다.
// src/commands/add.js
export async function runAdd(store, positional, flags) {
const [title] = positional;
const tags = (flags.tag ?? '')
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
const created = store.add(title, { tags, due: flags.due });
console.log('추가됨:', created.toJSON());
}--tag work,urgent,work처럼 쉼표로 여러 태그를 넣고 같은 태그를 중복으로 적어도, Todo 생성자의 Set 정리 덕분에 최종적으로는 ['work', 'urgent']만 남습니다.
마지막으로 태그 집계 명령을 추가합니다.
// src/commands/tags.js
export function runTags(store) {
const counts = store.tagCounts();
if (counts.size === 0) {
console.log('등록된 태그가 없습니다.');
return;
}
for (const [tag, count] of counts) {
console.log(`${tag}: ${count}개`);
}
}Map은 for...of로 순회하면 [키, 값] 쌍의 배열을 하나씩 내주므로, for (const [tag, count] of counts)처럼 바로 구조 분해할 수 있습니다.
src/index.js에 tags 명령 분기를 추가합니다.
// 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 { runTags } from './commands/tags.js';
const argv = process.argv.slice(2);
const { command, positional, flags } = parseArgs(argv);
const rawTodos = await loadTodos();
const store = TodoStore.fromArray(rawTodos);
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 {
console.log('알 수 없는 명령입니다:', command);
}3. 실행
node src/index.js add "발표 자료 검토" --tag work,urgent,worknode src/index.js tags확인
add실행 결과의tags필드에work,urgent가 중복 없이 한 번씩만 나타납니다.
추가됨: {
id: 6,
title: '발표 자료 검토',
done: false,
tags: [ 'work', 'urgent' ],
due: null,
createdAt: '(실행 시각)'
}tags실행 결과(기존 seed 데이터 기준, 방금 추가한 항목 포함):
study: 2개
portfolio: 1개
html: 1개
node: 1개
health: 1개
react: 1개
work: 1개
urgent: 1개study가 2개인 이유는 seed 데이터의 1번(ECMAScript 03편 실습 끝내기)과 3번(todo-cli에 검색 명령 추가) 항목이 둘 다 study 태그를 가지고 있기 때문입니다.
data/todos.json을 직접 열어봐도DIRTY관련 필드는 전혀 보이지 않습니다.Symbol키는JSON.stringify대상에서 자동으로 빠지기 때문입니다.
직접 해보기
tagCounts()가 반환한Map을 개수가 많은 순서로 정렬해 출력하도록runTags를 고쳐봅니다.new Set(['work', 'urgent']).has('work')를 콘솔에서 직접 실행해true가 나오는지 확인합니다.
정답 보기
// src/commands/tags.js
export function runTags(store) {
const counts = store.tagCounts();
if (counts.size === 0) {
console.log('등록된 태그가 없습니다.');
return;
}
const sorted = [...counts].sort((a, b) => b[1] - a[1]);
for (const [tag, count] of sorted) {
console.log(`${tag}: ${count}개`);
}
}Map을 스프레드(...)로 펼치면 [키, 값] 쌍의 배열이 되고, 이 배열을 일반 배열처럼 sort할 수 있습니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
counts.get(tag) ?? 0을 안 쓰고 counts.get(tag) + 1만 씀 | 처음 등장한 태그는 get이 undefined를 반환해 NaN이 됨 | ??로 초기값 0을 지정한다 |
| 태그가 여전히 중복으로 저장됨 | Todo 생성자가 아니라 다른 곳에서 배열을 직접 만듦 | 태그 배열은 항상 Todo 생성자를 거치게 한다 |
DIRTY 값이 data/todos.json에 보일까 걱정함 | Symbol 키의 동작을 오해함 | Symbol 키는 JSON.stringify에서 항상 제외되므로 신경 쓰지 않아도 된다 |