[번역] 객체 대신 Map을 더 사용해보기

Sonny·2023년 2월 16일
52

Article

목록 보기
8/20
post-thumbnail

원문 : https://www.builder.io/blog/maps

자바스크립트의 객체는 굉장합니다. 객체는 무엇이든 할 수 있습니다! 말 그대로 무엇이든 말이죠.

하지만 모든 것들과 마찬가지로, 단지 무언가를 할 수 있다고 해서 반드시 해야 한다는 의미는 아닙니다.

// 🚩
const mapOfThings = {}

mapOfThings[myThing.id] = myThing

delete mapOfThings[myThing.id]

예를 들어, 자바스크립트의 객체를 사용하여 자주 추가하거나 제거하는 키를 가지는 임의의 키-값 쌍을 저장하는 경우, 일반 객체 대신 map을 사용하는 것을 고려해야 합니다.

// ✅
const mapOfThings = new Map()

mapOfThings.set(myThing.id, myThing)

mapOfThings.delete(myThing.id)

객체의 성능 문제

객체에서 delete 연산자는 성능 저하로 악명이 높은 반면, map은 키를 제거하는 데 최적화되어 있으며 경우에 따라 훨씬 더 빠를 수 있습니다.

물론 이는 하나의 벤치마크 예시일 뿐입니다.(Core i7 MBP에서 Chorme v109로 실행) Zhenghao He가 만든 또 다른 벤치마크를 비교해 볼 수도 있습니다. 명심하세요. 이와 같은 마이크로 벤치마크는 불완전하기로 악명 높으므로 참고만 하는 것을 추천드립니다.

즉, 저나 다른 사람의 벤치마크를 신뢰할 필요는 없습니다. MDN은 map이 키를 자주 추가하거나 제거하는 사례에 특히 최적화되어 있다는 것을 명확하게 설명합니다. 이러한 사례에 최적화되지 않은 객체와 비교하면 다음과 같습니다.

이유가 궁금하신가요? 이는 자바스크립트 VM이 JS 객체의 형태를 가정하여 최적화하는 방법과 관련이 있습니다. 반면 map은 키가 동적으로 끊임없이 변경되는 해시맵의 쓰임새를 위해 특별히 제작되었습니다.

Miško(Builder.io의 CTO, Angular 및 Qwik 작성자)의 스레드에서 VM이 형태를 취하는 방식에 대해 자세히 읽어보세요.

자바스크립트에서 객체의 성능 특성과 키를 자주 추가하거나 제거하는 해시맵과 같은 사용 사례에 최적화되지 않은 이유를 설명하는 글인 What's up with monomorphism도 있는데 아주 좋습니다.

그러나 성능 외에도 map은 객체에 존재하는 여러 문제를 해결합니다.

내장된 키 문제

해시맵과 유사한 사례에 대한 객체의 주요 문제 중 하나는 객체가 이미 내장된 수많은 키로 인해 오염되었다는 것입니다. 무슨 의미일까요?

const myMap = {}

myMap.valueOf // => [Function: valueOf]
myMap.toString // => [Function: toString]
myMap.hasOwnProperty // => [Function: hasOwnProperty]
myMap.isPrototypeOf // => [Function: isPrototypeOf]
myMap.propertyIsEnumerable // => [Function: propertyIsEnumerable]
myMap.toLocaleString // => [Function: toLocaleString]
myMap.constructor // => [Function: Object]

따라서 비어있는 객체일지라도 이러한 프로퍼티 중 하나에 접근해보면 각 프로퍼티에는 이미 값이 존재합니다.

이것만으로도 임의 키 해시맵에 객체를 사용하지 않는 명확한 이유가 됩니다. 나중에 발견하게 될지도 모르는 곤란한 버그로 이어질 수 있기 때문입니다.

반복의 어색함

자바스크립트 객체가 키를 처리하는 이상한 방식에 대해 말하자면, 객체를 반복하는 데 있어 문제가 아주 많습니다.

예를 들어, 다음과 같이 하면 안 된다는 것을 이미 알고 있을 수 있습니다.

for (const key in myObject) {
  // 🚩 의도하지 않은 일부 상속된 키를 우연히 발견할 수 있습니다.
}

대신 이렇게 하라는 안내를 받았을 수도 있습니다.

