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 을 넘어서 조용히 틀렸습니다. 속도는 답이 맞은 다음의 이야기입니다.

판단도. 2 의 53 제곱을 넘을 수 있는 ID 라면 JSON 에서는 문자열로 두고 계산할 때만 BigInt 로 바꾼다. 돈이라면 원이나 센트 같은 최소 단위 정수를 Number 로 둔다. 계산용 대량 숫자라면 Float64Array 같은 타입 배열을 쓴다. 그 밖에는 보통 Number 로 충분하다.
어떤 숫자 표현을 쓸까. 대부분의 값은 Number 로 충분하다. 함정은 2^53 을 넘는 ID, 돈, 아주 큰 배열이다.

문자열: += 는 괜찮다, 만드는 도중에 읽지만 않으면

"문자열은 += 말고 배열에 모아 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 였습니다.

4 만 행 막대그래프. += 로 붙이기만 하면 2.2 밀리초. 배열에 push 하고 join 하면서 매번 요소 하나를 읽어도 3.4 밀리초. += 로 붙이면서 길어지는 문자열의 가운데 문자를 매번 읽으면 563.7 밀리초가 걸렸다.
4 만 행 CSV 문자열 만들기. 그냥 += 는 빠르지만, 매번 문자열 중간을 읽으면 제곱 시간이 된다. 3 프로세스 중앙값(각 5 회 중앙값), macOS 26.6.2 의 jsc, Apple M1 Pro.

실무 코드에서 이 "읽기"는 대개 순진해 보이는 검사 속에 숨어 있습니다. 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
막대그래프. JIT 를 모두 끈 인터프리터만으로는 30.8 밀리초. 베이스라인 JIT 를 허용하면 25.8 밀리초. 최적화 JIT 인 DFG 를 허용하면 5.0 밀리초로 떨어졌고, FTL 까지 포함한 기본 설정은 이 짧은 작업에서 5.1 밀리초였다.
같은 고정 작업(호출 400 회, 루프 80 만 회)을 JavaScriptCore 의 JIT 단계를 하나씩 끄고 실행. 5 프로세스 중앙값, macOS 26.6.2 의 jsc, Apple M1 Pro.

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

연습 문제

  1. 예측한 뒤 jsc 로 실행하세요. print(JSON.parse("[12345678901234567890]")[0]); print((2.675).toFixed(2)); print(Number.MAX_SAFE_INTEGER + 2);
  2. 동료의 벤치마크가 함수를 한 번 돌리고 "12ms" 라고 보고했습니다. tiers.js 를 본보기로, 대신 요청할 두 숫자를 적고 score(2000) 으로 재 보세요.

정답

  1. 12345678901234567000, 2.67, 9007199254740992. 20 자리 수는 가장 가까운 double 로 반올림되고, 2.675 는 실제로 2.67499999... 이며, MAX_SAFE_INTEGER + 2 는 표현할 수 없는 2^53 + 1 이라 2^53 으로 반올림됩니다.
  2. 첫 실행 시간과, 시간이 더 줄지 않을 만큼 돌린 뒤의 안정 상태 중앙값입니다. 둘 다 새 프로세스 여러 번으로 잽니다. 우리 측정에서 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.parse reviver 의 원문 접근, 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 로 충분합니다.

  • #javascript
  • #고급
  • #성능
  • #메모리
  • #bigint
  • #jit
  • #jsc
  • #javascriptcore

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