본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

Map과 Set

작성 2026. 10. 20.추가 수정
목차10개 항목
출처와 참고

Wes Bos의 「Beginner JavaScript」 강의 노트를 바탕으로, web.dev의 「Learn JavaScript」 강좌의 「Keyed collections」 모듈, javascript.info(모던 JavaScript 튜토리얼)의 「맵과 셋」, 「위크맵과 위크셋」 글을 더하고 MDN으로 확인해 재구성한 노트다. 객체를 해시 맵으로 쓸 때의 함정과 성능·메모리 비교는 Zhenghao He의 「When You Should Prefer Map Over Object In JavaScript」(2022.06, Front End Interview Handbook 블로그)로 보강했다. junh0328의 「prepare_frontend_interview」 면접 핸드북에서 룩업 테이블과 Set을 고르는 기준을 보강했다.

Map이란

Map은 키-값 쌍을 담는 컬렉션이다. 객체와 비슷하지만 어떤 타입의 값이든 키로 쓸 수 있고, 넣은 순서대로 순회한다. 데이터를 담는 용도로만 설계되어, 객체처럼 프로토타입 속성과 키가 섞일 걱정이 없다.

const prizes = new Map()
prizes.set(100, 'Bear').set(200, 'Duck') // set은 Map을 반환해 이어 쓸 수 있다
prizes.get(100)    // 'Bear': 숫자 키 그대로
prizes.has(300)    // false
prizes.size        // 2
prizes.delete(200) // true

같은 키로 다시 set하면 새 항목이 생기지 않고 값만 덮어쓴다. 키 비교 규칙은 Set과 같아서 NaN도 키로 찾을 수 있다. 처음부터 채우려면 [키, 값] 쌍의 이터러블을 넘긴다: new Map([['a', 1], [null, 0]]). const로 선언한 Map도 내용은 바꿀 수 있다(변수만 고정된다).

prizes['age'] = 1이나 prizes.age = 1처럼 대괄호·점 표기로 넣으면 항목이 아니라 Map 객체의 속성이 될 뿐이다. 항목은 늘 set·get으로 다룬다.

객체를 키로: 사전(dictionary)

객체 자체를 키로 쓰면 원본 객체를 건드리지 않고 그 객체에 대한 부가 정보를 따로 저장할 수 있다. 키는 내용이 아니라 참조로 비교되므로 같은 객체를 넘겨야 찾는다.

const person = { name: 'wes' }
const notes = new Map([[person, 'Really cool']])
notes.get(person)            // 'Really cool'
notes.get({ name: 'wes' })   // undefined: 다른 객체

객체 vs Map

일반 객체 {}Map
키 타입문자열·Symbol (숫자는 문자열로 바뀜)아무 값(객체, 함수, 숫자, NaN)
순서정수 키는 오름차순으로 앞에, 나머지는 넣은 순서. obj.foo = 1; obj[2] = 2; obj[1] = 3은 1, 2, foo 순으로 돈다넣은 순서 그대로
크기Object.keys(obj).length(열거 불가까지 Object.getOwnPropertyNames, Symbol까지 Reflect.ownKeys). 키 배열을 만들어야 해서 O(n)map.size
순회이터러블이 아니라 Object.entries(obj)로 배열을 만든 뒤바로 for...of, forEach
리터럴{ a: 1 }없음. new Map([['a', 1]])
메서드 저장가능(this로 자기 데이터 접근)데이터 전용
JSONJSON.stringify 그대로그대로는 {}가 된다
있는지 확인Object.hasOwn(obj, k) (값이 undefined여도 있으면 true)map.has(k)
전부 지우기속성마다 deletemap.clear()
잦은 추가·삭제보통이 용도에 맞게 최적화

고르는 기준: 코드를 쓸 때 키를 다 아는 레코드(사용자, 설정, API 응답), 한 번 쓰고 버리는 묶음은 객체다. 실행 중에 키가 계속 늘고 줄거나, 키를 미리 알 수 없거나(이벤트 이미터의 이벤트 이름별 리스너 목록), 키가 문자열이 아니거나, 넣은 순서가 의미 있는 사전(해시 맵) 은 Map이다.

객체를 해시 맵으로 쓸 때의 함정

ES2015 이전에는 해시 맵이 필요하면 빈 객체 {}를 썼다. 지금도 습관처럼 그렇게 쓰지만 객체는 사전 용도로 설계되지 않았다.

