ECMAScript 2023 살펴보기

💡 14번째 버전인 ECMAScript 2023에 추가된 스펙에 대해서 살펴봅니다.

1. 새로운 기능들

공부해야 하는 내용은 대개 우연으로 다가옵니다. 며칠 전 JavaScript 스터디를 진행하던 중 모던 JavaScript 튜토리얼에서 참고 링크로 되어 있는 ECMAScript 명세서를 열어보게 되었습니다. 같이 학습하고 있던 동료 분께서 “벌써 2024를 준비하네요?” 라는 말씀을 하셨습니다. 무슨 소리인가 하여 여쭤보니, 사이트 최상단에 표기되어 있는 글귀가 ECMAScript 2024 Language Specification으로 되어 있다는 것이었습니다. 순간적으로 ECMAScript 2023 스펙에 대해 궁금증이 생겼습니다.

이번 기회에 ECMAScript 2023 웹 사이트를 참고하면서 어떤 내용들이 추가되었는지 알아보았습니다. 적용되는 스펙은 tc39/proposals 레포지토리에서 확인할 수 있었고 각 제안의 명칭은 다음과 같습니다.


finished-proposals에 올라와 있는 스펙들

전체 내용을 Array Method, HashBang Comments, Symbol Key for WeakMap 이라는 이름으로 분류하여 정리했습니다.


2. Array Method

toSorted, toReversed, with, findLast, findLastIndex, toSpliced 메서드가 새롭게 추가됩니다.

새롭게 Array.prototype에 추가되는 메서드들이 있습니다. 본격적으로 설명하기 전에 먼저 JavaScript에서의 Array와 TypedArray에 대해서 간단하게 알아보겠습니다.

  • Array : 다양한 유형의 자료형을 저장할 수 있는 객체입니다. 요소에 순서가 부여되고 다양한 메서드를 사용할 수 있습니다.
  • TypedArray : 메모리에 직접 저장되는 원시 이진 데이터를 요소로 하는 객체로, 복잡하고 큰 데이터를 Array보다 더 빠르게 다룰 수 있습니다. 또한 크기가 정적으로 정해져 있다는 점에서 Array와 차이점을 보입니다. Array.prototype에서 사용할 수 있는 메서드를 TypedArray.prototype에서는 사용할 수 없는 경우가 있습니다.

ECMAScript 2023 (이하 2023 버전) 에서는 Array에서 사용할 수 있는 메서드가 6개, TypedArray에서 사용할 수 있는 메서드가 5개 추가됩니다. TypedArray에 추가된 메서드는 모두 Array에서도 사용할 수 있기 때문에 총 6개의 새로운 메서드에 대해서 알아보겠습니다.


1. toSorted

2023 버전에서 새롭게 추가되는 toSorted() 메서드는 대상이 되는 배열(Array와 TypedArray를 공통으로 부르기 위해서 배열이라고 부르겠습니다)에 대해서 toSorted()의 인자로 들어가는 함수(compareFn)를 실행하여 요소들을 알맞게 정렬합니다. 인자로 받은 함수로 배열의 요소를 정렬한다는 점에서 JavaScript에 있는 sort()와 동일한 동작을 하는 것처럼 보입니다. 차이점은 sort()의 경우 실행 결과로 반환되는 배열과 메서드를 실행한 배열이 모두 정렬이 되는 반면, toSorted()의 경우에는 반환되는 배열만 정렬된다는 점입니다. 즉, sort()는 In-Place Operation으로 배열 자체가 변경되지만 toSorted()는 그렇지 않습니다.

const sortTarget = [1, 2, 3, 4, 5];
const sorted = sortTarget.sort((a, b) => b - a); // 내림차순으로 배열 요소를 정렬 

console.log(sortTarget); // [5, 4, 3, 2, 1];
console.log(sorted); // [5, 4, 3, 2, 1];

sort()의 동작 방식은 함수의 실행 결과가 원본에 영향을 미치지 않는 순수 함수여야 한다는 함수형 패러다임에 부합하지 않습니다. 함수의 원본을 해치지 않기 위해서는 전체적인 배열 요소를 한 번 복사하는 과정이 필수적입니다.

const sortTarget = [1, 2, 3, 4, 5];
// 구조 분해 할당으로 배열을 복사한 뒤 내림차순으로 배열 요소를 정렬 
const sorted = [...sortTarget].sort((a, b) => b - a); 

console.log(sortTarget); // [1, 2, 3, 4, 5];
console.log(sorted); // [5, 4, 3, 2, 1];

정렬을 원했을 뿐인데 불필요한 단계가 하나 추가되었습니다. toSorted()를 사용하면 새로 정렬된 배열을 반환하기 때문에 원본 배열을 수정하지 않고 정렬된 배열을 얻을 수 있습니다.

