자바스크립트 Map을 Object 대신 사용해야 할 때는 언제일까요?
⚠ disclaimer
This post is a translated version of the article of zhenghao.io in Korean arbitrarily for the only purpose of case study and sharing JavaScript knowledge.
All the copyrights belong to the author of zhenghao.io including images. Thanks.
Map
과 Set
, WeakMap
과 WeakSet
, 그리고 Symbol
은 ES6 이후 자바스크립트의 고유한 내장 자료형(data-types)임에도 잘 사용하지 않게 됩니다.
특정 API의 인스턴스를 관리하기 위해 Map
을 사용하던 도중 Map
과 Object
둘 중 어떤 자료형을 해시맵으로 사용하면 좋을지 명확한 기준이 궁금했습니다.
마침 벤치마크 테스트와 함께 각 자료형에 대한 사용 가이드를 제시한 좋은 글이 있어 번역 후 소개드리게 되었습니다.
임의적인 의역이 포함되어 있고, 번역 품질이 그리 좋지 않을 수 있는 점 읽으시는 분들께 미리 양해를 구합니다. 😊
자바스크립트 Map을 Object 대신 사용해야할 때는 언제일까요?
레딧(reddit)에서 해당 주제에 대해 논의된 내용 보러 가기 링크 🔗
자바스크립트의 객체(Object)
자료형은 여러 데이터를 하나로 그룹핑함으로써 더욱 편하게 자바스크립트를 사용할 수 있게 해줍니다.
ES6 이후 객체
와 많이 닮았지만 해괴한 인터페이스를 가지고 있는 맵(Map)
을 얻었습니다.
그러나 많은 사람들이 해시맵(hash map)
으로 객체를 사용하고 key
를 문자열로 사용할 수 없는 상황에서만 맵을 사용하곤 합니다. 결과적으로 맵은 오늘날의 자바스크립트 커뮤니티에서 잘 사용되지 않고(underused) 있습니다.
이 글에서 맵
을 더 많이 사용할 것을 고려해야 하는 이유와, 벤치마크와 함께 성능 특성을 설명하겠습니다.
자바스크립트에서 "객체"란 매우 넓은 의미의 용여로 사용됩니다.
null
과undefined
를 제외하면 거의 모든것이 객체로 취급됩니다. 이 포스트에서 지칭하는 "객체"란 중괄호(curly braces)로 둘러쌓인 좁은 의미의 객체()만을 지칭합니다.
> 요약
-
아래의 상황에서
객체
를 사용하십시오.- 설정을 위한 객체(config object)와 같이 고정된 값과 필드의 저장이 필요할때.
- 자주 변경되지 않으며 한 번만 사용될 때
- 객체를 작성하는 시점에 어떤 값이 채워질지 미리 알 수 있는 경우
-
아래의 상황에서
맵
을 사용하십시오.- 자주 변경, 갱신되는 값을 저장할 해시, 또는 Dictionary(이 또한 hash table을 뜻합니다)가 필요할때
- 이벤트 에미터(Event emitter)와 같이 객체를 작성하는 시점에 어떤 값이 채워질지 미리 알 수 없는 경우 (동적인 작성이 필요)
-
저의 성능 측정 지표(benchmarks)에 따르면, 맵이 적은 수의 정수를 Key 값으로 사용하는 경우 거의 대부분의 상황에서 객체보다 더 높은 삽입/삭제/순회 처리 속도를 보여줬습니다. 또한 같은 크기의 객체에 비해 메모리를 적게 사용하는 것으로 드러났습니다.
> 왜 객체는 해시 맵으로써 사용하기에 부족할까요?
객체가 해시맵으로 사용될 때 가장 불리한 점은 프로퍼티의 Key값을 문자열(String) 또는 심볼(Symbol)로 밖에 사용할 수 없다는 점일 것입니다. 허용되는 키값이 전달되지 않았을때는 내장된 toString
메소드를 사용하여 타입을 문자열로 형변환(Casting)합니다.
const foo = [];
const bar = {};
const obj = { [foo]: "foo", [bar]: "bar" };
console.log(obj); // {"": 'foo', [object Object]: 'bar'}
가장 중요한 것은, 객체를 해시맵으로 사용하는 것은 혼동(confusion)과 보안상 위험을 초래할 수 있습니다.
> 의도치 않은 상속(Inheritance)
ES6 이전엔 해시맵을 얻는 방법은 빈 객체를 생성하는 것이 유일했습니다.
const hashMap = {};
그러나, 객체를 생성할때 객체는 더 이상 비어있지 않게 됩니다. 비록 해시맵
이 객체 리터럴을 통해 만들어졌다 할지라도, 이 객체는 자동으로 Object.prototype
으로부터 속성을 상속받게 됩니다. 이것이 바로 우리가 명시적으로 메소드를 정의하지 않아도 hasOwnProperty
, toString
, constructor
를 해시멥의 메소드로써 사용할 수 있는 이유입니다.
프로토타입 상속으로 인해, 생성된 객체 스스로가 가지고 있는 속성과 프로토타입 체인을 통해 상속받은 속성이 합쳐지게(conflated)됩니다.
결과적으로 우리는 특정 속성이 유저가 정의한 것인지, 프로토타입 체인을 통해 정의된 것인지 구분할 필요가 있습니다. (e.g. hasOwnProperty
는 Object.prototype
으로부터 프로토타입 체인을 통해 상속받은 메소드입니다.)
게다가 자바스크립트에서 속성 확인 메 커니즘(property resolution mechanism)이 작동하는 방식 때문에 런타임에서 Object.prototype
을 변경하게 되면, 모든 객체에 파급 효과를 가져옵니다.
이는 대규모 자바스크립트 어플리케이션을 심각한 보안 문제가 될 수 있는 프로토타입 오염 공격(prototype pollution attack)에 취약하게 만듭니다.
다행히, 우리는 Object.prototype
으로부터 상속받지 않는 객체를 Object.create(null)
을 사용하여 생성할 수 있습니다.
> 이름 충돌(name collision)
객체 고유 속성의 이름(name of property)이 프로토타입 체인 범위 내 같은 이름과 겹쳐 충돌하게 되면 프로그램 충돌로 이어지게 됩니다.
예를 들어 객체를 인자로 받는 foo
란 이름의 함수를 가정해봅시다.
function foo(obj) {
// ...
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
}
}
}
obj.hasOwnProperty(key)
는 신뢰할 수 없습니다. 자바스크립트의 속성 확인 매커니즘의 작동 방식을 고려할때, 인자로 전달된 obj
에 사용자가 임의로 등록한 동일한 이름의 hasOwnProperty
가 포함되어 있다면, 이는 Object.prototype.hasOwnProperty
를 가리게(shadow)됩니다.
결과적으로 런타임 중에 정확히 어떤 메서드가 호출될지 보장할 수 없게 됩니다.
몇가지 방어적 프로그래밍(depensive programming)으로 이것을 예방할 수는 있습니다. 예를 들어 우리는 진짜 hasOwnProperty
를 Object.prototype
로부터 빌려올 수 있습니다.
function foo(obj) {
// ...
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// ...
}
}
}
더 간결하게는 {}.hasOwnProperty.call(key)
와 같이 객체 리터럴에서 메서드를 호출할 수 있지만 여전히 번거롭습니다. 그래서 새로 추가된 정적 메소드인 Object.hasOwn
이 있습니다.
> Sub-optimal ergonimics
객체
는 해시맵으로 사용하기에 사용 친화적이지 않습니다.(...doesn't provide adequate ergonimics를 의역하였습니다) 많은 일반적인 작업들을 직관적으로 수행할 수 없게 됩니다.
>> Size
객체
는 예를 들어 속성의 갯수를 확인할 수 있는 size
와 같은 편리한 API를 가지고 있지 않습니다. 그리고 객체의 size를 구성하는 요소는 미묘한 차이가 있습니다.
- 만약 객체의 모든 속성이 열거 가능(enumerable)하다면
Object.keys()
를 사용하여 속성 키를 배열로 변환하여length
로 프로퍼티의 사이즈를 얻을 수 있습니다. - 만약 열거 불가한(non-enumerable) 속성을 가진 객체라면
Object.getOwnPropertyNames
를 사용하여 속성명 리스트를 가져와 길이를 알아낼 수 있습니다. - 만약 심볼형(Symbolic) 키라면
getOwnPropertySymbols
를 사용하거나, 또는Reflect.ownKeys
를 사용하여 열거 가능 여부와 관계없이(enumerable or not) 문자열 키와 심볼 키를 동시에 얻어내야 합니다.
위의 모든 선택지는 길이를 얻기 전에 먼저 키 배열을 구성해야 하기 때문에 O(n)
의 복잡도를 갖습니다.
> 반복
객체를 순환하는 것은 비슷한 복잡도를 가집니다.
우리는 오래되고 좋은 for ... in
루프를 사용할 수 있습니다. 하지만 for ... in
루프는 상속받은 열거 가능한 속성까지 순환에 포함시킵니다.
Object.prototype.foo = "bar";
const obj = { id: 1 };
for (const key in obj) {
console.log(key); // 'id', 'foo'
}
객체는 순환 가능(iterable)하지 않기 때문에, Symbol.iterator
를 명시적으로 정의하지 않는 이상 for ... of
루프를 사용할 수 없습니다.
Object.keys
, Object.values
, Object.entries
를 사용하여 열거 가능한 문자열 키(또는 값)의 목록을 가져오고, 대신 이를 통해 반복할 수 있습니다.
그러면 오버헤드(overhead)가 있는 추가 단계가 필요하게 됩니다.
마지막으로, 삽입 정렬 순서가 보장되지 않습니다. 대부분의 브라우저에서 정수(integer) 키는 오름차순으로 정렬되며, 문자열 키가 정수 키 앞에 삽입된 경우에도 문자열 키보다 우선합니다.
const obj = {};
obj.foo = "first";
obj[2] = "second";
obj[1] = "last";
console.log(obj); // {1: 'last', 2: 'second', foo: 'first'}
> 제거
객체에서 모든 속성을 지우는 쉬운 방법은 없습니다. 역사적으로 느린 것으로 알려진 delete
연산자를 통해 속성을 하나씩 지워야 합니다. 그러나 벤치마크는 그 성능이 실제로 Map.prototype.delete
보다 느리지 않은 것을 보여줍니다. 이는 나중에 자세히 설명하겠습니다.
> 속성 존재 확인
점/괄호 표기법(notation)으로 해당 속성의 존재 여부를 확인할 수 없습니다. 값이 없어서 undefined
가 아니라 값 자체가 undefined
일 수 있기 때문에 그렇습니다. 대신 Object.prototype.hasOwnProperty
또는 Object.hasOwn
메소드를 활용하여 객체 내 해당 속성의 존재 여부를 확인합니다.
const obj = { a: undefined };
Object.hasOwn(obj, "a"); // true