2020년 8월 22일

맵과 셋

지금까진 아래와 같은 복잡한 자료구조를 학습해 보았습니다.

  • 객체 – 키가 있는 컬렉션을 저장함
  • ë°°ì—´ – 순서가 있는 컬렉션을 저장함

하지만 현실 세계를 반영하기엔 이 두 자료구조 만으론 부족해서 맵(Map)과 셋(Set)이 등장하게 되었습니다.

ë§µ

맵(Map)은 키가 있는 데이터를 저장한다는 점에서 객체와 유사합니다. 다만, 맵은 키에 다양한 자료형을 허용한다는 점에서 차이가 있습니다.

맵에는 다음과 같은 주요 메서드와 프로퍼티가 있습니다.

  • new Map() – 맵을 만듭니다.
  • map.set(key, value) – key를 이용해 value를 저장합니다.
  • map.get(key) – key에 해당하는 값을 반환합니다. keyê°€ 존재하지 않으면 undefined를 반환합니다.
  • map.has(key) – keyê°€ 존재하면 true, 존재하지 않으면 false를 반환합니다.
  • map.delete(key) – key에 해당하는 값을 삭제합니다.
  • map.clear() – ë§µ 안의 모든 요소를 제거합니다.
  • map.size – 요소의 개수를 반환합니다.

예시:

let map = new Map();

map.set('1', 'str1');   // 문자형 키
map.set(1, 'num1');     // 숫자형 키
map.set(true, 'bool1'); // 불린형 키

// 객체는 키를 문자형으로 변환한다는 걸 기억하고 계신가요?
// 맵은 키의 타입을 변환시키지 않고 그대로 유지합니다. 따라서 아래의 코드는 출력되는 값이 다릅니다.
alert( map.get(1)   ); // 'num1'
alert( map.get('1') ); // 'str1'

alert( map.size ); // 3

맵은 객체와 달리 키를 문자형으로 변환하지 않습니다. 키엔 자료형 제약이 없습니다.

map[key]는 Map을 쓰는 바른 방법이 아닙니다.

map[key] = 2로 값을 설정하는 것 같이 map[key]를 사용할 수 있긴 합니다. 하지만 이 방법은 map을 일반 객체처럼 취급하게 됩니다. 따라서 여러 제약이 생기게 되죠.

map을 사용할 땐 map전용 메서드 set, get 등을 사용해야만 합니다.

맵은 키로 객체를 허용합니다.

예시:

let john = { name: "John" };

// 고객의 가게 방문 횟수를 세본다고 가정해 봅시다.
let visitsCountMap = new Map();

// john을 맵의 키로 사용하겠습니다.
visitsCountMap.set(john, 123);

alert( visitsCountMap.get(john) ); // 123

객체를 키로 사용할 수 있다는 점은 맵의 가장 중요한 기능 중 하나입니다. 객체에는 문자열 키를 사용할 수 있습니다. 하지만 객체 키는 사용할 수 없습니다.

객체형 키를 객체에 써봅시다.

let john = { name: "John" };

let visitsCountObj = {}; // 객체를 하나 만듭니다.

visitsCountObj[john] = 123; // 객체(john)를 키로 해서 객체에 값(123)을 저장해봅시다.

// 원하는 값(123)을 얻으려면 아래와 같이 키가 들어갈 자리에 `object Object`를 써줘야합니다.
alert( visitsCountObj["[object Object]"] ); // 123

visitsCountObj는 객체이기 때문에 모든 키를 문자형으로 변환시킵니다. 이 과정에서 john은 문자형으로 변환되어 "[object Object]"가 됩니다.

맵이 키를 비교하는 방식

맵은 SameValueZero라 불리는 알고리즘을 사용해 값의 등가 여부를 확인합니다. 이 알고리즘은 일치 연산자 ===와 거의 유사하지만, NaN과 NaN을 같다고 취급하는 것에서 일치 연산자와 차이가 있습니다. 따라서 맵에선 NaN도 키로 쓸 수 있습니다.

이 알고리즘은 수정하거나 커스터마이징 하는 것이 불가능합니다.

체이닝

