
이미 많은 자바스크립트 커뮤니티 사람이 알고 있듯이, 최근 레코드 및 튜플 제안이 철회되었습니다. 많은 사람들이 매우 화가 났고, 일부는 심지어 EcmaScript 표준 위원회를 비난하기도 했습니다. 이 글에서는 이 기능이 철회된 이유에 대해 논의하고자 합니다.
더 자세한 기술적 세부 사항을 살펴보기 전에 먼저 레코드와 튜플이 무엇인지 이해해 보겠습니다.
레코드와 튜플은 객체와 유사한 두 가지 새로운 원시 타입입니다. 두 가지 주요 기능은 다음과 같습니다.
=== 연산자를 사용할 때 구조적으로 비교됩니다.이것이 레코드와 튜플이 선언되고 기본적으로 작동하는 방식입니다.
// 튜플은 일반 배열과 유사하게 선언되며, 시작 부분에 #만 있습니다.
const tuple1 = #[1, 2, 4];
const tuple2 = #[1, 2, 3];
const tuple3 = #[1, 2, 3];
// 레코드는 일반 객체와 유사하게 선언되며, 시작 부분에 #이 있을 뿐입니다.
const record1 = #{
a: 1,
b: tuple2,
};
const record2 = #{
a: 1,
b: tuple3,
};
const record3 = #{
a: 1,
b: tuple2,
c: #{
a: tuple2
}
};
console.log(typeof tuple1); // "tuple"
console.log(typeof record1); // "record"
console.log(tuple1 === tuple2); // false, 구조가 일치하지 않는 경우
console.log(tuple2 === tuple3); // true, 구조가 일치
console.log(record1 === record3); // false, 구조가 일치하지 않는 경우
console.log(record1 === record2); // true, 구조가 일치
const map = new Map();
map.set(record1, "record1"); // 레코드와 튜플을 Map 키에도 사용 가능
console.log(map.get(record2)); // "record1", record1 record2와 같음
console.log(map.get(record3)); // undefined, Map에서 동일한 구조의 키가 발견되지 않는 경우
console.log(tuple1.length); // 3, 일반 배열처럼 사용 가능
// 일반 배열처럼 반복하고 1, 2, 4를 출력합니다.
for (const x of tuple1) {
console.log(x);
}
try {
// 모든 수정 시도에서 오류를 발생시킵니다.
tuple1[1] = 5;
} catch (e) {
console.error(e);
}
try {
// 모든 수정 시도에서 오류를 발생시킵니다.
record2['k'] = 5;
} catch (e) {
console.error(e);
}
try {
const record4 = #{
a: 1,
b: tuple2,
// 중첩된 구조도 기본값이 불변해야 하므로 오류가 발생합니다.
c: {
a: tuple2
}
};
} catch (e) {
console.error(e);
}
플레이 그라운드에서 실험해 볼 수 있습니다. 안타깝게도 typeof 키워드는 키워드는 올바르게 동작하지 않는데, 이는 단지 폴리필이기 때문이고 실제 자바스크립트 자체는 레코드와 튜플을 지원하지 않기 때문입니다.
레코드와 튜플은 일반 객체로부터도 생성할 수 있습니다.
const record = Record({
a: 4
});
const tuple = Tuple.from([1, 2, 3]);
console.log(record, tuple); // #{ a: 4 }, #[1, 2, 3]
그 외에도 다양한 기능이 있으며, 자세한 내용은 제안서 페이지를 확인하세요.
이 예시만 보면 레코드 및 튜플은 특히 상태 관리에 매우 유용한 기능으로 보입니다. 그렇다면 이 기능이 철회된 이유는 무엇일까요?
이 제안은 레코드와 튜플이라는 두 가지 원시 타입을 추가하는 것입니다. 새로운 원시 타입을 추가하는 것은 새로운 클래스나 API를 추가하는 것과는 다릅니다. 기본 타입은 자바스크립트 엔진의 핵심 작동 방식에 영향을 미치는데, 자바스크립트는 동적 언어이기 때문에 서로 다른 타입 간의 다양한 연산과 형 변환을 지속적으로 확인하고 처리해야 하기 때문입니다. 새로운 원시 타입은 이미 복잡한 자바스크립트 엔진에 더 많은 복잡성과 성능 오버헤드(레코드와 튜플을 사용하지 않는 코드의 경우에도)를 발생시킬 수 있습니다.
깊은 비교는 효율적으로 작업하기 위해 복잡성을 더합니다. 한 가지 가능한 최적화 방법은 새 객체에 구조적 공유(structured sharing)를 최대한 활용하는 것입니다. 이렇게 하면 기존 레코드/튜플을 최대한 많이 사용하려고 시도하므로 메모리 사용량이 제한되고 생성/비교 속도가 빨라질 수 있습니다. 또 다른 가능한 최적화는 튜플/레코드에 첨부된 해시값을 사용하는 것이므로, 많은 경우 2개의 튜플/레코드가 다른지 빠르게 판단하는 데 도움이 될 것입니다. 그러나 깊은 비교가 선형 시간보다 더 빠르게 작동한다는 것은 보장되지 않는다는 것이 중론입니다.
인터닝(interning)1 없이 이를 최적화하는 것은 어려워 보였기 때문에 구현자들은 커밋을 꺼려했습니다.
값 기반의 ===가 없으면 레코드와 튜플은 대부분의 타입에서 ===, SameValue(NaN === NaN 및 0 !== -0을 제외한 ===와 동일), SameValueZero(NaN === NaN을 제외한 ===와 동일)가 일치한다는 오랜 규칙을 깨뜨리게 됩니다. 이 규칙을 유지한다는 것은 복잡성 비용을 지불한다는 것을 의미했습니다. 이 규칙을 깨는 것은 JS에서 다섯 번째 방식의 동등성 비교가 생긴다는 것을 의미했습니다. 두 가지 옵션 중 어느 쪽도 주목받지 못했습니다. 또한 값 기반 동등성을 제거하면 레코드와 튜플이 거의 무의미해집니다 😒.
레코드와 튜플 제안을 진행하기에는 너무 많은 문제와 불확실성이 있었기 때문에 결국 이 기능은 철회되었습니다. 합성에 대한 새로운 대안 제안이 있습니다. 개인적으로 저는 아직 현재 구현이 그다지 인상적이지 않습니다. Map/Set에서는 구조적으로 비교되지만, WeakMap/WeakSet에서는 참조로 비교되는 등 몇 가지 불일치가 여전히 존재합니다.
const pos1 = Composite({ x: 1, y: 4 });
const pos2 = Composite({ x: 1, y: 4 });
Composite.equal(pos1, pos2); // true
const positions = new Set(); // 표준 ES Set
const weakPositions = new WeakSet(); // 표준 ES WeakSet
positions.add(pos1);
weakPositions.add(pos2);
positions.has(pos2); // true, 좋아요, 말이 되네요(적어도 우리가 원했던 건 이겁니다),
// 하지만 객체임에도 불구하고 일반적인 참조 아이덴티티 대신 특별한 방식으로 처리됩니다.
weakPositions.has(pos2); // false, 이전 동작과 일치하지만, 지금은 Set으로 처리
솔직히 말해서 구조적 키를 위한 불가피한 선택일 수 있지만, 좀 더 지켜볼 필요가 있습니다.
1: 인터닝(interning): 컴퓨터 과학에서 인터닝은 새로운 객체를 만드는 대신 필요에 따라 동일한 가치의 객체를 재사용하는 것을 말합니다. 이 생성 패턴은 여러 프로그래밍 언어에서 숫자와 문자열에 자주 사용됩니다. 파이썬과 같은 많은 객체 지향 언어에서는 정수 같은 원시 유형도 객체입니다. 많은 수의 정수형 객체를 생성하는 데 따른 오버헤드를 피하기 위해 이러한 객체는 인턴을 통해 재사용됩니다. 인터닝이 작동하려면 여러 변수 간에 상태가 공유되므로 인터닝된 객체는 불변이어야 합니다. 문자열 인터닝은 동일한 프로그램에서 동일한 값을 가진 많은 문자열이 필요한 경우 인터닝의 일반적인 응용 프로그램입니다.