정성에서 정량으로 🔬

youseock·2024년 2월 5일
0

[JS] Map vs Object

목록 보기
4/5
post-thumbnail

이 전 글에서 Object가 왜 Map에 비해서 느릴 수 있는지를 Deep Dive를 통해 알아보았다. 이 글에서는 어떤 상황에서 느리고, 얼마나 느린지를 정량적으로 측정하자.

변인요소(변경할 요소)

  • 프로퍼티의 수 ✅
  • update, delete을 수행할 확률 ✅
  • 검색 작업을 수행할 확률 ✅
  • 데이터 유형 및 크기 ❌
  • 다중 스레드 ❌

고정변인 ( 고정할 요소)

  • 사용할 컴퓨터
    • m1 Air 램 8기가
  • Node 버전
    • v18.16.1
  • 충전 상태
    • 충전 중
  • 해당 실험을 측정하는 것 외에 아무 작업도 하지 않음

측정할 요소

  • 사용 메모리(MB)
  • 걸린 시간(ms)

코드로 구현해보자

메모리와 걸린 시간을 측정할 코드를 작성하자

function measurePerformanceAndMemory(fn,...properties) {
  const initialMemory = process.memoryUsage().heapUsed;
  const startTime = process.hrtime();
  fn(...properties);
  const finalMemory = process.memoryUsage().heapUsed;
  const endTime = process.hrtime();

  const usedMemory = (finalMemory - initialMemory) / 1024 / 1024;
  const usedTime =
    (endTime[0] - startTime[0]) * 1000 + (endTime[1] - startTime[1]) / 1000000;
  console.log(`메모리 사용량: ${usedMemory} bytes`);
  console.log(`걸린 시간: ${usedTime} ms`);
}

프로퍼티로 사용할 key를 랜덤으로 만들어주는 녀석을 구현하자

  • 호출할 때 마다 특정 확률을 기준으로 true or false를 랜덤하게 뱉는 함수를 구현하자
  • key에 정수와 string이 무작위로 들어가면 좋겠는데, int를 string으로 바꿔주는 함수를 구현하자
  • 위 함수를 사용해서 원하는 프로퍼티의 수만큼 key를 만들어주는 함수를 구현하자
function random(threshold) {
  return Math.random() < threshold;
}

function intToString(number) {
  let result = [];
  const alphabet = "abcdefghijklmnopqrstuvwxyz";
  while (number > 0) {
    const remainder = (number - 1) % 26; // 1부터 26까지의 알파벳을 사용하기 위해 -1
    result.unshift(alphabet.charAt(remainder));
    number = Math.floor((number - 1) / 26);
  }

  return result.join("");
}

function createProperties(프로퍼티_수) {
  let keys = new Set();
  Math.floor(Number.MAX_SAFE_INTEGER * Math.random());
  while (keys.size !== 프로퍼티_수) {
    const randomKey = Math.floor(Number.MAX_SAFE_INTEGER * 						 Math.random());
    if (random()) {
      keys.add(randomKey);
    } else {
      keys.add(intToString(randomKey));
    }
  }
  return [...keys];
}
  • 결과는 아래와 같다.

업데이트 또는 삭제 확률을 받아서 시행하는 코드를 작성하자

  • 반복문을 수행하면서 업데이트나 삭제를 할텐데, 해당 반복문의 index보다 작은 값의 index면 높은 확률로 해당 프로퍼티가 key안에 있으니 그 값을 업데이트나 삭제 키로 사용하자
    • 높은 확룔로 존재한다는 의미는 다음과 같다
    • keys[i]를 저장해왔기 때문에 i보다 작은 값을 가진 예를 들어 keys[i-1]은 높은 확률로 해당 key 값을 가지겠지만, delete하는 과정에서 사라졌을 수도 있음.
for (let i = 0; i < 프로퍼티_수; i++) {
    const key = keys[i];
    obj[key] = value[i];
    if (random(업데이트_또는_삭제_확률)) {
      const randomKey = keys[Math.floor(Math.random() * i)];
      if (random(0.5)) {
        // 업데이트나 삭제를 무작위로 선택하기 위해 추가
        delete obj[randomKey];
      } else {
        obj[randomKey] = value[i];
      }
    }
  }

