이 전 글에서 Object가 왜 Map에 비해서 느릴 수 있는지를 Deep Dive를 통해 알아보았다. 이 글에서는 어떤 상황에서 느리고, 얼마나 느린지를 정량적으로 측정하자.
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`);
}
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];
}
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
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));
}
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문으로 되어 있다.
변수 명 | 초기 값 | 변경 수치 | 최종 값 | 경우의 수 |
---|---|---|---|---|
프로퍼티-수 | 1000 | 1000 | 10_000_000 | 10,000 |
업데이트-또는-삭제-확률 | 0 | 0.05 | 1 | 20 |
프로퍼티-접근-확률 | 0 | 0.05 | 1 | 20 |
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초 정도가 걸린다.프로퍼티 수가 적을 때, 무작위 셔플이 매우 빠르게 일어나기 때문에 프로퍼티 수를 필요할 때 마다 추가하는 쪽으로 설계했다.
기존에는 Map 테스트 후에 테스트 결과를 파일에 저장하고, Object 테스트 후에 테스트 결과를 파일에 저장하는 식으로 한 번의 테스트에 두 번의 I/O 접근이 있었는데, Node는 CPU 연산에 취약하기 때문에 이를 개선해 각 테스트마다 한 번만 접근하기로 결정했다.
saveInfo(
`Map,${프로퍼티_수},${업데이트_또는_삭제_확률},${업데이트_또는_삭제_확률},${mapResult.usedTime},${mapResult.usedMemory}\n
Object,${프로퍼티_수},${업데이트_또는_삭제_확률},${업데이트_또는_삭제_확률},${objResult.usedTime},${objResult.usedMemory}`
);
다음 챕터에서는 측정한 데이터로 시각화를 해보자