map.set을 호출할 때마다 맵 자신이 반환됩니다. 이를 이용하면 map.set을 '체이닝(chaining)'할 수 있습니다.

map.set('1', 'str1')
  .set(1, 'num1')
  .set(true, 'bool1');

맵의 요소에 반복 작업하기

다음 세 가지 메서드를 사용해 맵의 각 요소에 반복 작업을 할 수 있습니다.

  • map.keys() – 각 요소의 키를 모은 반복 가능한(iterable, 이터러블) 객체를 반환합니다.
  • map.values() – 각 요소의 값을 모은 이터러블 객체를 반환합니다.
  • map.entries() – 요소의 [키, ê°’]을 한 쌍으로 하는 이터러블 객체를 반환합니다. 이 이터러블 객체는 for..of반복문의 기초로 쓰입니다.

예시:

let recipeMap = new Map([
  ['cucumber', 500],
  ['tomatoes', 350],
  ['onion',    50]
]);

// 키(vegetable)를 대상으로 순회합니다.
for (let vegetable of recipeMap.keys()) {
  alert(vegetable); // cucumber, tomatoes, onion
}

// 값(amount)을 대상으로 순회합니다.
for (let amount of recipeMap.values()) {
  alert(amount); // 500, 350, 50
}

// [키, 값] 쌍을 대상으로 순회합니다.
for (let entry of recipeMap) { // recipeMap.entries()와 동일합니다.
  alert(entry); // cucumber,500 ...
}
맵은 삽입 순서를 기억합니다.

맵은 값이 삽입된 순서대로 순회를 실시합니다. 객체가 프로퍼티 순서를 기억하지 못하는 것과는 다릅니다.

여기에 더하여 맵은 배열과 유사하게 내장 메서드 forEach도 지원합니다.

// 각 (키, 값) 쌍을 대상으로 함수를 실행
recipeMap.forEach( (value, key, map) => {
  alert(`${key}: ${value}`); // cucumber: 500 ...
});

Object.entries: 객체를 맵으로 바꾸기

각 요소가 키-값 쌍인 배열이나 이터러블 객체를 초기화 용도로 맵에 전달해 새로운 맵을 만들 수 있습니다.

아래와 같이 말이죠.

// 각 요소가 [키, 값] 쌍인 배열
let map = new Map([
  ['1',  'str1'],
  [1,    'num1'],
  [true, 'bool1']
]);

alert( map.get('1') ); // str1

평범한 객체를 가지고 맵을 만들고 싶다면 내장 메서드 Object.entries(obj)를 활용해야 합니다. 이 메서드는 객체의 키-값 쌍을 요소([key, value])로 가지는 배열을 반환합니다.

예시:

let obj = {
  name: "John",
  age: 30
};

let map = new Map(Object.entries(obj));

alert( map.get('name') ); // John

Object.entries를 사용해 객체 obj를 배열 [ ["name","John"], ["age", 30] ]로 바꾸고, 이 배열을 이용해 새로운 맵을 만들어보았습니다.

Object.fromEntries: 맵을 객체로 바꾸기

방금까진 Object.entries(obj)를 사용해 평범한 객체를 맵으로 바꾸는 방법에 대해 알아보았습니다.

이젠 이 반대인 맵을 객체로 바꾸는 방법에 대해 알아보겠습니다. Object.fromEntries를 사용하면 가능합니다. 이 메서드는 각 요소가 [키, 값] 쌍인 배열을 객체로 바꿔줍니다.

let prices = Object.fromEntries([
  ['banana', 1],
  ['orange', 2],
  ['meat', 4]
]);

// now prices = { banana: 1, orange: 2, meat: 4 }

alert(prices.orange); // 2

Object.fromEntries를 사용해 맵을 객체로 바꿔봅시다.

자료가 맵에 저장되어있는데, 서드파티 코드에서 자료를 객체형태로 넘겨받길 원할 때 이 방법을 사용할 수 있습니다.

예시:

let map = new Map();
map.set('banana', 1);
map.set('orange', 2);
map.set('meat', 4);

let obj = Object.fromEntries(map.entries()); // 맵을 일반 객체로 변환 (*)

// 맵이 객체가 되었습니다!
// obj = { banana: 1, orange: 2, meat: 4 }