위 로직을 Map, Object로 변경하고 모듈화하면서 프로퍼티접근확률도 코드에 포함시키자.

Map

function testMap(keys, values, 업데이트_또는_삭제_확률, 프로퍼티_접근_확률) {
  const map = new Map();
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    map.set(key, values[i]);
    if (random(프로퍼티_접근_확률)) {
      const randomKey = keys[Math.floor(Math.random() * i)];
      map.get(randomKey);
    }
    if (random(업데이트_또는_삭제_확률)) {
      const randomKey = keys[Math.floor(Math.random() * i)];
      if (random(0.5)) {
        map.delete(randomKey);
      } else {
        map.set(randomKey, values[i]);
      }
    }
  }
}

Object

function testObj(keys, values, 업데이트_또는_삭제_확률, 프로퍼티_접근_확률) {
  const obj = {};
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    obj[key] = values[i];

    if (random(프로퍼티_접근_확률)) {
      const randomKey = keys[Math.floor(Math.random() * i)];
      obj[randomKey];
    }
    if (random(업데이트_또는_삭제_확률)) {
      const randomKey = keys[Math.floor(Math.random() * i)];
      if (random(0.5)) {
        delete obj[randomKey];
      } else {
        obj[randomKey] = values[i];
      }
    }
  }
}

테스트 함수 종합하기

function test(프로퍼티_수, 업데이트_또는_삭제_확률, 프로퍼티_접근_확률) {
  const keys = createProperties(프로퍼티_수);
  const values = createProperties(프로퍼티_수);
  measurePerformanceAndMemory(
    testMap,
    keys,
    values,
    업데이트_또는_삭제_확률,
    프로퍼티_접근_확률
  );
  measurePerformanceAndMemory(
    testObj,
    keys,
    values,
    업데이트_또는_삭제_확률,
    프로퍼티_접근_확률
  );
}

테스트 데모 중 문제 발생

사용한 메모리가 마이너스

메모리 사용량이 -인 경우가 나타났다. 코드 실행 과정에서 GC가 돌아가서 사용하지 않는 메모리를 해제한 것으로 파악된다.
Node를 실행할때 -expose-gc flag를 추가하고 측정 함수 앞에 global.gc()를 호출해서 해결했다.

-expose-gc flag를 추가

node --expose-gc ./mapVsObject.js

global.gc() 호출

function measurePerformanceAndMemory(fn, ...property) {
  global.gc();
	...
}

컴퓨터가 뜨거워진다

반복 작업을 한다면 발열 때문에 성능에 외부요인이 개입되지 않을까란 고민으로 cpu온도를 주기적으로 체크해서 휴식 시간을 주고 싶었다.
MacBook M1 Air를 사용하고 있는데, Node 환경에서 cpu 온도를 측정하는 방법은 Linux가 아니라 힘들어 보인다.

터미널 상에서 온도를 측정하고, 정상 범주라면 js 파일을 실행시키는 쪽으로 구상하자. 라고 생각했으나, Apple Silicon의 온도를 터미널 명령으로 알아낼 방법은 없었다.

고작 알아낼 수 있는 정보는 Thermal pressure가 어떻다정도..

그냥 추우웅분히 쉬어⌛주고, 하이테크 ❄️냉각❄️ 시스템을 구축하자

async function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

이제 측정을 시작해보자

  • 프로퍼티의 범위는 1000 ~ 10_000_000로 1000 단위로 측정한다.
  • update, delete을 수행할 확률은 0 ~ 1로 0.05 씩 측정한다.
  • 검색 작업을 수행할 확률은 0 ~ 1로 0.05 씩 측정한다.
function main() {
  for (let 프로퍼티_수 = 1000; 프로퍼티_수 <= 10_000_000; 프로퍼티_수 += 1000) {
    for (
      let 업데이트_또는_삭제_확률 = 0;
      업데이트_또는_삭제_확률 <= 1;
      업데이트_또는_삭제_확률 += 0.05
    ) {
      for (
        let 프로퍼티_접근_확률 = 0;
        프로퍼티_접근_확률 <= 1;
        프로퍼티_접근_확률 += 0.05
      ) {
        test(properties, 업데이트_또는_삭제_확률,프로퍼티_접근_확률);
      }
    }
  }
}

결과

