ES2021~ES2024 주요 기능
ES2021부터 ES2024까지 추가된 최신 기능들을 살펴봅니다. WeakRef, 논리 할당 연산자, 배열 비변경 메서드, Promise.withResolvers 등 실용적인 기능들이 포함됩니다.
ES2021 (ES12)
String.replaceAll()
const text = 'foo bar foo baz foo';
// 기존 방식: 전역 정규식 필요
text.replace(/foo/g, 'qux'); // 'qux bar qux baz qux'
// ES2021: replaceAll
text.replaceAll('foo', 'qux'); // 'qux bar qux baz qux'
// 특수문자 이스케이프 불필요
const html = '<div class="error">Error</div>';
html.replaceAll('<div', '<section').replaceAll('div>', 'section>');
// replace와 차이
'aaa'.replace('a', 'b'); // 'baa' (첫 번째만)
'aaa'.replaceAll('a', 'b'); // 'bbb' (모두)
// 정규표현식도 사용 가능 (g 플래그 필수)
'Hello World'.replaceAll(/[aeiou]/gi, '*'); // 'H*ll* W*rld'
Promise.any()
// 하나라도 fulfilled되면 그 결과 반환
// 모두 rejected되면 AggregateError
// 가장 빠른 서버 응답 사용
try {
const data = await Promise.any([
fetch('https://server1.example.com/api').then(r => r.json()),
fetch('https://server2.example.com/api').then(r => r.json()),
fetch('https://server3.example.com/api').then(r => r.json()),
]);
console.log('가장 빠른 응답:', data);
} catch (err) {
// err instanceof AggregateError
console.error('모든 서버 실패:', err.errors);
}
// Promise.any vs Promise.race
// race: 첫 번째 settled (실패도 포함)
// any: 첫 번째 fulfilled (성공만)
Promise.race([
Promise.reject('실패'),
Promise.resolve('성공'),
]).catch(err => console.log(err)); // '실패' (실패가 먼저 settled)
Promise.any([
Promise.reject('실패'),
Promise.resolve('성공'),
]).then(val => console.log(val)); // '성공' (성공한 것)
WeakRef
// 객체에 대한 약한 참조 (GC를 방해하지 않음)
class Cache {
#cache = new Map();
set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
// GC에 의해 수거됨
this.#cache.delete(key);
return undefined;
}
return value;
}
}
// 실전: 이미지 캐시
const imageCache = new Map();
function getCachedImage(src) {
const ref = imageCache.get(src);
const cached = ref?.deref();
if (cached) return cached;
const img = new Image();
img.src = src;
imageCache.set(src, new WeakRef(img));
return img;
}
// WeakRef.deref()는 GC 이전에는 객체 반환, 이후에는 undefined 반환
FinalizationRegistry
// 객체가 GC될 때 콜백 실행
const registry = new FinalizationRegistry((heldValue) => {
console.log(`${heldValue}가 GC에 의해 수거되었습니다`);
});
let obj = { name: 'Resource' };
registry.register(obj, 'Resource 객체');
obj = null; // 참조 제거
// 나중에 GC가 실행되면 콜백 호출
// 실전: 리소스 정리
class ManagedResource {
static #registry = new FinalizationRegistry((cleanup) => {
cleanup(); // 정리 함수 실행
});
constructor(resource) {
this.resource = resource;
ManagedResource.#registry.register(
this,
() => resource.dispose() // GC 시 자동 정리
);
}
}
논리 할당 연산자
// &&= (논리 AND 할당)
// x &&= y → x && (x = y)
let config = { debug: true, verbose: false };
config.debug &&= false; // debug가 truthy면 false로 설정
config.verbose &&= true; // verbose가 falsy면 변경 안 함
// ||= (논리 OR 할당)
// x ||= y → x || (x = y)
let settings = { theme: '', language: null };
settings.theme ||= 'dark'; // theme이 falsy면 'dark'로 설정
settings.language ||= 'ko'; // language가 null이면 'ko'로 설정
// ??= (Nullish 할당)
// x ??= y → x ?? (x = y)
let options = { timeout: 0, name: '' };
options.timeout ??= 5000; // 0은 null/undefined가 아니므로 변경 안 함
options.name ??= 'default'; // ''은 null/undefined가 아니므로 변경 안 함
options.cache ??= true; // undefined이므로 true로 설정
// 실전: 옵션 객체 기본값 설정
function createServer(options = {}) {
options.port ??= 3000;
options.host ??= 'localhost';
options.timeout ??= 30000;
options.debug &&= process.env.NODE_ENV === 'development';
return new Server(options);
}
숫자 구분자
// 큰 숫자 가독성 향상
const million = 1_000_000;
const billion = 1_000_000_000;
const hex = 0xFF_FF_FF;
const binary = 0b1010_0001_1000_0101;
const bytes = 0xFF_EC_D5_12;
const bigint = 9_007_199_254_740_991n;
// 소수점도 가능
const pi = 3.141_592_653_589_793;
const e = 2.718_281_828;
console.log(million); // 1000000 (숫자 값은 동일)
ES2022 (ES13)
Array.at()
const arr = [1, 2, 3, 4, 5];
// 기존: 마지막 요소 접근
arr[arr.length - 1]; // 5 (번거로움)
// at(): 인덱스 접근 (음수 가능)
arr.at(0); // 1
arr.at(-1); // 5 (마지막)
arr.at(-2); // 4 (끝에서 두 번째)
// 문자열과 TypedArray에도 적용
'hello'.at(-1); // 'o'
new Int32Array([1, 2, 3]).at(-1); // 3
// 실전
function getLast(arr) {
return arr.at(-1);
}
const history = ['page1', 'page2', 'page3'];
const currentPage = history.at(-1); // 'page3'
const previousPage = history.at(-2); // 'page2'
Object.hasOwn()
// Object.prototype.hasOwnProperty의 안전한 대체
const obj = { name: 'Alice' };
// 기존 방식 (안전하지 않을 수 있음)
obj.hasOwnProperty('name'); // true
// Object.create(null)로 만든 객체는 hasOwnProperty 없음
const bare = Object.create(null);
bare.name = 'Alice';
// bare.hasOwnProperty('name'); // TypeError!
// Object.hasOwn은 항상 안전
Object.hasOwn(bare, 'name'); // true
Object.hasOwn(obj, 'name'); // true
Object.hasOwn(obj, 'toString'); // false (프로토타입 속성)
// 실전
function processConfig(config) {
if (Object.hasOwn(config, 'timeout')) {
setupTimeout(config.timeout);
}
}
Error.cause
// 에러 체인 - 원인 에러 추적
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
return await response.json();
} catch (originalError) {
throw new Error(`사용자 ${userId} 데이터 로드 실패`, {
cause: originalError // 원인 에러 연결
});
}
}
// 에러 체인 탐색
try {
await fetchUserData(1);
} catch (err) {
console.error(err.message); // 사용자 1 데이터 로드 실패
console.error(err.cause.message); // 원인 에러 메시지
// 에러 체인 전체 출력
let current = err;
while (current) {
console.error(current.message);
current = current.cause;
}
}
// 커스텀 에러 클래스에서 활용
class DatabaseError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'DatabaseError';
}
}
async function queryDB(sql) {
try {
return await db.query(sql);
} catch (err) {
throw new DatabaseError(`쿼리 실패: ${sql}`, { cause: err });
}
}
Top-level await (모듈에서)
// config.mjs - 모듈 최상위에서 await 사용
const response = await fetch('/api/config');
export const config = await response.json();
// db.mjs
import { createClient } from 'redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
export { client };
// 이를 import하는 쪽은 자동으로 완료 대기
// app.mjs
import { config } from './config.mjs';
import { client } from './db.mjs';
// config와 client가 준비된 후 실행됨
클래스 private 필드/메서드
class BankAccount {
// private 필드: # 접두사
#balance = 0;
#owner;
#transactions = [];
constructor(owner, initialBalance = 0) {
this.#owner = owner;
this.#balance = initialBalance;
}
// private 메서드
#validateAmount(amount) {
if (amount <= 0) throw new Error('금액은 0보다 커야 합니다');
if (typeof amount !== 'number') throw new Error('금액은 숫자여야 합니다');
}
#recordTransaction(type, amount) {
this.#transactions.push({
type, amount,
balance: this.#balance,
timestamp: new Date()
});
}
// public 메서드
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
this.#recordTransaction('deposit', amount);
return this;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) throw new Error('잔액 부족');
this.#balance -= amount;
this.#recordTransaction('withdrawal', amount);
return this;
}
// getter
get balance() { return this.#balance; }
get owner() { return this.#owner; }
// static private
static #instanceCount = 0;
static getInstanceCount() { return BankAccount.#instanceCount; }
}
const account = new BankAccount('Alice', 10000);
account.deposit(5000).withdraw(3000);
console.log(account.balance); // 12000
// account.#balance; // SyntaxError! private 접근 불가
클래스 static 블록
class Config {
static #instance = null;
static debug;
static apiUrl;
// static 초기화 블록
static {
const env = process.env.NODE_ENV ?? 'development';
Config.debug = env !== 'production';
Config.apiUrl = env === 'production'
? 'https://api.example.com'
: 'http://localhost:3000';
}
}
console.log(Config.debug); // true (개발 환경)
console.log(Config.apiUrl); // 'http://localhost:3000'
ES2023 (ES14)
Array.findLast() / findLastIndex()
const arr = [1, 2, 3, 4, 5, 4, 3];
// findLast: 뒤에서부터 조건에 맞는 첫 번째 요소
arr.findLast(x => x > 3); // 4 (뒤에서 첫 번째)
arr.findLast(x => x > 10); // undefined
// findLastIndex: 뒤에서부터 조건에 맞는 인덱스
arr.findLastIndex(x => x > 3); // 5 (index 5의 값이 4)
// 실전: 최근 이벤트 찾기
const events = [
{ type: 'click', time: 100 },
{ type: 'scroll', time: 200 },
{ type: 'click', time: 300 },
{ type: 'keypress', time: 400 }
];
const lastClick = events.findLast(e => e.type === 'click');
// { type: 'click', time: 300 }
배열 비변경 메서드 (Immutable Array Methods)
const arr = [3, 1, 4, 1, 5, 9, 2];
const original = [...arr];
// toSorted: sort()의 비변경 버전
const sorted = arr.toSorted((a, b) => a - b);
// sorted: [1, 1, 2, 3, 4, 5, 9]
// arr: [3, 1, 4, 1, 5, 9, 2] (변경 안 됨)
// toReversed: reverse()의 비변경 버전
const reversed = arr.toReversed();
// reversed: [2, 9, 5, 1, 4, 1, 3]
// arr: 변경 안 됨
// toSpliced: splice()의 비변경 버전
const spliced = arr.toSpliced(2, 1, 99, 100);
// 인덱스 2에서 1개 제거하고 99, 100 삽입
// spliced: [3, 1, 99, 100, 1, 5, 9, 2]
// arr: 변경 안 됨
// with: 특정 인덱스 값 교체 (비변경)
const updated = arr.with(0, 999); // 인덱스 0을 999로
// updated: [999, 1, 4, 1, 5, 9, 2]
// arr: 변경 안 됨
// 실전: React/Vue 같은 프레임워크에서 불변 상태 업데이트
const [items, setItems] = useState([3, 1, 4]);
// 이전 방식: 복사 후 조작
setItems(prev => {
const next = [...prev];
next.sort();
return next;
});
// ES2023 방식: 더 간결
setItems(prev => prev.toSorted());
setItems(prev => prev.with(0, 99));
Hashbang Grammar
#!/usr/bin/env node
// 파일 맨 첫 줄에 hashbang(shebang) 허용
// Node.js, Deno 등 CLI 스크립트에서 사용
console.log('Hello from CLI script!');
ES2024 (ES15)
Promise.withResolvers()
// 기존: Promise 생성자 밖에서 resolve/reject 접근이 번거로움
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ES2024: Promise.withResolvers()로 간결하게
const { promise, resolve, reject } = Promise.withResolvers();
// 실전: 이벤트를 Promise로 변환
function waitForEvent(element, eventName) {
const { promise, resolve, reject } = Promise.withResolvers();
function handler(event) {
element.removeEventListener(eventName, handler);
resolve(event);
}
element.addEventListener(eventName, handler);
// 타임아웃 지원
const timeout = setTimeout(() => {
element.removeEventListener(eventName, handler);
reject(new Error(`${eventName} 이벤트 타임아웃`));
}, 5000);
return promise.finally(() => clearTimeout(timeout));
}
// 사용
const clickEvent = await waitForEvent(document.getElementById('btn'), 'click');
console.log('클릭됨:', clickEvent.target);
// 비동기 큐 구현
function createQueue() {
const pending = [];
let { promise, resolve } = Promise.withResolvers();
return {
push(item) {
pending.push(item);
const prev = resolve;
({ promise, resolve } = Promise.withResolvers());
prev();
},
async *[Symbol.asyncIterator]() {
while (true) {
await promise;
while (pending.length) yield pending.shift();
}
}
};
}
ArrayBuffer resize/transfer
// 크기 조절 가능한 ArrayBuffer
const resizable = new ArrayBuffer(1024, { maxByteLength: 4096 });
console.log(resizable.byteLength); // 1024
console.log(resizable.resizable); // true
console.log(resizable.maxByteLength); // 4096
// 크기 조절
resizable.resize(2048);
console.log(resizable.byteLength); // 2048
// transfer: ArrayBuffer 소유권 이전 (복사 없이)
const source = new ArrayBuffer(1024);
const view = new Uint8Array(source);
view[0] = 42;
// transfer: 새 ArrayBuffer로 소유권 이전, source는 무효화됨
const transferred = source.transfer(512); // 크기 변경도 가능
console.log(source.byteLength); // 0 (무효화됨)
console.log(transferred.byteLength); // 512
// 실전: WebSocket에서 수신 버퍼 관리
const buffer = new ArrayBuffer(4096, { maxByteLength: 16384 });
Object.groupBy() / Map.groupBy()
const products = [
{ name: 'Apple', category: 'fruits', price: 1000 },
{ name: 'Banana', category: 'fruits', price: 500 },
{ name: 'Carrot', category: 'veggies', price: 800 },
{ name: 'Potato', category: 'veggies', price: 600 },
{ name: 'Milk', category: 'dairy', price: 1500 },
];
// Object.groupBy: 키가 문자열인 일반 객체로 그룹화
const byCategory = Object.groupBy(products, item => item.category);
// {
// fruits: [{ name: 'Apple', ... }, { name: 'Banana', ... }],
// veggies: [{ name: 'Carrot', ... }, { name: 'Potato', ... }],
// dairy: [{ name: 'Milk', ... }]
// }
// 가격대로 그룹화
const byPriceRange = Object.groupBy(products, item => {
if (item.price < 700) return 'cheap';
if (item.price < 1200) return 'medium';
return 'expensive';
});
// Map.groupBy: 키가 어떤 타입이든 가능 (객체도 키)
const byPriceObj = Map.groupBy(products, item => item.price > 900 ? 'high' : 'low');
byPriceObj.get('high'); // 고가 상품 배열
byPriceObj.get('low'); // 저가 상품 배열
// 이전 방식 (reduce)
const grouped = products.reduce((acc, item) => {
const key = item.category;
(acc[key] ??= []).push(item);
return acc;
}, {});
Well-formed Unicode Strings
// String.prototype.isWellFormed(): 유효한 유니코드 문자열인지 확인
'hello'.isWellFormed(); // true
'\uD800'.isWellFormed(); // false (lone surrogate)
'\uD800\uDC00'.isWellFormed(); // true (valid surrogate pair)
// String.prototype.toWellFormed(): 유효하지 않은 부분을 U+FFFD로 대체
'\uD800hello'.toWellFormed(); // '\uFFFDhello'
// 실전: URL 인코딩 전 안전 처리
function safeEncodeURIComponent(str) {
return encodeURIComponent(str.toWellFormed());
}
버전별 요약
| 기능 | 버전 | 설명 |
|---|---|---|
String.replaceAll | ES2021 | 전체 문자열 치환 |
Promise.any | ES2021 | 첫 성공 Promise |
WeakRef | ES2021 | 약한 참조 |
| 논리 할당 연산자 `&&=, | =, ??=` | |
Array.at | ES2022 | 음수 인덱스 접근 |
Object.hasOwn | ES2022 | 안전한 hasOwnProperty |
Error.cause | ES2022 | 에러 체인 |
| Top-level await | ES2022 | 모듈 최상위 await |
Class private # | ES2022 | 진짜 private |
Array.findLast/findLastIndex | ES2023 | 뒤에서 탐색 |
toSorted/toReversed/toSpliced/with | ES2023 | 비변경 배열 메서드 |
Promise.withResolvers | ES2024 | Promise 외부 제어 |
Object.groupBy/Map.groupBy | ES2024 | 그룹화 |
| ArrayBuffer resize/transfer | ES2024 | 버퍼 관리 |
고수 팁
1. 비변경 메서드를 함수형 프로그래밍에 활용
// 파이프라인 스타일
const result = data
.toSorted((a, b) => b.score - a.score)
.toSpliced(3) // 상위 3개만
.map(formatUser); // 원본은 그대로
2. Object.groupBy로 다차원 집계
// 중첩 그룹화
const byYearAndMonth = Object.groupBy(
transactions,
tx => `${tx.date.getFullYear()}-${String(tx.date.getMonth() + 1).padStart(2, '0')}`
);
3. WeakRef와 FinalizationRegistry 조합으로 캐시 구현
class SmartCache {
#cache = new Map();
#registry = new FinalizationRegistry(key => {
this.#cache.delete(key);
});
set(key, value) {
const ref = new WeakRef(value);
this.#cache.set(key, ref);
this.#registry.register(value, key);
}
get(key) {
return this.#cache.get(key)?.deref();
}
}