자료를 저장 할 때 Object가 아닌 Map을 써볼까?

ChoiYongHyeun·2024년 1월 30일
1

프로그래밍 공부

목록 보기
5/15
post-thumbnail

이전 토이프로젝트를 하면서 가장 느꼈던 것은

지금 내가 사용하고 있는 자료구조가 정말 적합한 자료구조일까 ? 이게 최선일까?

라는 생각을 했었다.

그러면서 대부분의 자료들을 Object 에 담아 저장하였는데

문득 어제 밤 자기 전 Map 내장 객체에 대해 생각이 나서 오전에 공식 문서도 둘러보고 레딧도 둘러보면서 공부해봤다.


🐤 우리가 평소에 자료를 저장하는 형태

const guestBook = {
  1: { firstName: 'lee', age: 16 },
  2: { firstName: 'kim', age: 15 },
  3: { firstName: 'choi', age: 20 },
  4: { firstName: 'choi', age: 30 },
};

대부분의 자바스크립트 개발자는 자료를 저장 할 때 Object 자료구조에 정보를 저장하곤 한다.

이는 해시테이블을 기반으로 자료를 저장하기 때문에 조회가 빠르고 가장 익숙한 형태이기 때문일 것이다.

하지만 문제점은 Object 객체 자체는 자료를 저장하기만 위해서 만들어진 내장 객체가 아니기 때문에

실제 자료 구조를 조회하거나 조작 할 때 여러 제약들을 받는다.

🐣 불편함을 느껴보자

내부메소드의 부재

console.log(Object.keys(guestBook));
/*
[ '1', '2', '3', '4' ]
*/
console.log(Object.values(guestBook));
/*
[
  { firstName: 'lee', age: 16 },
  { firstName: 'kim', age: 15 },
  { firstName: 'choi', age: 20 },
  { firstName: 'choi', age: 30 }
]
*/
console.log(Object.entries(guestBook));
/*
[
  [ '1', { firstName: 'lee', age: 16 } ],
  [ '2', { firstName: 'kim', age: 15 } ],
  [ '3', { firstName: 'choi', age: 20 } ],
  [ '4', { firstName: 'choi', age: 30 } ]
]
*/

Object 로 생성된 geustBook 객체의 내부 프로퍼티나 내부 메소드로 keys , values , exntries 등이 존재하지 않기 때문에 Object 의 정적 메소드로만 조회가 가능하다.

불편 ...

크기를 재는 내부 프로퍼티 , 메소드의 부재

이번에는 몇 명이나 왔는지 확인하기 위해 guestBook 의 길이를 보려고 한다고 해보자

Object.prototype 에는 객체의 크기나 길이를 재는 프로퍼티나 메소드가 존재하지 않는다.

그렇기 때문에 Object.keys 나 다른 메소드를 통해 length 프로퍼티를 갖는 자료구조로 변환해준 후 길이를 재줘야 한다.

console.log(guestBook.hasOwnProperty('length')); // false
const totalKeys = Object.keys(guestBook);
console.log(totalKeys.hasOwnProperty('length')); // true 
console.log(totalKeys.length); // 4

불편 ...

반복문 사용시 제약 조건

for of 문은 iterable 한 객체들을 순회할 수 있게 해주는 반복문이다.

Object 로 생성된 객체들은 length 프로퍼티가 존재하지 않기 때문에 iterable 하지 않아 for of 문은 사용 할 수 없다.

for in 문은 Object 객체에서도 사용가능하다.

for in 문은 enumerable 한 프로퍼티 값들을 순회한다.

for (const key in guestBook) {
  console.log(key); /* 1 , 2 , 3 , 4 */
}

그럼 문제가 없네 ~ 싶지만 만약 guestBook 객체가 다른 객체로부터

상속받은 프로퍼티가 존재한다고 해보자 . 그런데 enumrable 한 ..

Object.prototype.foo = 'foo~!'; 
/* Object 에 enumerable 한 프로퍼티 설정
guestBook 은 Object 의 프로토타입을 상속받는다 */

const guestBook = { /* 생략 */ };
for (const key in guestBook) {
  console.log(key); /* 1 2 3 4 foo */
}

그러면 상속받은 프로퍼티까지 같이 순회가 되기 때문에 상속 받지 않고 온전히 guestBook 이 가지고 있는 프로퍼티만 순회하도록 조건을 걸어줘야 한다.

