Published on

ES6) Symbol을 Symbol답게 써보자

Authors
  • avatar
    Name
    Hoehyeon Jung
    Twitter

Symbol

ES5 이전의 자바스크립트는 String, Number, Boolean, null, undefined, 6개의 원시(Primitive) 타입이 존재했다. 하지만 ES6가 도입되면서 새로운 원시타입이 도입되었는데 그것이 Symbol이다. Symbol의 도입 배경과 특징 Well-known Symbol 등에 대해 알아보자.

도입 배경

ES6 이후로 자바스크립트는 모던 자바스크립트로의 변모를 진행하였다. 이후 다시 살펴볼 예정이지만 우선 for .. of 예로 들어보자. 객체 내의 iterator를 참조하여 함수 로직을 구현한다. 하지만 ES5까지의 개발 환경 내에서 특정 객체에서 iterator라는 property를 가지는 객체는 존재할 것이다. 그렇다고 해서 python과 같이 __iterator__와 같은 메서드를 쓴다해도 언젠가 이러한 property들이 중복되지 않으리라는 보장도 없을 것이고, 중복을 피하고자 부자연스럽게 (ex. __$$[[iterator]]__...) 만드는 것도 바람직한 해결책이 아니다. 그렇다면 어떻게 문제를 해결해야 할까? TC에서는 이러한 고민 해결을 위해 절대 중복되지 않는 새로운 원시 타입의 도입을 결정했다. 바로 이 원시 타입이 Symbol이다.

Symbol??

Symbol의 문법적 특징에 대해 알아보자.

const foo1 = Symbol('foo');
const foo2 = Symbol('foo');
console.log(foo1); // Symbol(foo)
console.log(typeof foo1); // symbol
console.log(foo1 === foo2); // false
const bar = new Symbol('bar'); // Type Error: Symbol is not a constructor

Symbol은 기본적으로 Unique한 값이다. 같은 원시타입으로 초기화한다고 해서 같은 값이 아닌 별도의 값으로 취급한다. 또한 Symbol로 생성된 값은 typeof로 타입을 확인하면 'symbol'로 분류한다. 또한 new 키워드를 통해 생성하면 생성자가 아니기 때문에 TypeError를 반환한다.

이러한 Unique한 값들을 어떻게 활용할 수 있을까? 아래를 확인해보자.

const foo = Symbol('foo');
const myObject = {};
myObject['foo'] = 'This is foo.';
myObject[foo] = 'This is Symbol foo!';
console.log(myObject.foo); // This is foo.
console.log(myObject[foo]); // This is Symbol foo!

객체의 property를 확인해보면 우선 Symbol로 선언된 값 역시 객체의 property로 선언할 수 있을 뿐더러, 기존에 문자열 foo로 선언된 property와 Symbol로 초기화된 foo가 서로 다른 것을 확인할 수 있다. 이러한 속성은 기존에 선언된 property값들과 Symbol로 선언된 값이 다르기 때문에 기존과의 호환성을 지킬 수 있게 된다.

Symbol is not always unique value.

그렇다면 Symbol은 항상 Unique한 value라고 단언할 수 있는가? 라고 묻는다면 항상 그렇다 볼 수는 없다.

console.log(Symbol('foo') === Symbol('foo')); // false
console.log(Symbol.for('foo') === Symbol.for('foo')); // true

Symbol.for() 메서드는 Symbol가 고유값을 가지게 만들기 보다는 global symbol registry에 값을 저장하게 한다. 이 global symbol registryglobal하기 떄문에 iframe이나 ServiceWorker같이 가르키는 Window가 서로 다르더라고 값이 같다.

그럼 우리가 Symbol이 고유한 값인지 그렇지 않은지 판별하는 방법은 없는걸까? 그렇지는 않다. Symbol.keyFor()메서드를 통해 해당 Symbol이 Global한 것인지 판별할 수 있다.

const localSymbol = Symbol('foo');
const globalSymbol = Symbol.for('foo');

console.log(Symbol.keyFor(localSymbol)); // undefined
console.log(Symbol.keyFor(globalSymbol)); // foo

Symbol is private?

객체의 key 값을 Symbol로 선언하면 어떤 이득이 있을까? 다음을 살펴보자.

const obj = {};
obj['key'] = 'value';
obj[Symbol('key')] = 'Symbol value';
console.log(Object.getOwnPropertyNames(obj)); // ['key']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(key)]

