12장 맵과 세트

2023.06.16

12.1 맵

  • 객체를 사용하는 것은 설계된 목적이 아니기 때문에 일반 맵에 객체를 사용하는데는 몇 가지 실무적인 문제가 있다.
    • 키는 문자열(또는 ES2015부터는 심볼도)만 될 수 있다.
    • ES2015까지는 객체의 엔트리를 루핑하는 경우 순서를 믿을 수 없었다. 2015에 순서가 추가되었지만 순서에 의존하는 것은 일반적으로 좋은 생각이 아니다. 왜냐면 순서는 속성이 객체에 추가된 순서와 속성 키의 양식에 따라 달라진다.
    • 객체는 대부분 속성이 추가되고 업데이트되며 제거되지 않는다는 가정하에 자바스크립트 엔진에 의해 최적화된다.
    • ES5까지는 toString과 hasOwnProperty 같은 속성이 있는 프로토타입 없이 객체를 생성할 수 없었다. 자신의 키와 충돌할 가능성은 거의 없지만 여전히 문제였다.
  • 맵은 이러한 문제를 다음과 같이 해결한다.
    • 키와 값 모두 모든 값(객체 포함)이 될 수 있다.
    • 엔트리 순서가 정의된다. 엔트리가 추가된 순서다.
    • 자바스크립트 엔진은 사용 사례가 다르기 때문에 객체와 맵을 다르게 최적화한다.
    • 맵은 기본적으로 비어 있다.

12.1.1 기본 맵 동작

// Create a map
console.log('Creating');
const m = new Map();

// Add some entries
console.log('Adding four entries');
m.set(60, 'sixty');
m.set(4, 'four');
// `set` returns the map, so you can chain `set` calls together
m.set(50, 'fifty').set(3, 'three');

// See how many entries are in the map
console.log(`Entries: ${m.size}`); // Entries: 4

// Get an entry's value
let value = m.get(60);
console.log(`60: ${value}`); // 60: sixty
console.log(`3: ${m.get(3)}`); // 3: three

// `get` returns `undefined` if no entry has the given key
console.log(`14: ${m.get(14)}`); // 14: undefined

// Keys are not strings
console.log('Look for key "4" instead of 4:');
console.log(`"4": ${m.get('4')}`); // "4": undefined (the key is 4, not "4")
console.log('Look for key 4:');
console.log(`4: ${m.get(4)}`); // 4: four

// Update an entry
console.log('Updating entry for key 3 to THREE');
m.set(3, 'THREE');
console.log(`3: ${m.get(3)}`); // 3: THREE
console.log(`Entries: ${m.size}`); // Entries: 4 (still)

// Delete an entry
console.log('Deleting the entry for key 4');
m.delete(4);

// Check if an entry exists
console.log(`Entry for 7? ${m.has(7)}`); // Entry for 7? false
console.log(`Entry for 3? ${m.has(3)}`); // Entry for 3? true

// Keys don't have to be all of the same type
m.set('testing', 'one two three');
console.log(m.get('testing')); // one two three

// Keys can be objects
const obj1 = {};
m.set(obj1, 'value for obj1');
console.log(m.get(obj1)); // value for obj1

// Different objects are always different keys, even if they have
// the same properties
const obj2 = {};
m.set(obj2, 'value for obj2');
console.log(`obj1: ${m.get(obj1)}`); // obj1: value for obj1
console.log(`obj2: ${m.get(obj2)}`); // obj2: value for obj2

// null, undefined, and NaN are perfectly valid keys
m.set(null, 'value for null');
m.set(undefined, 'value for undefined');
m.set(NaN, 'value for NaN');
console.log(`null: ${m.get(null)}`); // null: value for null
console.log(`undefined: ${m.get(undefined)}`); // undefined: value for undefined
console.log(`NaN: ${m.get(NaN)}`); // NaN: value for NaN

// Delete all entries
console.log('Clearing the map');
m.clear();
console.log(`Entries now: ${m.size}`); // Entries now: 0

12.1.2 키 동등성

