본문 바로가기

Symbol, Map, Set

고유 키인 Symbol, 임의 타입 키를 갖는 Map, 중복을 제거하는 Set의 자료 구조와 순회 방식을 익힙니다.

8장의 앞 글들에서는 let과 const, 화살표 함수, 구조 분해 할당과 스프레드 문법으로 문법이 어떻게 현대화되었는지 살펴봤습니다. ES2015(ES6)는 문법뿐 아니라 데이터 타입과 컬렉션(collection) 객체에도 큰 변화를 가져왔습니다. 이 글에서는 새 원시 타입인 Symbol과, 객체와 배열만으로 다루기 불편했던 데이터를 담는 컬렉션 Map과 Set을 살펴봅니다.


Symbol

Symbol은 ES2015에 추가된 원시 타입(primitive type)입니다. Symbol 값은 만들 때마다 다른 값이 되는 유일한 값이고 바꿀 수 없습니다. 주로 객체 속성의 키로 써서, 다른 코드가 만든 속성과 이름이 겹치지 않는 속성을 만들 때 씁니다.

Symbol 만들기

Symbol() 함수를 호출해 만들며, 원시값이라 new를 붙이지 않습니다. new Symbol()처럼 호출하면 TypeError가 납니다.

const mySymbol1 = Symbol();
const mySymbol2 = Symbol();

console.log(mySymbol1 === mySymbol2); // 결과: false (항상 유일한 값)

// 설명을 추가할 수 있습니다 (디버깅 용이)
const id = Symbol('id');
const id2 = Symbol('id'); // 설명이 같아도 심볼 값은 다릅니다.
console.log(id === id2); // 결과: false

Symbol('id')의 'id'는 설명(description)으로, 콘솔에 Symbol(id)처럼 보여 디버깅을 돕습니다. 설명이 같아도 만들 때마다 다른 값이므로, 설명은 값을 구별하는 데 쓰이지 않습니다.

객체의 속성 키로 쓰기

Symbol의 주된 쓰임은 객체 속성의 키입니다. Symbol로 만든 키는 어떤 문자열 키나 다른 Symbol 키와도 겹치지 않습니다.

const userId = Symbol('user_id');
const userRole = Symbol('user_role');

const user = {
    name: "Alice",
    [userId]: 1234, // Computed property name (계산된 프로퍼티 이름) 문법 사용
    [userRole]: "admin"
};

console.log(user.name);    // 결과: Alice
console.log(user[userId]); // 결과: 1234
console.log(user[userRole]); // 결과: admin

// 다른 곳에서 동일한 설명으로 심볼을 만들어도 충돌하지 않습니다.
const anotherUserId = Symbol('user_id');
user[anotherUserId] = 5678; // 새로운 고유한 프로퍼티가 추가됩니다.
console.log(user[anotherUserId]); // 결과: 5678
console.log(user[userId]);       // 결과: 1234 (기존 userId와 다름)

객체 리터럴 안에서 [userId]처럼 대괄호로 감싸면 변수에 담긴 값을 키로 쓰는 계산된 속성 이름(computed property name)이 됩니다. 같은 설명 'user_id'로 Symbol을 하나 더 만들어 속성을 추가해도, 기존 userId 속성은 그대로 남습니다. 여러 라이브러리가 같은 객체에 정보를 덧붙여도 서로 덮어쓰지 않게 할 수 있습니다.

열거에서 빠지는 Symbol 키

Symbol 키로 만든 속성은 for...in, Object.keys(), Object.values(), Object.entries() 같은 일반적인 열거에서 빠집니다. 다음 코드는 문자열 키 두 개와 Symbol 키 하나를 가진 객체를 여러 방법으로 열거해, 어느 방법에서 Symbol 키가 빠지고 어느 방법에서 보이는지 비교합니다.

const myObj = {
    name: "Bob",
    age: 40,
    [Symbol('secret')]: "비밀 데이터"
};

for (let key in myObj) {
    console.log(key); // name, age (secret 심볼은 출력되지 않음)
}

