본문으로 바로 가기
로고
개발

[JS] Set과 Map

읽는 시간 11분
Set과 Map 글의 썸네일"

#들어가며

출처 PYPL
출처 PYPL

JavaScript는 유연함과 다양한 분야에 사용될 수 있어 세계적으로도 인기 있는 프로그래밍 언어입니다. 다른 프로그래밍 언어에서는 해쉬맵이라던지, 딕셔너리 같은 자료구조가 존재하지만 JavaScript에는 거의 모든 것이 Object로 이루어져 있다는 특징이 존재합니다. Object로 웬만하면 다 처리할 수 있지만 Object가 최선이 아닌 경우도 있기 때문에, JavaScript에서는 Map과 Set 같은 고급 자료구조를 제공하여 데이터를 더 효율적으로 관리할 수 있는 방법을 제공합니다. JavaScript의 객체 사용에 대한 한계와, Map 및 Set을 통해 이러한 한계를 어떻게 극복할 수 있는지 살펴봅시다.

#객체를 사용하는 경우

책의 정보를 관리하는 간단한 시스템을 고려해봅시다. 여기서 각 책은 고유한 ID로 식별되며, 이 ID를 사용해 해당 책의 정보에 접근할 수 있습니다. JavaScript 객체를 사용하여 이를 구현하려고 할 때, 아래와 같은 형태로 코드를 작성할 수 있습니다.

JavaScript 객체를 사용하여 데이터를 저장하고 관리할 때, 객체의 프로퍼티 접근은 실제로 문자열로 변환되는 점에 주목해야 합니다. 예를 들어, 객체의 키로 숫자를 사용하려고 해도, 이는 내부적으로 문자열로 변환됩니다. 이러한 특성은 대부분의 경우에 문제가 되지 않지만, 특정 상황에서는 예기치 않은 결과를 초래할 수 있습니다.

js
const booksById = {
  42: { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] },
  102: { id: 102, title: "Learning JavaScript", authors: ["John Doe", "Jane Doe"] }
};
 
// id를 받아 책을 반환하는 함수
function getBook(id){
  return booksById[id]
}
 
console.log(getBook(42)); // { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] }
console.log(getBook('42')); // { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] }
console.log(getBook('constructor')); // [Function: Object]
js
const booksById = {
  42: { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] },
  102: { id: 102, title: "Learning JavaScript", authors: ["John Doe", "Jane Doe"] }
};
 
// id를 받아 책을 반환하는 함수
function getBook(id){
  return booksById[id]
}
 
console.log(getBook(42)); // { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] }
console.log(getBook('42')); // { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] }
console.log(getBook('constructor')); // [Function: Object]

getBook(42)getBook(42)를 호출하면 해당 ID의 책 정보를 성공적으로 검색할 수 있습니다. 하지만 getBook('constructor')getBook('constructor') 호출에서 반환된 [Function: Object] 값은 우리가 예상하지 못한 값입니다. JavaScript의 모든 객체는 기본적으로 Object.prototype에서 상속받은 속성(constructor도 그 중 하나)들을 가지고 있고 객체의 내장 메서드 이름이나 예약어를 사용하여 호출하면, 예상치 못한 결과나 오류가 발생할 수 있습니다. 따라서, 객체에 constructor 키가 명시적으로 존재하지 않아도, Object.prototype.constructor에 접근하게 되는 것입니다.

이를 방지하기 위해서 hasOwnProperty 메서드로 가드 코드를 추가해서 아래와 같이 처리할 수도 있습니다.

js
function getBook(id){
  if(booksById.hasOwnProperty(id)){
    return booksById[id]
  }
}
 
// hasOwnProperty 메서드 자체도 오버라이드될 수 있기 때문에 이렇게 사용하는게 더 안전
function getBook(id){
  if(Object.prototype.hasOwnProperty.call(booksById, id)){
    return booksById[id]
  }
}
js
function getBook(id){
  if(booksById.hasOwnProperty(id)){
    return booksById[id]
  }
}
 
// hasOwnProperty 메서드 자체도 오버라이드될 수 있기 때문에 이렇게 사용하는게 더 안전
function getBook(id){
  if(Object.prototype.hasOwnProperty.call(booksById, id)){
    return booksById[id]
  }
}

