Symbol의 근황


얼마 전 for...of 구문을 MDN에서 검색할 일이 있었다. 정의에 연결된 문서가 반복 가능한 객체였는데, 여기서 Symbol.iterator가 언급되었다. “Symbol이 여기에서도 사용되었구나 🤔” 하면서 생각해 보니 Symbol에 대해 아는 게 간단한 정의뿐이었다.

사실 애플리케이션 개발 속에서 Symbol을 굳이 사용할 만한 일이 없었다. ES2015에 도입된 새로운 원시형 타입이라는 것만 알고 있었지, 그 외 도입 배경이나 활용 방법에 대해 아는 바가 없어 한 번 알아보기로 했다.

Symbol 기본 문법

혹시라도 Symbol이 어떤 문법인지 기억이 안 나시는 분들을 위해 아주 간단한 기본 개념을 공유하겠다. MDN 문서의 초반부 요약이니 좀 더 자세한 내용은 원본을 확인 바란다.

  • Symbol은 원시형 데이터다.
  • 기본 문법은 Symbol([description])이다.

    • new 연산자는 지원하지 않는다.
  • Symbol()에서 반환되는 모든 값은 고유하다.
  • 객체 프로퍼티의 키로 사용될 수 있다.
const symbol1 = Symbol();
const symbol2 = Symbol(42);
const symbol3 = Symbol('foo');

console.log(typeof symbol1); // symbol
console.log(symbol2 === 42); // false
console.log(symbol3.toString()); // Symbol(foo)
console.log(Symbol('foo') === Symbol('foo')); // false

Symbol은 무슨 이유로 도입되었을까

사실 처음에 Symbol을 알아보기로 마음먹은 순간부터 이걸 왜 도입했는지 동기가 궁금했다. 다들 알다시피 Symbol은 ES2015부터 도입되었다. 이때 추가된 것들이 엄청 많았고, 안 그래도 할 일이 많았을 텐데 5년이 지나도 어디 딱히 쓸 데가 없는 이 문법을 왜 집어넣었을까 궁금해졌다.

Symbol의 초기 개발 목적은 private 속성 지원(참조)이었다고 한다. 하지만 Symbol을 알 수 있는 방법들(예: Object.getOwnPropertySymbols, Reflect.ownKeys)이 존재하여 사실상 원래 취지에는 맞지 않게 되었다.

좀 더 조사하다 보니 private Symbol을 위한 제안(참조)도 오래전에 있었는데, 현재 스펙을 보면 반영이 안 되었다. 관련된 히스토리를 더 찾아보고 싶었지만 워낙 과거 자료들이라 검색에 실패했다. 😥 그래도 그 당시 도입 배경이 정보 은닉이었다니 원대한 꿈을 안고 시작한 스펙이라 할 수 있겠다.

그럼 현실 개발에서 Symbol은 어떻게 사용할 수 있을까

개인적으로는 Symbol이 애플리케이션 레벨에서 활용되기엔 어렵다고 생각한다. 구글링을 해보면 몇 가지 사례가 나온다.

  • 고유하다는 속성을 이용하여, 식별자 또는 리터럴 상수화에 사용
const COLOR_RED = Symbol('Red');
  • 일반적인 객체 정보 조회(Object.getOwnProperties)에서는 무시된다는 점을 이용하여 객체의 메타 데이터를 기록
const size = Symbol('size');
class Stack {
  constructor() {
    this[size] = 0;
  }
  pushItem(item) {
    this[this[size]] = item;
    this[size] += 1;
  }
  getSize() {
    return this[size];
  }
}

const s = new Stack();
s.pushItem('foo');

console.log(Object.getOwnPropertyNames(s)); // ['0']
console.log(Object.getOwnPropertySymbols(s)); // [Symbol(size)]
console.log(s[0]); // 'foo'

하지만 식별자의 경우 uuid 관련 패키지도 많고, 최신 브라우저에선 crypto.randomUUID를 통해 생성할 수도 있다. 리터럴 상수화의 경우는 사실 string 변수로도 대부분의 경우를 커버할 수 있을 것 같고, 메타 데이터 사례 또한 극히 일부의 상황에서만 사용되지 않을까라고 생각한다.

메타 레벨의 Symbol

개인적으로 애플리케이션 레벨에서 실효성 있는 코드를 찾기 어려웠지만 메타프로그래밍을 위한 케이스는 찾아볼 수 있었다.

제일 먼저 Symbol을 사용하면 프레임워크의 메서드를 사용자가 실수로 오버라이딩하는 것을 방지할 수 있기에 유의미하게 사용되는 사례가 있는지 알아보려고 Angular, React, Vue, Preact, Svelte 프로젝트 내 검색을 해봤는데 Vue 3(참조)를 제외하고 코어 레벨에서 사용된 케이스는 발견하지 못했다. 😥

그럼 돌아와서 Symbol이 언어 자체의 메타 레벨 속성으로 사용된 예는 어떤 것이 있을까?

흔히 잘 알려진 심볼이라고 불리는 사전 정의된 Symbol이 있다. Symbol을 키로 사용하면 고유함이라는 특성 때문에 일반 키와 충돌하지 않아야 한다는 메타 레벨 키의 조건을 잘 충족시킬 수 있기 때문이다. 대표적인 예를 보면 다음과 같다.

Symbol.unscopables

이 잘 알려진 심볼은 with에서 발생한 충돌을 해결하고자 만들어졌는데, 특정 객체의 속성을 with 문의 바인딩에서 제외하는 역할을 한다. 그 특정한 예시를 보면 다음과 같다.

function f(foo, values) {
  with(foo) {
    console.log(values);
  }
}