오래 걸려도 너무 오래 걸린다. ⌛⌛

실험을 다시 설계해보자(리팩토링도 곁들여)

테스트 케이스 수에 대해 고려해보자

영향을 미칠 요소가 총 3개로 각 요소들의 영향을 측정하기 위해 3중 for문으로 되어 있다.

변수 명초기 값변경 수치최종 값경우의 수
프로퍼티-수1000100010_000_00010,000
업데이트-또는-삭제-확률00.05120
프로퍼티-접근-확률00.05120

3중 for 문으로 되어 있으니 10_000 ✖️ 20 ✖️ 20 ✖️ 2(objFn, mapFn) = 800_000 회 이다.

프로퍼티-수가 천만 일 때 수행 시간이 약 10초이고, 1000일 때 0초이기 때문에 평균 수행 시간은 대략 5초다.
이를 토대로 걸릴 시간을 예상해보면 약 1만 시간이 걸린다. 😱😱

테스트 케이스 수 줄이기

프로퍼티-수를 100_000 에서 시작해 100_000을 더하는 식으로 변경하자

업데이트-또는-삭제-확률을 0에서 시작해 0.1을 더하는 식으로 변경하자

프로퍼티-접근-확률을 0에서 시작해 0.1을 더하는 식으로 변경하자

이렇게 된다면 100(A) ✖️ 10 (B) ✖️ 10 (C) ✖️ 2(D)= 20_000 회가 나온다. 20_000 회 당 5초가 걸린다고 가정하면 약 하루(1.15 day)가 조금 넘는 시간이면 측정할 수 있다는 결론을 얻을 수 있다.

(A : 프로퍼티수, B: 업데이트또는삭제확률, C : 프로퍼티접근확률, D: 수행할 함수의 종류)

프로퍼티를 반복적으로 만드는 작업을 개선하자

프로퍼티-수가 천만 일 때 랜덤한 key를 만드는 작업이 얼마나 걸리는 지 측정해보았다. 느린 작업이 될 것이라 판단하고 있었지만, 생각했었던 것보다 훨씬 오래 걸렸다.
각 조건마다 새로운 keys를 만드는 것보단 keys를 한 번만 만들고 셔플하면 어떨까란 생각을 했고, 실제로 훨씬 적은 시간이 걸렸다.

이러한 정보를 토대로 프로퍼티-수의 백만 단위가 바뀔 때 마다 백만 개의 프로퍼티를 만들어서 기존 프로퍼티 배열에 추가한다. 각 조건마다는 셔플하기로 결정했다.

프로퍼티 수가 천만인 keys를 만들고 조건마다 셔플하면 안 될까?
sort 작업은 평균 O(n log n)를 가진다.

프로퍼티 수가 백만일 경우, 무작위 셔플은 0.45초 정도가 걸린다.
프로퍼티 수가 천만일 경우, 무작위 셔플은 6.06초 정도가 걸린다.

프로퍼티 수가 적을 때, 무작위 셔플이 매우 빠르게 일어나기 때문에 프로퍼티 수를 필요할 때 마다 추가하는 쪽으로 설계했다.

I/O 접근을 줄이자

기존에는 Map 테스트 후에 테스트 결과를 파일에 저장하고, Object 테스트 후에 테스트 결과를 파일에 저장하는 식으로 한 번의 테스트에 두 번의 I/O 접근이 있었는데, Node는 CPU 연산에 취약하기 때문에 이를 개선해 각 테스트마다 한 번만 접근하기로 결정했다.

saveInfo(
`Map,${프로퍼티_수},${업데이트_또는_삭제_확률},${업데이트_또는_삭제_확률},${mapResult.usedTime},${mapResult.usedMemory}\n
 Object,${프로퍼티_수},${업데이트_또는_삭제_확률},${업데이트_또는_삭제_확률},${objResult.usedTime},${objResult.usedMemory}`
 );

저장 형식을 CSV로 변경하자

  • 저장되는 용량을 줄이는 효과와 데이터를 다루기 쉽다는 장점이 있다.

코드 품질을 개선하자

여기서 확인 가능합니다.

다음 챕터에서는 측정한 데이터로 시각화를 해보자

profile
자바스크립트 애호가

0개의 댓글

관련 채용 정보