Skip to Content
WebJavaScriptECMAScript10. Map·Set·Symbol로 태그 관리

이번 편의 결과물: 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.stringifyfor...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는 이 파일 밖에서 가져다 쓸 수 있도록 exportSymbol 상수입니다. 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}개`); } }

Mapfor...of로 순회하면 [키, 값] 쌍의 배열을 하나씩 내주므로, for (const [tag, count] of counts)처럼 바로 구조 분해할 수 있습니다.

src/index.jstags 명령 분기를 추가합니다.

// 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,work
node 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 대상에서 자동으로 빠지기 때문입니다.

직접 해보기

  1. tagCounts()가 반환한 Map을 개수가 많은 순서로 정렬해 출력하도록 runTags를 고쳐봅니다.
  2. 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만 씀처음 등장한 태그는 getundefined를 반환해 NaN이 됨??로 초기값 0을 지정한다
태그가 여전히 중복으로 저장됨Todo 생성자가 아니라 다른 곳에서 배열을 직접 만듦태그 배열은 항상 Todo 생성자를 거치게 한다
DIRTY 값이 data/todos.json에 보일까 걱정함Symbol 키의 동작을 오해함Symbol 키는 JSON.stringify에서 항상 제외되므로 신경 쓰지 않아도 된다

확인 문제

문제 14지선다
Map과 일반 객체({})의 차이로 옳은 것은?
문제 24지선다
Array.from(new Set(['a', 'b', 'a']))의 결과는?
문제 34지선다
Symbol을 객체의 프로퍼티 키로 쓸 때의 특징은?
문제 44지선다
tagCounts 메서드에서 counts.set(tag, (counts.get(tag) ?? 0) + 1)이 하는 일은?

참고 자료

Last updated on