Javascript ES17(ECMAScript 2026) 문법정리
has → get → set 세 줄이 한 줄이 된다
Map으로 카운터나 그룹핑을 만들 때마다 똑같은 세 줄을 썼다. 키가 있으면 꺼내 쓰고 없으면 기본값을 넣는, 아무 판단도 들어 있지 않은 분기다.
// before — 있으면 꺼내고 없으면 넣는다
const counts = new Map();
for (const w of words) {
if (counts.has(w)) counts.set(w, counts.get(w) + 1);
else counts.set(w, 1);
}
// after — getOrInsert가 그 분기를 삼킨다
const counts = new Map();
for (const w of words) counts.set(w, counts.getOrInsert(w, 0) + 1);
그룹핑은 차이가 더 크다. getOrInsert는 없을 때만 값을 넣고, 있든 없든 최종 값을 돌려주므로 곧바로 이어서 쓸 수 있다.
const grouped = new Map();
for (const [key, value] of rows) grouped.getOrInsert(key, []).push(value);
// [["x", [1, 3]], ["y", [2]]]
여기에 함정이 하나 있다. getOrInsert(key, [])의 두 번째 인자는 키가 이미 있어도 매번 평가된다. 빈 배열 리터럴이야 상관없지만 기본값이 DB 조회나 무거운 객체 생성이면 얘기가 다르다. 그래서 지연이 필요한 자리에는 getOrInsertComputed를 쓴다. 콜백은 키를 인자로 받고 키가 없을 때만 호출된다. 콜백이 함수가 아니면 TypeError다.
// 기본값이 비쌀 때 — 없을 때만 호출된다
sessions.getOrInsertComputed(userId, (id) => createSession(id));
WeakMap에도 같은 이름의 메서드 두 개가 함께 들어갔다.
이어 붙이려고 제너레이터를 쓰던 자리
이터레이터 두세 개를 순서대로 소비하려면 지금까지는 제너레이터를 하나 만들어야 했다. Iterator.concat이 그 자리를 대신한다.
// before — 이어붙이기만 하는 제너레이터
function* concatAll(...its) { for (const it of its) yield* it; }
[...concatAll([1, 2], new Set([3, 4]), [5])]; // [1, 2, 3, 4, 5]
// after
[...Iterator.concat([1, 2], new Set([3, 4]), [5])]; // [1, 2, 3, 4, 5]
돌아오는 값은 Iterator Helper라서 뒤에 take나 map을 그대로 붙일 수 있고, 게으르게 동작한다. Iterator.concat([1,2],[3,4,5]).take(3).toArray()는 뒤 배열을 끝까지 읽지 않고 [1, 2, 3]을 돌려준다.
이름이 concat이라 배열 쪽을 떠올리기 쉬운데 받는 건 이터레이터가 아니라 이터러블이다. next만 있고 [Symbol.iterator]가 없는 손수 만든 이터레이터를 넘기면 이렇게 막힌다.
Iterator.concat({ next: () => ({ done: true }) });
// TypeError — 문구는 엔진마다 다르다
Iterator.concat(Iterator.from({ next: () => ({ done: true }) })); // Iterator.from으로 감싸면 통과
reduce로 더한 합계가 0이 나왔다
부동소수점 덧셈은 순서에 따라 중간값이 반올림된다. 큰 수와 작은 수가 섞이면 작은 쪽이 통째로 사라진다.
// before
[1e20, 0.1, -1e20].reduce((a, b) => a + b, 0); // 0 ← 0.1이 증발
[0.1, 0.2, 0.3].reduce((a, b) => a + b, 0); // 0.6000000000000001
// after
Math.sumPrecise([1e20, 0.1, -1e20]); // 0.1
Math.sumPrecise([0.1, 0.2, 0.3]); // 0.6
Math.sumPrecise는 "임의 정밀도로 더한 뒤 배정밀도로 되돌린 값"을 규정한다. 알고리즘을 못 박지 않고 결과만 못 박은 셈이라, 엔진이 무엇을 쓰든 답은 같다.
돌려주는 값에는 걸릴 만한 자리가 둘 있다. 빈 이터러블은 0이 아니라 -0이다(부동소수점 덧셈의 항등원이 -0이기 때문이다). 그리고 숫자가 아닌 값을 넣으면 조용히 변환하지 않고 던진다. Math.max와 반대다.
Object.is(Math.sumPrecise([]), -0); // true
Math.sumPrecise([1, "2"]); // TypeError: Math.sumPrecise was passed a non-number
합계를 Object.is로 비교하거나 부호로 분기하는 코드라면 여기서 한 번은 걸린다.
instanceof Error가 거짓말하는 두 경우
에러 로깅 유틸을 만들 때마다 err instanceof Error로 걸렀는데 이게 틀리는 상황이 둘 있다. 하나는 프로토타입만 흉내 낸 객체를 통과시키는 경우, 다른 하나는 iframe이나 node:vm처럼 다른 realm에서 만들어진 진짜 에러를 떨어뜨리는 경우다.
// before
const fake = { __proto__: Error.prototype, message: "fake" };
fake instanceof Error; // true ← 가짜가 통과
const other = vm.runInNewContext("new Error('cross')");
other instanceof Error; // false ← 진짜가 탈락
// after
Error.isError(fake); // false
Error.isError(other); // true
Object.prototype.toString.call(x) === "[object Error]"로 우회하던 방법도 이제는 못 믿는다. Symbol.toStringTag를 가진 객체가 같은 문자열을 만들어내기 때문이다.
const tagged = { get [Symbol.toStringTag]() { return "Error"; } };
Object.prototype.toString.call(tagged); // "[object Error]"
Error.isError(tagged); // false
Error.isError는 내부 슬롯을 보므로 위장이 통하지 않는다. DOMException은 형식상 Error의 하위 클래스가 아닌데도 true를 돌려준다. 브랜드 검사 관점에서 에러로 취급하는 편이 실제 쓰임에 맞기 때문이다.
for await로 배열을 채우던 다섯 줄
페이지네이션 API나 스트림을 배열로 모을 때 쓰던 정형구가 있다.
// before
const items = [];
for await (const page of fetchPages("/api/items")) items.push(page);
// after
const items = await Array.fromAsync(fetchPages("/api/items"));
const labels = await Array.fromAsync(fetchPages("/api/items"), (p, i) => `${i}:${p.length}`);
Promise.all의 축약형으로 오해하기 쉬운데 성격이 정반대다. Promise.all은 프로미스를 다 받아 동시에 기다리고, Array.fromAsync는 앞의 값이 정착해야 다음 값을 꺼낸다. 값을 그때그때 만들어내는 이터러블에서 차이가 드러난다.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 값을 미리 만들지 않고 꺼낼 때 만드는 제너레이터
function* lazyPromises(n = 5, ms = 100) {
for (let i = 0; i < n; i++) yield sleep(ms).then(() => i);
}
await Array.fromAsync(lazyPromises()); // 502ms — 하나씩 순차로
await Promise.all(lazyPromises()); // 100ms — 다섯 개 동시에
100ms짜리 다섯 개가 502ms와 100ms로 갈린다(Node.js 22.22.2와 Bun 1.3.13에서 같은 값이 나왔다). 반대로 이미 만들어 둔 프로미스 배열을 넘기면 실행은 어차피 동시에 시작되므로 시간 차는 사라지고, 결과 순서만 보장된다. 앞 결과가 다음 요청에 필요하거나 한 번에 하나씩만 흘려보내야 할 때 Array.fromAsync를 쓰고, 그냥 빨리 끝내야 하면 Promise.all을 쓴다.
btoa(String.fromCharCode(...bytes))와 작별
바이트 배열을 Base64로 바꾸는 코드는 오래 흉했다. 문자열을 한 번 경유해야 했기 때문이다.
// before
const b64 = btoa(String.fromCharCode(...bytes));
const back = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
// after
const b64 = bytes.toBase64(); // "/wD+fw=="
const back = Uint8Array.fromBase64(b64);
bytes.toHex(); // "ff00fe7f"
before 쪽은 짧아 보여도 스프레드가 인자 개수만큼 스택을 먹는다. V8(Node.js 22.22.2, Chromium 141)에서 10만 바이트는 통과했지만 20만 바이트는 RangeError: Maximum call stack size exceeded로 죽었다. JavaScriptCore(Bun 1.3.13)에서는 같은 크기가 통과했다. 임계값은 엔진과 그때의 스택 상황을 탄다. 즉 이 코드는 터지는 코드가 아니라 언젠가 터지는 코드였다. 그 편이 더 나쁘다.
URL에 실을 때 쓰던 replace 체인도 옵션으로 들어왔다.
bytes.toBase64({ alphabet: "base64url", omitPadding: true }); // "_wD-fw"
디코딩 쪽에는 스트리밍용 메서드가 하나 더 있다. setFromBase64는 이미 있는 버퍼를 채우고, 몇 글자를 읽어 몇 바이트를 썼는지 돌려준다.
const target = new Uint8Array(8);
target.setFromBase64("Zm9vYmFy"); // { read: 8, written: 6 }
target.toHex(); // "666f6f6261720000"
Uint8Array.fromBase64("Zm9v!"); // SyntaxError — 잘못된 문자는 조용히 넘어가지 않는다
JSON.parse가 큰 정수를 조용히 뭉갠다
서버가 64비트 정수 ID를 JSON으로 내려주면 JSON.parse는 아무 말 없이 Number로 만든다. 그 과정에서 마지막 자리가 바뀐다.
// before
JSON.parse('{"id":9007199254740993}').id; // 9007199254740992 ← 1이 사라졌다
// 그래서 파싱 전에 정규식으로 문자열화하는 우회를 썼다
JSON.parse(raw.replace(/:(\d{16,})/g, ':"$1"')).id; // "9007199254740993"
이제 reviver가 세 번째 인자로 원문 정보를 받는다. context.source는 그 값이 원래 어떻게 적혀 있었는지를 담은 문자열이다.
// after
const parsed = JSON.parse('{"id":9007199254740993}', (key, value, context) =>
typeof value === "number" && /^-?\d+$/.test(context.source)
? BigInt(context.source) // 원문 그대로 BigInt로
: value,
);
parsed.id; // 9007199254740993n
반대 방향은 JSON.rawJSON이 맡는다. 직렬화기가 손대지 않고 그대로 박아 넣을 조각을 만든다.
JSON.stringify({ id: 9007199254740993n }); // TypeError
JSON.stringify({ id: JSON.rawJSON("9007199254740993") }); // {"id":9007199254740993}
JSON.isRawJSON(JSON.rawJSON("1")); // true
JSON.rawJSON('[1,2]'); // SyntaxError — 원시값만 된다
객체나 배열은 받지 않고, 줄바꿈이 그대로 든 문자열도 거절한다. 통째로 만들어 둔 JSON 문서를 끼워 넣는 용도가 아니라, 숫자 하나·문자열 하나의 표기를 지키는 용도다.
스펙에 들어간 날과 쓸 수 있게 된 날은 다르다
여기까지가 일곱 개다. 그런데 "ES2026 기능"이라는 말에는 함정이 있다. 스펙 편입은 브라우저가 다 구현한 다음에 찍는 도장에 가깝다. 일곱 개 중 여섯 개는 승인일(2026-06-30)보다 먼저 브라우저에서 쓸 수 있었고 오히려 Error.isError는 승인이 끝난 지금도 Baseline에 못 들어갔다.