함정내용우회
키가 문자열로 바뀐다{ [arr]: 'a', [obj]: 'b' }(빈 배열과 빈 객체를 계산된 키로)는 { '': 'a', '[object Object]': 'b' }가 된다. 객체 키는 모두 같은 키로 뭉개진다Map
상속받은 속성이 섞인다{}는 비어 보여도 Object.prototype의 toString·constructor·hasOwnProperty를 물려받는다. for...in은 누군가 Object.prototype에 더한 열거 가능 속성까지 돌고, 'constructor' in obj는 넣은 적 없어도 true다Object.create(null), Object.keys
프로토타입 오염Object.prototype을 바꾸면 모든 객체에 퍼진다. 사용자 입력을 키로 받아 obj[key] = value로 넣으면 __proto__ 같은 키로 공격받을 수 있다Object.create(null), Map
메서드 이름 충돌사용자 데이터에 hasOwnProperty라는 키가 있으면 obj.hasOwnProperty(k)가 그 값을 부른다Object.hasOwn(obj, k)(ES2022). 예전에는 Object.prototype.hasOwnProperty.call(obj, k)로 빌려 썼다

Map은 데이터를 get·set으로만 다루므로 사용자 데이터와 내장 속성이 섞이지 않는다. 점 표기보다 한 단계 번거로운 대신 얻는 분리다. 객체에 메타데이터를 덧붙이는 용도라면 Map이 아니라 WeakMap을 써야 메모리가 새지 않는다(아래).

성능과 메모리

V8에서 잰 벤치마크로는 키가 문자열이거나 Math.random()으로 만든 숫자 문자열일 때 Map이 추가·삭제·순회 모두 빠르다. 항목이 수천 개 이하일 때 추가 속도가 두 배 가까이 나고, 수백만 개로 늘수록 차이가 줄어든다. 예외는 작은 정수 키다. V8은 정수 인덱스 속성을 배열처럼 따로 저장해 최적화하므로, 키가 작은 정수면 객체가 추가에서 더 빠를 수 있다(순회는 여전히 Map이 빠르다).

메모리는 같은 항목 수에서 Map이 20~50% 적게 쓴다. 객체는 속성마다 writable·enumerable·configurable 같은 속성 설명자를 들고 있기 때문이다(속성 설명자). 문자열 키 객체는 항목이 약 830만 개를 넘으면 엔진 내부 한도 때문에 급격히 느려진다.

벤치마크 결과는 엔진 버전과 키 모양에 따라 달라지므로, 성능 때문에 바꾼다면 실제 앱에서 재 본다. 고르는 1차 기준은 성능이 아니라 위의 레코드·사전 구분이다.

순회와 변환

Map은 iterable이라 for...of로 돌면 [키, 값] 배열이 나온다. 구조 분해로 받는다.

for (const [points, prize] of prizes) {
  ul.insertAdjacentHTML('beforeend', `<li>${points} - ${prize}</li>`)
}
prizes.forEach((prize, points) => {}) // forEach는 (값, 키) 순서
const allPoints = [...prizes.keys()]  // keys()·values()·entries()는 이터레이터
const [[firstKey, firstValue]] = prizes // 중첩 구조 분해로 첫 항목 꺼내기
변환코드
객체 → Mapnew Map(Object.entries(obj))
Map → 객체Object.fromEntries(map) (키가 문자열로 바뀐다)
Map → JSONJSON.stringify(Object.fromEntries(map)) 또는 JSON.stringify([...map])
배열 → 키별 MapMap.groupBy(items, (x) => x.type) (ES2024)

객체 키는 JSON으로 옮길 수 없다. Map만의 장점(객체 키, 숫자 키)을 쓰고 있다면 직렬화할 때 그 정보가 사라진다는 점을 감안한다.

Set

Set은 중복 없는 값의 모음이다. 같은지는 ===와 거의 같은 규칙(SameValueZero, NaN끼리는 같다고 본다)으로 판단한다.

const tags = new Set(['js', 'css', 'js'])
tags.size            // 2
tags.add('html').has('html') // true
tags.delete('css')
const unique = [...new Set(arr)] // 배열 중복 제거
  • has는 배열의 includes보다 빠르다. 많은 값에 대해 "이미 있나"를 반복해서 물을 때 Set으로 바꿔 둔다.
  • 객체는 참조로 비교되므로 내용이 같은 객체 두 개는 둘 다 남는다.
  • 인덱스가 없어 set[0]으로 꺼낼 수 없지만, 순회 순서는 넣은 순서다. 이미 있는 값을 다시 add하면 무시되고 원래 자리를 지킨다.
  • 배열로 돌릴 때는 [...set]이나 Array.from(set). forEach((value, sameValue, set) => …)의 둘째 인자는 Map과 모양을 맞추려고 값을 한 번 더 준 것이다.

ES2025부터 집합 연산 메서드가 표준이다. 모두 새 Set을 반환하거나 불리언을 답한다.