12.1.3 이터러블에서 맵 만들기

  • 이터러블 객체(일반적으로 배열의 배열)를 전달하여 맵에 대한 엔트리를 제공할 수도 있다.
  • 이터러블이나 해당 엔트리가 배열일 필요는 없다. 모든 이터러블을 사용할 수 있으며 "0"과 "1" 속성을 가진 모든 객체를 엔트리에 사용할 수 있다.
  • Map 생성자에 전달하기만 하면 맵을 복사할 수 있다.

12.1.4 맵 내용 반복하기

  • 맵은 이터러블이다. 기본 이터레이터는 각 엔트리에 대해 [key, value] 배열을 생성한다.(이 때문에 Map 생성자에 전달하여 맵을 복사할 수 있다)
  • 맵은 또한 키를 반복하기 위한 keys 메서드와 값을 반복하기 위한 values 메서드를 제공한다.
  • 다음과 같이 이터러블 디스트럭처링이 있는 for-of 루프에서 그렇게 할 수 있다.
const m = new Map([
  ['one', 'uno'],
  ['two', 'due'],
  ['three', 'tre'],
]);
for (const [key, value] of m) {
  console.log(`${key} => ${value}`);
}
// one => uno
// two => due
// three => tre

// 물론, 디스트럭처링을 사용할 필요는 없다. 루프 본문에서 배열을 사용할 수 있다.
for (const entry of m) {
  console.log(`${entry[0]} => ${entry[1]}`);
}
  • 기본 이터레이터는 entries 메서드를 통해 사용할 수 있다.(사실 Map.prototype. entries와 Map.prototype{Symbol.iterator]는 동일한 함수를 참조한다)
  • 맵 엔트리에는 엔트리가 생성된 순서인 순서가 있다.
  • 엔트리 값을 업데이트해도 순서가 바뀌지 않는다. 그러나 엔트리를 삭제한 다음 동일한 키를 사용하여 다른 엔트리를 추가하면 새 엔트리가 맵의 "끝”에 놓인다.

12.1.5 맵 서브클래싱하기

  • 다른 내장 기능과 마찬가지로 Map도 상속될 수 있다.
class MyMap extends Map {
  filter(predicate, thisArg) {
    const newMap = new (this.constructor[Symbol.species] || MyMap)();
    for (const [key, value] of this) {
      if (predicate.call(thisArg, key, value, this)) {
        newMap.set(key, value);
      }
    }
    return newMap;
  }
}

// Usage:
const m1 = new MyMap([
  ['one', 'uno'],
  ['two', 'due'],
  ['three', 'tre'],
]);
const m2 = m1.filter((key) => key.includes('t'));
for (const [key, value] of m2) {
  console.log(`${key} => ${value}`);
}
// two => due
// three => tre
console.log(`m2 instanceof MyMap? ${m2 instanceof MyMap}`);
// m2 instanceof MyMap? true
  • Symbol.species의 사용에 주목하자.
    • Map 생성자는 this를 반환하는 Symbol.species 게터(getter)를 가지고 있다.
    • MyMap은 해당 기본값을 재정의하지 않으므로 해당 filter는 MyMap 인스턴스를 생성하는 동시에 이후에 만들 서브클래스(예: MySpecialMap)에서 filter가 해당 서브클래스, MyMap 또는 Map(또는 다른 것. 하지만 가능성은 낮아 보임)의 인스턴스를 생성해야 하는지 여부를 제어할 수 있다.

12.1.6 성능

  • 맵의 구현은 당연히 개별 자바스크립트 엔진에 달려 있지만 사양에서는 아래와 같이 정의했다.

평균적으로 컬렉션의 요소수에 대해 하위 선형인 접근 시간을 제공하는 해시 테이블 또는 기타 메커니즘을 사용하여 구현해야 한다.

  • 이는 예를 들어 엔트리를 추가하는 것이 배열을 검색하여 엔트리가 포함되어 있는지 여부를 확인하고 포함하지 않으면 추가하는 것보다 평균적으로 더 빨라야 함을 의미한다.
map.set(key, value);

// 위 코드는 평균적으로 아래 코드보다 빨라야 한다.

if (!array.some((e) => e.key === key)) {
  array.push({ key, value });
}