alert(obj.orange); // 2

map.entries()를 호출하면 맵의 [키, 값]을 요소로 가지는 이터러블을 반환합니다. Object.fromEntries를 사용하기 위해 딱 맞는 형태이죠.

(*)로 표시한 줄을 좀 더 짧게 줄이는 것도 가능합니다.

let obj = Object.fromEntries(map); // .entries()를 생략함

Object.fromEntries는 인수로 이터러블 객체를 받기 때문에 짧게 줄인 코드도 이전 코드와 동일하게 동작합니다. 꼭 배열을 전달해줄 필요는 없습니다. 그리고 map에서의 일반적인 반복은 map.entries()를 사용했을 때와 같은 키-값 쌍을 반환합니다. 따라서 map과 동일한 키-값을 가진 일반 객체를 얻게 됩니다.

ì…‹

셋(Set)은 중복을 허용하지 않는 값을 모아놓은 특별한 컬렉션입니다. 셋에 키가 없는 값이 저장됩니다.

주요 메서드는 다음과 같습니다.

  • new Set(iterable) – 셋을 만듭니다. 이터러블 객체를 전달받으면(대개 배열을 전달받음) ê·¸ 안의 값을 복사해 셋에 넣어줍니다.
  • set.add(value) – 값을 추가하고 ì…‹ 자신을 반환합니다.
  • set.delete(value) – 값을 제거합니다. 호출 시점에 ì…‹ 내에 값이 있어서 제거에 성공하면 true, 아니면 false를 반환합니다.
  • set.has(value) – ì…‹ 내에 값이 존재하면 true, 아니면 false를 반환합니다.
  • set.clear() – 셋을 비웁니다.
  • set.size – 셋에 몇 개의 값이 있는지 세줍니다.

셋 내에 동일한 값(value)이 있다면 set.add(value)을 아무리 많이 호출하더라도 아무런 반응이 없을 겁니다. 셋 내의 값에 중복이 없는 이유가 바로 이 때문이죠.

방문자 방명록을 만든다고 가정해 봅시다. 한 방문자가 여러 번 방문해도 방문자를 중복해서 기록하지 않겠다고 결정 내린 상황입니다. 즉, 한 방문자는 '단 한 번만 기록’되어야 합니다.

이때 적합한 자료구조가 바로 셋입니다.

let set = new Set();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

// 어떤 고객(john, mary)은 여러 번 방문할 수 있습니다.
set.add(john);
set.add(pete);
set.add(mary);
set.add(john);
set.add(mary);

// 셋에는 유일무이한 값만 저장됩니다.
alert( set.size ); // 3

for (let user of set) {
  alert(user.name); // // John, Pete, Mary 순으로 출력됩니다.
}

셋 대신 배열을 사용하여 방문자 정보를 저장한 후, 중복 값 여부는 배열 메서드인 arr.find를 이용해 확인할 수도 있습니다. 하지만 arr.find는 배열 내 요소 전체를 뒤져 중복 값을 찾기 때문에, 셋보다 성능 면에서 떨어집니다. 반면, 셋은 값의 유일무이함을 확인하는데 최적화되어있습니다.

셋의 값에 반복 작업하기

for..of나 forEach를 사용하면 셋의 값을 대상으로 반복 작업을 수행할 수 있습니다.

let set = new Set(["oranges", "apples", "bananas"]);

for (let value of set) alert(value);

// forEach를 사용해도 동일하게 동작합니다.
set.forEach((value, valueAgain, set) => {
  alert(value);
});

흥미로운 점이 눈에 띄네요. forEach에 쓰인 콜백 함수는 세 개의 인수를 받는데, 첫 번째는 값, 두 번째도 같은 값인 valueAgain을 받고 있습니다. 세 번째는 목표하는 객체(셋)이고요. 동일한 값이 인수에 두 번 등장하고 있습니다.

이렇게 구현된 이유는 맵과의 호환성 때문입니다. 맵의 forEach에 쓰인 콜백이 세 개의 인수를 받을 때를 위해서죠. 이상해 보이겠지만 이렇게 구현해 놓았기 때문에 맵을 셋으로 혹은 셋을 맵으로 교체하기가 쉽습니다.

