All Articles

JavaScript Symbol에 대해 알아보기

Ref: MDN, 2ality, 러닝 자바스크립트

개인적으로 공부한 내용을 정리한 것이다 보니, 틀린 부분이 있을 수 있습니다. 혹여 이 글을 보시는 분은 그 점에 유의해주시면 감사하겠습니다. 댓글 달아주시면 더욱 감사하겠습니다 😊


Symbol

ES6에서 도입된 새로운 데이터 타입이다. 고유한 ID를 제공하는 토큰이다. 팩토리 함수인 Symbol()로 생성할 수 있다. 함수로 호출했을 때의 String과 비슷한 면이 있다.

String() // ""
Symbol() // Symbol()

Symbol() 팩토리 함수는 파라미터에 string 값을 받을 수 있다. 특별히 코드 내에서 어떤 기능을 하는 것은 아니고, 단순히 symbol 값에 대해 설명하는 역할을 한다. 문서에서는 description이라고 표현하고 있고, 실제로 Symbol의 프로토타입에 description이라는 프로퍼티가 정의되어 있어서, 해당 description에 접근할 수 있다. 그리고 읽기 전용으로 혹여 새로운 값을 할당하더라도 변하지 않는다. (description에 대한 getter가 내장돼있다)

const sym = Symbol('hello')
console.log(sym) // Symbol(hello)
sym.description // 'hello'

sym.description = 'hello World'
sym.description // 'hello'

내 생각에 중요한 부분

  • 항상 유일하다 -> 고유한 식별자를 가질 수 있게 된다

    • 이 부분에서 식별자 기반의 컨텍스트를 가지는 객체 지향에 사용하는 것이라 생각한다. 이 부분에서 new Set()과 비슷한 느낌이다.
const sym = Symbol('hello')
const sym2 = Symbol('hello')

console.log(sym === sym2) // false

그리고 symbol은 string 값으로 자동 변환되지 않는다.

이 말이 무슨 말이냐면, 보통 자바스크립트의 원시 값은 특정 상황에서 string 값으로 자동으로 변환된다.

5 + 'abc' // '5abc'

하지만 symbol 값은 에러를 뱉는다

const sym = Symbol('hello')

sym + 'abc' // Uncaught TypeError: Cannot convert a Symbol value to a string
alert(sym) // Uncaught TypeError: Cannot convert a Symbol value to a string

당연스럽게도 문자열로 바꿔준 후 호출하면 정상적으로 된다.

sym.toString() + 'abc' // 'Symbol(hello)abc'
alert(sym.toString()) // Symbol(hello)

이런 특징은 symbol은 고유한 식별자로 작동해야 한다는 목적과 상관이 있는 것 같다. symbol을 할당한 변수가 실수로 string으로 변한다면, 고유한 식별자로서의 기능을 잃게 되는 것이니, 이를 위한 방어수단이라고 생각한다.

프로퍼티의 키로 사용할 때 특이점이 있다

const APPLE = Symbol('This brand is on point')

const brand = {
  [APPLE]: 'expensive',
  example: 'cheap',
}

console.log(brand) // {example: "cheap", Symbol(This brand is on point): "expensive"}

괄호를 써서 표현했는데, 괄호 없이 정의하면 string으로 취급되고 symbol로 저장되지 않는다. 점 연산자로 정의하는 것도 불가능하다. (점 연산자는 문자열 프로퍼티로 동작한다)

const SAMSUNG = Symbol('This brand is on point')

brand.SAMSUNG = 'expensive'
brand[SAMSUNG] = 'expensive'

console.log(brand)
/*
  example: "cheap"
  SAMSUNG: "expensive"
  Symbol(This brand is on point): "expensive"
  Symbol(This brand is on point): "expensive"
*/

그리고 앞에서 symbol 값을 저장했던 변수로도 접근할 수 있다.

brand[APPLE] // "expensive"

객체를 조회할 때 할당했던 변수명이 나오지 않아서 좀 헷갈릴 여지가 있어 보인다.

symbol은 열거 가능한 속성이 아니고 몇 가지 메서드에서 나타나지 않는다

사실인지 확인해보자. 총 세 개의 프로퍼티(symbol을 키로 사용하는 프로퍼티, 일반 프로퍼티, 열거 속성을 false로 바꾼 프로퍼티)를 포함하는 객체를 정의해보자. 2ality의 예제를 보겠다.

const obj = {
  [Symbol('sym')]: 1,
  enum: 2,
  nonEnum: 3,
}
Object.defineProperty(obj, 'nonEnum', { enumerable: false }) 
// nonEnum 프로퍼티의 열거 속성을 false로 바꾼다

symbol 값을 가지는 프로퍼티 키를 무시하는 몇 가지 Object 메서드가 있다. 그리고 열거 가능하지 않기 때문에, 열거 가능한 요소만 출력하는 메서드는 제대로 동작하지 않는다.

Object.getOwnPropertyNames(obj)
// (2) ["enum", "nonEnum"]

이 메서드는 enumerable이 false로 돼있는 프로퍼티의 키까지 반환한다.

