📌 JSNation 2022 컨퍼런스 중 진행된 세션을 글로 옮겼습니다. 원본 영상은 아래 링크를 참조해주세요.
출처: https://portal.gitnation.org/contents/record-and-tuple-immutable-data-structures-in-js
오늘 저는 Record와 Tuple에 대해 이야기하려고 합니다. Record와 Tuple은 곧 자바스크립트에서 사용할 수 있을 새로운 기능으로, 자바스크립트에서 데이터를 다루는 방식을 재정의하기를 바라고 있습니다.
오늘 다룰 내용들은 다음과 같습니다.
오늘날의 자바스크립트에서 사랑받고 널리 사용되고 있는 객체(Object) 타입에 대해 먼저 다룰 것입니다. 그 후에 Record와 Tuple에 대해 이야기하고, 마지막으로 사용할 수 있다면 언제 사용 가능할지 말씀드리려고 합니다.
먼저 객체에 대해 살펴봅시다. 여러분들은 이미 사용하고 계실겁니다. 객체 외에도 문자열, 숫자와 같은 기본적인 다른 타입에 대해서도 다룰겁니다. 기초적인 내용이지만 짚고 넘어가려고 합니다. 또한 이들이 어떻게 참조되고 어떻게 변경 가능한지도 살펴볼 예정입니다.
원시 타입(Primitives)부터 살펴보겠습니다.
let proposalName = "Record & Tuple"; // ⬅ 문자열 값
let proposalName2 = "Record & Tuple"; // ⬅ 다른 문자열일까요 아니면 같은 문자열일까요?
assert(proposalName === proposalName2); // OK ✅
문자열의 경우를 살펴봅시다.
첫째 줄에 문자열 변수를 하나 생성했습니다. 둘째 줄에서는 두 번째 문자열 변수를 생성하고 있습니다. 이 때 저희는 새로운 문자열 변수를 생성한 것일까요? 아니면 같은 문자열 변수일까요?
자바스크립트의 경우에 문자열을 변수에 넣고 해당 변수가 동일한 문자열과 같은 값을 가지는지 비교해보면 같은 값을 가진다는 결과가 나옵니다. 이는 숫자의 경우에도 동일합니다.
let proposal = { name: "Record & Tuple" }; // ⬅ 문자열을 값으로 갖는 객체
let proposal2 = { name: "Record & Tuple" }; // ⬅ 다른 객체일까요 아니면 같은 객체일까요?
assert(proposal === proposal2); // NOT OK 🛑
객체의 경우, 첫 번째 줄에서 객체를 하나 생성하고 두 번째 줄에서 두 번째 객체를 생성하고 있습니다. 여기서 두 객체의 내부 값이 동일하다는 사실은 상관없이 각각 새로운 객체에 새로운 참조를 만들게 됩니다. 따라서 서로 다른 메모리 공간을 가리키게 되며 두 가지 다른 참조가 서로 다른 메모리 공간에 위치하게 되는 것입니다.
자바스크립트에서 이 두 참조는 같지 않습니다. 완전히 다르며 일치할 수 없습니다.
let proposalName = "Record & Tuple";
proposalName[10] = "o"; // 🛑 TypeError: Cannot assign to read only property '10' of string
assert(proposalName === "Record & Tople"); // NOT OK 🛑
엄격모드(strict mode)에서 문자열 중 하나의 문자를 바꾸고자 해보신 적이 있으시다면 아시겠지만, 이는 동작하지 않습니다. 바꾸려고 하면 에러가 발생하여 문자열의 내부 값을 변경할 수 없습니다.
const proposal = { name: "Record & Tuple" };
proposal.name = "Record & Toople";
assert(proposal.name === "Record & Toople"); // OK ✅
하지만 아마 특정 키에 해당하는 값을 할당하여 객체의 내부 값을 변경해본 경험은 있으실겁니다. 여기 예시에서는 name
키에 할당된 값을 변경하고 있으며 에러 없이 실행됩니다.
동결된 상태가 아니라면요
const proposal = {
name: "Record & Tuple",
championGroup: ["Robin", "Rick", "Dan", "Nicolo"],
};
Object.freeze(proposal);
proposal.name = "Record & Toople"; // 🛑 TypeError: Cannot assign to read only property 'name'
assert(proposal.name === "Record & Toople"); // NOT OK 🛑
하지만 아마 여러분들은 객체를 동결시켜 불변하도록 만들 수 있다는 것을 아실 겁니다. 네 맞습니다. 객체를 동결시켜 키에 할당된 값을 바꿀 수 없도록 만들 수 있어요.
proposal.championGroup.push("Ashley");
assert(proposal.championGroup.length === 5); // OK ✅
하지만, 객체 동결은 재귀적이지 않습니다.
따라서 객체 안의 배열을 변경하고 싶다면 가능합니다. 사실 여기에서는 좋은 일입니다. 제 동료 Ashley를 추가하고 싶었거든요.
const
+ Object.freeze
는 중첩된 객체의 값을 보장하지 못합니다아마 여러분들은 때로는 값의 변경이 상황을 더욱 악화시킬 수 있다는 것을 아실겁니다. 관련해서 여러분에게 보여드리고싶은 흥미로운 예시가 있습니다.
const config = {
db: { host: "pg0" /* ... */ },
// ...
};
Object.freeze(config);
await initConnections(config); // 💥❓ initConnections가 config를 변경하지 않을 것이라고 신뢰할 수 있나요?
assert(config.db.host === "pg0"); // 🛑❓ 이 값은 변경되었을 수 있습니다.
위와 같은 초기화 함수가 있다고 가정해봅시다.
별로 신뢰가 가지 않지만 사용해야합니다. 제가 입력한 설정을 바꾸지 않을 것이라고 믿을 수 없습니다. 여러분들은 경험한 적 있으실지 모르겠지만 실제로 저에게는 꽤 여러번 일어난 일입니다. 애플리케이션의 다른 곳에서 사용할 것이기 때문에 이 설정 값은 변경되지 않기를 바랄 것입니다. 하지만 initConnections
가 변경할 위험이 있습니다.
따라서 저는 설정값을 동결시켜 initConnections
에 넘길 것입니다. 그럼에도 제 데이터베이스 키가 변경되지 않을 것이라는 보장이 없죠.
await initConnections(JSON.parse(JSON.stringify(config))); // 기본 설정 값의 변경을 막기 위해 복사합니다
그렇다면 이렇게하면 어떨까요?
JSON.stringify
를 한 후 JSON.parse
를 통해 모든 값을 복사하는 겁니다. 꽤 괜찮아보이죠?
이렇게 하면 너무 느려질거에요. 만약에 굉장히 자주 사용되는 코드라면 꽤 심각하게 안 좋은 성능을 야기할 것입니다.
JSON.parse(JSON.stringify(...))
대신 structuredClone(...)
그리고 만약에 이 방법을 사용하고 싶으면 structuredClone
을 사용하세요.
대부분의 환경에서 지원하고 있습니다. 웹브라우저, 노드, 디노 등 모두 structuredClone
을 지원하니 JSON.parse(JSON.stringify(...))
대신 structuedClone
을 사용하세요.
Map
키는 정확한 참조를 필요로 합니다참조에 대한 이야기를 조금 해볼까 합니다. 이는 가끔씩 문제를 야기합니다.
const boaty = { name: "Boaty McBoatface" };
const coords = { lat: 13, lon: -24 };
const boats = new Map([[coords, boaty] /*...*/]);
assert(boats.get(coords) === boaty); // OK ✅
const coordsCopy = { ...coords };
assert(boats.get(coordsCopy) === boaty); // NOT OK🛑
첫 번째 줄에서 name
을 가진 boat
를 생성합니다. 이어서 두 번째 줄에서 배에 대한 좌표를 생성하고 있습니다. 그리고 모든 배의 좌표를 boats
맵을 통해 추적하려고 합니다. es6부터 사용 가능한 es map으로 배와 좌표를 연결하고 있습니다. 배를 확인하고 싶으면 맵에 좌표를 입력하면 됩니다.
여기에서 어떠한 이유로 모든 좌표들을 복사하려고 합니다. 이유는 묻지 마세요.
그런데 복사한 좌표를 이용해 배를 찾아보려고하면 찾을 수 없습니다. 복사한 좌표는 새로운 참조를 갖기 때문이죠.
어떻게 해결할 수 있을까요?
문자열화 해봅시다.
아주 간단하죠. 문자열을 반환하고 값이니까 맵에 매치되는 값을 바로 찾을 수 있을겁니다.
하지만 이는 더 심각한 상황을 초래할 수 있습니다.
'{"lat":13, "lon":-24}' !== '{"lon":-24, "lat":13}'
만약에 단순히 키의 순서만 바꿔서 입력한다고 했을 때 동일하지 않은 문자열이 되죠. 따라서 이 방법은 취약한 방법이기에 다른 방법을 찾아야 합니다.
게다가 이 방법으로는 객체 사이클이 있을 수 없으며 직렬화 할 수 없는 객체나 값을 사용할 수 없습니다.
해결책을 알아보기 전에 잠시 지금까지 살펴본 내용을 요약해봅시다.
원시 타입은 꽤 멋집니다. 변수는 참조가 아닌 값을 가지고 있기 때문에 쉽게 비교할 수 있고 또 변경 불가합니다.
객체는 참조를 가지고 있다는 특성 때문에 때때로 문제가 될 때가 있습니다. 또, 동결할 수 있지만 견고하지 못합니다. (객체가 문제가 있다는 말이 아닙니다. 자바스크립트와 같은 언어에서 객체는 매우 중요합니다.)
만약에 다양한 값들을 같이 다룰 수 있으면서 원시 값처럼 취급될 수 있다면 어떨까요?
이는 이제 여러분들께 소개하고자 했던 record와 tuple에 대한 이야기로 이어집니다.
const record = #{
name: "Record & Tuple",
stage: 2,
};
여러분들의 첫번째 Record
입니다. 객체와 매우 유사하게 생겼지만 보시다시피 앞에 해시 기호가 추가되어있습니다. 이 기호가 record를 만듭니다.
const tuple = #["Record & Tuple", 2];
이것이 Tuple
입니다. 동일하게 앞에 해시 기호로 인해 배열에서 튜플이 되는 거죠.
단순히 한 글자 차이이지만 꽤 큰 변화를 가져올 것입니다.
const proposals = #[
#{ name: "Record & Tuple", stage: 2 },
#{ name: "Change Array by Copy", stage: 3 },
#{ name: "Symbols as WeakMap keys", stage: 3 },
];
const rt = #{ championGroup: #["Robin", "Rick", "Dan", "Nicolo", "Ashley"] };
물론 이 둘을 조합할 수도 있습니다. Record 안에 Tuple을 넣어도 되고 Tuple 안에 Record를 넣어도 되죠.
const record = #{
name: "Record & Tuple",
stage: 2,
};
record.name = "Record & Toople"; // TypeError 🛑
기본적으로 불변성을 가집니다. 만들고 해시 기호를 넣으면 동결할 필요가 없습니다. 한 번 생성되면 변경할 수 없습니다.
const proposals = #[
#{ name: "Record & Tuple", stage: 2 },
#{ name: "Change Array by Copy", stage: 3 },
#{ name: "Symbols as WeakMap keys", stage: 3 },
];
proposals[0].name = "Record & Toople"; // TypeError 🛑
깊은 구조에서도 동일합니다. 따라서 재귀적으로 동결하는 데에도 문제가 없죠.
// objects
assert([1, 2, 3] === [1, 2, 3]); // NOT OK 🛑
assert({ a: 1 } === { a: 1 }); // NOT OK 🛑
// record & tuple
assert(#{ a: 1 } === #{ a: 1 }); // OK ✅
assert(#[1, 2, 3] === #[1, 2, 3]); // OK ✅
불변성도 중요하지만 동일성을 가진다는 특성이 무엇보다 멋진 부분이라고 생각합니다.
참조를 비교하는 것이 아니기 때문에 같은 순서의 값을 가지는 두 개의 tuple이 있다면 이 두 tuple은 동일합니다. 만약에 같은 키와 값을 갖는 한 쌍의 record가 있다면 이 두 record 또한 동일합니다.
assert(typeof #{ a: 1 } === "record"); // OK ✅
assert(typeof #[1, 2, 3] === "tuple"); // OK ✅
이는 Record와 Tuple이 원시 타입이기 때문입니다. 따라서 typeof
로 확인해보면 object
가 아닌 각각 record
와 tuple
을 반환합니다.
앞서 살펴보았던 객체가 갖는 문제들을 해결해봅시다.
const
+ Object.freeze
는 중첩된 객체의 값을 보장하지 못합니다해결 ✅ : Record는 참조가 아닌 값을 통한 비교가 이루어집니다.
const config = #{
db: #{ host: "pg0" /* ... */ },
// ...
};
await initConnections(config); // 완전히 불변한 설정값입니다
assert(config.db.host === "pg0"); // OK ✅
이제 객체를 동결할 필요가 없습니다. 이미 완전한 불변성을 갖고 있기 때문이죠.
만약 initConnenctions
가 설정값을 변경하려 한다면 에러를 발생시킬겁니다. 따라서 위 문제는 해결됐죠.
Map
키는 정확한 참조를 필요로 합니다해결 ✅ : Record는 참조가 아닌 값을 통한 비교가 이루어집니다.
const boaty = { name: "Boaty McBoatface" };
const coords = #{ lat: 13, lon: -24 };
const boats = new Map([[coords, boaty] /*...*/]);
assert(boats.get(coords) === boaty); // OK ✅
const coordsCopy = #{ ...coords };
assert(boats.get(coordsCopy) === boaty); // OK ✅
두 번째 문제를 살펴보면, map
에서도 앞서 말했던 동일성을 가집니다.
즉, 좌표들이 object
에서 record
로 바뀌면 좌표들의 참조값을 비교하는 것이 아니라 내부 값을 비교하게 됩니다. 따라서 동일한 좌표를 복사한 record를 만들면 내부 값이 동일하기 때문에 동일성을 가지게 되고, 복사를 하더라도 배를 찾을 수 있게 되는 것이죠.
그럼 객체 사이클과 직렬화 불가능한 객체와 값들은 어떨까요?
record
와 tuple
을 활용해 이 또한 해결할 수 있습니다. 관련 내용은 잠시 후에 살펴보도록 하죠.
stringify에 따른 키 순서 취약성은 어떨까요? 키의 순서를 바꾸는 것은 영향을 끼칠까요?
const coords1 = #{ lat: 13, lon: -24 };
const coords2 = #{ lon: -24, lat: 13 };
assert(coords1 === coords2); // OK ✅
Object.keys(coords1); // ➡ ["lat", "lon"]
Object.keys(coords2); // ➡ ["lat", "lon"] (객체와 다른 점입니다)
record
의 경우 키의 순서는 상관 없습니다. 삽입 순서를 기억하지 않습니다. 위와 같이 사전순으로 정렬된 키를 반환합니다. 두 좌표를 키의 순서를 다르게 해서 값을 삽입한다해도 여전히 같은 값을 가지고 동등성을 가지는 것이죠.
Record
와 Tuple
을 사용한 결과하지만 세상에 공짜는 없죠.
record
와 tuple
에서 할 수 없는 것들이 있습니다. 한 번 살펴봅시다.
const config = #{
db: { host: "pg0" /*...*/ }, // TypeError 🛑
//⬆ 해시 기호가 없기 때문에 record가 아닙니다
};
먼저, 객체를 넣을 수 없습니다.
위의 예시를 보면 DB 객체 앞에 해시 기호가 없습니다. 따라서 record
가 아닌 객체이죠.
그리고 record는 내부 값으로 객체를 허용하지 않습니다.
const config = #{
db: #{
host: "pg0",
// ...
onConnect: () => {}, // TypeError 🛑
},
// ...
};
유감스럽게도 함수에도 동일하게 적용됩니다. 함수도 객체에 포함되기 때문이죠. 수정할 수 있는 방법이 있긴 하지만 너무 길어질 것 같으니 이번에는 다루지 않도록 하겠습니다.
const champions = #["Robin", "Rick", "Dan"];
champions.push("Nicolo", "Ashley"); // TypeError: champions.push is not a function 🛑
그럼 이제 어떻게 값을 변경할 수 있는지 알아보도록 하겠습니다.
당연히 값을 그대로 변경할 수는 없습니다. 그럼 어떻게 해야할까요?
const stage1And2Champions = #["Robin", "Rick", "Dan"];
const stage3Champions = #["Nicolo", "Ashley"];
const allChampions = #[...stage1And2Champions, ...stage3Champions];
assert(allChampions === #["Robin", "Rick", "Dan", "Nicolo", "Ashley"]); // OK ✅
전개 연산자를 사용해서 두 개의 tuple을 연결할 수 있습니다.
const proposal = #{ name: "Record & Tuple", stage: 2 };
const proposalSoon = #{ ...proposal, stage: 3 };
assert(proposalSoon === #{ name: "Record & Tuple", stage: 3 }); // OK ✅
records도 마찬가지입니다.
proposal
의 값을 하나 변경하고 싶다면 전개 연산자를 사용해 proposal
을 전개하고 바꾸고 싶은 키를 넣으면 됩니다. 아직 진행중이지만, 곧 완성됩니다.
#["Robin", "Rick", "Dan", "Nicolo", "Ashley"].reverse(); // Tuple은 값을 변경할 수 없습니다
배열 프로토타입에는 유용한 메소드들이 많습니다. 예를 들면 reverse
같은 것이 있죠.
아쉽게도 tuple은 값을 변경할 수 없기 때문에 사용할 수 없습니다.
const reversed = #["Robin", "Rick", "Dan", "Nicolo", "Ashley"].toReversed(); // 복사하여 새로운 tuple을 반환합니다
assert(reversed === #["Ashely", "Nicolo", "Dan", "Rick", "Robin"]); // OK ✅
따라서 저희는 toReversed
를 만들었습니다. toReversed
는 tuple의 값을 변경하지 않습니다.
대신 뒤바뀐 값을 가진 tuple을 반환합니다.
이외에도 새로운 tuple을 반환하는 유용한 메소드들이 있습니다.
.toReversed()
는 새로운 tuple을 반환하는 .reverse()
입니다.toSorted([predicateFn])
는 새로운 tuple을 반환하는 .sort([predicateFn])
입니다..with(index,value)
는 새로운 tuple을 반환하는 [index] = value
입니다..toSpliced(...)
는 새로운 tuple을 반환하는 .splice(...)
입니다.#{}
/ #[]
아마 이 기능을 언제 사용할 수 있는지 궁금하실겁니다.
Record & Tuple 은 TC39
의 2단계
제안입니다.
TC39
는 TC39에는 4가지 단계가 있습니다. 2단계는 중간인셈이죠. 2단계는 위원회에서 이 기능이 언어에 적용되기를 기대하고 있음을 의미합니다. 따라서 오늘 보여드린 내용과 조금은 다를 수 있지만 궁극적으로 언어에 적용될 것입니다.
다음 단계로 가기위해서 어떤 일을 하고있을까요?
Babel transform
과 polyfill
또한 존재합니다.Test262
spec test도 작성되어 있습니다.Firefox
에도 구현되어 있습니다.언제 이 기능을 사용할 수 있을까요?
My appreciation for your article transcends the boundaries of time and space, transcending the ordinary and venturing into https://onlyuponline.io the realm of extraordinary. You have created a sanctuary of inspiration where thoughts take flight and minds find solace. Thank you for this unparalleled gift.
In-depth knowledge is demonstrated through analysis. It has a fish eat fish strategy
감사합니당!! 튜플이라는 게 있군요 0ㅅ0