const sortTarget = [1, 2, 3, 4, 5];
const sorted = sortTarget.toSorted((a, b) => b - a); // 내림차순으로 배열 요소를 정렬 

console.log(sortTarget); // [1, 2, 3, 4, 5];
console.log(sorted); // [5, 4, 3, 2, 1];

toSorted()의 실행 결과가 원본에 영향을 미치지 않는 것을 확인할 수 있습니다.


2. toReversed

toReversed()는 앞서 소개한 toSorted()와 네이밍 형태가 비슷한 것으로 보아 동작을 유추해볼 수 있을 것 같습니다. 배열 메서드로 존재하는 reverse()는 In-Place 방식으로 동작하며 대상 배열의 나열 순서를 뒤집습니다.

const reverseTarget = [1, 2, 3, 4, 5];
const reversed = reverseTarget.reverse();

console.log(reverseTarget); // [5, 4, 3, 2, 1]
console.log(reversed); // [5, 4, 3, 2, 1]

반면 toReversed()는 원본 배열과 다른 새로운 배열을 반환합니다.

const reverseTarget = [1, 2, 3, 4, 5];
const reversed = reverseTarget.toReversed();

console.log(reverseTarget); // [1, 2, 3, 4, 5]
console.log(reversed); // [5, 4, 3, 2, 1]


3. with

위에서 소개한 두 메서드와는 달리 with()는 이름에서 동작이 예상될만큼의 충분한 힌트를 얻기 힘들어 보입니다. 기존에 있는 비슷한 이름의 메서드가 떠오르지 않기 때문에 한 번 명세를 살펴보겠습니다.


index와 value를 인자로 받습니다.

매개변수에 대한 정보만 제시하고 있고 정확하게 어떤 동작을 하는 지에 대해서는 여러 단계로 표현하고 있습니다. 명세를 보면서 간단하게 Polyfill을 만들어보았습니다.

// myWith라는 이름으로 with 동작을 추가합니다.
if (!Array.prototype.myWith) {
  Array.prototype.myWith = function (index, value) {
    const O = this; // (1)
    const len = O.length; // (2)

    const relativeIndex = (function (index) { // (3)ToIntegerOrInfinity
      const ToNumber = (target) => {
        if (typeof target === 'number') {
          return target; 
        } else if (target === undefined) {
          return NaN;
        } else if (target === null || target === false) {
          return +0;
        } else if (target === true) {
          return 1;
        } else {
          return Number(target);
        }
      }

      let number = ToNumber(index);

      if (Number.isNaN(number) || number === 0) {
        return 0;
      } else if (number === Infinity || number === -Infinity) {
        return number;
      } else {
        return Math.trunc(number);
      }
    })(index);

    let actualIndex;

    if (relativeIndex) {
      actualIndex = relativeIndex; // (4)
    } else {
      actualIndex = len + relativeIndex; // (5)
    }

    // (6)
    if (actualIndex >= len || actualIndex < 0) {
      throw new RangeError('Out of Bound Error');
    } 

    const A = Array.from({length: len}); // (7)
    let k = 0; // (8)

    // (9)
    while (k < len) {
      const Pk = String(k);
      const fromValue = k === actualIndex ? value : this[Pk];

      A[Pk] = fromValue;
      k++;
    }

    // (10)
    return A;
  }
}

const withTarget = [1, 2, 3, 4, 5];
const beenWith = withTarget.myWith(2, 10);

console.log(withTarget); // [1, 2, 3, 4, 5]
console.log(beenWith); // [1, 2, 10, 4, 5]

간단하게 만들어본 myWith()를 실행한 결과, 원본 배열에 영향을 주지 않으면서 index에 있는 값을 value로 교체한다는 것을 알 수 있습니다. 즉, 아래 코드를 단순하게 수행할 수 있도록 도와줍니다.

const withTarget = [1, 2, 3, 4, 5];
const index = 2;
const value = 10;
const beenWith = [...withTarget.slice(0, index),
                  value,
                  ...withTarget.slice(index+1)];

console.log(withTarget); // [1, 2, 3, 4, 5]
console.log(beenWith); // [1, 2, 10, 4, 5]

with()를 사용하면 배열에서 한 요소만 다른 값으로 변경하고 싶을 때, 위의 코드처럼 복잡한 단계를 거치지 않고 간단하게 사용할 수 있습니다.


4. findLast