Object.getOwnPropertySymbols(obj)
// [Symbol(sym)]

이렇게 symbol 값만 별도로 확인할 수 있는 메서드가 존재한다.

Reflect.ownKeys(obj)
// (3) ["enum", "nonEnum", Symbol(sym)]

이 메서드는 화끈하게 모든 종류의 키 값을 반환한다.

for (const p in obj) { console.log(p) }
// enum
Object.keys(obj)
// ["enum"]

열거가능한 프로퍼티에만 작동하는 for in이나 Object.keys에는 포함되지 않는다.


2ality에서 설명하는 활용 예제

concept을 표현할 때 사용하는 symbol

ES5까지는 enum 상수 같은 concept을 표현할 때 string을 사용했다.

const POSITION_TOP = 'TOP'
const POSITION_RIGHT = 'RIGHT'
const POSITION_BOTTOM = 'BOTTOM'
const POSITION_LEFT = 'LEFT'

function getReversedPosition(position) {
  switch (position) {
    case POSITION_TOP:
      return POSITION_BOTTOM
    case POSITION_LEFT:
      return POSITION_RIGHT
    default:
      throw new Exception(`Unknown position: ${position}`)
  }
}

직접 ‘top’, ‘right’ 같은 값을 입력해주는 대신 POSITION_TOP 같은 상수를 통해서 해당 position을 표현했다.

우리는 position이라는 의미로 top 같은 요소들을 사용하고 싶다. 그런데 여기서 string은 고유한 값이 아니기 때문에 예기치 못한 오류를 발생시킬 수 있다. 예를들어 롤 캐릭터의 포지션을 표현하는 상수를 정의했다고 가정해보자

const LOL_POSITION = {
  RIVEN: 'TOP'
}

그리고 위에서 정의한 getReversedPosition 함수의 파라미터에 넘기면,

getReversedPosition(LOL_POSITION.RIVEN)

원래 의도와 다르게 ‘BOTTOM’을 리턴한다 😢

TOP이라는 값은 더 이상 고유한 값이 아니다. 의도대로라면 RIVEN을 넘겼을 때는 해당 함수에서 에러를 리턴해야 한다. 하지만 고유 값이 아닌 상수에 할당된 string을 기준으로 평가하기 때문에 의도하지 않는 결과가 도출된다.

symbol을 사용하면 이 문제를 해결할 수 있다.

const POSITION_TOP = Symbol('TOP')
const POSITION_RIGHT = Symbol('RIGHT')
const POSITION_BOTTOM = Symbol('BOTTOM')
const POSITION_LEFT = Symbol('LEFT')
// description을 안넘겨주면 리턴 값이 Symbol() 이런식으로 나온다.
// 헷갈릴 여지가 있으니 description을 넣어주자

정의했던 함수는 바꿀 필요가 없다. 다시 RIVEN을 넘겨보면,

getReversedPosition(LOL_POSITION.RIVEN)
// Uncaught ReferenceError: Exception is not defined

POSITION_TOP 등이 고유한 식별자로 평가되기 때문에, 다른 값이 들어오면 에러를 던진다.


아래의 내용은 아직 제가 정확히 이해하기가 어렵네요. 그래서 우선 2ality의 내용을 번역하는 정도로 정리해놓았습니다.

프로퍼티의 키로 symbol 사용하기

다른 키와 충돌하지 않는 키가 되도록 프로퍼티를 생성하면 두 가지 상황에서 유용하다.

  • mixin으로 여러 당사자가 같은 객체의 내부 프로퍼티에 기여하는 경우
  • base-level 프로퍼티와 충돌하는 meta-level 프로퍼티를 보존하고 싶을 때

내부 프로퍼티의 키로 symbol을 사용하는 경우

믹스인은 객체나 프로토타입의 기능을 늘리도록 해주는 객체의 파편 혹은 메서드의 세트같은 개념이다. 메서드가 키를 symbol 값으로 가지면 다른 객체나 믹스인에 추가된 메서드와 충돌할 수 없다. symbol은 고유한 식별자이기 때문이다.

사용성의 이유로 메서드의 key가 string 값이기를 바랄 수도 있다. 내부 메서드는 믹스인이나 서로 협력할 필요가 있는 경우에만 알려져 있다. 이런 경우 key로 symbol 값을 가지는 게 이익이 된다.

symbol은 실제로 privacy를 제공하지는 않는다. symbol 값을 가진 프로퍼티의 키를 찾는 게 쉽기 때문이다. 그래도 다른 프로퍼티의 키와는 충돌하지 않는 다는 것을 보장하기에는 충분하다. 외부에서 private한 데이터에 접근하는 일을 정말로 방지하고 싶다면, WeakMap이나 closure를 사용해야 한다. 예를들면 아래 예제와 같다.

// 프라이빗 프로퍼티 당 하나의 WeakMap
const PASSWORD = new WeakMap()
class Login {
  constructor(name, password) {
    this.name = name
    PASSWORD.set(this, password)
  }
  hasPassword(pw) {
    return PASSWORD.get(this) === pw
  }
}