console.log(Object.keys(myObj));    // 결과: ["name", "age"]
console.log(Object.getOwnPropertyNames(myObj)); // 결과: ["name", "age"]

// Symbol 키를 얻는 방법
console.log(Object.getOwnPropertySymbols(myObj)); // 결과: [Symbol(secret)]
// 모든 프로퍼티 키(문자열 + 심볼)를 얻는 방법
console.log(Reflect.ownKeys(myObj)); // 결과: ["name", "age", Symbol(secret)]

다만 열거에서 빠질 뿐 숨겨진 것은 아닙니다. Object.getOwnPropertySymbols()나 Reflect.ownKeys()로 얼마든지 찾을 수 있으므로, 비밀 정보를 감추거나 접근을 막는 수단으로 쓰면 안 됩니다. JSON.stringify()도 Symbol 키 속성을 결과에서 뺍니다.

전역 Symbol 레지스트리

Symbol.for(key)는 전역 Symbol 레지스트리(global symbol registry)에서 그 키로 등록된 Symbol을 찾아 돌려주고, 없으면 새로 만들어 등록합니다. 그래서 같은 키로 호출하면 항상 같은 Symbol을 얻습니다.

const globalId1 = Symbol.for('shared_id');
const globalId2 = Symbol.for('shared_id');

console.log(globalId1 === globalId2); // 결과: true (동일한 Symbol)

const localId = Symbol('local_id'); // Symbol()로 생성된 심볼은 전역 레지스트리에 등록되지 않음

console.log(Symbol.keyFor(globalId1)); // 결과: "shared_id" (전역 레지스트리에서 키 가져오기)
console.log(Symbol.keyFor(localId));   // 결과: undefined (전역 레지스트리에 없으므로)

Symbol.keyFor()는 전역 레지스트리에 등록된 Symbol의 키를 돌려주며, Symbol()로 만든 등록되지 않은 Symbol에는 undefined를 돌려줍니다. 전역 레지스트리는 서로 다른 모듈이나 라이브러리가 같은 Symbol을 함께 써야 할 때 유용합니다.


컬렉션 고르기

데이터를 담을 그릇을 고르기 전에, 담으려는 것이 이름이 정해진 필드의 묶음인지, 아무 값이나 키로 삼아 다른 값을 연결하는 것인지, 중복 없는 값의 모임인지를 먼저 정합니다.

Object·Map·Set·Weak 컬렉션 선택

DECISION CRITERIA

Object·Map·Set·Weak 컬렉션 선택

값의 집합인지 키와 값의 연결인지 구분한다. 열거와 size가 필요하면 일반 컬렉션을, 객체 수명에 묶는 보조 정보에는 약한 컬렉션을 검토한다.

데이터 관계와 열거 요구를 먼저 정하고, 약한 참조가 필요한지 확인한다.
컬렉션선택 조건키·값 범위열거 계약주의점
Set중복 없는 값 · 전체 열거 필요모든 값삽입 순서로 열거 · size객체는 내용 대신 참조 동일성으로 비교
WeakSet객체·비등록 Symbol의 보조 포함 여부객체 / 비등록 Symbol전체 열거·size 없음항목을 강하게 붙잡지 않음 · GC 시점 보장 없음
Map임의 키로 값 연결 · 전체 열거 필요모든 키삽입 순서로 열거 · size객체 키를 그대로 사용 · JSON 변환은 직접 설계
WeakMap객체·비등록 Symbol 수명에 메타데이터 연결객체 / 비등록 Symbol 키전체 열거·size 없음키의 수거를 방해하지 않음 · 값은 임의 타입
Object이름 있는 필드로 레코드 표현문자열 / Symbol 키열거 API별 범위 다름JSON은 Symbol 키를 제외 · Map과 순서 규칙이 다름

Set

선택 조건 중복 없는 값 · 전체 열거 필요

키·값 범위 모든 값

열거 계약 삽입 순서로 열거 · size

주의점 객체는 내용 대신 참조 동일성으로 비교