기존에 객체의 property 조회를 위한 메서드로서 Object.getOwnPropertyNames(), for .. in 등과 같은 메서드를 사용하였다. 하지만 이러한 메서드로는 객체 내부에 Symbol로 선언된 key들은 탐색되지 않는다. 즉 Private한 값을 가지게 된 것이다. 그렇다고 해서 완전히 Private하다고 볼 수 없다. Object.getOwnPropertySymbols()와 같은 메서드로 실제로 탐색이 가능하기 때문이다.

Symbol is copy as default

const obj = {};
obj['key'] = 'value';
obj[Symbol('key')] = 'Symbol value';
const newObj = {};
Object.assign(newObj, obj);
console.log(Object.getOwnPropertySymbols(newObj)); // [Symbol(key)]

Symbol로 선언된 property 역시 Object.assign과 같은 메서드로 복사할 경우 복사가 된다. 이는 해당 property가 Enumerable 속성을 가지기 때문이다. 따라서 이를 방지하려면 Object.defineProperty()로 해당 property의 enumerable 속성을 false로 해야 한다.

Symbol is not coercible into primitives

+Symbol(1) // TypeError: Cannot convert a Symbol value to a number
''+Symbol('a') // TypeError: Cannot convert a Symbol value to a string

Symbol은 원시타입으로 강제적인 변환이 불가능하여 위와 같은 메서드들은 TypeError를 반환한다.

Well known Symbols

그렇다면 왜 새로운 원시타입을 도입하면서까지 중복 문제를 해결해야 했을까? 바로 해당 속성을 참조하여 동작하는 메서드들이 있기 때문이다. 이러한 메서드들이 사용하는 Symbol들이 여러 개 있는데 이러한 Symbol 요소들을 Well-known Symbols라고 한다.

Symbol.iterator

자바스크립트 엔진에서는 해당 property를 가진 객체들은 iterable 프로토콜을 만족하여 이에 따른 순회가 가능하다고 간주한다.

class Collection {
  *[Symbol.iterator]() {
    var i = 0;
    while(this[i] !== undefined) {
      yield this[i];
      ++i;
    }
  }
}
var myCollection = new Collection();
myCollection[0] = 1;
myCollection[1] = 2;
for(var value of myCollection) {
    console.log(value); // 1, 2
}

예제를 살펴보면 generator로 구현되었는 Symbol.iterator에 대하여 for .. of로 동작이 가능하다.

Symbol.hasInstance

class Myclass {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}
console.log([] instanceof Myclass); // true

instanceof는 해당 객체의 Symbol.hasInstance property를 기반으로 반환값을 결정한다.

Symbol.species

class Array1 extends Array {
  static get [Symbol.species]() { return Array; }
}
const a = new Array1(1, 2, 3);
const mapped = a.map(x => x * x);

console.log(mapped instanceof Array1); // false

console.log(mapped instanceof Array); // true

Symbol.species에서 반환한 값을 기준으로 instanceof를 판단하게 된다.

Symbol.isConcatSpreadable

arrayIshInstance = [3, 4];
collectionInstance = [5, 6];
collectionInstance[Symbol.isConcatSpreadable] = false;
spreadableTest = [1,2].concat(arrayIshInstance).concat(collectionInstance);
console.log(spreadableTest); // [1, 2, 3, 4, Array[5, 6]]

Array 객체에서 Symbol.isConcatSpreadable를 false로 둘 경우 .concat() 메서드 실행 시, 내부 배열의 Spread 연산을 적용하지 않고 concat을 수행한다.

Symbol.toStringTag

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

console.log(Object.prototype.toString.call(new ValidatorClass())); // [object Validator]

Object.prototype.toString 메서드를 호출할 때에는 Symbol.toStringTag에서 반환되는 문자열을 기준으로 반환값을 결정해준다. 이러한 방식을 통해 해당 객체의 type을 결정하는데 있어 더욱 정확하게 분류가 가능하다. 이전에 작성한 포스트에서 clone을 모방한 함수 작성을 하였는데 링크 당시에는 type 비교를 typeof로 비교하였다. 하지만 typeof의 경우 String, Number 등의 원시 타입을 제외한 type을 Object로 판별하기 때문에 다른 값들이 넘어가서 copy가 안될 수도 있다. 하지만 Object.prototype.toString 메서드를 이용하면 좀 더 세밀한 비교를 통한 copy가 가능하다.

마치며

Symbol에 대해서는 실제적으로 접해본 일이 거의 없는 만큼 정확한 개념을 접해볼 기회가 없었는데 이번 기회를 통해 제대로 이해할 수 있었다. 또한 다양한 메서드들이 Well-known Symbol을 통해 Symbol을 호출하여 처리하는 것을 알 수가 있었다.

참조