#Map

이러한 문제를 효과적으로 해결하기 위해 ES6에서는 Map이라는 새로운 자료 구조를 도입했습니다. Map은 키와 값을 연결하는 구조이지만, 문자열과 심볼만 가능한 객체와 달리 어떠한 값이든 키로 사용할 수 있으며, Object.prototype으로부터 상속받은 어떤 이름의 프로퍼티와도 충돌하지 않습니다. 또 데이터가 삽입된 순서가 보장됩니다.

js
// Map 객체 생성
const booksById = new Map();
 
// 데이터 추가
booksById.set(42, { id: 42, title: "제목", authors: ["Adams", "Douglas"] });
 
// 데이터 확인
console.log(booksById.has(42)); // true
console.log(booksById.get(42)); // { id: 42, title: "제목", authors: ["Adams", "Douglas"] }
js
// Map 객체 생성
const booksById = new Map();
 
// 데이터 추가
booksById.set(42, { id: 42, title: "제목", authors: ["Adams", "Douglas"] });
 
// 데이터 확인
console.log(booksById.has(42)); // true
console.log(booksById.get(42)); // { id: 42, title: "제목", authors: ["Adams", "Douglas"] }

Map을 생성할 때 초기 데이터를 넣는 방법도 있습니다. 예를 들어, 아래 예시에서는 Object.entries()Object.entries()를 사용하여 객체를 Map으로 변환하는 방법을 보여줍니다.

js
const booksById = {
  42: { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] },
  102: { id: 102, title: "Learning JavaScript", authors: ["John Doe", "Jane Doe"] }
};
 
const booksByIdFromObj = new Map(Object.entries(booksById));
 
console.log(booksByIdFromObj.get(42)); // undefined
console.log(booksByIdFromObj.get("42")); // 객체에서는 문자열로 변환되어 저장되므로 "42"로 접근
js
const booksById = {
  42: { id: 42, title: "리액트를 다루는 기술", authors: ["김민준"] },
  102: { id: 102, title: "Learning JavaScript", authors: ["John Doe", "Jane Doe"] }
};
 
const booksByIdFromObj = new Map(Object.entries(booksById));
 
console.log(booksByIdFromObj.get(42)); // undefined
console.log(booksByIdFromObj.get("42")); // 객체에서는 문자열로 변환되어 저장되므로 "42"로 접근

또한, 배열을 이용하여 Map을 초기화하는 방법도 있습니다. 배열의 각 요소가 "[키, 값]" 쌍인 경우, 이 배열을 Map 생성자에 전달하여 Map을 초기화할 수 있습니다.

js
const booksById = [
  { id: 42, title: "제목1", authors: ["Adams"] },
  { id: 102, title: "제목2", authors: ["Douglas"] }
];
 
const booksByIdFromArray = new Map(booksById.map(book => [book.id, book]));
 
console.log(booksByIdFromArray.get(42)); // { id: 42, title: "제목1", authors: ["Adams"] }
js
const booksById = [
  { id: 42, title: "제목1", authors: ["Adams"] },
  { id: 102, title: "제목2", authors: ["Douglas"] }
];
 
const booksByIdFromArray = new Map(booksById.map(book => [book.id, book]));
 
console.log(booksByIdFromArray.get(42)); // { id: 42, title: "제목1", authors: ["Adams"] }

Map의 가장 큰 이점 중 하나는 키로 사용할 수 있는 값의 타입에 있어서의 유연성입니다. 숫자 타입의 키를 사용하면 문자열 타입으로 자동 변환되지 않습니다. 이로 인해, 객체를 사용할 때 발생할 수 있는 타입 변환 문제를 피할 수 있습니다.

#Map 사용 시 주의점

하지만 Map을 사용할 때 주의해야 할 점도 있습니다. Map 객체는 자동으로 JSON으로 직렬화되지 않습니다. 즉, JSON.stringify()JSON.stringify()를 사용하여 Map을 직렬화하려고 하면, 빈 객체로 표현됩니다. 이를 해결하기 위해서는 Map의 엔트리(entries)를 배열로 변환한 후 직렬화해야 합니다.

js
// booksById가 Map 이라면
 