WeakSet

선택 조건 객체·비등록 Symbol의 보조 포함 여부

키·값 범위 객체 / 비등록 Symbol

열거 계약 전체 열거·size 없음

주의점 항목을 강하게 붙잡지 않음 · GC 시점 보장 없음

Map

선택 조건 임의 키로 값 연결 · 전체 열거 필요

키·값 범위 모든 키

열거 계약 삽입 순서로 열거 · size

주의점 객체 키를 그대로 사용 · JSON 변환은 직접 설계

WeakMap

선택 조건 객체·비등록 Symbol 수명에 메타데이터 연결

키·값 범위 객체 / 비등록 Symbol 키

열거 계약 전체 열거·size 없음

주의점 키의 수거를 방해하지 않음 · 값은 임의 타입

Object

선택 조건 이름 있는 필드로 레코드 표현

키·값 범위 문자열 / Symbol 키

열거 계약 열거 API별 범위 다름

주의점 JSON은 Symbol 키를 제외 · Map과 순서 규칙이 다름

약한 참조 ↔ 전체 열거 포기

WeakMap·WeakSet은 객체와 비등록 Symbol을 약하게 보유한다. 전체 목록·항목 수·GC 완료 시점을 관찰할 수 없으므로 캐시 만료 시각을 보장하는 도구로 쓰지 않는다.

위 표는 다섯 가지 그릇을 고르는 조건, 담을 수 있는 키와 값, 전체를 열거할 수 있는지, 주의할 점으로 비교합니다. 중복 없는 값을 모으고 전체를 열거해야 한다면 Set을, 아무 타입의 키로 값을 연결하고 전체를 열거해야 한다면 Map을 씁니다. 두 컬렉션 모두 삽입한 순서로 열거되고 size로 개수를 알 수 있습니다. 이름이 정해진 필드로 하나의 레코드를 표현할 때는 일반 객체가 알맞으며, 객체의 키는 문자열이나 Symbol만 될 수 있습니다.

WeakSet과 WeakMap은 객체나 등록되지 않은 Symbol을 약하게 참조하는 변형입니다. 어떤 객체에 딸린 보조 정보를 그 객체의 수명에 묶어 두고 싶을 때 씁니다. 표 아래의 설명처럼 약한 참조를 얻는 대신 전체 목록과 항목 수를 볼 수 없고, 가비지 컬렉션이 언제 항목을 지울지도 알 수 없습니다. 그래서 정해진 시간에 만료되는 캐시처럼 지워지는 시점이 중요한 곳에는 쓰지 않습니다.

표의 주의할 점도 함께 봐 둡니다. Set과 Map은 객체를 내용이 아니라 같은 객체인지로 비교하고, Map은 JSON.stringify()로 그대로 변환되지 않아 변환 방법을 직접 정해야 합니다. 일반 객체는 JSON에서 Symbol 키가 빠지고, 열거하는 순서 규칙도 Map과 다릅니다.


Map

Map은 키와 값의 쌍을 저장하는 컬렉션입니다. 일반 객체와 비슷하지만 몇 가지가 다릅니다. 먼저 Map의 키는 원시값, 객체, 함수, Symbol까지 어떤 타입이든 될 수 있습니다. 일반 객체의 키는 문자열이나 Symbol뿐이라, obj[1]처럼 숫자로 접근해도 문자열 "1"로 바뀝니다.

Map은 항목을 삽입한 순서대로 열거합니다. 일반 객체에도 순서 규칙이 있지만, "1"이나 "2"처럼 배열 인덱스 모양인 문자열 키는 숫자 오름차순으로 먼저 나오고, 열거하는 API마다 자체 속성과 상속받은 속성, Symbol 키를 포함하는 범위도 다릅니다. 또 Map은 size 속성으로 개수를 바로 알 수 있지만, 일반 객체는 Object.keys(obj).length처럼 키를 모아 세야 합니다. 항목을 자주 추가하고 지우는 경우에는 Map이 더 빠를 수 있지만, 성능은 엔진과 데이터 크기, 작업에 따라 다르므로 필요하면 직접 측정해야 합니다.