var obj = {};
f([1,2,3], obj);

ES2015 이전에선, valuesobj로 바인딩이 되었겠지만 ES2015부터 도입된 Array.prototype.values로 인해 foo라는 배열이 갖는 프로토타입 메서드와 이름 충돌을 일으키게 되었다. 그래서 Array.prototype[Symbol.unscopables]를 내부적으로 사용하여 메서드를 가리도록 처리되어 있다.

일반 객체에 Symbl.unscopables를 처리하여 with 바인딩을 제외하는 코드를 살펴보자.

const human = {
  name: 'John',
  age: 30,
  [Symbol.unscopables]: {
    age: true
  }
};

with(human) {
  console.log(age); // Uncaught ReferenceError: age is not defined
}

이렇게 age를 가릴 수 있게 된다.

Symbol.toPrimitive

이것은 객체를 원시형으로 형 변환 시 사용되는데, 예를 들어 alert(obj)를 호출하면 자동으로 객체가 문자열로 바뀌는 상황을 생각하면 된다. 실제로 Symbol.toPrimitive 가 사용되는 예시로 Date.prototype[Symbol.toPrimitive](hint) 가 있다.

Date 객체는 다른 일반 객체와 달리 형 변환의 기본 값을 number로 취급하도록 Symbol.toPrimitive를 사용하여 구현되었다. 그래서 Date 객체 간 덧셈, 뺄셈 같은 연산이 자동 처리가 되는 것이다.

간단한 예제를 통해 동작을 살펴보자.

const human = {
  name: 'John',
  age: 30,
  [Symbol.toPrimitive](hint) {
    return hint === 'string' ? `name: ${this.name}` : this.age
  }
};

console.log(`${human}`); // name: John
console.log(human + 10); // 40

객체 연산을 할 경우 Symbol.toPrimitive(hint)를 이용하여 형 변환에 따라 hint 값이 변경되면서 적절한 연산을 수행하는 것이다.

Symbol.toStringTag

ES2015 이전 객체는 내부 슬롯으로 [[Class]]를 갖고 있었고, 타입 체크를 위해 Object.prototype.toString 을 사용하면 이 내부 슬롯을 이용하여 정보를 출력해 줬었다. 하지만 ES2015부터 [[Class]] 내부 슬롯이 사라지고 기존 코드의 호환성을 맞춰주기 위해 Symbol.toStringTag를 미리 정의하여 해당 태그 값을 출력해 주도록 변경되었다.

아래 코드를 수행시키는 내부 동작에 Symbol.toStringTag가 사용된 것이다.

console.log(Object.prototype.toString.call({})); // [object Object];

이제 일반 객체에 적용시켜보는 예제를 살펴보자.

class Stack {
  get [Symbol.toStringTag]() {
    return 'Stack';
  }
}

const s = new Stack();
console.log(Object.prototype.toString.call(s)); // '[object Stack]'

영역(realm)을 공유하는 잘 알려진 심볼들

잘 알려진 심볼을 알아보던 중 ECMAScript 스펙에서 "잘 알려진 심볼 값들은 모든 영역(realm)에서 공유된다."라는 말이 나왔다.

영역이란 전역 환경과 해당 전역 환경의 스코프 내에서 로드되는 코드와 기타 상태 및 리소스 등으로 구성되는 개념이다. 예로 iframe 간 전역 객체 변수가 서로 다르다는 것을 생각해 볼 수 있다.

아래 코드를 먼저 살펴보면 iframeSymbol.iterator는 같다는 점을 알 수 있다.

<!DOCTYPE html>
  <head>
    <script>
      function test() {
        const iframe = frames[0];
        console.log(Window === iframe.Window); // false
        console.log(Symbol.iterator === iframe.Symbol.iterator); // true
      }
    </script>
  </head>
  <body>
      <iframe srcdoc="<script>window.parent.test()</script>">
  </iframe>
  </body>
</html>

Symbol 또한 각 영역에 개별적으로 존재하지만 이런 사전 정의된 잘 알려진 심볼들은 엔진에 의해 관리되어 영역을 넘나들 수 있다.

라이브러리 개발 시 이렇게 영역을 넘나드는 Symbol을 만들 필요가 있다면 전역 심볼 레지스트리(Global symbol registry) 를 사용해야 한다. 전역 심볼 레지스트리에 키를 등록하고 조회하려면 Symbol.forSymbol.keyFor 메서드를 사용하면 된다.

참고로, 잘 알려진 심볼은 전역 심볼 레지스트리에서 찾을 수 없다는 점만 알아두면 된다.

정리하며

여러 자료를 읽어보며 간단하게 Symbol의 도입 배경과 현재의 활용 방법을 정리해 봤다. 결국 원대했던 도입 배경에 비해 근황은 잘 알려진 심볼을 사용한 메타프로그래밍과, 프레임워크 레벨에서 사용할 고유 키 생성 역할로 소소하게 굳어져 있다.

이런 개발 영역은 특별한 케이스이기에 실제 애플리케이션 개발에서 마주하기 어려울 것이다. 하지만 필자는 TOAST UI 라이브러리를 개발하고 있기에 Symbol도 하나의 옵션으로 기억해두고 언젠가 기회가 된다면 사용해 보려 한다.

References

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Symbol
https://ko.javascript.info/symbol#ref-397
https://exploringjs.com/es6/ch_symbols.html
https://exploringjs.com/es6/ch_oop-besides-classes.html
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/with
https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-well-known-symbols
https://tc39.es/ecma262/multipage/executable-code-and-execution-contexts.html#sec-code-realms

이진우2022.02.08
Back to list