for (const key in myObject) {
  if (myObject.hasOwnProperty(key)) {
    // 🚩
  }
}

하지만 myObject.hasOwnProperty는 다른 값으로 쉽게 재정의될 수 있으므로 여전히 문제가 있습니다. 다른 사용자가 myObject.hasOwnProperty = () => explode().를 수행하는 것을 막을 수는 없습니다.

따라서 다음과 같은 짜증나는 작업을 대신 수행해야 합니다.

for (const key in myObject) {
  if (Object.prototype.hasOwnProperty.call(myObject, key) {
    // 😕
  }
}

또는 코드가 지저분해 보이지 않도록 원한다면 for 루프를 완전히 포기하고 forEach와 함께 Object.keys를 사용할 수 있습니다.

Object.keys(myObject).forEach(key => {
  // 😬
})

그러나 map을 사용하면 이런 문제가 전혀 없습니다. 표준 이터레이터와 함께 표준 for 루프를 사용할 수 있으며 keyvalue를 한번에 가져오기 위해 매우 멋진 구조 분해 패턴을 사용할 수 있습니다.

for (const [key, value] of myMap) {
 // 😍
}

좋네요.

키 순서

map의 또 다른 장점은 키 순서를 유지한다는 것입니다. 이 기능은 오랫동안 요구되어 왔었는데, 이제 map에 존재합니다.

또한 정확한 순서로 map에서 직접 키를 분해할 수 있다는 멋진 기능을 제공합니다.

const [[firstKey, firstValue]] = myMap

이를 통해 O(1) LRU 캐시 구현과 같은 몇 가지 흥미로운 사용 사례를 얻을 수도 있습니다.

복사

객체에는 몇 가지 장점이 있습니다. 예를 들어, 객체를 펼치거나 할당하는 등 객체를 복사하기가 매우 쉽다는 점입니다.

const copied = {...myObject}
const copied = Object.assign({}, myObject)

하지만 map 또한 복사하기가 쉽습니다.

const copied = new Map(myMap)

위 코드가 작동하는 이유는 Map의 생성자가 [key, value] 튜플의 이터러블을 사용하기 때문입니다. 편리하게도 map은 이터러블하여 키와 값의 튜플을 생성합니다. 멋지네요.

마찬가지로, structuredClone를 사용하여 객체와 마찬가지로 map의 깊은 복사본을 만들 수도 있습니다.

const deepCopy = structuredClone(myMap)

map을 객체로, 객체를 map으로 변환

Object.fromEntries를 사용하여 map을 객체로 쉽게 변환할 수 있습니다.

const myObj = Object.fromEntries(myMap)

Object.entries를 사용하여 객체를 map으로도 쉽게 변환할 수 있습니다.

쉽네요!

이제 이것을 알았으므로 더 이상 튜플을 사용하여 map을 구성할 필요가 없습니다.

const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])

대신 객체처럼 구성할 수 있습니다. 제 눈에는 이 방식이 조금 더 좋아보입니다.

const myMap = new Map(Object.entries({
  key: 'value',
  keyTwo: 'valueTwo',
}))

또는 편리하고 작은 헬퍼를 만들 수도 있습니다.

const makeMap = (obj) => new Map(Object.entries(obj))

const myMap = makeMap({ key: 'value' })

타입스크립트 코드로 작성하면 다음과 같습니다.

const makeMap = <V = unknown>(obj: Record<string, V>) =>
  new Map<string, V>(Object.entries(obj))

const myMap = makeMap({ key: 'value' })
// => Map<string, string>

저는 이 방식을 좋아합니다.

키 타입

map은 단지 자바스크립트에서 키 값의 map을 처리하는 인체 공학적이고 더 좋은 성능의 방법만은 아닙니다. 일반 객체로는 전혀 할 수 없는 작업도 할 수 있습니다.

예를 들어, map은 문자열만 키로 갖는 것에 대한 제한이 없습니다. 모든 타입의 객체를 map의 키로 사용할 수 있습니다. 무엇이든 말이죠.

myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function() {}, value)
myMap.set(myDog, value)

왜일까요?

이에 대한 한 가지 유용한 사용 사례는 객체를 직접 수정할 필요 없이 메타데이터를 객체와 연결하는 것입니다.

const metadata = new Map()

metadata.set(myDomNode, {
  internalId: '...'
})