Login의 인스턴스는 WeakMap PASSWORD에 키를 가지고 있다. WeakMap은 인스턴스가 가비지-콜렉티드 되는 것을 예방하지 않는다. 키가 더 이상 존재하지 않는 개체의 entry는 WeakMap에서 제거된다.

내부 프로퍼티에서 symbol 키를 사용하고 싶으면 아래와 같이 작성할 수 있다.

const PASSWORD = Symbol()
class Login {
  constructor(name, password) {
    this.name = name
    this[password] = password
  }
  hasPassword(pw) {
    return this[PASSWORD] === pw
  }
}

메타 레벨 프로퍼티의 키로 사용하는 심볼

고유 식별자를 가지는 symbol은 normal 프로퍼티 키와 다른 레벨에 존재하는 public 프로퍼티의 키로 쓰기에 이상적이다. 왜냐하면 meta-level 키와 normal 키는 충돌하지 않아야만 하기 때문이다. meta-level 프로퍼티의 한 가지 예는 객체가 라이브러리에서 처리하는 방법을 사용자가 정의해서 구현할 수 있는 메서드다. symbol 키를 사용하면 라이브러리를 사용할 때 일반 메서드를 커스텀된 메서드로 착각하지 않도록 막아준다.

ES6에서 Iterability는 이러한 커스텀화의 하나이다. 키가 Symbol.iterator가 저장돼 있는 symbol 메서드면, 객체는 iterable이다. 아래의 코드에서 객체는 iterable이다.

let obj = {
    data: [ 'hello', 'world' ],
    [Symbol.iterator]() {
        const self = this;
        let index = 0;
        return {
            next() {
                if (index < self.data.length) {
                    return {
                        value: self.data[index++]
                    };
                } else {
                    return { done: true };
                }
            }
        };
    }
};

객체의 iterability는 for-of loop나 비슷한 자바스크립트 기능을 사용할 수 있게 해준다.

for (let x of obj) {
	console.log(x)
}

symbol로 영역 넘나들기

코드의 영역은 코드 조각이 존재하는 문맥이다. 전역 변수나 불러온 모듈 등을 포함한다. 코드가 한 영역 내부에 존재해도, 다른 영역에 있는 코드에 접근할 수도 있다. 예를 들어서 브라우저에 각각의 프레임이 있고 자기만의 영역을 가지고 있다고 가정해보자. 한 프레임 내에서 실행을 또 다른 프레임으로 건너뛸 수 있다. 아래의 HTML을 보자.

<head>
  <script>
  	function test(arr) {
      var iframe = frames[0]
      console.log(Array === iframe.Array)
      console.log(arr instanceof Array)
      console.log(arr instanceof iframe.Array)
      
      console.log(Symbol.iterator === iframe.Symbol.iterator)
    }
  </script>
</head>
<body>
  <iframe srcdoc="<script>window.parent.test([])</script>"></iframe>
</body>

문제는 각각의 영역이 자기 자신의 배열 복사본을 가진다는 것이다. 객체는 독립적인 식별자를 가지기 때문이다. 이런 local 카피는 근본적으로는 같은 객체일지라도 다르게 간주된다. 비슷하게 어떤 한 영역에서 불러온 라이브러리나 사용자의 코드와 각각의 영역등은 같은 객체의 다른 버전을 가진다.

대조적으로, 원시값인 boolean이나 number나 string은 개별적인 독자성을 가지지 않고 같은 값의 여러 복사본을 가진다. 이건 문제가 아니다. 복사본은 값에 의해 비교되고, 동일하게 간주된다. 독자성이 아니라 컨텐츠의 관점에서 보자.

symbol은 개별적인 독자성을 가진다. 덧붙여서 영역을 다른 원시값 처럼 맘대로 넘나들 수는 없다. 이건 영역을 넘나들며 동작하는 Symbol.iterator와 같은 symbol의 문제이다. 객체가 한 영역에서 iterable이라면, 다른 곳에서도 iterable하다. 영역을 교차하는 symbol이 자바스크립트 엔진에서 제공된다면, 엔진은 각각의 영역에서 같은 값이 사용된다고 확신할 것이다. 그러나 라이브러리에서 우리는 전역 symbol registry의 형태로 오는 추가적인 support가 필요하다. 이 registry는 모든 영역에 퍼져 있고, symbol에 string을 매핑한다. 각각의 symbol과 라이브러리는 가능한 고유하게 string으로 작성해야 한다. symbol을 생성하기 위해서 Symbol()을 사용할 필요는 없다. string과 매핑하는 symbol에 대한 registry를 요구할 뿐이다. registry에 이미 string entry가 있는 경우, 관련된 symbol이 반환된다. 반대로 entry와 symbol은 처음에 생성된다.

Symbol.for()로 symbol에 대한 레지스트리를 요구받고, Symbol.keyFor()로 symbol과 관련된 string을 받아온다.

let sym = Symbol.for('Hello everybody!')
Symbol.keyFor(sym)
// 'Hello everybody!'

예상한대로 자바스크립트 엔진에서 제공 받는 Symbol.iterator와 같은 교차 영역 symbol은 registry에 없다.