find()라는 메서드가 떠오르면서 동작이 어느 정도 예상됩니다. find()는 인자로 전달한 조건(callback)을 만족하는 요소가 배열에 있을 경우 가장 처음에 발견한 요소를 반환하고, 그렇지 않을 경우 undefined를 반환합니다. 추가된 findLast()는 find()의 동작을 배열의 마지막 요소부터 시작하여 가장 처음에 발견한 요소를 반환하고, 요소를 찾지 못할 경우 find()와 동일하게 undefined를 반환합니다.

const findTarget = [1, 2, 3, 4, 5];

const found = findTarget.find(el => el > 2);
const notFound = findTarget.find(el => el > 5);

console.log(found); // 3
console.log(notFound); // undefined

const lastFound = findTarget.findLast(el => el > 3);
const notLastFound = findTarget.findLast(el => el < 1);

console.log(lastFound); // 5
console.log(notLastFound); // undefined

기존에는 동일한 동작을 하기 위해서 배열을 복사하고 reverse() 메서드를 사용하는 단계를 거쳐야 했지만, findLast()는 불필요한 단계 없이 필요한 동작만 수행할 수 있습니다.


5. findLastIndex

findLast()가 배열의 뒷쪽부터 탐색하면서 조건을 만족하는 요소를 반환했다면 findLastIndex()는 해당 요소의 인덱스를 반환합니다. 또한 요소를 찾지 못했을 경우 -1을 반환합니다.

const findTarget = [1, 2, 3, 4, 5];

const foundIndex = findTarget.findIndex(el => el > 2);
const notFoundIndex = findTarget.findIndex(el => el > 5);

console.log(foundIndex); // 2
console.log(notFoundIndex); // -1

const lastFoundIndex = findTarget.findLastIndex(el => el > 3);
const notLastFoundIndex = findTarget.findLastIndex(el => el < 1);

console.log(lastFoundIndex); // 4
console.log(notLastFoundIndex); // -1

조건을 만족하는 마지막 요소의 인덱스를 확인하기 위해 불필요한 과정이 들어가거나 성능 때문에 for 문이 강제되는 상황이 있었겠지만 이제는 findLastIndex()가 이를 해결해줍니다.


여기까지 총 5개의 메서드 toSorted, toReversed, width, findLast, findLastIndex를 알아보았습니다. 본 섹션의 맨 처음에 언급했듯이 여기까지 소개한 메서드들은 Array나 TypedArray에서 공통적으로 사용할 수 있습니다.


6. toSpliced

마지막으로 소개할 메서드는 Array에 추가된 toSpliced()입니다. (TypedArray는 해당되지 않습니다) Array의 splice()는 splice(제거를 시작할 인덱스, 제거 개수, 새롭게 넣을 요소)형태로 사용하며 원본 배열은 수정되고 배열에서 제거된 부분이 메서드 결과로 반환합니다. 앞서 소개한 메서드들과 비슷한 맥락으로 toSpliced()는 원본 배열을 수정하지 않고 제거된 부분으로 새로운 배열을 만듭니다. (인자로 새롭게 넣을 요소를 명시하면 해당 요소가 추가되기도 합니다)

const spliceTarget = [1, 2, 3, 4, 5];
const spliced = spliceTarget.splice(0, 2, ...[6, 7]);

console.log(spliceTarget); //  [6, 7, 3, 4, 5]
console.log(spliced); // [1, 2]

spliceTarget에서 spliced가 분리된 것을 확인할 수 있습니다. 이와 달리 toSpliced()를 사용하면 원본 객체를 유지할 수 있습니다.

const spliceTarget = [1, 2, 3, 4, 5];
const spliced = spliceTarget.toSpliced(0, 2, ...[6, 7]);

console.log(spliceTarget); // [1, 2, 3, 4, 5]
console.log(spliced); // [6, 7, 3, 4, 5]

이렇듯 2023 버전에서는 기존에 있던 메서드를 순수 함수 버전으로 제공하거나 편의성을 위한 메서드들이 새롭게 추가됩니다. 이러한 새로운 스펙들은 map, filter, reduce와 같은 메서드들과 조합하여 긴밀하게 사용할 수 있을 것 같습니다.


3. HashBang Comments

js 파일의 맨 처음이 #!로 시작할 경우 해당 라인을 무시합니다.