셋에도 맵과 마찬가지로 반복 작업을 위한 메서드가 있습니다.

  • set.keys() – ì…‹ 내의 모든 값을 포함하는 이터러블 객체를 반환합니다.
  • set.values() – set.keys와 동일한 작업을 합니다. 맵과의 호환성을 위해 만들어진 메서드입니다.
  • set.entries() – ì…‹ 내의 각 값을 이용해 만든 [value, value] 배열을 포함하는 이터러블 객체를 반환합니다. 맵과의 호환성을 위해 만들어졌습니다.

요약

맵은 키가 있는 값이 저장된 컬렉션입니다.

주요 메서드와 프로퍼티:

  • new Map([iterable]) – 맵을 만듭니다. [key,value]쌍이 있는 iterable(예: ë°°ì—´)을 선택적으로 넘길 수 있는데, 이때 넘긴 이터러블 객체는 ë§µ 초기화에 사용됩니다.
  • map.set(key, value) – 키를 이용해 값을 저장합니다.
  • map.get(key) – 키에 해당하는 값을 반환합니다. keyê°€ 존재하지 않으면 undefined를 반환합니다.
  • map.has(key) – 키가 있으면 true, 없으면 false를 반환합니다.
  • map.delete(key) – 키에 해당하는 값을 삭제합니다.
  • map.clear() – ë§µ 안의 모든 요소를 제거합니다.
  • map.size – 요소의 개수를 반환합니다.

일반적인 객체와의 차이점:

  • 키의 타입에 제약이 없습니다. 객체도 키가 될 수 있습니다.
  • size 프로퍼티 등의 유용한 메서드나 프로퍼티가 있습니다.

셋은 중복이 없는 값을 저장할 때 쓰이는 컬렉션입니다.

주요 메서드와 프로퍼티:

  • new Set([iterable]) – 셋을 만듭니다. iterable 객체를 선택적으로 전달받을 수 있는데(대개 배열을 전달받음), 이터러블 객체 안의 요소는 셋을 초기화하는데 쓰입니다.
  • set.add(value) – 값을 추가하고 ì…‹ 자신을 반환합니다. ì…‹ 내에 이미 valueê°€ 있는 경우 아무런 작업을 하지 않습니다.
  • set.delete(value) – 값을 제거합니다. 호출 시점에 ì…‹ 내에 값이 있어서 제거에 성공하면 true, 아니면 false를 반환합니다.
  • set.has(value) – ì…‹ 내에 값이 존재하면 true, 아니면 false를 반환합니다.
  • set.clear() – 셋을 비웁니다.
  • set.size – 셋에 몇 개의 값이 있는지 세줍니다.

맵과 셋에 반복 작업을 할 땐, 해당 컬렉션에 요소나 값을 추가한 순서대로 반복 작업이 수행됩니다. 따라서 이 두 컬렉션은 정렬이 되어있지 않다고 할 수 없습니다. 그렇지만 컬렉션 내 요소나 값을 재 정렬하거나 (배열에서 인덱스를 이용해 요소를 가져오는 것처럼) 숫자를 이용해 특정 요소나 값을 가지고 오는 것은 불가능합니다.

과제

중요도: 5

arr은 배열이라 가정합시다.

arr에서 중복 값을 제거해 주는 함수 unique(arr)를 만들어보세요.

예시:

function unique(arr) {
  /* 제출 답안 */
}

let values = ["Hare", "Krishna", "Hare", "Krishna",
  "Krishna", "Krishna", "Hare", "Hare", ":-O"
];

alert( unique(values) ); // 얼럿창엔 `Hare, Krishna, :-O`만 출력되어야 합니다.

참고 1: 여기선 배열 안의 요소가 모두 문자열이지만 제출 답안을 작성할 땐, 배열 내 어떤 자료형이 들어와도 동작할 수 있어야 합니다.

참고 2: Set을 사용해보세요.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

중요도: 4

애너그램(어구전철)은 단어나 문장을 구성하고 있는 문자의 순서를 바꾸어 다른 단어나 문장을 만드는 놀이입니다.

예시:

nap - pan
ear - are - era
cheaters - hectares - teachers