Map 만들기와 기본 메서드

new Map()으로 만들고 set(), get(), has(), delete(), clear() 메서드와 size 속성을 씁니다. set()을 이미 있는 키로 다시 호출하면 새 항목이 생기지 않고 값만 바뀌며, size는 메서드가 아니라 속성이므로 괄호 없이 읽습니다. 다음 코드에서 size가 4, 3, 0으로 줄어드는 과정을 따라가 보세요.

const myMap = new Map();

// 요소 추가: set(key, value)
myMap.set('name', 'Alice');
myMap.set(1, 'One');
myMap.set(true, 'Truth');
const objKey = { a: 1 };
myMap.set(objKey, 'This is an object key');

// 요소 가져오기: get(key)
console.log(myMap.get('name')); // 결과: Alice
console.log(myMap.get(1));    // 결과: One
console.log(myMap.get(objKey)); // 결과: This is an object key

// 요소 존재 여부 확인: has(key)
console.log(myMap.has('name')); // 결과: true
console.log(myMap.has('city')); // 결과: false

// 요소 개수: size
console.log(myMap.size); // 결과: 4

// 요소 삭제: delete(key)
myMap.delete(true);
console.log(myMap.size); // 결과: 3

// 모든 요소 삭제: clear()
myMap.clear();
console.log(myMap.size); // 결과: 0

문자열, 숫자, 불리언, 객체를 모두 키로 썼습니다. objKey 객체를 키로 쓸 때는 그 객체 자체가 키가 되므로, 같은 객체를 넘겨야 값을 찾을 수 있습니다.

Map 순회하기

keys(), values(), entries()로 키, 값, [키, 값] 쌍을 순회할 수 있습니다. Map 자체를 for...of로 순회하면 entries()와 같이 [키, 값] 쌍이 나오므로, 구조 분해와 함께 쓰면 편리합니다.

const userRoles = new Map([
    ['admin', '관리자'],
    ['editor', '편집자'],
    ['viewer', '시청자']
]);

// 1. Map.prototype.keys(): 키를 순회
for (let key of userRoles.keys()) {
    console.log(key); // admin, editor, viewer
}

// 2. Map.prototype.values(): 값을 순회
for (let value of userRoles.values()) {
    console.log(value); // 관리자, 편집자, 시청자
}

// 3. Map.prototype.entries() 또는 기본 순회: [key, value] 쌍을 순회
for (let entry of userRoles.entries()) {
    console.log(entry); // ['admin', '관리자'], ['editor', '편집자'], ['viewer', '시청자']
}
// 또는 구조 분해 할당과 함께 사용
for (let [role, description] of userRoles) { // userRoles는 기본적으로 entries() 이터레이터를 반환
    console.log(`${role}: ${description}`);
}
/*
결과:
admin: 관리자
editor: 편집자
viewer: 시청자
*/

더 깊이: 명세는 Map의 속도를 어떻게 정할까

ECMAScript 명세는 Map을 어떤 자료 구조로 만들지 정하지 않습니다. 대신 원소의 개수가 늘어나도 평균 접근 시간이 개수에 비례해 늘지 않도록, 즉 원소 수에 대해 선형보다 느리게 늘어나도록(sublinear) 해시 테이블이나 그와 비슷한 방법으로 구현하라고만 요구합니다. 그래서 엔진은 해시 테이블처럼 평균 O(1)에 찾는 구조를 쓸 수도 있고, 탐색 트리처럼 O(log n)에 찾는 구조를 쓸 수도 있습니다. 어느 쪽이든 원소마다 앞에서부터 하나씩 비교하는 O(n) 방식은 허용되지 않습니다.