metadata.get(myDomNode)
// => { internalId: '...' }

예를 들어 임시 상태를 데이터베이스에서 읽고 쓰는 객체에 연결하려는 경우에 유용합니다. 객체 참조와 직접 연결된 임시 데이터를 위험 없이 얼마든지 추가할 수 있습니다.

const metadata = new Map()

metadata.set(myTodo, {
  focused: true
})

metadata.get(myTodo)
// => { focused: true }

이제 myToDo를 데이터베이스에 다시 저장하면 저장하려는 값만 있고 별도의 map에 있는 임시 상태는 포함되지 않습니다.

하지만 여기에는 한 가지 문제가 있습니다.

일반적으로 가비지 컬렉터는 이 객체를 수집하고 메모리에서 제거합니다. 그러나 map이 참조를 보유하고 있기 때문에 가비지 수집이 되지 않아 메모리 누수가 발생합니다.

WeakMaps

여기에서 WeakMap을 사용할 수 있습니다. Weak Map은 객체에 대한 약한 참조를 보유하므로 위의 메모리 누수를 완벽하게 해결합니다.

따라서 다른 모든 참조가 제거되면 객체가 자동으로 가비지 수집되어 이 Weak Map에서 제거됩니다.

const metadata = new WeakMap()

// ✅ 다른 참조가 없을 때 자동으로 myTodo가 map에서 제거되어 메모리 누수가 없습니다.
metadata.set(myTodo, {
  focused: true
})

더 많은 Map 메서드

계속 진행하기 전에 map에 대해 알아야 할 몇 가지 유용한 사항은 다음과 같습니다.

map.clear() // map 전체를 제거하기
map.size // map의 사이즈를 가져오기
map.keys() // map의 모든 키에 대한 이터레이터
map.values() // map의 모든 값에 대한 이터레이터

map이 좋은 메소드를 가지고 있다는 걸 아시겠죠. 넘어가볼까요?

Sets

map에 대해 이야기할 때는 사촌인 Set도 언급해야 합니다. 어떤 집합이 아이템을 가지고 있는 경우 Set를 통해 더 나은 성능으로 쉽게 추가, 제거 및 조회할 수 있는 고유한 요소 목록을 만들 수 있습니다.

const set = new Set([1, 2, 3])

set.add(3)
set.delete(4)
set.has(5)

경우에 따라 set은 배열을 사용했을 때보다 훨씬 더 나은 성능을 제공할 수 있습니다.

어쩌구 저쩌구 마이크로 벤치마크는 완벽하지 않습니다. 실제 조건에서 자신의 코드를 테스트하여 이익을 얻는지 확인하거나, 제 말을 그대로 받아들이지 마세요.

마찬가지로, 자바스크립트에서 메모리 누수를 방지하는 WeakSet 클래스도 있습니다.

// 여기에는 메모리 누수가 없습니다, 캡틴
const checkedTodos = new WeakSet([todo1, todo2, todo3])

직렬화

map과 set에 비해 일반 객체와 배열이 갖는 마지막 이점은 직렬화라고 할 수 있습니다.

당신은 당신이 저를 알고 있다고 생각했어요. 하지만 당신을 위한 답이 있어요,

네, 객체 및 map에 대한 JSON.strigify() / JSON.parse() 지원은 매우 편리합니다.

하지만 JSON을 예쁘게 출력하려면 항상 null을 두 번째 인자로 추가해야 한다는 사실을 알고 계셨나요? 이 매개변수가 무엇을 하는지 아시나요?

JSON.stringify(obj, null, 2)
//                  ^^^^ 이것은 어떤 일을 할까요

결과적으로 이 매개변수는 우리에게 매우 도움이 될 수 있습니다. 대체자라고 하며 사용자 정의 유형을 직렬화하는 방법을 정의할 수 있습니다.

이를 사용하여 직렬화를 하기 위해 map과 set을 객체와 배열로 쉽게 변환할 수 있습니다.

JSON.stringify(obj, (key, value) => {
  // map을 일반 객체로 변환
  if (value instanceof Map) {
    return Object.fromEntries(value)
  }
  // set을 일반 배열로 변환
  if (value instanceof Set) {
    return Array.from(value)
  }
  return value
})

자바스크립트 개발자가 직장을 그만 둔 이유는 무엇인가요? 그들은 배열을 얻지 못했기 때문(arrays == a raise)이에요. 하하, 좋아요.