해당 제안에서는 JavaScript 파일을 CLI에서 실행하는 경우 JavaScript 엔진이 이를 해석하기 전에, 소스코드 맨 처음에 HashBang 기호(#!)로 시작하는 라인을 무시하는 스펙을 추가하고 있습니다. 예를 들어 파일이 #!/usr/bin/env node로 시작한다면 해당 줄을 무시합니다.

위와 같은 코드 라인은 유닉스 계열 운영체제에게 실행 가능한 파일이라는 것을 시스템에 알리고 어떤 인터프리터로 실행해야 하는지 명시하는 역할을 합니다. 이를 해석하는 과정은 JavaScript 엔진에 전달하기 전에 수행하기도 하고 엔진 내부에서 수행하기도 하는데, 이러한 동작이 엔진 내부에서 진행되도록 통일하기 위해 제안된 스펙입니다. HashBang Comments는 파일의 맨 처음 부분에서만 유효한데, 이는 다른 부분에 적용되어도 별다른 기능을 기대할 수 없기 때문입니다.


4. Symbol Key for WeakMap

Symbol을 WeakMap의 key로 사용할 수 있습니다.

마지막 내용을 소개하기 전에 Symbol과 WeakMap에 대해 간단하게 알아보겠습니다.

  • Symbol : Object의 key로 사용되며 유일한 식별자를 만들 수 있습니다. Symbol의 description이 같아도 서로 다른 Symbol로 인식됩니다. String과 함께 Object의 key로 활용될 수 있는 유이한 타입입니다.
  • WeakMap : ES2015에서 추가된 객체로 key로 사용하는 값이 반드시 객체여야 합니다. key로 사용한 객체가 Garbage Collection 대상이 되기 때문에 key에 대응하는 Value가 사라질 수 있습니다. 부가 데이터를 저장하거나 값을 캐싱할 때 활용할 수 있습니다.

다음 코드처럼 WeakMap에서는 key로 Object를 사용해야 합니다.

const weakMap = new WeakMap();
const keyObj = {};
const keyStr = 'key';

weakMap.set(keyObj, "value"); // 0: {Object => "value"}
weakMap.set(keyStr, "value"); 
// Uncaught TypeError: Invalid value used as weak map key

해당 제안에서는 기존 WeakMap에서는 Object만을 key로 사용할 수 있었지만 Symbol이 고유한 값을 가진다는 특징을 이용하여 WeakMap에서도 key로 사용할 수 있도록 기능 확장을 제안하고 있습니다.

const weakMap = new WeakMap();
const symbolKey = Symbol("write description here");
weakMap.set(symbolKey, "Symbol Key for WeakMap");

console.log(weakMap.get(symbolKey)); // Symbol Key for WeakMap

제안서에 있는 Use Cases를 살펴보면 Records & Tuples Proposal의 문제를 해결할 수 있다고 합니다. Records & Tuples은 아직 stage 2에 있어서 정식으로 추가된 스펙은 아니지만, 요약하면 JavaScript에 불변 객체인 Record와 Tuple을 추가하자는 내용입니다. 해당 자료형에서 가지고 있는 문제 중 하나는 값으로 객체나 함수 등을 저장할 수 없고 오직 원시값(문자열, 숫자 등)만 저장할 수 있다는 점입니다.

여기에 대한 해결책으로 WeakMap을 사용하여 값으로 객체나 함수를 저장할 수 있습니다. 뿐만 아니라 WeakMap에서 Symbol을 key로 사용할 수 있게 되면 Record나 Tuple같은 불변 객체를 value로 저장했을 때, 전역 스코프에서 유니크한 Symbol로 불변 객체에 접근할 수 있습니다. 이를 통해 프로그램 전체에서 동일한 값을 공유할 수 있습니다. 만약 Object를 key로 사용하는 경우에는 GC에 의해서 key와 함께 불변 객체가 사라질 위험이 있기 때문에 Symbol의 이점이 뚜렷할 것 같습니다.

한편, 사용할 수 있는 Symbol에 제약이 존재합니다.

  • Registered symbols (등록된 심볼) : 전역 심볼 레지스트리에 이미 등록되어 있는 값으로, WeakMap에서 key로 사용할 수 없습니다.
  • Well-Known symbols (잘 알려진 심볼) : 전역 심볼 레지스트리에 등록되어 있지 않기 때문에 WeakMap에서 key로 사용할 수 있습니다. EX) Symbol.iterator, Symbol.hasInstance 등

Symbol.for을 사용하면 전역 심볼을 추가할 수 있는데, 전역 심볼 레지스트리에 등록되지 않은 Symbol은 WeakMap의 key로 사용할 수 있습니다.


5. 맺으며

ECMAScript 명세와 tc39 레포지토리를 살펴보며 새로운 기능들에 대해 이해할 수 있는 신선한 경험이었습니다. 특히 ECMAScript의 기능 명세에서 추상화된 동작들을 DFS 방식으로 탐구해보았는데, 역시나 깊이가 정말.. 정말 깊었고 새로운 키워드들이 매우 많았습니다. 스펙에 대해서 세세하게 살펴본 것은 처음인데, 앞으로 새로운 기능이 나오면 어떻게 자료를 찾아봐야 하는지 체득할 수 있는 시간이었습니다.


[참고]