일반 객체와 비교할 때 MDN은 키와 값을 자주 더하고 지우는 상황에서는 Map이 더 낫고, 일반 객체는 그런 쓰임에 맞춰 최적화되어 있지 않다고 설명합니다. V8의 설명에 따르면 속성을 많이 더하고 지우는 객체는 모양을 기록하는 빠른 방식 대신 느린 사전 방식으로 바뀌기도 하므로, 일반 객체를 키가 계속 바뀌는 사전처럼 쓰면 엔진의 최적화를 살리기 어렵습니다. 또 일반 객체에는 프로토타입에서 물려받은 키가 있어서, 사용자가 입력한 값을 키로 쓰면 "toString" 같은 이름이 이미 있는 것처럼 보일 수 있습니다. Map에는 직접 넣은 키만 있으므로 이런 충돌이 없습니다.

반대로 일반 객체는 JSON.stringify()와 JSON.parse()로 바로 주고받을 수 있지만 Map은 그렇지 않습니다. 이름이 정해진 필드의 묶음이나 JSON으로 오가는 데이터는 일반 객체로, 실행 중에 키가 계속 늘고 줄어드는 사전은 Map으로 다루는 것이 이 차이에 맞는 선택입니다. 두 방식의 비교는 MDN의 Map 설명에 표로 정리되어 있습니다.


Set

Set은 중복되지 않는 값을 모아 두는 컬렉션입니다. 배열과 비슷하지만, 같은 값을 두 번 넣어도 하나만 남는다는 점이 다릅니다.

Set 만들기와 기본 메서드

new Set()으로 만들고 add(), has(), delete(), clear() 메서드와 size 속성을 씁니다. Map과 달리 키 없이 값만 넣으므로 값을 꺼내는 get()은 없고, 들어 있는지는 has()로 확인합니다.

const mySet = new Set();

// 요소 추가: add(value)
mySet.add(1);
mySet.add(5);
mySet.add('text');
mySet.add(1); // 중복 값은 무시됩니다.

console.log(mySet); // 결과: Set(3) {1, 5, "text"}

// 요소 존재 여부 확인: has(value)
console.log(mySet.has(1));    // 결과: true
console.log(mySet.has(10));   // 결과: false

// 요소 개수: size
console.log(mySet.size); // 결과: 3

// 요소 삭제: delete(value)
mySet.delete(5);
console.log(mySet.size); // 결과: 2 (1, "text"만 남음)

// 모든 요소 삭제: clear()
mySet.clear();
console.log(mySet.size); // 결과: 0

1을 두 번 넣었지만 두 번째 add(1)은 무시되어 size가 3입니다. 같은 값인지는 ===와 거의 같은 규칙으로 판단하므로, 숫자 1과 문자열 "1"은 서로 다른 값이고 내용이 같은 객체 두 개도 따로 들어갑니다.

Set 순회하기

Set도 for...of로 삽입한 순서대로 순회합니다. Set에는 키가 따로 없어서 keys()와 values()는 둘 다 값을 순회하고, entries()는 Map과 모양을 맞추기 위해 [값, 값] 쌍을 순회합니다.

const uniqueNumbers = new Set([10, 20, 30, 20, 40]);
console.log(uniqueNumbers); // Set(4) {10, 20, 30, 40}

for (let num of uniqueNumbers) {
    console.log(num); // 10, 20, 30, 40
}

// Array.from()을 사용하여 Set을 배열로 변환
const numArray = Array.from(uniqueNumbers);
console.log(numArray); // 결과: [10, 20, 30, 40]

// 중복 제거 활용 예시
const mixedArray = [1, 2, 2, 3, 4, 4, 5, 'a', 'a', 'b'];
const uniqueMixedArray = [...new Set(mixedArray)]; // Set을 통해 중복 제거 후 스프레드 연산자로 배열 변환
console.log(uniqueMixedArray); // 결과: [1, 2, 3, 4, 5, 'a', 'b']

배열을 Set으로 만들었다가 다시 배열로 펼치면 중복을 간단히 없앨 수 있습니다. 순서는 각 값이 처음 나온 순서를 따릅니다.

ES2025에는 두 Set을 합치거나 비교하는 집합 연산 메서드도 추가되었습니다.