for (const key in guestBook) {
  if (!guestBook.hasOwnProperty(key)) continue;
  console.log(key); /* 1 2 3 4 */
}

불편 ...

우발적 키 (Prototype attack)

Object 로 생성된 {} 객체들은 수 많은 Object 의 프로토타입들을 내부 프로퍼티 및 메소드로 가지고 있다.

const obj = {};
obj.a = 1;
obj.b = 2;
console.log(obj.hasOwnProperty('a')); /* true */

우리는 . 접근자나 [] 접근자를 통해 프로퍼티값을 설정해주기도 하고

내부 프로퍼티나 메소드를 호출하기도 한다.

위처럼 .hasOwnProperty 로 내부 메소드를 호출한 것과 같다.

그럼 만약 내가 hasOwnProperty 라는 키를 이용해 변수를 설정해줘보자

const obj = {};
obj.a = 1;
obj.b = 2;
obj.hasOwnProperty = 3;
console.log(obj.hasOwnProperty('a')); 
/* TypeError: obj.hasOwnProperty is not a function */

이미 .hasOwnProperty 를 통해 호출되는 것은 프로토타입이 아닌 3 이라는 원시값이기 때문에

더 이상 나는 프로토타입을 사용 할 수 없게 된다.

불편 ..

키 값들의 순서 미보장

Object 에 저장된 key 값들은 내가 어떤 순서로 저장을 하든 상관없이 무조건 오름차순으로 저장된다.

오름차순은 숫자의 크기가 아닌 ASCII 코드 값을 기준으로 한다.
숫자 크기가 아니다.

const obj = {};
obj[3] = 'first';
obj[2] = 'second';
obj[1] = 'thrid';

console.log(obj); /* { '1': 'thrid', '2': 'second', '3': 'first' } */
console.log(Object.values(obj)); /* [ 'thrid', 'second', 'first' ] */

또 이 예시를 만들면서 느낀점은 Objectkey 값으론 항상 문자열만 가능하단 점이다.

그렇기 때문에 숫자값을 키값으로 넣어주고 싶다면 [] 를 이용해 설정해야 하며

심지어 설정된 후에는 문자열로 자동 변환된다.

불편 ..

직관적이지 못한 프로퍼티 존재여부

const guestData = Object.values(guestBook);

const ageBook = {};

guestData.forEach(({ firstName, age }) => {
  if (!ageBook[firstName]) ageBook[firstName] = [];
  ageBook[firstName].push(age);
});

console.log(ageBook); /* { lee: [ 16 ], kim: [ 15 ], choi: [ 20, 30 ] } */

이번에는 geustBook 내부 사람들의 데이터에서 이름 별 나이 분포를 알고싶다고 해보자

그래서 ageBook 이란 Object 를 만들어주고 만약 ageBook 에서 firstName 프로퍼티가 존재하지 않으면 빈 리스트를 생성해주도록 했다.

여기서 !ageBook[firstName]boolean 일까 ?

아니다. 해당 프로퍼티명이 존재하지 않기 때문에 undefined 값이 반환되고 falsy 한 값으로 평가되기 때문에 false 로 평가되는 것 뿐이다.

실제 내가 하는 행위의 문맥과 다르다.

guestData.forEach(({ firstName, age }) => {
  if (!ageBook.hasOwnProperty(firstName)) ageBook[firstName] = [];
  ageBook[firstName].push(age);
});

좀 더 문맥에 맞게 쓰려고 한다면 이렇게 하는 것이 더 맞긴 할 것이다.


🐤 그럼 Map 을 써보자

사용하기 용이한 메소드

const map = new Map();
map.set(key , value)

MapMap 생성자를 통해 생성 할 수 있으며 새로운 값을 추가 할 때에는

[key , value] 값을 인수로 받은 set 메소드를 호출한다.

const map = new Map();

map.a = 1;
console.log(map); // Map(0) { a: 1 }
console.log(map.has('a')); // false

마치 Object 에서 . 접근자를 이용해 값을 설정하는 것처럼 보일 수 있으나

이는 자료구조에 담은 것이 아니라 map 이란 인스턴스의 프로퍼티 값으로 설정 된 것일 뿐 자료구조에 담긴 것은 아니다.

const map = new Map();

map.set('a', 1);

console.log(map); /* Map(1) { 'a' => 1 } */

정상적으로 값이 추가가 되면 다음과 같은 형태로 나타난다.

