티스토리 뷰

서론

자바스크립트로 프론트엔드 웹 코딩을 하면서 Key - Value 쌍의 데이터를 저장하고 조회할 때 어떤 자료구조를 이용하시나요? 열이면 여덟 아홉 객체(Object)를 이용하는 듯합니다.

interface Kimchi {
  name: string;
  alias: string;
  score: number;
  ingredients: string[];
}

const kimchis: Record<number, Kimchi[]> = {
  1: [
    {
      name: "백김치",
      alias: "맵린이",
      score: 1,
      ingredients: ["소금", "무", "양파", "배", "배추", "설탕"]
    },
    {
      name: "나박김치",
      alias: "깍두기먹을바엔나박김치",
      score: 1,
      ingredients: ["소금", "무", "배추", "미나리", "파"]
    }
  ],
  2: [
    {
      name: "오이소박이",
      alias: "오이지",
      score: 2,
      ingredients: ["소금", "오이", "고춧가루"]
    },
    {
      name: "섞밖지",
      alias: "이것은무가아니다",
      score: 2,
      ingredients: ["무", "고춧가루", "시래기", "젓갈"]
    }
    // ...other records...
  ],
  3: [
    {
      name: "배추김치",
      alias: "김.치.",
      score: 3,
      ingredients: ["소금", "배추", "고춧가루"]
    },
    {
      name: "고구마순김치",
      alias: "고구마",
      score: 3,
      ingredients: ["고구마줄기", "고춧가루", "젓갈"]
    }
    // ...other records...
  ],
  4: [
    {
      name: "고들빼기",
      alias: "봄김치",
      score: 4,
      ingredients: ["고들빼기", "고춧가루", "멸치젓"]
    }
    // ...other records...
  ]
  // ...other records...
};

여기 김치 데이터를 예시로 이용하겠습니다. 김치의 맵기 score에 따라 값이 Key - Value 쌍으로 분류된 김치 데이터입니다. 김치의 맵기는 맵찔이인 필자의 지극히 주관적인 평가가 반영되어 있으며 백김치의 맵기를 1로 기준으로 하는 상대적인 데이터임을 고지합니다.
(

배추김치 === 3 백김치

)

 

하기 예시 코드들에서도 타입스크립트 타이핑을 한 코드를 이용하겠지만, Javascript-only 개발자 분들도 변수 인터페이스 부분만을 제외한다면 충분히 본 코드가 이해되리라 생각합니다.

 

백엔드에서 받은 데이터들을 위와 같은 형태로 localStorage 같은 공간에 저장을 해서 조회, 검색, 수정 등을 수행한다면 아무런 문제 없이 정상적으로 작동할 것입니다. 키가 있는 컬렉션을 저장하는 자료구조로서 중괄호를 열어서 그 안에 키와 값을 집어넣는 객체(Object)와 복수 개의 데이터셋을 순서대로 담는 배열(Array)이라는 자료구조를 적절히 이용한 형태라 할 수 있겠습니다. 하지만 과연 저 하나의 변수에 담긴 데이터셋의 개수가 몇 백, 몇 천 개로 증가한 데이터의 조회, 검색, 수정도 수용할 수 있는 형태일까요? 너무나도 빠르게 발전한 클라이언트의 컴퓨팅 성능과 브라우저의 발달로 비효율적인 코드와 자료구조들이 "효율적인 것처럼" 보이기만 할 뿐인 것들이 아닐지 우리는 다시 살펴봐야 합니다.

 

맵(Map)이란

자바스크립트에서 객체(Object)는 여러 형태의 데이터를 손쉽게 묶을 수 있는 아주 편리하고 손쉬운 자료구조입니다. 그리고 ECMA Script 6+부터는 맵(Map)이라는 자료구조 형태가 추가되었습니다. 하지만 많이 사용되지는 않는, 그리고 객체보다는 다소 복잡해 보이는 자료 형태로 보일 뿐이죠.

 

초기 자바스크립트가 var 선언 예약어만을 통해서 모든 형태의 변수를 정의하고자 했던 초기 자바스크립트의 철학은 const, let 등으로 세분화되어 정의할 수 있도록 변화하였고, prototype을 상속하여 새 메서드를 정의를 가이드해 오던 패턴에서 벗어나 클래스(Class)를 이용한 객체 지향 형태로 코드 패턴의 기조는 변화해오고 있습니다.


맵(Map)과 세트(Set)가 자바스크립트에서 등장하게 된 배경 역시 이와 비슷합니다. 폴리글랏 프로그래밍의 일종으로서, 다른 프로그래밍 언어에서 자주 쓰이는 자료구조를 그대로 자바스크립트에도 들고 온 형태라 할 수 있습니다. Reddit: Why You Should Prefer Map Over Object In Javascript에서 맵과 객체를 사용해야 하는 순간에 대한 심도 깊은 논의가 이루어졌습니다. 이 논의와 다른 여러 글들을 정리해 본 필자의 이 짧은 글을 통해서 어떻게 우리는 맵(Map)을 이해하고, 간편하게 위와 같은 객체(Object)와 배열(Array) 형태의 자료를 맵(Map)의 형태로 적용해 나갈 수 있을 것인지 알아볼 것입니다.

 