이제 이것을 재사용 가능한 기본 함수로 추상화하고 직렬화할 수 있습니다.

const test = { set: new Set([1, 2, 3]), map: new Map([["key", "value"]]) }

JSON.stringify(test, replacer)
// => { set: [1, 2, 3], map: { key: value } }

다시 변환하기 위해 JSON.parse와 동일한 트릭을 사용할 수 있지만, 리바이버(reviver) 매개 변수를 사용하여 배열을 set으로 변환하고 객체를 map으로 다시 변환할 수 있습니다.

JSON.parse(string, (key, value) => {
  if (Array.isArray(value)) {
    return new Set(value)
  }
  if (value && typeof value === 'object') {
    return new Map(Object.entries(value))
  }
  return value
})

또한 대체자리바이버는 모두 재귀적으로 동작하므로 JSON 트리의 어느 곳에서나 map과 set을 직렬화 및 역직렬화할 수 있습니다.

그러나 위의 직렬화 구현에는 작은 문제가 하나 있습니다.

현재 일반 객체나 배열을 구문 분석 시 map 또는 set와 구별하지 않으므로 JSON에서 일반 객체와 map을 혼합할 수 없습니다. 그렇지 않으면 다음과 같이 문제가 발생하게 됩니다.

const obj = { hello: 'world' }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>

예를 들어, __type이라고 하는 특수 프로퍼티를 만들어 이 문제를 해결할 수 있습니다. __type은 다음과 같이 일반 객체나 배열이 아닌 map 또는 set이어야 함을 나타냅니다.

function replacer(key, value) {
  if (value instanceof Map) {
    return { __type: 'Map', value: Object.fromEntries(value) }
  }
  if (value instanceof Set) {
    return { __type: 'Set', value: Array.from(value) }
  }
  return value
}

function reviver(key, value) {
  if (value?.__type === 'Set') {
    return new Set(value.value)
  }
  if (value?.__type === 'Map') {
    return new Map(Object.entries(value.value))
  }
  return value
}

const obj = { set: new Set([1, 2]), map: new Map([['key', 'value']]) }
const str = JSON.stringify(obj, replacer)
const newObj = JSON.parse(str, reviver)
// { set: new Set([1, 2]), map: new Map([['key', 'value']]) }

이제 set 및 map에 대한 JSON 직렬화 및 역직렬화를 완벽하게 지원합니다. 근사하네요.

언제 어떤 것을 사용해야 할까요

모든 event에 제목과 날짜가 있어야 하는 경우와 같이 키 집합이 잘 정의되어 구조화된 객체의 경우, 일반적으로 객체를 원할 수 있습니다.

// 구조화된 객체의 경우, 객체를 사용하세요.
const event = {
  title: 'Builder.io Conf',
  date: new Date()
}

고정된 키 집합이 있을 때 빠른 읽기 및 쓰기에 매우 최적화되어 있습니다.

키를 여러 개 가질 수 있고 키를 자주 추가 및 제거해야 할 경우, 더 나은 성능과 효율성 측면을 위해 map을 사용하는 것이 좋습니다.

// 동적 해시맵의 경우, Map을 사용하세요.
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

요소의 순서가 중요하고 의도적으로 중복을 허용하는 배열을 만들 때는 보통 일반 배열이 좋습니다.

// 정렬된 목록이나 중복 항목이 필요할 수 있는 목록의 경우, 배열을 사용하세요.
const myArray = [1, 2, 3, 2, 1]

그러나 중복을 원하지 않고 항목의 순서가 중요하지 않다면 set을 사용하는 걸 고려하세요.

// 정렬되지 않은 고유 목록의 경우 Set을 사용하세요.
const set = new Set([1, 2, 3])
profile
FrontEnd Developer

1개의 댓글

comment-user-thumbnail
2023년 2월 17일

자바스크립트 개발자가 직장을 그만 둔 이유는 무엇인가요? 그들은 배열을 얻지 못했기 때문(arrays == a raise)이에요. 하하, 좋아요.

번역하기 엄청 힘든 문장이네요.
이해 못하신 분을 위해서 설명을 드리면,
I got a raise = 나 연봉인상 되었어 라는 뜻이라서 만들어진 말장난입니다.

답글 달기