애너그램으로 만든 단어를 걸러내는 함수 aclean(arr)을 만들어보세요.

예시:

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) ); // "nap,teachers,ear"나 "PAN,cheaters,era"이 출력되어야 합니다.

애너그램 그룹에서 한 단어는 남아있어야 합니다.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

단어를 글자 단위로 쪼갠 후, 알파벳 순으로 정렬하면 애너그램을 찾을 수 있습니다. 이 과정을 거치면 같은 그룹에 속하는 애너그램이 동일한 단어가 되기 때문입니다.

예시:

nap, pan -> anp
ear, era, are -> aer
cheaters, hectares, teachers -> aceehrst
...

알파벳 순으로 정렬된 글자를 맵의 키로 사용해, 키 하나엔 값 하나만 저장되도록 하겠습니다.

function aclean(arr) {
  let map = new Map();

  for (let word of arr) {
    // 단어를 글자 단위로 쪼갠 후, 알파벳 순으로 정렬한 다음에 다시 합칩니다.
    let sorted = word.toLowerCase().split('').sort().join(''); // (*)
    map.set(sorted, word);
  }

  return Array.from(map.values());
}

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) );

(*)로 표시한 줄에서 여러 메서드를 체이닝 해 글자를 정렬해 보았습니다.

(*)로 표시한 줄을 여러 줄에 나눠서 작성하면 아래와 같은 코드가 됩니다.

let sorted = word // PAN
  .toLowerCase() // pan
  .split('') // ['p','a','n']
  .sort() // ['a','n','p']
  .join(''); // anp

'PAN'과 'nap'은 동일하게 'anp'라는 글자로 정렬되죠.

아래 코드는 단어를 맵에 저장합니다.

map.set(sorted, word);

정렬 이후의 글자 구성이 같은 단어를 또다시 만나게 되면, 키가 동일하므로 값이 덮어씌워 집니다. 따라서 맵엔 글자 구성이 같은 단어는 단 한 번만 저장됩니다.

함수 마지막 줄의 map.values()는 맵의 값을 담은 반복 가능한 객체를 반환하는데, Array.from은 이 반복 가능한 객체를 배열로 바꿔줍니다(키는 필요하지 않기 때문에 map.values()를 사용함).

이 문제에서 키는 문자형이므로 맵 대신 일반적인 객체를 사용할 수도 있습니다.

객체를 사용한 해답은 아래와 같습니다.

function aclean(arr) {
  let obj = {};

  for (let i = 0; i < arr.length; i++) {
    let sorted = arr[i].toLowerCase().split("").sort().join("");
    obj[sorted] = arr[i];
  }

  return Object.values(obj);
}

let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"];

alert( aclean(arr) );

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

map.keys()를 사용해 배열을 반환받고, 이 배열을 변수에 저장해 .push와 같은 배열 메서드를 적용하고 싶다고 해봅시다.

작동하지 않네요.

let map = new Map();

map.set("name", "John");

let keys = map.keys();

// Error: keys.push is not a function
keys.push("more");

이유가 무엇일까요? keys.push가 작동하게 하려면 어떻게 코드를 고쳐야 할까요?

keys.push가 동작하지 않은 이유는 map.keys()가 배열이 아니라 이터러블을 반환하기 때문입니다.

Array.from을 이용하면 맵을 배열로 변환할 수 있습니다.

let map = new Map();

map.set("name", "John");

let keys = Array.from(map.keys());

keys.push("more");

alert(keys); // name, more
튜토리얼 지도

댓글

댓글을 달기 전에 마우스를 올렸을 때 나타나는 글을 먼저 읽어주세요.
  • 추가 코멘트, 질문 및 답변을 자유롭게 남겨주세요. 개선해야 í•  것이 있다면 댓글 대신 이슈를 만들어주세요.
  • 잘 이해되지 않는 부분은 구체적으로 언급해주세요.
  • 댓글에 한 줄짜리 코드를 삽입하고 싶다면 <code> 태그를, 여러 줄로 구성된 코드를 삽입하고 싶다면 <pre> 태그를 이용하세요. 10줄 이상의 코드는 plnkr, JSBin, codepen 등의 샌드박스를 사용하세요.