자바스크립트의 객체 대신 Map 사용해보기

이럴 땐 map을 써보세요.

이 글은 builder.io의
“Use Maps More and Objects Less”
라는 글을 참고했습니다.


객체와 Map, 그리고 특징

자바스크립트의 객체는 무엇이든 할 수 있는 데이터 타입입니다. 하지만, 다른 모든 것들과 마찬가지로, 단지 무언가를 할 수 있다고 해서 반드시 해야 한다는 의미는 아닙니다.

객체를 사용하여 임의의 키-값 쌍을 저장하는 경우
const mapOfThings = {}
mapOfThings[myThing.id] = myThing
delete mapOfThings[myThing.id]

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

map을 사용하여 임의의 키-값 쌍을 저장하는 경우
const mapOfThings = new Map()
mapOfThings.set(myThing.id, myThing)
mapOfThings.delete(myThing.id)

객체의 성능 문제

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

builder.io

MDN은 map이 키를 자주 추가하거나 제거하는 사례에 특히 최적화되어 있다는 것을 명확하게 설명하고 있습니다. 이러한 사례에 최적화되지 않은 객체와 비교하면 다음과 같습니다.

builder.io

이는 자바스크립트 VM이 JS 객체의 형태를 가정하여 최적화하는 방법과 관련이 있는데, map은 키가 동적으로 끊임없이 변경되는 해시맵의 쓰임새를 위해 특별히 제작된 함수로 성능 외에도 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 루프를 포기하고 forEachObject.keys를 사용할 수도 있습니다.

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

하지만 map을 사용하면 더 간단하게 처리할 수 있습니다. 표준 이터레이터와 함께 표준 for 루프를 사용할 수 있고, keyvalue를 한번에 가져오는 구조 분해 패턴을 사용할 수도 있습니다.

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

키 순서

map의 또 다른 장점은 키 순서를 유지하는 것입니다. 이 기능은 오랫동안 요구되어 왔었지만, map에는 구현이 되어있습니다. 또 map은 정확한 순서로 map에서 직접 키를 분해할 수 있는 멋진 기능도 제공합니다.

const [[firstKey, firstValue]] = myMap

복사

객체에는 몇 가지 장점이 있는데, 대표적으로 객체를 펼치거나 할당하는 등의 객체 복사가 매우 쉽다는 점입니다.

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은 문자열만 키로 갖는 것에 대한 제한이 없어, 모든 타입의 객체를 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의 모든 값에 대한 이터레이터 수행

Sets

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

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

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

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

builder.io

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

const checkedTodos = new WeakSet([todo1, todo2, todo3])

직렬화

mapset에 비해 일반 객체와 배열이 갖는 이점은 직렬화라고 할 수 있는데, 객체 및 map에 대한 JSON.strigify()JSON.parse() 메서드는 매우 편리합니다. 그런데 JSON을 예쁘게 출력하기 위해서는 항상 null을 두 번째 인자로 추가해 주어야 합니다. 그런데 이 두 번째로 추가되는 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
})

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

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와 동일한 트릭을 사용할 수 있지만, 리바이버라는 매개 변수를 사용하여 배열을 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 직렬화 및 역직렬화를 완벽하게 지원하도록 만들 수 있는데, 객체와 Map은 언제, 어떤 것을 사용해야 할까요?

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

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

객체는 고정된 키 집합이 있는 경우, 빠른 읽기 및 쓰기에 매우 최적화되어 있지만, 만약 키를 여러 개 가질 수 있고 키를 자주 추가하거나, 제거해야 하는 경우라면 더 나은 성능과 효율성 측면을 위해 map을 사용하는 것이 좋습니다.

// 동적 해시맵의 경우
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

요소의 순서가 중요하고 의도적인 중복을 허용하는 배열을 만드는 경우에는 보통 일반 배열을 사용하는 것이 좋습니다.

// 정렬된 목록이나 중복 항목이 필요할 수 있는 목록의 경우
const myArray = [1, 2, 3, 2, 1]

하지만 중복을 원하지 않고 항목의 순서가 중요하지 않다면 set을 사용하는 것이 좋습니다.

// 정렬되지 않은 고유 목록의 경우
const set = new Set([1, 2, 3])

답글 남기기