const frontEnd = new Set(["HTML", "CSS", "JavaScript"]);
const backEnd = new Set(["JavaScript", "SQL"]);

console.log(frontEnd.union(backEnd));        // Set(4) {"HTML", "CSS", "JavaScript", "SQL"} (합집합)
console.log(frontEnd.intersection(backEnd)); // Set(1) {"JavaScript"} (교집합)
console.log(frontEnd.difference(backEnd));   // Set(2) {"HTML", "CSS"} (차집합)
console.log(frontEnd.isSubsetOf(new Set(["HTML", "CSS", "JavaScript", "SQL"]))); // true

union(), intersection(), difference() 세 메서드는 원래 Set을 바꾸지 않고 새 Set을 돌려주고, isSubsetOf()는 이 Set의 모든 값이 인수로 넘긴 Set에도 있는지를 true나 false로 돌려줍니다. 비교적 최근에 추가된 기능이므로, 오래된 브라우저까지 지원해야 한다면 사용하기 전에 지원 범위를 확인하세요.


WeakMap과 WeakSet

Map과 Set에는 약한 참조(weak reference)를 쓰는 변형인 WeakMap과 WeakSet이 있습니다. WeakMap의 키와 WeakSet의 값은 약하게 참조되므로, 그 객체를 가리키는 다른 강한 참조가 없으면 컬렉션에 들어 있어도 가비지 컬렉션의 대상이 될 수 있습니다.

현재 표준에서 WeakMap의 키와 WeakSet의 값으로는 객체나, 전역 레지스트리에 등록되지 않은 Symbol만 쓸 수 있습니다. WeakMap의 값은 어떤 타입이든 됩니다. DOM 요소마다 부가 정보를 연결해 두는 캐시처럼, 대상 객체가 사라지면 함께 사라져야 하는 정보에 유용합니다.

let obj1 = { id: 1 };
let obj2 = { id: 2 };

const wm = new WeakMap();
wm.set(obj1, "데이터1");

// obj1 객체를 가리키는 다른 참조가 없어지면, 그 객체와 WeakMap 항목은 회수될 수 있음
obj1 = null;

const ws = new WeakSet();
ws.add(obj2);

// obj2 객체도 다른 참조가 없어지면 WeakSet의 항목과 함께 회수될 수 있음
obj2 = null;

메모리 관리와 가비지 컬렉션에서 본 것처럼, 회수될 수 있다는 것과 그 즉시 회수된다는 것은 다릅니다. WeakMap과 WeakSet은 언제 비워질지 모르므로 size, clear(), 순회 메서드를 제공하지 않습니다. 목록이 필요하다고 키를 따로 배열에 모아 두면, 그 배열이 강한 참조가 되어 약한 참조를 쓴 목적이 사라집니다.


같은 내용과 같은 객체

Map과 Set은 객체 키를 내용이 아니라 같은 객체인지로 비교합니다. 원시값은 SameValueZero라는 규칙으로 비교하는데, 이 규칙에서는 NaN끼리 같은 값이고 +0과 -0도 같은 값입니다.

const member = { id: 7 };
const visits = new Map([[member, 1]]);
console.log(visits.get({ id: 7 }));
console.log(visits.get(member));
console.log(new Set([NaN, NaN, +0, -0]).size);
visits.set(member, undefined);
console.log(visits.get(member), visits.has(member));

출력은 undefined, 1, 2, undefined true입니다. { id: 7 }을 새로 만들어 넘기면 내용은 같아도 저장된 member와 다른 객체라서 값을 찾지 못합니다. Set은 NaN 두 개를 하나로, +0과 -0을 하나로 보아 크기가 2입니다.

마지막 줄은 값이 undefined로 저장된 경우입니다. get()은 키가 없을 때도 undefined를 돌려주므로, get()만으로는 키가 없는 것과 값이 undefined인 것을 구별할 수 없습니다. 키가 실제로 있는지는 has()로 확인합니다.