맵(Map)의 형태로 바꾸어보자

맵(Map)의 장점을 다루기 전에 우선 위 객체(Object) 형태를 맵(Map) 형태로 바꾸어보겠습니다. 배열(Array)과 혼합되어 있는 형태로서, 다음 두 가지 형태로 선언해 볼 수 있습니다.

const kimchiA: Record<number, Map<string, Kimchi>> = {
  1: new Map([
    [
      "백김치",
      {
        name: "백김치",
        alias: "맵린이",
        score: 1,
        ingredients: ["소금", "무", "양파", "배", "배추", "설탕"]
      }
    ],
    [
      "나박김치",
      {
        name: "나박김치",
        alias: "깍두기먹을바엔나박김치",
        score: 1,
        ingredients: ["소금", "무", "배추", "미나리", "파"]
      }
    ]
  ]),
  2: new Map([
    [
      "오이소박이",
      {
        name: "오이소박이",
        alias: "오이지",
        score: 2,
        ingredients: ["소금", "오이", "고춧가루"]
      }
    ],
    [
      "섞밖지",
      {
        name: "섞밖지",
        alias: "이것은무가아니다",
        score: 2,
        ingredients: ["무", "고춧가루", "시래기", "젓갈"]
      }
    ]
    // ...other records...
  ]),
  3: new Map([
    [
      "배추김치",
      {
        name: "배추김치",
        alias: "김.치.",
        score: 3,
        ingredients: ["소금", "배추", "고춧가루"]
      }
    ]
    // ...other records...
  ])
  // ...other records...
};
const kimchiB: Map<[number, name], Kimchi> = new Map([
  [
    [1, "백김치"],
    {
      name: "백김치",
      alias: "맵린이",
      score: 1,
      ingredients: ["소금", "무", "양파", "배", "배추", "설탕"]
    }
  ],
  [
    [1, "나박김치"],
    {
      name: "나박김치",
      alias: "깍두기먹을바엔나박김치",
      score: 1,
      ingredients: ["소금", "무", "배추", "미나리", "파"]
    }
  ],
  [
    [2, "오이소박이"],
    {
      name: "오이소박이",
      alias: "오이지",
      score: 2,
      ingredients: ["소금", "오이", "고춧가루"]
    }
  ],
  [
    [2, "섞밖지"],
    {
      name: "섞밖지",
      alias: "이것은무가아니다",
      score: 2,
      ingredients: ["무", "고춧가루", "시래기", "젓갈"]
    }
  ],
  [
    [3, "배추김치"],
    {
      name: "배추김치",
      alias: "김.치.",
      score: 3,
      ingredients: ["소금", "배추", "고춧가루"]
    }
  ]
  // ...other records...
]);

kimchiA는 맵기 score를 키 값을 가진 객체(Object) 안에 각각 배열(Array) 대신 맵(Map)이 구성되어 있는 형태입니다. kimchiB는 [score, name]의 배열(Array)을 키 값으로 가진 맵(Map)이 single-depth로 이루어진 형태입니다. kimchiAkimchiB 중 어느 것을 이용할지는 본 자료가 어떻게 자주 호출되는지, 어떠한 복수 데이터 안에서 생성, 삭제, 수정이 이루어지는지에 따라 달라질 것입니다. 만약 맵기 score 별로 복수 개의 데이터가 자주 호출되는 자료면 kimchiA 형태가 유리할 것이고, 임의의 score를 가진 데이터가 생성, 삭제되는 형태라면 kimchiB 형태가 유리할 것입니다.

 

사실 맵(Map) 자료구조는 객체(Object)를 프로토타입으로 삼는 자식 자료구조입니다. 맵은 단지 키 컬렉션을 가진 객체의 일종이라는 말입니다.

console.log(typeof kimchiA);
// Returns object

 

맵(Map)의 특징

// 위 kimchiA, kimchiB 참고
const kimchiA: Record<number, Map<string, Kimchi>>;
const kimchiB: Map<[number, name], Kimchi>;

kimchiA[4].set("고들빼기", {
  name: "고들빼기",
  alias: "봄김치",
  score: 4,
  ingredients: ["고들빼기", "고춧가루", "멸치젓"]
});
kimchiA[3].set("고구마순김치", {
  name: "고구마순김치",
  alias: "고구마",
  score: 3,
  ingredients: ["고구마줄기", "고춧가루", "젓갈"]
});

console.log(kimchiA[4].has("고들빼기"));
// expected output: true
console.log(kimchiA[4].get("고들빼기"));
// expected output: { name: "고들빼기", alias: "봄김치" ...}

