[번역] Record & Tuple: 불변성을 가지는 자바스크립트의 자료구조

eunbinn·2022년 8월 22일
27

FrontEnd 번역

목록 보기
11/14
post-thumbnail

📌 JSNation 2022 컨퍼런스 중 진행된 세션을 글로 옮겼습니다. 원본 영상은 아래 링크를 참조해주세요.

출처: https://portal.gitnation.org/contents/record-and-tuple-immutable-data-structures-in-js

오늘 저는 Record와 Tuple에 대해 이야기하려고 합니다. Record와 Tuple은 곧 자바스크립트에서 사용할 수 있을 새로운 기능으로, 자바스크립트에서 데이터를 다루는 방식을 재정의하기를 바라고 있습니다.

오늘 다룰 내용들은 다음과 같습니다.

오늘날의 자바스크립트에서 사랑받고 널리 사용되고 있는 객체(Object) 타입에 대해 먼저 다룰 것입니다. 그 후에 Record와 Tuple에 대해 이야기하고, 마지막으로 사용할 수 있다면 언제 사용 가능할지 말씀드리려고 합니다.

객체(Object) 타입의 참조와 가변성

먼저 객체에 대해 살펴봅시다. 여러분들은 이미 사용하고 계실겁니다. 객체 외에도 문자열, 숫자와 같은 기본적인 다른 타입에 대해서도 다룰겁니다. 기초적인 내용이지만 짚고 넘어가려고 합니다. 또한 이들이 어떻게 참조되고 어떻게 변경 가능한지도 살펴볼 예정입니다.

원시 타입(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를 추가하고 싶었거든요.

이는 몇 가지 문제를 야기합니다

문제 1. 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를 통해 모든 값을 복사하는 겁니다. 꽤 괜찮아보이죠?

  • Performance hit: CPU + Memory

이렇게 하면 너무 느려질거에요. 만약에 굉장히 자주 사용되는 코드라면 꽤 심각하게 안 좋은 성능을 야기할 것입니다.

  • JSON.parse(JSON.stringify(...)) 대신 structuredClone(...)

그리고 만약에 이 방법을 사용하고 싶으면 structuredClone을 사용하세요.
대부분의 환경에서 지원하고 있습니다. 웹브라우저, 노드, 디노 등 모두 structuredClone을 지원하니 JSON.parse(JSON.stringify(...)) 대신 structuedClone을 사용하세요.

문제 2. 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으로 배와 좌표를 연결하고 있습니다. 배를 확인하고 싶으면 맵에 좌표를 입력하면 됩니다.

여기에서 어떠한 이유로 모든 좌표들을 복사하려고 합니다. 이유는 묻지 마세요.
그런데 복사한 좌표를 이용해 배를 찾아보려고하면 찾을 수 없습니다. 복사한 좌표는 새로운 참조를 갖기 때문이죠.

어떻게 해결할 수 있을까요?
문자열화 해봅시다.

  • 키를 JSON 문자열로 직렬화합니다 (JSON.stringify(coords))

아주 간단하죠. 문자열을 반환하고 값이니까 맵에 매치되는 값을 바로 찾을 수 있을겁니다.

하지만 이는 더 심각한 상황을 초래할 수 있습니다.

  • ⚠️ 키 순서 취약성 '{"lat":13, "lon":-24}' !== '{"lon":-24, "lat":13}'

만약에 단순히 키의 순서만 바꿔서 입력한다고 했을 때 동일하지 않은 문자열이 되죠. 따라서 이 방법은 취약한 방법이기에 다른 방법을 찾아야 합니다.

  • 객체 사이클, 직렬화 불가한 객체와 값들

게다가 이 방법으로는 객체 사이클이 있을 수 없으며 직렬화 할 수 없는 객체나 값을 사용할 수 없습니다.

해결책을 알아보기 전에 잠시 지금까지 살펴본 내용을 요약해봅시다.

원시 타입은 꽤 멋집니다. 변수는 참조가 아닌 값을 가지고 있기 때문에 쉽게 비교할 수 있고 또 변경 불가합니다.

객체는 참조를 가지고 있다는 특성 때문에 때때로 문제가 될 때가 있습니다. 또, 동결할 수 있지만 견고하지 못합니다. (객체가 문제가 있다는 말이 아닙니다. 자바스크립트와 같은 언어에서 객체는 매우 중요합니다.)

만약에 다양한 값들을 같이 다룰 수 있으면서 원시 값처럼 취급될 수 있다면 어떨까요?
이는 이제 여러분들께 소개하고자 했던 record와 tuple에 대한 이야기로 이어집니다.

Record와 Tuple이란 무엇인가요 (TC39 2단계 제안)

구문

  • Record
const record = #{
  name: "Record & Tuple",
  stage: 2,
};