12.2 세트

  • 세트는 고유한 값의 모음이다.
  • 맵의 키에 대해 배운 모든 내용은 세트의 값에 적용된다.
    • 세트는 0을 제외한 모든 값을 보유할 수 있으며 만약 을 추가하면 으로 변환된다.
    • 값은 등가0 연산을 사용하여 비교한다.
    • 세트의 값 순서는 세트에 추가된 순서이다.
    • 동일한 값을 다시 추가해도 위치가 변경되지 않는다.
    • 값을 제거한 다음 나중에 다시 추가하면 변경된다.
    • 세트에 값을 추가할 때 세트는 먼저 값을 보유하고 있는지 여부를 확인하고 없는 경우에만 추가한다.
  • 세트 이전에는 이 목적으로 객체가 사용되기도 했지만 맵에 객체를 사용하는 것과 같은 종류의 단점이 있다.

12.2.1 기본 세트 동작

// Create a set
console.log('Creating');
const s = new Set();

// Add some entries
console.log('Adding four entries');
s.add('two');
s.add('four');
// The `add` method returns the set, so you can chain `add` calls together
s.add('one').add('three');

// Check if an entry exists
console.log(`Has "two"? ${s.has('two')}`); // Has "two"? true

// See how many entries are in the set
console.log(`Entries: ${s.size}`); // Entries: 4

// Adding the same values again doesn't add them
s.add('one').add('three');
console.log(`Entries: ${s.size}`); // Entries: 4 (still)

// Delete an entry
console.log('Deleting entry "two"');
s.delete('two');
console.log(`Has "two"? ${s.has('two')}`); // Has "two"? false

// Clear the set (delete all entries)
console.log('Clearing the set');
s.clear();
console.log(`Entries: ${s.size}`); // Entries: 0

12.2.2 이터러블로부터 세트 만들기

  • 세트 생성자는 이터러블을 허용하고 이터러블을 넣는 경우 (순서대로) 세트를 채운다.
  • 이터러블이 동일한 값을 두 번 반환하여도 결과 세트를 하나만 보유한다.
const s = new Set(['one', 'two', 'three', 'three', 'four']);
console.log(s.has('two')); // true
console.log(s.size); // 4

12.2.3 세트 내용 반복하기

  • 세트의 값 순서는 세트에 추가된 순서이다.
  • 동일한 값을 다시 추가해도 위치가 변경되지 않는다.
  • 값을 제거한 다음 나중에 다시 추가하면 변경된다.
const s = new Set(['one', 'two', 'three']);
for (const value of s) {
  console.log(value);
}

s.add('one'); // Again
for (const value of s) {
  console.log(value);
}
// one
// two
// three

s.delete('one');
s.add('one');
for (const value of s) {
  console.log(value);
}
// two
// three
// one
  • 세트가 이터러블이라는 것을 사용하는 것은 배열을 복사하면서 중복 엔트리를 제거하는 편리한 방법이기도 하다.
  • 고유한 값만 원한다면 배열이 아닌 처음부터 세트를 사용할 수 있었을 것이다.
const a1 = [1, 2, 3, 4, 1, 2, 3, 4];
const a2 = Array.from(new Set(a1));
console.log(a2.length); // 4
console.log(a2.join(', ')); // 1, 2, 3, 4
  • Set.prototype[Symbol.iterator], Set.prototype.values, Set.prototype.keys는 모두 같은 함수를 참조한다.
  • 세트는 또한 두 엔트리 모두 세트의 값을 포함하는 두 엔트리 배열을 반환하는 entries 메서드를 제공한다.
const s = new Set(['a', 'b', 'c']);
for (const value of s) {
  // or `of s.values()`
  console.log(value);
}
// a
// b
// c

for (const key of s.keys()) {
  console.log(key);
}
// a
// b
// c

for (const [key, value] of s.entries()) {
  console.log(`${key} => ${value}`);
}
// a => a
// b => b
// c => c

12.2.4 세트 서브클래싱하기

  • 세트는 서브클래싱하기 쉽다.
  • 맵과 마찬가지로 새 세트를 생성하는 메서드를 추가하려는 경우 species 패턴을 사용하여 새 인스턴스를 만들 수 있다.