그래서 버전 표를 외우는 대신, 런타임에 직접 물어보는 스크립트를 하나 두고 쓴다. typeof로 존재만 보면 폴리필이나 부분 구현에 속으니 호출해서 기대한 값이 나오는지까지 본다.
// es2026-check.mjs — ES2026 일곱 개 기능을 실제로 호출해 확인한다
const checks = {
"Map.prototype.getOrInsert": () => new Map().getOrInsert("k", 1) === 1,
"Map.prototype.getOrInsertComputed": () =>
new Map().getOrInsertComputed("k", (k) => k.length) === 1,
"Iterator.concat": () =>
Array.from(Iterator.concat([1, 2], [3])).join() === "1,2,3",
// 빈 이터러블이 -0인지까지 봐야 어설픈 폴리필이 걸린다
"Math.sumPrecise": () =>
Math.sumPrecise([1e20, 0.1, -1e20]) === 0.1 &&
Object.is(Math.sumPrecise([]), -0),
"Error.isError": () =>
Error.isError(new TypeError("x")) && !Error.isError({ message: "x" }),
// 유일한 비동기 API라 Promise를 돌려주는지만 동기적으로 본다
"Array.fromAsync": () => Array.fromAsync([1]) instanceof Promise,
"Uint8Array.prototype.toBase64": () =>
new Uint8Array([102, 111, 111]).toBase64() === "Zm9v",
"Uint8Array.fromBase64": () => Uint8Array.fromBase64("Zm9v")[0] === 102,
"Uint8Array.prototype.toHex": () => new Uint8Array([255]).toHex() === "ff",
"JSON.parse reviver context.source": () =>
JSON.parse("1", (k, v, ctx) => ctx?.source) === "1",
"JSON.rawJSON": () =>
JSON.stringify({ v: JSON.rawJSON("9007199254740993") }) ===
'{"v":9007199254740993}',
};
const rows = Object.entries(checks).map(([name, probe]) => {
// 미구현이면 TypeError, 부분 구현이면 값이 어긋난다. 둘 다 false로 접는다
let ok = false;
try { ok = probe() === true; } catch { ok = false; }
return [ok ? "O" : "X", name];
});
console.log(rows.map(([m, n]) => `${m} ${n}`).join("\n"));
console.log(`\n${rows.filter(([m]) => m === "O").length}/${rows.length} 통과`);
같은 스크립트를 손에 있던 런타임 세 개에 돌렸더니 이렇게 갈렸다.
- Node.js 22.22.2 (V8 12.4)는 11개 중 3개.
Array.fromAsync와 JSON 두 개만 통과한다. - Chromium 141은 11개 중 7개.
getOrInsert·Iterator.concat·Math.sumPrecise가 빠진다. 이 셋은 각각 Chrome 145·146·147부터다. - Bun 1.3.13 (JavaScriptCore)은 11개 전부 통과.
Node.js 26.0.0(2026년 5월 5일 릴리스)은 릴리스 노트에 Upsert와 Iterator Sequencing을 명시했고 V8 14.6, 즉 Chromium 146 계열을 싣는다. Math.sumPrecise는 Chrome 147부터라 여기엔 아직 없을 가능성이 크다(직접 확인하지 못한 추정이다). 서버와 브라우저의 지원 경계가 다르다는 뜻이라, 같은 유틸을 양쪽에서 공유한다면 낮은 쪽에 맞추거나 스크립트로 확인하는 편이 안전하다.
목록에 잘못 끼어 있던 것들
처음에 헷갈렸던 셋은 전부 finished-proposals.md의 2027 칸에 있다. using/await using과 Symbol.dispose를 들여오는 Explicit Resource Management, Temporal, Atomics.pause, 그리고 두 이터레이터를 짝지어 도는 Joint Iteration이다. Stage 4에 도달한 시점이 2026년 3월 회의를 넘겼기 때문이지 구현이 늦어서가 아니다. Temporal은 이미 Firefox와 Chromium에 들어왔고 Node.js 26에서는 기본 활성화다. 지금 써도 되지만 "ES2026 기능"은 아니다.
일곱 개 중 오늘 쓸 것과 미룰 것
ES2026은 문법을 바꾸지 않는다. 새 키워드도 새 연산자도 없고, 있던 자리의 정형구를 표준 메서드로 걷어낸 판이다. 그리고 "ES2026 기능"은 지원 여부를 뜻하지 않는다. 판단 기준은 승인 연도가 아니라 내 런타임의 응답이다.
위의 es2026-check.mjs를 프로젝트 런타임에 한 번 돌려보고, 통과한 것부터 새 코드에 쓰기 시작하라.
참고
- TC39, Finished Proposals — Expected Publication Year 기준 ES2026/ES2027 구분
- Ecma International, ECMA-262, 17th edition — ECMAScript 2026 언어 명세
- InfoWorld, ECMAScript 2026 specification approved — 2026년 6월 30일 승인
- MDN, Math.sumPrecise · Error.isError · Iterator.concat · Map.getOrInsert — Baseline 도달 시점
- caniuse — 브라우저별 최초 지원 버전 (2026-09 확인)
- Node.js, Node.js 26.0.0 릴리스 노트
본문의 실행 결과는 2026년 9월 기준 Node.js 22.22.2 · Chromium 141.0.7390.37 · Bun 1.3.13에서 직접 측정한 값이다.