호출될 때 Map(n) 에 나오는 n 은 눈치를 챘겠지만 자료구조에 담긴 값들의 길이이다.

get , has , delete , clear

Map 은 자료구조를 조작 할 수 있는 다양한 프로토타입 메소드를 제공한다.

const map = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3],
]);
/* get 메소드 */
console.log(map.get('a'));
/* has 메소드 */
console.log(map.has('b'));
/* delete 메소드 */
console.log(map.delete('c'));
console.log(map);
/* clear 메소드 */
map.clear();
console.log(map);

키값으로 값을 접근 할 수 있는 get 메소드와

해당 키값이 존재하는지 확인하는 has 메소드

그리고 값을 제거하는 delete 메소드

delete 메소드는 인수로 받은 키값이 존재하면 제거 후 true 를 반환하고
존재하지 않을 경우엔 false를 반환만 한다.

그리고 모든 값을 제거하는 claer 메소드를 제공한다.

이 뿐만 아니라 Object 에서 key , value , entries 를 호출하기 위해선

생성자인 Object 의 정적 메소드를 이용해야 했으나

Map 에서는 프로토타입으로 존재한다.

console.log(map.keys()); [Map Iterator] { 'a', 'b', 'c' }
console.log(map.values()); [Map Iterator] { 1, 2, 3 }
console.log(map.entries()); [Map Entries] { [ 'a', 1 ], [ 'b', 2 ], [ 'c', 3 ] }

구우웃 ~

또한 자료의 개수를 셀 수 있는 .size 프로퍼티도 가지고 있기 때문에

자료들의 개수를 O(1) 만에 구할 수 있다.

Map 은 어떤 값이든 키 값으로 받을 수 있다

Object 에서는 key 값으로 사용 가능한 것은 오로지 StringSymbol 값 밖에 없었다.

하지만 Map 에서는 어떤 값이든 키 값으로 사용 가능하다.

심지어 NaN 마저도 말이다.

const map = new Map();

map.set('일', '가');
map.set(1, '나');
map.set(function foo() {}, '다');
map.set(NaN, '라');

console.log(map);
/*
Map(4) { '일' => '가', 1 => '나', [Function: foo] => '다', NaN => '라' }
*/

심지어 객체 또한 키 값으로 받을 수 있다.

const keyObj = { a: 1 };
const valueObj = { b: 2 };
const map = new Map();
map.set(keyObj, valueObj);
console.log(map); /* Map(1) { { a: 1 } => { b: 2 } } */

또한 MapObject 의 프로토타입을 상속받지 않기 때문에 덮어씌우는 것에 대해서 영향이 전혀 없다.

Mapiterable 한 객체이다.

const map = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3],
]);

map.forEach((...rest) => {
  console.log(rest);
});
/*
[ 1, 'a', Map(3) { 'a' => 1, 'b' => 2, 'c' => 3 } ]
[ 2, 'b', Map(3) { 'a' => 1, 'b' => 2, 'c' => 3 } ]
[ 3, 'c', Map(3) { 'a' => 1, 'b' => 2, 'c' => 3 } ]
*/

for (const [key, value] of map) {
  console.log(key, value); 
  /*
  a 1
  b 2
  c 3
  */
}

console.log([...map]); /* [ [ 'a', 1 ], [ 'b', 2 ], [ 'c', 3 ] ] */

따라서 forEach , for of , 스프레드 문법 모두 사용 가능하다.

Map은 자료가 들어온 순서를 보존한다

const map = new Map([
  [3, 'first'],
  [2, 'second'],
  [1, 'thrid'],
]);

map.forEach((key, value) => {
  console.log(key, value);
  /*
  first 3
  second 2
  thrid 1
  */
});

값들을 순회히보면 Map 은 정보가 들어온 순을 기억하는 모습을 볼 수 있다.


그럼 우리는 Object 를 사용하지 말고 Map 만 사용해야 할까?

이처럼 Map 은 자료를 저장 및 삽입, 삭제, 길이 조회 등을 효과적으로 하기 위한 객체이다.

그럼 우리는 Object 를 쓰지 말고 Map 만 써야 할까 ?

처음에 나는 아 ~ 이제 앞으로 자료를 저장 할 때에는 Map 만 써야지 ~

이런 마음으로 공부를 시작했는데

Map 을 써야하는 이유에서 다양한 프로토타입 메소드를 제공함으로서 사용하기 편하다라는 점은 누구나 동의하는 바였지만