class MySet extends Set {
  addAll(iterable) {
    for (const value of iterable) {
      this.add(value);
    }
    return this;
  }
}

// Usage
const s = new MySet();
s.addAll(['a', 'b', 'c']);
s.addAll([1, 2, 3]);
for (const value of s) {
  console.log(value);
}
// a
// b
// c
// 1
// 2
// 3

12.2.5 성능

  • 맵과 마찬가지로 세트의 구현은 자바스크립트 엔진에 달려 있지만, 맵과 마찬가지로 사양에서는 아래와 같이 요구한다.

해시 테이블 또는 다른 메커니즘을 사용하여 평균적으로 컬렉션에 있는 요소의 수의 선형이 아닌 접근 시간을 제공해야 한다.

set.add(value);

// 위 코드는 평균적으로 아래 코드보다 빨라야 한다.

if (!array.includes(value)) {
  array.push(value);
}

12.3 위크맵

  • 위크맵을 사용하면 키를 메모리에 유지하지 않고도 객체(키)와 관련된 값을 저장할 수 있다.
  • 키는 맵에 약하게 유지된다. 객체가 메모리에 유지되는 유일한 이유가 맵인 경우 해당 엔트리(키와 값)가 맵에서 자동으로 제거되어 키 객체가 가비지 컬렉션에 적합한 상태로 남는다.
  • 동일한 객체가 둘 이상의 위크맵에서 키로 사용되는 경우에도 마찬가지다.
  • 예를 들어 DOM 요소와 관련된 정보를 요소 자체의 속성에 저장하지 않고 DOM 요소를 키로 저장하려는 정보를 값으로 사용하여 위크맵에 저장할 수 있다. DOM 요소가 DOM에서 제거되고 다른 어떤 것도 참조하지 않으면 해당 요소에 대한 엔트리가 맵에서 자동으로 제거되고 DOM 요소의 메모리를 회수할 수 있다.

12.3.1 위크맵은 이터러블이 아니다.

  • 위크맵의 중요한 측면 중 하나는 특정 키를 모르면 맵에서 가져올 수 없다는 것이다.
  • 이는 구현상으로 다른 이유로 키가 도달할 수 없는 시점과 엔트리가 맵에서 제거되는 시점 사이에 지연이 있을 수 있기 때문이다.
  • 위크맵의 키를 모르는 경우 키를 얻을 수 있는 방법을 제공하지 않는다.
  • 위크맵은 이터러블이 아니고 내용에 대한 정보를 거의 제공하지 않는다.
  • 제공하는 내용은 다음과 같다.
    • has: 키를 넘기면 해당 키에 대한 엔트리가 있는지 여부를 알려준다.
    • get: 키를 넘기면 위크맵의 키 엔트리에 대한 값을 제공한다.
    • delete: 키를 넘기면 해당 키의 엔트리를 삭제하고 플래그를 반환한다. (엔트리가 발견되어 삭제된 경우 true, 찾지 못한 경우 false).
  • 위크맵은 API의 공통 부분이 의도적으로 서로 유사하게 만들었지만 맵의 서브클래스는 아니다.

12.3.2 사용 사례와 예

  • 사용 사례: 프라이빗 정보
    • 위크맵을 사용하면 다른 모든 코드가 Example 객체로 완료되고 이에 대한 참조가 삭제되면 자바스크립트 엔진이 해당 객체에 대한 위크맵 엔트리를 제거한다.
const Example = (() => {
  const privateMap = new WeakMap();

  return class Example {
    constructor() {
      privateMap.set(this, 0);
    }

    incrementCounter() {
      const result = privateMap.get(this) + 1;
      privateMap.set(this, result);
      return result;
    }

    showCounter() {
      console.log(`Counter is ${privateMap.get(this)}`);
    }
  };
})();

const e1 = new Example();
e1.incrementCounter();
console.log(e1); // (some representation of the object)

const e2 = new Example();
e2.incrementCounter();
e2.incrementCounter();
e2.incrementCounter();