const newKey = [4, "고들빼기"];
kimchiB.set(newKey, {
  name: "고들빼기",
  alias: "봄김치",
  score: 4,
  ingredients: ["고들빼기", "고춧가루", "멸치젓"]
});

console.log(kimchiB.get(newKey));
// expected output: { name: "고들빼기", alias: "봄김치" ...}

console.log(kimchiB.size);
// expected output: 6 or more

kimchiB.delete(newKey);

console.log(kimchiB.size);
// expected output: 5 or more

새로운 맵(Map)을 정의하고, 값 생성, 조회, 삭제 등을 실행하는 예제 코드입니다. 얼핏 보면 객체의 값을 생성, 조회, 삭제하는 메서드는 달라 보이지만, 기능적인 면에서는 크게 달라 보이지 않습니다. 맵(Map)이 어떤 장점이 있을까요?

 

맵(Map)의 키는 순회가능하다

자바스크립트의 주요 패턴 중 하나인 반복자(Iterator) 패턴을 구현하기에 맵(Map) 자료구조는 장점을 가지고 있습니다. 반복자는 valuedone이라는 두 개의 속성을 들고 있습니다. 반복자(Iterator)의 done 속성이 true가 될 때까지 콜백함수에서, 또는 반복문에서 반복자(Iterator)의 next() 메서드를 호출하여 다음 키 값을 불러올 수 있습니다.

const it = makeRangeIterator(1, 4);
let result = it.next();
while (!result.done) {
  console.log(result.value); // 1 2 3
  result = it.next();
}
console.log("Iterated over sequence of size: ", result.value);

이를 위 kimchiA의 Map 자료에도 동일한 패턴을 적용할 수 있습니다.

let result = kimchiB.next();
while (!result.done) {
  console.log(result.value);
  result = kimchiB.next();
}
console.log("Iterated over sequence of size: ", result.value);

객체의 키 순환과 직접 비교해 볼 수 있는 예제를 보면, 보다 높아진 가독성의 코드를 확인할 수 있습니다.

for (const [score, kimchi] of Object.entries(kimchis)) {
  console.log(score, kimchi);
}

for (const [score, kimchi] of kimchiA) {
  console.log(score, kimchi);
}

 

맵(Map)의 키로서 다양한 자료구조를 정의할 수 있다

kimchiB의 키 값의 형태가 다소 생소해 보일 수 있겠습니다. 객체의 키는 문자열(string) 또는 심볼(symbol)만 이용할 수 있는 반면, 맵(Map)에서는 문자열(string), 숫자(number), 부울(boolean), 심볼(symbol) 등의 다양한 자료형을 키값으로 이용할 수 있습니다. 또한 배열(Array)이나 객체(Object), 맵(Map), Week 맵(WeekMap)등의 모든 복잡한 형태도 모두 키 값으로서 이용할 수 있습니다.

console.log(kimchis[1].filter((val) => val === "백김치"));
console.log(kimchiA[1].get("백김치"));
const key = [1, "백김치"];
console.log(kimchiB.get(key));

 

맵의 크기를 쉽게 구할 수 있다

객체(Object)의 값 개수를 구하려면 Object.keys(obj).length 등의 형태로 확인해야 합니다. 하지만 맵(Map)은 계산 절차 없이 kimchiB.size 값으로 다른 객체 선언 없이 상시 확인할 수 있습니다.

console.log(kimchis[1].keys().length);
console.log(kimchiA[1].size);

 

더 높은 성능을 낸다

맵(Map)을 사용해야 하는 가장 주요한 이유는 성능입니다. 빈번히 Key-Value 쌍을 추가하고 삭제할 때 객체(Object)보다 더 빠른 속도를 보입니다. 대다수의 단순한 프론트엔드 웹에서는 큰 차이를 보이지 않을 수 있지만, 수 백개, 수 천개의 데이터가 동시다발적으로 생성, 삭제가 이루어져야 하는 자료형에서는 가시적인 차이를 확인할 수 있습니다.

 

맵(Map)을 객체(Object)보다 선호해야 할 순간은?

맵(Map) 객체는 키 컬렉션이 있는 객체의 한 종류로서, 객체의 속성(Property)을 자주 변경해야 할 때 그 빛을 발합니다. 자주 순회가 일어날 때에도 반복자(Iterator)를 통해서 깔끔하게 순회할 때에도 장점이 있다고 언급했습니다. 모든 객체(Object)를 맵(Map)으로 변환하는 것이 유리하지는 않습니다. 작은 컬렉션을 가지고 있는 경우, 또는 빠른 프로토타이핑을 원하는 임의의 개발 팀에서 맵(Map)을 적용하는 것은 다소 생산성을 떨어트릴 수 있는 요인이 되기도 할 것입니다. 충분히 성숙한 프론트엔드 애플리케이션에서, 최적의 성능을 요구하는 서비스에 적용하기를 추천드립니다.

댓글