메서드뜻
a.union(b) / a.intersection(b)합집합 / 교집합
a.difference(b) / a.symmetricDifference(b)차집합 / 대칭차
a.isSubsetOf(b) / a.isSupersetOf(b) / a.isDisjointFrom(b)부분집합 / 상위집합 / 서로소 여부

룩업 테이블

룩업 테이블(lookup table)은 키로 값을 바로 꺼내도록 미리 만들어 둔 표다. 배열을 매번 처음부터 훑는 검색(O(n))이나 길게 늘어진 if·switch 분기를 해시 조회 한 번(평균 O(1))으로 바꾼다. JavaScript에서는 객체나 Map이 그 표 역할을 한다.

쓰임예
분기 대신 조회const labels = { draft: '임시 저장', done: '완료' }를 두고 labels[status] ?? '알 수 없음'
id로 찾기const byId = new Map(users.map((u) => [u.id, u]))로 한 번 바꿔 두고 byId.get(id)
키별로 묶기메시지를 날짜별로: Object.groupBy(messages, (m) => m.date), 키가 객체면 Map.groupBy
키 기준 중복 제거new Map(items.map((x) => [x.id, x]))는 같은 id 중 마지막 항목만 남긴다. 첫 항목을 남기려면 if (!map.has(k)) map.set(k, x)로 넣는다

Set과 고르는 기준은 「무엇이 중복인가」다.

  • 값 자체가 겹치는지만 보면 Set이다([...new Set(ids)]). 객체는 참조로 비교하므로 내용이 같은 객체 두 개를 Set으로는 걸러 내지 못한다.
  • 객체를 어떤 키(id, 날짜) 기준으로 거르거나 묶거나, 키에 딸린 값을 다시 꺼내야 하면 룩업 테이블이다.
  • 표를 따로 만드는 만큼 메모리를 더 쓰고, 원본이 바뀌면 다시 만들어야 한다. 한두 번 찾고 끝날 일이면 find로 충분하다.
  • 순서가 중요하면 Map을 쓴다. Set과 Map은 넣은 순서를 지키지만, 객체 룩업 테이블은 '2', '10'처럼 정수로 읽히는 키를 오름차순으로 먼저 늘어놓는다.

WeakMap과 WeakSet

키(WeakSet은 값)로 객체, 또는 전역 레지스트리에 등록하지 않은 Symbol(ES2023)만 받고, 그 대상을 약하게 참조한다. 숫자·문자열 같은 원시값이나 Symbol.for()로 만든 Symbol은 언제든 똑같이 다시 만들 수 있어 "더 이상 아무도 안 쓴다"를 판단할 수 없으므로 거부된다(set·add에서 TypeError, Symbol). 다른 곳에서 그 객체를 더 이상 쓰지 않으면 항목째 가비지 컬렉션된다. 그래서 크기를 알 수 없고 순회도 할 수 없다.

메서드는 get·set·has·delete(WeakSet은 add·has·delete)뿐이고 size·keys()·forEach가 없다. 가비지 컬렉션이 언제 항목을 치울지 엔진만 알기 때문이다.

남의 객체(DOM 요소, 외부 라이브러리 객체)에 부가 데이터나 객체별 캐시를 붙일 때 쓴다. 객체가 사라지면 항목도 저절로 사라져, 일반 Map처럼 요소를 붙잡아 메모리가 새지 않는다. WeakSet은 "이 객체를 이미 처리했나"처럼 표시만 해 두는 용도(방문 표시, 순환 참조 검사)에 맞다. 예제 코드와 도달 가능성 규칙, WeakRef는 가비지 컬렉션과 메모리 누수에 있다.

정리

  • 정해진 모양의 레코드는 객체, 동적인 키-값 모음은 Map. 객체를 사전으로 쓰면 키 문자열 변환, 상속 속성, 프로토타입 오염, size 부재를 떠안는다.
  • Map은 작은 정수 키가 아니면 대체로 더 빠르고 메모리도 적게 쓴다.
  • Map은 아무 값이나 키로 쓰고, 넣은 순서를 지키고, size가 있다. 객체 키는 참조로 찾는다.
  • Map은 JSON이 안 된다. Object.fromEntries로 바꿔서 보낸다.
  • 중복 제거와 빠른 포함 검사는 Set. 집합 연산은 union·intersection 등 표준 메서드로.
  • 객체에 데이터를 덧붙이되 수명을 같이하게 하려면 WeakMap.

관련 노트: 배열·객체·문자열 다루기, 이터레이터와 제너레이터, 변수와 데이터 타입, 기초 자료구조, ECMAScript 표준과 TC39 프로세스, 가비지 컬렉션과 메모리 누수, 구조 분해 할당

원본 기록9

댓글

GitHub 계정으로 댓글을 남길 수 있어요.