e1.showCounter(); // Counter is 1
e2.showCounter(); // Counter is 3
  • 사용 사례: 제어할 수 없는 객체에 대한 정보 저장
    • 객체를 제공하는 API를 다루고 있고 해당 객체와 관련된 정보를 추적해야 한다고 가정
    • 위크맵은 키를 약하게 보유하므로 API 객체가 가비지 컬렉션되는 것을 방지하지 않는다.
(async () => {
  const statusDisplay = document.getElementById('status');
  const personDisplay = document.getElementById('person');
  try {
    // DOM 요소와 관련된 정보를 보유할 위크맵
    const personMap = new WeakMap();
    await init();

    async function init() {
      const peopleList = document.getElementById('people');
      const people = await getPeople();

      // 이 루프에서 div를 키로 사용하여 위크맵의 각 div와 관련된 사람을 저장한다.
      for (const person of people) {
        const personDiv = createPersonElement(person);
        personMap.set(personDiv, person);
        peopleList.appendChild(personDiv);
      }
    }

    async function getPeople() {
      // 서버 또는 이와 유사한 것에서 사람 정보를 가져오는 작업
      return [
        { name: 'Joe Bloggs', position: 'Front-End Developer' },
        { name: 'Abha Patel', position: 'Senior Software Architect' },
        { name: 'Guo Wong', position: 'Database Analyst' },
      ];
    }

    function createPersonElement(person) {
      const div = document.createElement('div');
      div.className = 'person';
      div.innerHTML = '<a href="#show" class="remove">X</a> <span class="name"></span>';
      div.querySelector('span').textContent = person.name;
      div.querySelector('a').addEventListener('click', removePerson);
      div.addEventListener('click', showPerson);
      return div;
    }

    function stopEvent(e) {
      e.preventDefault();
      e.stopPropagation();
    }

    function showPerson(e) {
      stopEvent(e);
      // 위크맵에서 클릭한 요소를 찾아 사람을 표시한다.
      const person = personMap.get(this);
      if (person) {
        const { name, position } = person;
        personDisplay.textContent = `${name}'s position is: ${position}`;
      }
    }

    function removePerson(e) {
      stopEvent(e);
      this.closest('div').remove();
    }
  } catch (error) {
    statusDisplay.innerHTML = `Error: ${error.message}`;
  }
})();

12.3.3 키를 참조하는 값

  • 위의 예시에서 person 객체가 DOM 요소를 다시 참조했다고 하더라도 메모리에 키가 유지되지 않는다.
  • 이에 대해 사양에는 아래와 같이 적혀 있다.

위크맵 키/값 쌍의 키로 사용되는 객체가 해당 위크맵 내에서 시작하는 참조 체인을 따라야 도달할 수 있는 경우 해당 키/값 쌍은 접근할 수 없으며 위크맵에서 자동으로 제거된다.

  • 위크맵 외부의 그 어느 것도 해당 키 객체를 더 이상 참조하지 않으면 자바스크립트 엔진이 엔트리를 제거하고 객체를 가비지 컬렉션에 사용할 수 있도록 한다.

12.4 위크세트

  • 위크세트는 위크맵에 대응되는 세트이다.
  • 즉, 세트에 있는 것이 객체 정리를 방해하지 않는 객체 집합이다.
  • 위크세트는 키가 세트의 값이고 값이 “맞다. 객체가 세트에 있다."라는 의미에서 true인 위크맵으로 생각할 수 있다. 또는 세트를 키와 값이 동일한 맵으로 생각해도 괜찮다.
  • 위크세트는 이터러블이 아니며 다음만 제공한다.
    • add: 세트에 객체를 추가한다.
    • has: 객체가 세트에 있는지 확인한다.
    • delete: 세트에서 객체를 삭제(제거)한다.
  • 위크세트는 세트의 서브클래스가 아니지만 위크맵/맵과 마찬가지로 API의 공통 부분은 의도적으로 일치시켰다.

12.4.1 사용 사례: 추적

  • 객체를 사용하기 전에 해당 객체가 과거에 사용되었는지 여부를 알아야 하지만 이를 객체에 플래그로 저장히자 않는다고 가정하자.
  • 일종의 일회용 액세스 토큰일 수 있다.
  • 위크세트는 객체를 메모리에 강제로 유지하지 않고 이를 수행하는 간단한 방법이다.