여러분들의 첫번째 Record입니다. 객체와 매우 유사하게 생겼지만 보시다시피 앞에 해시 기호가 추가되어있습니다. 이 기호가 record를 만듭니다.

  • Tuple
const tuple = #["Record & Tuple", 2];

이것이 Tuple입니다. 동일하게 앞에 해시 기호로 인해 배열에서 튜플이 되는 거죠.

단순히 한 글자 차이이지만 꽤 큰 변화를 가져올 것입니다.

  • Tuple 안에 Record
const proposals = #[
  #{ name: "Record & Tuple", stage: 2 },
  #{ name: "Change Array by Copy", stage: 3 },
  #{ name: "Symbols as WeakMap keys", stage: 3 },
];
  • Record 안에 Tuple
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 🛑

깊은 구조에서도 동일합니다. 따라서 재귀적으로 동결하는 데에도 문제가 없죠.

동일성(Equality)

// 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 또한 동일합니다.

원시타입(primitives)

assert(typeof #{ a: 1 } === "record"); // OK ✅
assert(typeof #[1, 2, 3] === "tuple"); // OK ✅

이는 Record와 Tuple이 원시 타입이기 때문입니다. 따라서 typeof로 확인해보면 object가 아닌 각각 recordtuple을 반환합니다.

Record와 Tuple로 객체가 가졌던 문제를 해결할 수 있을까요?

앞서 살펴보았던 객체가 갖는 문제들을 해결해봅시다.

문제 1. const + Object.freeze는 중첩된 객체의 값을 보장하지 못합니다

해결 ✅ : Record는 참조가 아닌 값을 통한 비교가 이루어집니다.

const config = #{
  db: #{ host: "pg0" /* ... */ },
  // ...
};

await initConnections(config); // 완전히 불변한 설정값입니다
assert(config.db.host === "pg0"); // OK ✅

이제 객체를 동결할 필요가 없습니다. 이미 완전한 불변성을 갖고 있기 때문이죠.
만약 initConnenctions 가 설정값을 변경하려 한다면 에러를 발생시킬겁니다. 따라서 위 문제는 해결됐죠.

문제 2. 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를 만들면 내부 값이 동일하기 때문에 동일성을 가지게 되고, 복사를 하더라도 배를 찾을 수 있게 되는 것이죠.

그럼 객체 사이클과 직렬화 불가능한 객체와 값들은 어떨까요?
recordtuple을 활용해 이 또한 해결할 수 있습니다. 관련 내용은 잠시 후에 살펴보도록 하죠.

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의 경우 키의 순서는 상관 없습니다. 삽입 순서를 기억하지 않습니다. 위와 같이 사전순으로 정렬된 키를 반환합니다. 두 좌표를 키의 순서를 다르게 해서 값을 삽입한다해도 여전히 같은 값을 가지고 동등성을 가지는 것이죠.

RecordTuple을 사용한 결과

하지만 세상에 공짜는 없죠.
recordtuple에서 할 수 없는 것들이 있습니다. 한 번 살펴봅시다.

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을 전개하고 바꾸고 싶은 키를 넣으면 됩니다. 아직 진행중이지만, 곧 완성됩니다.

Tuple 메소드

#["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 요약

  • 해시 기호를 앞에 붙이는 형태의 구문: #{} / #[]
  • 불변성
    • 업데이트는 전개연산자와 Tuple 메소드를 통해 가능
  • 참조 동일성이 아닌 값의 동일성
  • 값으로 객체나 함수를 가질 수 없음
    • 업데이트 될 가능성이 있으니 계속 지켜봐주세요

제안서의 현주소

아마 이 기능을 언제 사용할 수 있는지 궁금하실겁니다.

Record & Tuple 은 TC392단계 제안입니다.

  • TC39자바스크립트ECMA스크립트 언어를 표준화시키는 곳입니다.

TC39에는 4가지 단계가 있습니다. 2단계는 중간인셈이죠. 2단계는 위원회에서 이 기능이 언어에 적용되기를 기대하고 있음을 의미합니다. 따라서 오늘 보여드린 내용과 조금은 다를 수 있지만 궁극적으로 언어에 적용될 것입니다.

다음 단계로 가기위해서 어떤 일을 하고있을까요?

  • 제안된 스펙은 많은 사람들이 리뷰하고 있습니다.
  • Babel transformpolyfill 또한 존재합니다.
  • Test262 spec test도 작성되어 있습니다.
  • 컴파일 시 적용할 수 있는 flag로서 Firefox에도 구현되어 있습니다.

언제 이 기능을 사용할 수 있을까요?

1개의 댓글

comment-user-thumbnail
2022년 8월 23일

감사합니당!! 튜플이라는 게 있군요 0ㅅ0

답글 달기