JS 성능·메모리·숫자: jsc 로 잰 함정과 실측값
JavaScript 강좌 6: 2^53 넘는 ID, 53 배 느린 BigInt, += 문자열 함정(2ms 대 564ms), 구멍 난 배열, JIT 단계별 31→5ms, Map 과 WeakMap 누수.
시리즈: JavaScript 강좌 6/6 (고급). JavaScript 를 실서비스에 올리고 있고, API 에서 받은 ID 가 틀리거나, 데이터가 늘수록 화면이 느려지거나, 메모리가 계속 오르는 일을 겪어 본 개발자를 위한 글입니다. 다 읽으면 JavaScript 숫자가 어디서부터 부정확해지는지와 대처법, 어떤 문자열·배열·객체 패턴이 빠르고 느린지와 그 이유, JIT 컴파일러가 측정값을 어떻게 바꾸는지, 캐시와 누수를 어떻게 구별하는지 알게 됩니다.
사내에서 "문자열은 += 말고 배열에 모아서 join 하라던데요?" 라는 질문을 받은 적이 있습니다. JavaScript 성능 조언에는 어느 해 어느 엔진에서만 맞았던 속설이 많습니다. 유일한 방어책은 실제로 배포하는 엔진에서 재 보는 것입니다. 아래 모든 측정은 Safari 와 iOS 의 모든 브라우저 안에 있는 엔진인 JavaScriptCore 한 곳에서, 고정 입력·고정 시드·반복 실행으로 했고, V8(Chrome, Node.js)로 옮겨 갈 만한 결과와 아닌 결과를 구분해 적었습니다.
실행 환경: macOS 26.6.2 의 JavaScriptCore(프레임워크 버전 21624.5.1.11.3)를 jsc 셸로 실행, Apple Silicon Mac (M1 Pro). Node.js, Deno, 브라우저는 실행하지 않았습니다. 시간은 performance.now() 로 쟀는데, 이 jsc 에서는 20µs 단위로 올라갔습니다. 그래서 0.1ms 쯤 아래의 단발 측정은 싣지 않았고, 5~21 회의 중앙값을 쓰며 대부분은 새 프로세스로 여러 번 돌렸습니다. fullGC() 와 gcHeapSize() 는 jsc 전용 함수입니다. 1 강의 별칭: alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc
숫자: double 이 정확함을 잃는 지점
JavaScript 의 보통 숫자 타입은 하나, IEEE 754 64 비트 부동소수점("double")입니다. 정밀도가 53 비트라서 정수는 2^53 − 1 까지만 정확하고, 0.1 같은 소수는 결코 정확하지 않습니다. 실무에서 만나는 경계를 한 파일에 모았습니다.
// numbers.js — where a double stops being exact
print(Number.MAX_SAFE_INTEGER, 2 ** 53 === 2 ** 53 + 1, Number.isSafeInteger(2 ** 53));
print(9007199254740993, 9007199254740993 === 9007199254740992);
const row = JSON.parse('{"id": 9007199254740993, "idStr": "9007199254740993"}');
print(row.id, row.idStr, BigInt(row.idStr));
print(0.1 + 0.2, 0.1 + 0.2 === 0.3, Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
print((1.005).toFixed(2), (1.005).toPrecision(20), Math.round(1.005 * 100) / 100);
print(parseInt(0.0000005), String(0.0000005), parseInt("08"), parseInt("1e3"), Number("1e3"));
print(Number.MAX_VALUE * 2, -1 / 0, 0 / 0, Object.is(-0, 0), -0 === 0);
print([0.1, 0.2, 0.3].reduce((a, b) => a + b), Math.sumPrecise([0.1, 0.2, 0.3]));
// money: integers of the smallest unit
const pricesYen = [1980, 2480, 980]; const taxRate = 10;
print(pricesYen.reduce((a, b) => a + b) * (100 + taxRate) / 100);
const cents = [1999, 2999, 99]; print((cents.reduce((a, b) => a + b) / 100).toFixed(2));
$ jsc numbers.js
9007199254740991 true false
9007199254740992 true
9007199254740992 9007199254740993 9007199254740993
0.30000000000000004 false true
1.00 1.0049999999999998934 1
5 5e-7 8 1 1000
Infinity -Infinity NaN false true
0.6000000000000001 0.6
5984
50.97
줄마다 함정이 있습니다.
- 2^53 과 2^53 + 1 은 같은 수입니다. 리터럴
9007199254740993은 소스를 읽는 순간 이미...992가 됩니다. JSON.parse는 큰 ID 를 조용히 바꿉니다. 숫자id는...992로 돌아왔고, 문자열idStr은 살아남았습니다. 스노플레이크 방식 ID 나 64 비트 기본 키가 대표적인 피해자입니다.toFixed는 1.005 를 올리지 않습니다. 1.005 는 실제로 1.00499999999999989... 이기 때문입니다. 돈의 반올림은 정수에서 해야 합니다.parseInt는 문자열을 받습니다.parseInt(0.0000005)는 5 입니다. 숫자가 먼저"5e-7"로 바뀌기 때문입니다. 숫자에는Math.trunc를 쓰세요.Math.sumPrecise(아주 최근 추가, 이 jsc 에서만 확인)는 중간 반올림 없이 더합니다.0.6000000000000001대신0.6이 나왔습니다.- 돈은 최소 단위(원, 센트)의 정수로 두면 정확합니다. 5984 와 "50.97".
흔한 실수 1: JSON 의 64 비트 ID
이렇게 씁니다: const user = await res.json(); fetch("/users/" + user.id). 서버는 64 비트 정수 ID 를 보냅니다.
실제로 일어나는 일: 9,007,199,254,740,991 을 넘는 ID 는 가장 가까운 double 로 반올림되어 다른 레코드를 조회(또는 수정!)하고, 아무 오류도 나지 않습니다.
고치는 법: 가장 좋은 방법은 서버에서 그런 ID 를 JSON 문자열로 보내는 것입니다. 서버를 못 바꾼다면, 최신 엔진에서는 JSON.parse 의 reviver 가 원문을 볼 수 있고, JSON.rawJSON 으로 손실 없이 숫자를 다시 쓸 수 있습니다.
// bigids.js — keep 64-bit IDs exact when parsing and writing JSON
const text = '{"id": 9007199254740993, "price": 1980}';
print("plain parse:", JSON.parse(text).id);
// 1) reviver with source text (newer API: check your target runtimes)
const row = JSON.parse(text, (key, value, ctx) => key === "id" ? BigInt(ctx.source) : value);
print("reviver:", row.id, typeof row.id, row.price);
// 2) write it back as a bare JSON number without losing digits
print("stringify:", JSON.stringify(row, (k, v) => typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v));
$ jsc bigids.js
plain parse: 9007199254740992
reviver: 9007199254740993 bigint 1980
stringify: {"id":9007199254740993,"price":1980}
두 API 모두 이 jsc 에서 동작했지만, 오래된 브라우저와 Node 에는 없습니다(여기서는 실행 안 함). 기대기 전에 대상 환경을 확인하세요. JSON 숫자를 double 로 받는 다른 언어에도 같은 문제가 있습니다. PHP 의 JSON 숫자 타입 글도 참고하세요.
BigInt: 정확하고, 엄격하고, 약 53 배 느리다
BigInt(ES2020)는 크기 제한 없는 정수이고 n 을 붙여 씁니다. Number 와 조용히 섞이기를 거부하는데, 이는 좋은 일이고 오류 메시지는 알아 둘 만합니다.
// bigint.js — exact big integers, and the errors you will meet
const big = 2n ** 64n;
print(big, typeof big, 7n / 2n, -7n / 2n, 10n ** 30n);
try { print(1n + 1); } catch (e) { print(e); }
try { print(Math.max(1n, 2n)); } catch (e) { print(e); }
try { JSON.stringify({ id: 1n }); } catch (e) { print(e); }
print(JSON.stringify({ id: 1n }, (k, v) => typeof v === "bigint" ? v.toString() : v));
print(1n == 1, 1n === 1, 2n > 1, Number(2n ** 60n), BigInt.asUintN(8, 257n));
try { BigInt(1.5); } catch (e) { print(e); }
try { new BigInt(1); } catch (e) { print(e); }
const h = new BigInt64Array(2); h[0] = 2n ** 63n - 1n; h[1] = h[0] + 1n; print(h[0], h[1]);
$ jsc bigint.js
18446744073709551616 bigint 3 -3 1000000000000000000000000000000
TypeError: Invalid mix of BigInt and other type in addition.
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
TypeError: JSON.stringify cannot serialize BigInt.
{"id":"1"}
true false true 1152921504606847000 1
RangeError: Not an integer
TypeError: function is not a constructor (evaluating 'new BigInt(1)')
9223372036854775807 -9223372036854775808
BigInt 나눗셈은 0 쪽으로 버립니다(-7n / 2n 은 -3n). Math 함수는 BigInt 를 받지 않고, JSON.stringify 는 거부하며, BigInt64Array 의 요소는 C 의 int64_t 처럼 2^63 에서 넘쳐 돌아갑니다. Number() 로 되돌리면 다시 정밀도를 잃습니다(...847000).
같은 루프를 두 방식으로 써서 비용을 쟀습니다(프로세스당 7 회, 3 프로세스).
// bigint_speed.js — sum of i*i for i < 1,000,000 as Number and as BigInt
const N = 1_000_000;
function numSum() { let s = 0; for (let i = 0; i < N; i++) s += i * i; return s; }
function bigSum() { let s = 0n; for (let i = 0n; i < 1_000_000n; i++) s += i * i; return s; }
for (const [name, fn] of [["Number", numSum], ["BigInt", bigSum]]) {
const ts = []; let r;
for (let k = 0; k < 7; k++) { const t = performance.now(); r = fn(); ts.push(performance.now() - t); }
ts.sort((a, b) => a - b);
print(`${name}: median ${ts[3].toFixed(2)} ms result=${r} exact=${BigInt(r) === 333332833333500000n}`);
}
$ jsc bigint_speed.js
Number: median 0.94 ms result=333332833333127550 exact=false
BigInt: median 49.86 ms result=333332833333500000 exact=true
세 프로세스의 중앙값은 Number 0.94ms, BigInt 49.86ms 로 약 53 배였습니다. 그리고 Number 결과를 보세요. 합이 중간에 2^53 을 넘어서 조용히 틀렸습니다. 속도는 답이 맞은 다음의 이야기입니다.
문자열: += 는 괜찮다, 만드는 도중에 읽지만 않으면
"문자열은 += 말고 배열에 모아 join" 이라는 오래된 조언을 확인했습니다. 20 만 행 CSV(3,446,270 자)를 네 방식으로 만들고, 방식마다 새 프로세스에서 7 회씩, 두 바퀴 돌렸습니다.
// strings.js — build a 200,000-line CSV four ways (each strategy in its own process)
// usage: jsc strings.js -- plus | join | template | concat
const N = 200_000;
const row = (i) => i + "," + (i * 7 % 1000) + ",item" + (i % 97) + "\n";
const strategies = {
plus() { let s = ""; for (let i = 0; i < N; i++) s += row(i); return s; },
join() { const a = []; for (let i = 0; i < N; i++) a.push(row(i)); return a.join(""); },
template() { let s = ""; for (let i = 0; i < N; i++) s = `${s}${i},${i * 7 % 1000},item${i % 97}\n`; return s; },
concat() { let s = ""; for (let i = 0; i < N; i++) s = s.concat(row(i)); return s; },
};
const name = arguments[0], fn = strategies[name];
const times = [];
let len = 0, check = 0;
for (let rep = 0; rep < 7; rep++) {
const t = performance.now();
const s = fn();
check = s.charCodeAt(s.length >> 1); // touch the middle: forces a flat string
times.push(performance.now() - t); len = s.length;
}
const sorted = [...times].sort((a, b) => a - b);
print(`${name.padEnd(8)} len=${len} first=${times[0].toFixed(1)}ms median=${sorted[3].toFixed(1)}ms min=${sorted[0].toFixed(1)}ms`);
$ for v in plus join template concat; do jsc strings.js -- $v; done
plus len=3446270 first=19.7ms median=13.8ms min=12.6ms
join len=3446270 first=16.0ms median=15.6ms min=14.5ms
template len=3446270 first=14.1ms median=12.0ms min=10.7ms
concat len=3446270 first=15.7ms median=13.7ms min=12.4ms
두 번째 바퀴의 중앙값은 13.5, 15.2, 11.4, 13.7ms 였습니다. 넷 다 서로 30% 안쪽이고 += 가 가장 느리지도 않았습니다. JavaScriptCore 는 (V8 도) a + b 를 복사하지 않고 양쪽을 가리키는 작은 노드, 즉 로프(rope) 로 표현하기 때문입니다. 복사는 누군가 문자가 필요할 때 한 번 일어납니다(평탄화, flattening).
그렇다면 += 를 느리게 만드는 법도 보입니다. 매 반복마다 문자열 안을 읽어서 매번 평탄화(전체 복사)를 일으키면 됩니다.
// strings2.js — += is fine, until you read the string while it grows
const row = (i) => i + "," + (i * 7 % 1000) + ",item" + (i % 97) + "\n";
function plusOnly(N) { let s = ""; for (let i = 0; i < N; i++) s += row(i); return s.charCodeAt(s.length >> 1); }
function plusPeek(N) { let s = ""; let c = 0; for (let i = 0; i < N; i++) { s += row(i); c += s.charCodeAt(s.length >> 1); } return c; }
function joinPeek(N) { const a = []; let c = 0; for (let i = 0; i < N; i++) { a.push(row(i)); c += a[a.length >> 1].charCodeAt(0); } return a.join("").length + c; }
for (const N of [5_000, 10_000, 20_000, 40_000]) {
const r = {};
for (const [name, fn] of [["plusOnly", plusOnly], ["plusPeek", plusPeek], ["joinPeek", joinPeek]]) {
const ts = []; for (let k = 0; k < 5; k++) { const t = performance.now(); fn(N); ts.push(performance.now() - t); }
ts.sort((a, b) => a - b); r[name] = ts[2].toFixed(1);
}
print(`N=${String(N).padStart(6)} += only ${r.plusOnly.padStart(6)} ms += and read middle ${r.plusPeek.padStart(7)} ms array + read ${r.joinPeek.padStart(5)} ms`);
}
$ jsc strings2.js
N= 5000 += only 0.5 ms += and read middle 14.3 ms array + read 0.4 ms
N= 10000 += only 0.7 ms += and read middle 46.3 ms array + read 0.7 ms
N= 20000 += only 1.7 ms += and read middle 123.0 ms array + read 1.5 ms
N= 40000 += only 2.3 ms += and read middle 629.7 ms array + read 4.3 ms
N 을 두 배로 하면 가운데 열은 대략 네 배가 됩니다. 제곱 시간입니다. 세 프로세스에서 N = 40,000 의 "+= 하면서 읽기"는 629.7, 513.4, 563.7ms, "+= 만"은 2.2~2.3ms 였습니다.
실무 코드에서 이 "읽기"는 대개 순진해 보이는 검사 속에 숨어 있습니다. charCodeAt 자리에 흔한 검사 네 가지를 넣어 봤습니다(N = 40,000, 5 회 중앙값, 두 프로세스).
$ jsc strings3.js # s += row(i); then one check per iteration
length 5.9 ms
endsWith 511.9 ms
lastChar 504.3 ms
includes 605.3 ms
두 번째 프로세스는 1.5, 486.7, 476.4, 587.0ms 였습니다. s.length 는 로프에 저장돼 있어 계속 싸지만, s.endsWith(","), s[s.length - 1], s.includes(...) 는 문자가 필요해서 매번 전체 복사를 일으킵니다. 만드는 도중 검사해야 한다면 조각을 배열에 두고 마지막 조각을 검사하세요.
배열: 구멍, 섞인 타입, 타입 배열
구멍(빠진 인덱스)은 undefined 가 아니고, 메서드마다 다르게 취급합니다.
// holes.js — holes are not undefined
const a = new Array(3);
print(a.length, a, 0 in a, a.map(() => 1), Array.from(a, () => 1));
const b = [1, , 3];
print(b.indexOf(undefined), b.includes(undefined), Object.keys(b));
b.forEach((v, i) => print("forEach visits", i));
const u8 = new Uint8Array([250, 255]); u8[0] += 10; u8[1] = 256;
const c8 = new Uint8ClampedArray([250]); c8[0] += 10;
print(u8, c8, new Int8Array([200]));
const f32 = new Float32Array([0.1]); print(f32[0], f32[0] === 0.1);
try { new Float64Array(-1); } catch (e) { print(e); }
const t = new Int32Array(4); t.push?.(1); print(typeof t.push, t.length);
$ jsc holes.js
3 ,, false ,, 1,1,1
-1 true 0,2
forEach visits 0
forEach visits 2
4,0 255 -56
0.10000000149011612 false
RangeError: length cannot be negative
undefined 4
new Array(3).map(...) 은 다시 구멍을 돌려줬습니다. map 은 구멍을 건너뛰기 때문이고, Array.from 은 그렇지 않았습니다. indexOf 는 undefined 가 없다고, includes 는 있다고 합니다. 타입 배열은 길이와 타입이 고정이고 조용히 넘쳐 돕니다(260 은 4, 256 은 0, Int8Array 의 200 은 -56). Float32Array 는 0.1 을 보통 숫자보다 덜 정확하게 저장합니다.
배열의 내부 종류가 속도에 영향을 줄까요? 고정 시드 생성기로 double 100 만 개를 다섯 방식으로 채우고 11 번 합을 냈습니다(새 프로세스로 세 바퀴, 힙 바이트는 fullGC() 후 gcHeapSize()).
// arrays.js — same 1,000,000 doubles, different containers (fixed LCG seed)
// usage: jsc arrays.js -- push | prealloc | holey | mixed | f64
const N = 1_000_000;
let seed = 12345; const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const kind = arguments[0];
fullGC(); const before = gcHeapSize();
let a;
if (kind === "push") { a = []; for (let i = 0; i < N; i++) a.push(rnd()); }
if (kind === "prealloc") { a = new Array(N); for (let i = 0; i < N; i++) a[i] = rnd(); }
if (kind === "holey") { a = []; for (let i = 0; i < N; i++) a.push(rnd()); delete a[N >> 1]; }
if (kind === "mixed") { a = []; for (let i = 0; i < N; i++) a.push(rnd()); a[N >> 1] = "x"; }
if (kind === "f64") { a = new Float64Array(N); for (let i = 0; i < N; i++) a[i] = rnd(); }
fullGC(); const bytes = gcHeapSize() - before;
function sum(arr) { let s = 0; for (let i = 0; i < arr.length; i++) { const v = arr[i]; if (typeof v === "number") s += v; } return s; }
const ts = []; let s = 0;
for (let r = 0; r < 11; r++) { const t = performance.now(); s = sum(a); ts.push(performance.now() - t); }
const first = ts[0]; ts.sort((x, y) => x - y);
print(`${kind.padEnd(8)} heap=${(bytes / N).toFixed(1)} B/elem sum first=${first.toFixed(2)}ms median=${ts[5].toFixed(2)}ms s=${s.toFixed(3)}`);
$ for k in push prealloc holey mixed f64; do jsc arrays.js -- $k; done
push heap=11.9 B/elem sum first=2.24ms median=0.94ms s=495272.410
prealloc heap=8.1 B/elem sum first=2.32ms median=0.94ms s=495272.410
holey heap=11.9 B/elem sum first=3.60ms median=3.56ms s=495272.275
mixed heap=11.9 B/elem sum first=3.88ms median=3.58ms s=495272.275
f64 heap=8.1 B/elem sum first=3.12ms median=0.94ms s=495272.410
| 배열 | 요소당 힙 | 100 만 개 합 (3 바퀴 중앙값) |
|---|---|---|
double 100 만 개 push |
11.9 B | 0.94 ms |
new Array(N) 후 모든 인덱스 채움 |
8.1 B | 0.94 ms |
push 후 요소 하나 delete |
11.9 B | 3.58 ms |
push 후 문자열 요소 하나 |
11.9 B | 3.58 ms |
Float64Array |
8.1 B | 0.94 ms |
100 만 개 숫자 중 delete 하나, 문자열 하나만으로 이후의 합이 모두 약 3.8 배 느려졌습니다. 엔진이 배열 전체를 더 일반적인 저장 방식으로 바꿔서, 루프가 요소마다 검사해야 하기 때문입니다. push 가 요소당 약 3.8 바이트 더 쓴 것은 내부 저장소가 여유 용량을 두고 커지기 때문입니다. new Array(N) 으로 미리 잡은 것은 읽기 전에 모든 인덱스를 채웠기 때문에 여기서는 괜찮았습니다. V8 에서는 new Array(N) 이 구멍 있는 종류로 시작해 되돌아가지 않는 것으로 알려져 있으므로(여기서는 실행 안 함) 이식성을 생각하면 Array.from({length: N}, fn) 이나 push 가 무난합니다. Float64Array 는 같은 속도에 가장 작은 크기이고 구멍이나 섞인 타입이 될 수 없습니다. 수치 코드(오디오, 이미지, ML)가 타입 배열을 쓰는 이유입니다.
객체: 모양 하나가 열여섯보다 빠르다
엔진은 같은 키를 같은 순서로 가진 객체에 숨은 모양(shape) 을 공유시킵니다(JavaScriptCore 는 Structure, V8 은 Map 또는 hidden class 라고 부름). 한 가지 모양만 보는 속성 읽기 지점은 빠르고, 여러 모양을 보면 찾아봐야 합니다. 모두 x 를 가진 객체 100 만 개에 추가 키를 넣어 모양을 1, 2, 4, 8, 16 가지로 만들고 x 의 합을 21 번 구했습니다.
// shapes.js — same property read, objects built with 1, 4 or 16 different key orders
// usage: jsc shapes.js -- <number of shapes>
const SHAPES = Number(arguments[0]), N = 1_000_000;
const keys = ["a","b","c","d","e"];
function make(i) { // all objects have x; extra keys added in an order that depends on i % SHAPES
const o = {}; const k = i % SHAPES;
for (let j = 0; j < 4; j++) if (k & (1 << j)) o[keys[j]] = j;
o.x = i % 10; return o;
}
const objs = Array.from({ length: N }, (_, i) => make(i));
function sumX(arr) { let s = 0; for (let i = 0; i < arr.length; i++) s += arr[i].x; return s; }
const ts = []; let s = 0;
for (let r = 0; r < 21; r++) { const t = performance.now(); s = sumX(objs); ts.push(performance.now() - t); }
ts.sort((a, b) => a - b);
print(`shapes=${String(SHAPES).padStart(2)} median=${ts[10].toFixed(2)} ms per 1,000,000 reads s=${s}`);
$ for n in 1 2 4 8 16; do jsc shapes.js -- $n; done
shapes= 1 median=1.02 ms per 1,000,000 reads s=4500000
shapes= 2 median=1.12 ms per 1,000,000 reads s=4500000
shapes= 4 median=1.54 ms per 1,000,000 reads s=4500000
shapes= 8 median=2.90 ms per 1,000,000 reads s=4500000
shapes=16 median=3.04 ms per 1,000,000 reads s=4500000
세 바퀴 중앙값은 1.02, 1.12, 1.54, 2.90, 3.00ms. 모양 하나에서 여덟로 가자 같은 arr[i].x 가 약 2.8 배 느려졌고, 그 뒤로는 평평했습니다. 실무 규칙은 이렇습니다. 같은 종류의 객체는 한 곳(생성자나 팩토리 함수)에서 모든 필드를 같은 순서로 만들고, 나중에 속성을 추가하거나 delete 하지 마세요. 객체 크기가 달라 메모리 배치가 달라지는 효과도 일부 섞였을 수 있는데, 둘을 분리하지는 않았습니다.
JIT 워밍업: 첫 실행은 거짓말을 한다
JavaScriptCore 에는 실행 단계가 넷 있습니다. 인터프리터(LLInt), 빠른 Baseline JIT, 최적화 DFG JIT, 가장 공격적인 FTL JIT 입니다. 코드는 뜨거워질수록 위 단계로 올라갑니다. jsc 셸은 단계를 끌 수 있어서 각 단계가 얼마나 기여하는지 볼 수 있습니다. 같은 고정 작업(10 회 호출 묶음 40 개, 루프 총 80 만 회)을 설정마다 5 프로세스씩 돌렸습니다.
// tiers.js — fixed workload, timed in batches; run with different JIT tiers switched off
function score(n) {
let h = 0;
for (let i = 0; i < n; i++) h = (h * 31 + i) % 1000003;
return h / 7;
}
const batches = []; let out = 0;
const T0 = performance.now();
for (let b = 0; b < 40; b++) {
const t = performance.now();
for (let k = 0; k < 10; k++) out += score(2000); // 10 calls x 2,000 iterations
batches.push(performance.now() - t);
}
const total = performance.now() - T0;
const us = (x) => (x * 1000).toFixed(0);
print(`total=${total.toFixed(1)}ms batch#1=${us(batches[0])}us #2=${us(batches[1])}us #5=${us(batches[4])}us #10=${us(batches[9])}us #40=${us(batches[39])}us check=${out.toFixed(1)}`);
$ jsc --useJIT=false tiers.js
total=31.1ms batch#1=820us #2=800us #5=800us #10=780us #40=740us check=39407657.1
$ jsc --useDFGJIT=false tiers.js
total=25.8ms batch#1=780us #2=620us #5=640us #10=640us #40=640us check=39407657.1
$ jsc --useFTLJIT=false tiers.js
total=5.0ms batch#1=500us #2=120us #5=100us #10=120us #40=120us check=39407657.1
$ jsc tiers.js
total=5.1ms batch#1=480us #2=120us #5=120us #10=120us #40=100us check=39407657.1
5 프로세스 중앙값은 인터프리터만 30.8ms, Baseline 추가 25.8ms, DFG 추가 5.0ms, 기본 5.1ms 였습니다. 기본 설정에서 첫 묶음은 480~620µs, 이후 묶음은 100~120µs 였습니다. 첫 실행은 우리 코드가 아니라 주로 인터프리터와 컴파일 시간을 잰 셈입니다. 5ms 짜리 작업에서는 FTL 의 효과가 없었고, 50 배 긴 실행(2,000 묶음, 3 프로세스)에서는 기본 230.3ms, FTL 끔 249.0ms 로 약 8% 빨랐습니다.
실무 규칙 둘. 벤치마크는 충분히 반복하고 첫 실행과 안정 상태를 따로 보고하세요. 이 글의 측정이 모두 그렇게 했습니다. 그리고 사용자는 첫 실행만 겪는 경우가 많다는 것을 기억하세요. 페이지 로드 때 한 번 도는 함수는 인터프리터 속도로 돕니다. 네 설정 모두 check 값이 같으므로 같은 계산을 했다는 것도 확인됩니다.
메모리: 캐시인가, 누수인가
가비지 컬렉션 언어는 아무도 참조하지 않는 객체를 해제합니다. 그래서 JavaScript 의 누수는 거의 언제나 잊어버린 참조이고, 객체를 키로 쓰는 Map 캐시가 아주 흔한 원인입니다.
// leak.js — a "cache" keyed by objects: Map keeps them alive, WeakMap does not
// usage: jsc leak.js -- Map | WeakMap
const Kind = globalThis[arguments[0]];
const cache = new Kind();
const MB = (b) => (b / 1048576).toFixed(1) + " MB";
fullGC(); const base = gcHeapSize();
function handleRequest(i) { // each request object is used once, then dropped
const req = { id: i, body: new Array(64).fill(i) };
cache.set(req, { parsedAt: i }); // "memoize" per request
return req.body.length;
}
let n = 0;
for (let i = 0; i < 50_000; i++) n += handleRequest(i);
fullGC();
print(`${arguments[0].padEnd(7)} after 50,000 requests + full GC: heap +${MB(gcHeapSize() - base)} size=${cache.size ?? "(WeakMap has no size)"}`);
$ jsc leak.js -- Map; jsc leak.js -- WeakMap
Map after 50,000 requests + full GC: heap +36.8 MB size=50000
WeakMap after 50,000 requests + full GC: heap +0.1 MB size=(WeakMap has no size)
세 번 모두 같았습니다. Map 은 모든 요청 객체를 살려 뒀고(36.8MB, 하나에 약 770 바이트, 서버라면 끝없이 증가), WeakMap 은 놓아줬습니다. WeakMap 의 항목은 키를 살려 두지 않기 때문입니다. 대가는 WeakMap 에 size 가 없고 순회할 수 없으며 키가 객체여야 한다는 점입니다. 브라우저라면 DevTools 힙 스냅숏, Node 라면 --expose-gc 와 process.memoryUsage() 로 확인합니다(둘 다 실행 안 함).
판단표
| 상황 | 이렇게 | 여기서 잰 값 |
|---|---|---|
| 2^53 을 넘을 수 있는 정수 ID | JSON 은 문자열, 계산할 때만 BigInt |
...993 이 ...992 로 |
| 돈 | 최소 단위 정수 | 1.005 의 toFixed(2) 가 1.00 |
| 정확한 큰 정수 연산 | BigInt |
Number 보다 53 배 느리지만 정확 |
| 큰 문자열 만들기 | += 든 join 이든 괜찮음 |
3.4MB 에 11~16ms |
| 만드는 도중 검사 | 문자열 말고 조각을 검사 | 564ms 대 2.2ms |
| 큰 숫자 배열 | 타입 배열, 또는 구멍 없고 한 타입인 배열 | 구멍·혼합 3.8 배 느림 |
| 같은 종류 객체가 많음 | 생성자 하나, 같은 키 순서, delete 금지 |
모양 8 개면 2.8 배 느림 |
| 벤치마크 | 워밍업, 첫 실행과 안정 상태 따로 보고 | 첫 묶음 4~5 배 느림 |
| 객체별 캐시 | WeakMap |
36.8MB 대 0.1MB |
연습 문제
- 예측한 뒤 jsc 로 실행하세요.
print(JSON.parse("[12345678901234567890]")[0]); print((2.675).toFixed(2)); print(Number.MAX_SAFE_INTEGER + 2); - 동료의 벤치마크가 함수를 한 번 돌리고 "12ms" 라고 보고했습니다.
tiers.js를 본보기로, 대신 요청할 두 숫자를 적고score(2000)으로 재 보세요.
정답
12345678901234567000,2.67,9007199254740992. 20 자리 수는 가장 가까운 double 로 반올림되고, 2.675 는 실제로 2.67499999... 이며,MAX_SAFE_INTEGER + 2는 표현할 수 없는 2^53 + 1 이라 2^53 으로 반올림됩니다.- 첫 실행 시간과, 시간이 더 줄지 않을 만큼 돌린 뒤의 안정 상태 중앙값입니다. 둘 다 새 프로세스 여러 번으로 잽니다. 우리 측정에서 10 회 호출 첫 묶음은 480~620µs, 안정 상태는 100~120µs 였습니다. "12ms" 숫자 하나로는 어느 쪽인지 알 수 없습니다.
솔직한 한계 / 버전 메모
- 엔진 하나. 모든 숫자는 macOS 26.6.2 의 JavaScriptCore, Apple M1 Pro 의 것입니다. V8(Chrome, Node.js, Deno)은 단계(Ignition, Sparkplug, Maglev, TurboFan)와 휴리스틱이 다릅니다. 방향(구멍과 혼합 타입의 비용, 많은 모양의 비용, 워밍업의 중요성, 로프 덕분에 싼
+=)은 V8 에 대해서도 잘 알려져 있지만 비율은 다를 것입니다. V8 은 실행하지 않았습니다. - jsc 전용:
print,fullGC,gcHeapSize,--useJIT=false등의 단계 옵션은 jsc 셸에만 있습니다. 힙 크기는 JavaScriptCore 자체 집계입니다. - 타이머 해상도: 여기서
performance.now()는 20µs 단위였으므로 짧은 호출 하나가 아니라 묶음을 쟀습니다. 브라우저는 보안상 타이머를 더 거칠게 만듭니다(실행 안 함). - 버전: 타입 배열은 ES2015,
BigInt와BigInt64Array는 ES2020,WeakRef/FinalizationRegistry는 ES2021.JSON.rawJSON,JSON.parsereviver 의 원문 접근,Math.sumPrecise는 훨씬 최근 것으로, 이 jsc 에서 동작하는 것만 확인했습니다.
이전: 5 강: 비동기, Promise, 이벤트 루프 · 이 글이 시리즈의 마지막 강의입니다.
시리즈: 1 타입과 동등 비교 · 2 함수, 스코프, 클로저 · 3 객체와 배열 · 4 클래스, 프로토타입, 모듈 · 관련 글: Dart 성능: JIT 와 AOT, 메모리 · Java JVM, JIT, GC · 프로그래밍 학습 로드맵
자주 묻는 질문
JSON.parse 가 17 자리, 19 자리 ID 를 바꿔 버리는 이유는?
JavaScript 의 숫자는 모두 64 비트 double 이라 정수는 2^53 - 1 = 9007199254740991 까지만 정확합니다. JSON.parse('{"id": 9007199254740993}') 는 오류 없이 9007199254740992 를 돌려줬습니다. 큰 ID 는 문자열로 주고받거나, 최신 엔진이라면 reviver 의 원문(ctx.source)으로 BigInt 를 만드세요. 둘 다 jsc 에서 확인했습니다.
JavaScript 에서 += 로 문자열을 만들면 느린가요?
그 자체로는 아닙니다. JavaScriptCore 에서 20 만 행을 += 로 만들면 약 14ms 로, 배열 push + join(약 15ms)과 비슷했습니다. 길어지는 문자열 안을 매 반복마다 읽으면 제곱 시간이 됩니다. 4 만 행에서 += 와 charCodeAt 한 번을 함께 하면 564ms, += 만 하면 2.2ms 였습니다.
BigInt 는 Number 보다 느린가요?
많이 느립니다. 100 만 개의 i*i 합을 Number 로 구하면 0.94ms, BigInt 로는 49.9ms 로 약 53 배였습니다(jsc, Apple M1 Pro). 다만 Number 쪽 결과는 2^53 을 넘어서 조용히 틀려 있었습니다. 2^53 을 넘는 정확성이 필요한 곳(ID, 암호, 정확한 카운터)에만 BigInt 를 쓰고 나머지는 Number 로 충분합니다.