얼마 전 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 이전에선, values
가 obj
로 바인딩이 되었겠지만 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]'
잘 알려진 심볼을 알아보던 중 ECMAScript 스펙에서 "잘 알려진 심볼 값들은 모든 영역(realm)에서 공유된다."라는 말이 나왔다.
영역이란 전역 환경과 해당 전역 환경의 스코프 내에서 로드되는 코드와 기타 상태 및 리소스 등으로 구성되는 개념이다. 예로 iframe
간 전역 객체 변수가 서로 다르다는 것을 생각해 볼 수 있다.
아래 코드를 먼저 살펴보면 iframe
간 Symbol.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.for
와 Symbol.keyFor
메서드를 사용하면 된다.
참고로, 잘 알려진 심볼은 전역 심볼 레지스트리에서 찾을 수 없다는 점만 알아두면 된다.
여러 자료를 읽어보며 간단하게 Symbol
의 도입 배경과 현재의 활용 방법을 정리해 봤다. 결국 원대했던 도입 배경에 비해 근황은 잘 알려진 심볼을 사용한 메타프로그래밍과, 프레임워크 레벨에서 사용할 고유 키 생성 역할로 소소하게 굳어져 있다.
이런 개발 영역은 특별한 케이스이기에 실제 애플리케이션 개발에서 마주하기 어려울 것이다. 하지만 필자는 TOAST UI 라이브러리를 개발하고 있기에 Symbol
도 하나의 옵션으로 기억해두고 언젠가 기회가 된다면 사용해 보려 한다.
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