본문으로 건너뛰기
기술스택 · stack/javascript/Map과 Set: 중복 제거, 캐시, 객체 대신 쓸 때

JavaScript · 5장 자료구조와 자료형

Map과 Set: 중복 제거, 캐시, 객체 대신 쓸 때

배열과 객체만으로도 대부분의 일을 할 수 있지만, 어색해지는 순간이 있어요. 중복을 지우려고 filter와 indexOf를 엮거나, 객체를 키로 쓰다가 값이 덮어써질 때예요. 이럴 때 쓰라고 만든 자료구조가 Set과 Map이에요.

#5.7

01. Set: 중복 없는 모음

Set은 같은 값을 두 번 담지 않는 모음이에요. 배열을 넣어 만들면 중복이 저절로 빠지고, 다시 펼치면 중복 없는 배열이 돼요. 넣은 순서는 지켜져요. has로 들어 있는지 묻는 건 항목이 아무리 많아도 빨라서, 큰 목록에서 "이미 있나?"를 자주 묻는다면 배열의 includes보다 Set이 나아요.

const tags = ['js', 'css', 'js', 'html', 'css'];

const unique = new Set(tags);
console.log(unique.size, unique.has('js')); // 3 true
console.log([...unique]);                   // ['js', 'css', 'html'] — 중복 제거한 배열

unique.add('react');
unique.add('js');                           // 이미 있으면 아무 일도 없어요
unique.delete('css');
console.log([...unique]);                   // ['js', 'html', 'react']

두 모음을 합치거나 겹치는 것만 고르는 메서드도 있어요(2024년부터 주요 브라우저 지원). 선택한 태그와 추천 태그를 비교할 때처럼 쓰여요.

const mine = new Set(['js', 'css', 'html']);
const recommended = new Set(['css', 'react']);

console.log([...mine.union(recommended)]);        // 합집합 ['js', 'css', 'html', 'react']
console.log([...mine.intersection(recommended)]); // 교집합 ['css']
console.log([...mine.difference(recommended)]);   // 차집합 ['js', 'html']

console.log(new Set([{ id: 1 }, { id: 1 }]).size); // 2 — 객체는 같은 객체인지로 판단해요

마지막 줄처럼 Set은 ===와 같은 기준으로 같은지 봐요(NaN끼리는 같다고 봐요). 내용이 같은 객체 두 개는 다른 값이에요. 객체 목록에서 id 기준으로 중복을 지우려면 아래의 Map을 써요.

02. Map: 무엇이든 키가 되는 사전

Map은 키와 값을 짝지어 담는다는 점에서 객체와 비슷해요. 다른 점은 키로 무엇이든 쓸 수 있다는 거예요. 객체의 키는 문자열(과 심볼)뿐이라서, 숫자나 객체를 키로 넣으면 몰래 문자열로 바뀌어요. 객체를 키로 쓰면 모두 '[object Object]'라는 같은 글자가 되어 값이 덮어써져요.

const sky = { name: '하늘' };
const river = { name: '가람' };

const visitsObj = {};
visitsObj[sky] = 1;
visitsObj[river] = 2;         // 같은 키 '[object Object]'에 덮어써요
console.log(visitsObj);       // { [object Object]: 2 }

const visits = new Map();
visits.set(sky, 1);
visits.set(river, 2);         // 객체 자체가 키예요
console.log(visits.size, visits.get(sky), visits.get(river)); // 2 1 2
①사용자 객체 두 개를 키로 넣어요. 객체는 키를 문자열로 바꿔서 둘 다 '[object Object]'가 되고, 두 번째 값이 첫 번째를 덮어써요. Map은 객체를 그대로 키로 써서 두 칸이 따로 남아요.

Map은 set · get · has · delete와 size로 다뤄요. 넣은 순서대로 돌 수 있고, for (const [키, 값] of map)처럼 짝으로 꺼내요. 개수 세기처럼 키가 계속 늘어나는 일에 잘 맞아요.

const log = ['u1', 'u2', 'u1', 'u3', 'u2', 'u1'];

const counts = new Map();
for (const id of log) counts.set(id, (counts.get(id) ?? 0) + 1);

for (const [id, n] of counts) console.log(id, n); // u1 3 · u2 2 · u3 1
console.log(counts.has('u4'), counts.size);        // false 3

자주 쓰는 패턴은 캐시예요. 시간이 걸리는 계산이나 요청 결과를 키로 저장해 두고, 같은 키가 오면 다시 계산하지 않아요. 다만 캐시는 지우지 않으면 끝없이 커져요. 크기 제한을 두거나 다 쓴 항목을 지우는 일을 잊지 마세요(메모리 누수).

const cache = new Map();
let calls = 0;

function slowSquare(n) {
  if (cache.has(n)) return cache.get(n); // 이미 계산했으면 바로 돌려줘요
  calls++;
  const result = n * n;                  // 오래 걸리는 일이라고 해요
  cache.set(n, result);
  return result;
}