정리

  • Symbol은 만들 때마다 유일한 원시값으로, 다른 키와 겹치지 않는 속성 키를 만들 때 쓰며 일반 열거와 JSON에서 빠지지만 숨겨지지는 않습니다.
  • Symbol.for()는 전역 레지스트리에서 같은 키의 Symbol을 공유하게 해 줍니다.
  • Map은 어떤 타입이든 키로 쓰고, 삽입 순서로 열거하며, size로 개수를 알 수 있는 키-값 컬렉션입니다.
  • Set은 중복 없는 값을 삽입 순서로 모으며, 배열의 중복 제거나 ES2025의 집합 연산에 씁니다.
  • Map과 Set은 객체를 같은 객체인지로, 원시값을 SameValueZero로 비교하고, 키의 존재는 get()이 아니라 has()로 확인합니다.
  • WeakMap과 WeakSet은 객체를 약하게 참조해 수명에 묶인 보조 정보를 담지만, 열거와 size가 없고 회수 시점도 알 수 없습니다.

연습 문제

1. Map의 키 순서

const map = new Map([["a", 1], ["b", 2]]);에 map.set("a", 3)을 하면 키의 순서는 어떻게 될까요? 이어서 map.delete("a")와 map.set("a", 4)를 하면 어떻게 될까요? [...map.keys()]로 확인하기 전에 예상해 보세요.

해설 보기

처음에는 ["a", "b"]입니다. 이미 있는 키의 값만 바꾸면 처음 삽입한 위치가 그대로 유지됩니다.

delete("a")로 지운 뒤 다시 set("a", 4)를 하면 새로 삽입한 것이므로 맨 뒤에 붙어 ["b", "a"]가 됩니다. 순서는 키가 처음 들어온 시점이 정하며, 값을 바꾸는 것은 순서에 영향을 주지 않습니다.

2. 객체를 담은 Set

new Set([{ id: 1 }, { id: 1 }]).size는 얼마일까요? id가 같은 객체를 하나로 합치려면 어떻게 해야 할까요?

해설 보기

2입니다. 두 객체는 내용이 같아도 따로 만든 다른 객체이고, Set은 객체를 같은 객체인지로 비교하므로 둘 다 남습니다.

id를 기준으로 합치려면 원시값인 id를 키로 쓰는 Map을 만들면 됩니다. [...new Map(items.map(item => [item.id, item])).values()]처럼 쓰면 같은 id의 항목은 하나만 남습니다. 같은 키를 다시 set하면 값만 바뀌므로, 남는 항목은 그 id의 마지막 객체이고 위치는 그 id가 처음 나온 자리입니다.

3. JSON으로 바꾸기

다음 세 줄은 각각 무엇을 출력할까요?

const scores = new Map([["kim", 90], ["lee", 85]]);
console.log(JSON.stringify(scores));
console.log(JSON.stringify(Object.fromEntries(scores)));
console.log(JSON.stringify({ [Symbol("secret")]: 1, visible: 2 }));
해설 보기

차례로 {}, {"kim":90,"lee":85}, {"visible":2}입니다. Map의 항목은 객체의 속성이 아니라서 JSON.stringify()는 빈 객체로 바꿉니다.

Object.fromEntries()로 [키, 값] 쌍을 일반 객체의 속성으로 옮기면 JSON으로 바꿀 수 있습니다. 다만 키가 문자열이 아닌 Map은 이렇게 하면 키가 문자열로 바뀌므로, 그런 경우에는 [...scores]처럼 쌍의 배열로 바꾸는 방법도 있습니다. 세 번째 줄처럼 Symbol 키 속성은 JSON에서 빠집니다.

Map의 키 비교와 순서는 MDN의 Map 문서에서, 약한 참조의 제약은 MDN의 WeakMap 문서에서 확인할 수 있습니다.

다음 글 Proxy와 Reflect에서는 객체의 속성을 읽고 쓰는 기본 동작을 가로채 바꾸는 Proxy와, 그 기본 동작을 함수로 제공하는 Reflect를 살펴봅니다.