console.log(JSON.stringify(booksById)); // {}
console.log(JSON.stringify([...booksById.entries()])); // "[[42,{"id":42,"title":"제목","authors":["Adams","Douglas"]}]]"
js
// booksById가 Map 이라면
 
console.log(JSON.stringify(booksById)); // {}
console.log(JSON.stringify([...booksById.entries()])); // "[[42,{"id":42,"title":"제목","authors":["Adams","Douglas"]}]]"

#Set

이제 JavaScript에서 제공하는 또 다른 자료구조인 Set에 대해 알아보겠습니다. Set은 고유한 값을 저장하기 위한 컬렉션입니다. 배열과 유사하게 데이터를 순서대로 저장하지만, Set 내에는 같은 값이 두 번 존재할 수 없습니다. 이 특성 때문에 데이터의 중복을 방지하고자 할 때 매우 유용합니다. 장바구니에 담긴 책의 ID를 관리한다고 할 때, Set을 사용하지않고 구현한다면 아래와 같이 작성할 수 있습니다.

js
const cart = {
  booksIds: [42, 7, 13]
};
 
function isInCart(bookId) {
  return cart.booksIds.includes(bookId);
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false
js
const cart = {
  booksIds: [42, 7, 13]
};
 
function isInCart(bookId) {
  return cart.booksIds.includes(bookId);
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false

includes 메서드는 간단하고 직관적으로 배열 내의 요소 존재 여부를 확인할 수 있습니다. 하지만 배열의 크기가 커질수록, includes 메서드를 통한 검색 속도는 느려집니다. 이는 includes 메서드가 배열의 모든 값을 순회하기 때문입니다.

이를 개선하기 위해 객체를 사용한 방법도 고려할 수 있습니다.

js
const cart = {
  booksIds: {
    42: true,
    7: true,
    13: true,
  }
};
 
function isInCart(bookId) {
  return !!cart.booksIds[bookId];
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false
js
const cart = {
  booksIds: {
    42: true,
    7: true,
    13: true,
  }
};
 
function isInCart(bookId) {
  return !!cart.booksIds[bookId];
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false

이 방식은 includes 메서드를 사용할 때보다 검색 속도가 빠릅니다. 객체의 프로퍼티 접근은 일반적으로 배열을 순회하는 것보다 효율적입니다. 하지만 앞서 객체를 사용할 때의 문제점(객체의 키, 내장 프로퍼티 이름이나 메서드와 충돌 위험성)이 여기서도 적용됩니다. 이러한 문제를 해결하고 데이터의 중복을 방지하기 위해 Set을 사용할 수 있습니다.

js
const cart = {
  booksIds: new Set([42, 7, 13])
};
 
function isInCart(bookId) {
  // Set은 has 메서드를 제공한다.
  return cart.booksIds.has(bookId);
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false
js
const cart = {
  booksIds: new Set([42, 7, 13])
};
 
function isInCart(bookId) {
  // Set은 has 메서드를 제공한다.
  return cart.booksIds.has(bookId);
}
 
console.log(isInCart(42)); // true
console.log(isInCart(9)); // false

#그 외 다양한 메서드들

값을 추가 제거하는 add, delete 외 에도 집합 연산을 수행할 수 있는 다양한 메서드를 제공합니다.

MDN Set methods

#마치며

이번 글을 통해 SetMap에 대해 공부하면서, 어떤 자료구조를 어떻게 사용해야 될까? 를 고려하며 개발해야겠다는 생각을 했습니다. 이전까지는 대부분 객체나 배열을 사용하는 것에 익숙했는데, 이제는 중복을 허용하지 않는 값의 집합을 다루어야 하거나 값의 존재 여부를 빈번하게 확인해야 하는 상황에서는 Set을, 복잡한 키-값 쌍의 데이터를 더 유연하게 관리할 필요가 있거나 삽입 순서가 중요할 때는 Map을 고려하는 것이 훨씬 더 효율적인 선택이 될 수 있다는 것을 배웠습니다.
이 글을 마치며 모든 상황에 SetMap을 사용하자는게 아니라는 점을 강조하고 싶습니다. 특정 문제를 해결하는 데 있어 이런 자료구조의 이점을 활용한다면 보다 효율적이고 간결한 코드를 작성하는데 도움이 될 것입니다.

#참고자료

MDN Map

MDN Set