slowSquare(12); slowSquare(12); slowSquare(7); slowSquare(12);
console.log(calls, cache.size); // 2 2 — 네 번 불렀지만 계산은 두 번

03. 객체와 Map, 언제 무엇을

둘 다 키와 값을 담지만 쓰임이 달라요. 기준은 "키가 미리 정해져 있나"예요.

객체Map
잘 맞는 일모양이 정해진 기록 (회원 한 명, 설정 하나)계속 늘고 주는 사전 (개수 세기, 캐시, id → 항목)
키문자열 · 심볼무엇이든 (객체 · 숫자 포함)
개수Object.keys(o).lengthmap.size
JSONJSON.stringify 그대로 돼요{}가 돼요 — Object.fromEntries로 바꿔서 보내요

서버와 주고받는 데이터나 화면 상태처럼 JSON으로 저장 · 전송하는 값은 객체가 편해요. 메모리 안에서만 쓰고 키가 동적으로 늘어나는 사전이면 Map이 맞아요. 둘 사이를 오가는 건 new Map(Object.entries(obj))와 Object.fromEntries(map)이에요(fromEntries).

WeakMap · WeakSet은 키(WeakSet은 값)로 객체만 받고, 그 객체를 붙잡아 두지 않는 Map · Set이에요. 다른 곳에서 객체를 더 쓰지 않으면 WeakMap 안의 항목도 함께 치워져요(도달 가능성). 대신 돌거나 크기를 셀 수 없어요. 화면 요소나 남의 객체에 정보를 붙여 두되, 그 객체가 사라질 때 같이 사라지게 하고 싶을 때 써요.

두 번 받아 온 상품 목록을 합쳤더니 같은 상품이 두 번씩 들어갔어요. new Set으로는 중복이 지워지지 않아요. id가 같으면 나중에 온 항목을 남기는 방식으로 고쳐서 ['연필 600', '공책 2000', '지우개 800']이 나오게 해 보세요.

const page1 = [{ id: 1, name: '연필', price: 500 }, { id: 2, name: '공책', price: 2000 }];
const page2 = [{ id: 1, name: '연필', price: 600 }, { id: 3, name: '지우개', price: 800 }];

const merged = [...new Set([...page1, ...page2])];
console.log(merged.map(p => `${p.name} ${p.price}`));
해답 보기

내용이 같아도 객체는 서로 다른 값이라 Set이 지우지 못해요. id를 키로 Map에 넣으면 같은 id는 나중 값으로 덮어써지고, 처음 넣은 자리(순서)는 그대로 지켜져요. 마지막에 map.values()를 펼쳐 배열로 바꿔요.

const byId = new Map();
for (const p of [...page1, ...page2]) byId.set(p.id, p);
const merged = [...byId.values()];
// ['연필 600', '공책 2000', '지우개 800']

04. 실전 포인트

  • 언제 쓰나요? 값 중복 제거는 [...new Set(arr)], 큰 목록의 포함 여부는 set.has, 개수 세기 · 캐시 · id로 찾기는 Map, 객체에 정보를 붙이되 누수 없이 하려면 WeakMap을 써요.
  • 대신 쓰는 것: JSON으로 저장 · 전송하거나 모양이 정해진 기록은 객체가 편해요. 항목이 몇 개 안 되는 목록에서 한두 번 찾는 일이라면 배열의 includes · find로 충분해요.
  • 흔한 실수: 객체를 객체의 키로 쓰는 것, 객체 배열을 new Set으로 중복 제거하려는 것, Map을 JSON.stringify해서 {}가 전송되는 것, 캐시 Map을 지우지 않아 계속 커지는 것이에요.

정리

  • Set은 중복 없는 모음이에요. [...new Set(arr)]로 중복을 지우고, has로 빠르게 확인해요.
  • Map은 무엇이든 키로 쓰는 사전이에요. 객체를 객체의 키로 쓰면 '[object Object]'로 덮어써져요.
  • 모양이 정해진 기록 · JSON은 객체, 동적으로 늘어나는 사전 · 캐시는 Map을 써요.
  • WeakMap · WeakSet은 객체를 붙잡지 않아서, 객체가 사라지면 항목도 함께 사라져요.
new Set([1, 1, '1']).size는 얼마일까요?

2예요. Set은 === 기준이라 숫자 1과 문자열 '1'은 다른 값이고, 숫자 1 두 개만 하나로 합쳐져요.

객체 cache에 cache[user] = data로 사용자별 데이터를 넣으면 어떤 문제가 생길까요?

객체의 키는 문자열로 바뀌어서 모든 사용자 객체가 '[object Object]'라는 같은 키가 돼요. 사용자마다 값이 덮어써지니, 객체를 키로 쓰려면 Map을 써요.

Map에 담은 데이터를 서버로 보낼 때 JSON.stringify(map)을 쓰면 어떻게 될까요?

'{}'가 돼요. JSON은 Map을 모르기 때문이에요. Object.fromEntries(map)으로 객체로 바꾼 뒤 보내요(키가 문자열일 때).

SIMPLE · 기술스택 / JavaScript — 5장 자료구조와 자료형