const SingleUseObject = (() => {
  const used = new WeakSet();

  return class SingleUseObject {
    constructor(name) {
      this.name = name;
    }
    use() {
      if (used.has(this)) {
        throw new Error(`${this.name} has already been used`);
      }
      console.log(`Using ${this.name}`);
      used.add(this);
    }
  };
})();

const suo1 = new SingleUseObject('suo1');
const suo2 = new SingleUseObject('suo2');
suo1.use(); // Using suo1
try {
  suo1.use();
} catch (e) {
  console.error('Error: ' + e.message); // Error: suo1 has already been used
}
suo2.use(); // Using suo2

12.4.2 사용 사례: 브랜딩

  • 브랜딩은 추적의 또 다른 형태이다.
  • 객체를 제공하는 라이브러리를 설계하고 나중에 해당 객체를 다시 받아 작업을 수행한다고 가정하자.
  • 객체가 라이브러리에서 반환된 것인지 라이브러리를 사용하는 코드에서 위조되지 않았는지 확신하는 경우에 위크세트를 사용할 수 있다.
const Thingy = (() => {
  const known = new WeakSet();
  let nextId = 1;

  return class Thingy {
    constructor(name) {
      this.name = name;
      this.id = nextId++;
      Object.freeze(this);
      known.add(this);
    }

    action() {
      if (!known.has(this)) {
        throw new Error('Unknown Thingy');
      }
      // Code here knows that this object was created
      // by this class
      console.log(`Action on Thingy #${this.id} (${this.name})`);
    }
  };
})();

// In other code using it:

// Using real ones
const t1 = new Thingy('t1');
t1.action(); // Action on Thingy #1 (t1)
const t2 = new Thingy('t2');
t2.action(); // Action on Thingy #2 (t2)

// Trying to use a fake one
const faket2 = Object.create(Thingy.prototype);
faket2.name = 'faket2';
faket2.id = 2;
Object.freeze(faket2);
faket2.action(); // Error: Unknown Thingy
  • 해당 객체를 고정하여 어떤식으로도 변경할 수 없도록 한다. 속성은 읽기 전용이며 재구성할수 없고 속성을 추가하거나 제거할 수 없으며 프로토타입 변경할 수 없다.

12.5 과거 습관을 새롭게

12.5.1 객체를 범용 맵으로 사용하는 대신 맵 사용

  • 객체 대신 맵을 사용하자.
  • 맵은 범용 매핑에 더 적합하며 키가 이미 문자열이 아니라면 키를 문자열로 만들지 말자.

12.5.2 세트를 위한 객체 대신 세트 사용

  • 객체 대신 세트를 사용하자. 또는 적절하다면 위크세트를 사용하자.

12.5.3 공개 속성 대신 비공개 정보를 저장하기 위해 위크맵 사용

  • 위크맵을 사용하여 정보가 완전히 비공개되도록 하자.

이 새로운 습관에 대한 두 가지 주의 사항이 있다.

  1. 이를 위해 위크맵을 사용하면 코드 복잡성이 증가한다. 주어진 상황에서 비공개의 이득은 복잡성 비용보다 가치가 있을 수도 있고 그렇지 않을 수도 있다. 많은 언어에는 "프라이빗" 속성이 있지만 어쨌든 이러한 속성에 접근할 수 있다. 예를 들어 자바의 프라이빗 필드는 리플렉션을 통해 접근할 수 있다. 따라서 명명 규칙을 사용하는 것이 실제로 자바의 프라이빗 속성을 사용하는 것보다 훨씬 나쁘지는 않다.
  2. 머지 않아 클래스 구문(적어도)은 위크맵을 사용하지 않고 프라이빗 필드를 갖는 수단을 제공할 것이다. 지금 프라이빗 정보에 위크맵을 사용하는 습관을 들이면 나중에 리팩토링할 수 있다.

결국 경우에 따라 다르다. 일부 정보는 실제로 적절하게 비공개되어야 한다.(코드에서, 디버거에서 비공개로 사용할 수 없음을 기억하자). 다른 정보는 "사용하지 말라"라는 규칙으로 표시하면 괜찮을 것이다.