Object 보다 성능이 뛰어나다는 점에서는 여러 반대 의견이 존재했다.

https://stackoverflow.com/questions/18541940/map-vs-object-in-javascript
https://dev.to/faisalpathan/why-to-use-map-over-object-in-js-306m
https://www.reddit.com/r/javascript/comments/vgs7y1/why_you_should_prefer_map_over_object_in/

Map 이 성능이 더 좋다고 주장하는 쪽에서는

Map 의 키 값이 커질수록 Object 보다 속도가 삽입 및 삭제, 조회 속도가 빠르다는 것이다.

키 값이 커진다는 것은 키 값에 들어가는 값이 1000 이하

해당 When You Should Prefer Map Over Object In JavaScript 포스트에 들어가면 실험 결과를 볼 수 있다.

포스트에서는 key 값의 범위가 0 ~1000 이하일 때에는 ObjectMap 보다 삽입 및 조회 , 삭제 속도가 빨랐다고 하며 1000 이상 일 때는 Map 이 더 빨랐다고 한다.

그래서 나도 크롬 브라우저에서 실험해봤다.

    <script>
      const keys = [...Array(1000000)].map((_, idx) => [idx, idx]);
      const obj = Object.fromEntries(keys);
      const map = new Map(keys);

      let startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        obj[i] = i;
      }
      let endTime = new Date();
      console.log(`Object add: ${endTime - startTime}ms`);

      startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        map.set(i, i);
      }
      endTime = new Date();
      console.log(`Map add: ${endTime - startTime}ms`);

      startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        map.delete(i);
      }
      endTime = new Date();
      console.log(`Map delete: ${endTime - startTime}ms`);

      startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        obj[i];
      }
      endTime = new Date();
      console.log(`Object get: ${endTime - startTime}ms`);

      startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        delete obj[i];
      }
      endTime = new Date();
      console.log(`Object delete: ${endTime - startTime}ms`);

      // Measure time for deleting values from a Map
      startTime = new Date();
      for (let i = 0; i < 1000000; i++) {
        map.get(i);
      }
      endTime = new Date();
      console.log(`Map get: ${endTime - startTime}ms`);
    </script>

키 값의 범위가 0 ~ 1000000 일 때는 모든 면에서 Object 의 속도가 Map 보다 훨씬 빨랐다.

그럼 이번엔 키 값의 범위를 1000 ~ 100000000 천만에서 억까지 올려보자

범위를 늘리니 Map 이 좀 더 빠르다.

흠 ..

다만 반대 입장에서는 실제 필드에서는 프론트엔드는 그렇게 큰 용량의 자료구조 형태를 받을 일이 적을 뿐더러

오히려 혼란만 가중시킬 수 있다는 의견이 있었다.

자바스크립트를 이용하는 코더 중 80% 이상이 Map 을 쓰지 않아봤을 것이라고 이야기 하는 사람도 있다.
자기는 15년동안 코딩을 했는데 한 번도 필요한 적이 없었다고 한다.

그럼 우리는 Map 을 언제 사용하는게 좋을까 ?

  • 키 값으로 문자열뿐이 아니라 다양한 형태의 값을 넣어주고 싶을 때
  • 키 값으로 Object 의 프로토타입 명과 같은 것을 넣어주고 싶을 때
  • 값이 들어간 순서를 중요하게 보존 하고 싶을 때
    	> 사실 이거는 그러면 그냥 배열을 쓰면 되지 않나 싶다.
  • 속도가 조금 더 느릴지언정 코드를 조금 더 간결하고 깔끔하게 하고 싶을 때
    	> 개인 취향일 수 있지만 나는 사실 `Map` 을 이용해서 쓰는 것이 더 코드가 깔끔해보였다. 

회고

글을 작성 할 때만 해도

아 ~ 이 글 쓰고 앞으로는 Map 만 써야지 ㅋㅋ 깔끔하고 이쁘네 개꿀 ㅋㅋ

이랬는데

찾다 보니 필요성을 크게 못느껴버렸다.

사실 나는 Map 을 쓰는게 훨신 깔끔해서 여러 댓글로 논쟁이 오고 갈 때

마음 속으로 Map 이 이기기를 간절히 기도했지만

그런 프로토타입들이 부러워서 그런거라면

그냥 Object 를 상속 받은 후 프로토타입 메소드를 만지작 거리는게 더 나을 것 같다.

슬퍼요 ..

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글