5.5 Symbol과 Well-known Symbol
Symbol은 ES6에서 추가된 원시 타입으로, 항상 고유한 값을 가집니다. Well-known Symbol을 통해 JavaScript 내장 동작을 커스터마이징할 수 있습니다.
Symbol 기초
// Symbol은 항상 고유
const sym1 = Symbol();
const sym2 = Symbol();
console.log(sym1 === sym2); // false
// 설명(description) 추가 — 디버깅용
const id = Symbol("user id");
const version = Symbol("version");
console.log(id.toString()); // "Symbol(user id)"
console.log(id.description); // "user id"
// Symbol은 암시적 형변환 불가
try {
const str = "접두사 " + id; // TypeError!
} catch (e) {
console.log(e.message);
}
// 명시적 변환은 가능
console.log(id.toString()); // "Symbol(user id)"
console.log(String(id)); // "Symbol(user id)"
// +id → TypeError (숫자 변환 불가)
// `${id}` → TypeError (템플릿 리터럴에서도 불가)
객체 키로서의 Symbol
Symbol을 객체 프로퍼티 키로 사용하면 일반 문자열 키와 충돌 없이 메타데이터를 추가할 수 있습니다.
const TYPE = Symbol("type");
const VERSION = Symbol("version");
const INTERNAL = Symbol("internal state");
class DataModel {
constructor(data, type) {
// 공개 데이터
Object.assign(this, data);
// Symbol 키: 외부 코드와 충돌 없는 메타데이터
this[TYPE] = type;
this[VERSION] = 1;
this[INTERNAL] = { dirty: false, created: Date.now() };
}
getType() { return this[TYPE]; }
getVersion() { return this[VERSION]; }
markDirty() {
this[INTERNAL].dirty = true;
}
}
const user = new DataModel({ name: "Alice", email: "alice@example.com" }, "User");
// Symbol 키는 일반 열거에서 제외됨
console.log(Object.keys(user)); // ["name", "email"] (Symbol 제외)
console.log(JSON.stringify(user)); // '{"name":"Alice","email":"alice@example.com"}'
// Symbol 키 접근
console.log(user.getType()); // "User"
console.log(user[TYPE]); // "User"
// Symbol 키 얻기
const symbolKeys = Object.getOwnPropertySymbols(user);
console.log(symbolKeys.length); // 3
// Reflect.ownKeys: 문자열 + Symbol 키 모두
console.log(Reflect.ownKeys(user)); // ["name", "email", Symbol(type), ...]
Symbol.for() — 전역 레지스트리
Symbol.for()는 전역 심볼 레지스트리에서 키로 심볼을 공유합니다.
// Symbol.for: 같은 키면 같은 심볼 반환
const s1 = Symbol.for("app.user.id");
const s2 = Symbol.for("app.user.id");
console.log(s1 === s2); // true
// Symbol(): 항상 새로운 심볼
const s3 = Symbol("app.user.id");
console.log(s1 === s3); // false
// 역방향: 심볼로 키 찾기
console.log(Symbol.keyFor(s1)); // "app.user.id"
console.log(Symbol.keyFor(s3)); // undefined (전역 레지스트리에 없음)
// 라이브러리 간 심볼 공유 패턴
// 라이브러리 A
const STATUS = Symbol.for("myapp.status");
// 라이브러리 B (같은 앱, 다른 모듈)
const STATUS_B = Symbol.for("myapp.status");
const obj = {};
obj[STATUS] = "active";
console.log(obj[STATUS_B]); // "active" (같은 심볼)
Well-known Symbol 1: Symbol.iterator
Symbol.iterator를 구현하면 객체를 for...of, 스프레드, 구조 분해에서 사용할 수 있습니다.
class Range {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const { end, step } = this;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { value: undefined, done: true };
},
[Symbol.iterator]() { return this; }, // 이터레이터 자신도 이터러블
};
}
// 제너레이터로 더 간결하게
// *[Symbol.iterator]() {
// for (let i = this.start; i <= this.end; i += this.step) yield i;
// }
}
const range = new Range(1, 10, 2);
// for...of
for (const n of range) {
process.stdout.write(`${n} `);
}
// 1 3 5 7 9
console.log();
// 스프레드
console.log([...range]); // [1, 3, 5, 7, 9]
// 구조 분해
const [first, second, ...rest] = range;
console.log(first, second, rest); // 1 3 [5, 7, 9]
// Math.min/max와 함께
console.log(Math.max(...range)); // 9
Well-known Symbol 2: Symbol.toPrimitive
타입 변환 시의 동작을 커스터마이징합니다.
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
[Symbol.toPrimitive](hint) {
console.log(`hint: ${hint}`);
switch (hint) {
case "number": return this.celsius;
case "string": return `${this.celsius}°C`;
case "default": return this.celsius;
}
}
toString() { return `Temperature(${this.celsius}°C)`; }
}
const temp = new Temperature(25);
// 숫자 연산 (hint: "number")
console.log(temp + 5); // hint: default → 30
console.log(temp * 2); // hint: number → 50
console.log(temp > 20); // hint: number → true
// 문자열 컨텍스트 (hint: "string")
console.log(`온도: ${temp}`); // hint: string → "온도: 25°C"
// 비교 (hint: "default")
console.log(temp == 25); // hint: default → true
// valueOf와 Symbol.toPrimitive의 관계
class Money {
constructor(amount, currency = "KRW") {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === "string") return `${this.amount.toLocaleString()}${this.currency}`;
return this.amount;
}
}
const price = new Money(50000);
console.log(`가격: ${price}`); // "가격: 50,000KRW"
console.log(price + 10000); // 60000
console.log(price > 30000); // true
Well-known Symbol 3: Symbol.hasInstance
instanceof 연산자의 동작을 커스터마이징합니다.
class TypeValidator {
static [Symbol.hasInstance](value) {
return this.validate(value);
}
}
class Integer extends TypeValidator {
static validate(value) {
return Number.isInteger(value);
}
}
class PositiveNumber extends TypeValidator {
static validate(value) {
return typeof value === "number" && value > 0 && !isNaN(value);
}
}
class NonEmptyString extends TypeValidator {
static validate(value) {
return typeof value === "string" && value.trim().length > 0;
}
}
console.log(42 instanceof Integer); // true
console.log(3.14 instanceof Integer); // false
console.log(-1 instanceof PositiveNumber); // false
console.log(10 instanceof PositiveNumber); // true
console.log("" instanceof NonEmptyString); // false
console.log("hello" instanceof NonEmptyString); // true
// 조건부 타입 체크 함수
function assertType(value, Type) {
if (!(value instanceof Type)) {
throw new TypeError(
`기대 타입: ${Type.name}, 받은 값: ${JSON.stringify(value)}`
);
}
return value;
}
assertType(42, Integer); // OK
assertType("hi", NonEmptyString); // OK
// assertType(3.14, Integer); // TypeError!
Well-known Symbol 4: Symbol.toStringTag
Object.prototype.toString.call() 결과를 커스터마이징합니다.
// 기본 동작
console.log(Object.prototype.toString.call([])); // "[object Array]"
console.log(Object.prototype.toString.call(new Map())); // "[object Map]"
console.log(Object.prototype.toString.call(null)); // "[object Null]"
// 커스텀 태그
class Collection {
get [Symbol.toStringTag]() {
return "Collection";
}
}
const col = new Collection();
console.log(Object.prototype.toString.call(col)); // "[object Collection]"
console.log(col.toString()); // "[object Collection]" (기본 toString 사용 시)
// 타입 감지 유틸리티
function getType(value) {
return Object.prototype.toString.call(value).slice(8, -1);
}
console.log(getType([])); // "Array"
console.log(getType({})); // "Object"
console.log(getType(new Map())); // "Map"
console.log(getType(new Set())); // "Set"
console.log(getType(null)); // "Null"
console.log(getType(undefined)); // "Undefined"
console.log(getType(async () => {})); // "AsyncFunction"
console.log(getType(col)); // "Collection"
기타 Well-known Symbols
// Symbol.isConcatSpreadable: Array.prototype.concat에서 펼칠지 여부
const arr = [1, 2, 3];
const fakeArray = { 0: 4, 1: 5, length: 2 };
// 기본: 유사 배열은 펼쳐지지 않음
console.log([0].concat(fakeArray)); // [0, { 0: 4, 1: 5, length: 2 }]
fakeArray[Symbol.isConcatSpreadable] = true;
console.log([0].concat(fakeArray)); // [0, 4, 5]
// 배열을 펼치지 않게 설정
const noSpread = [6, 7, 8];
noSpread[Symbol.isConcatSpreadable] = false;
console.log([1, 2].concat(noSpread)); // [1, 2, [6, 7, 8]]
// Symbol.species: 파생 메서드의 반환 타입 제어
class MyArray extends Array {
static get [Symbol.species]() { return Array; } // map 등이 Array를 반환
}
const myArr = new MyArray(1, 2, 3);
const mapped = myArr.map(x => x * 2);
console.log(mapped instanceof MyArray); // false (Array 반환)
console.log(mapped instanceof Array); // true
// Symbol.unscopables: with문에서 제외할 프로퍼티
// (with문은 strict mode에서 금지이므로 실용성 낮음)
실전 예제: 플러그인 시스템
Symbol을 활용한 확장 가능한 플러그인 시스템입니다.
// 플러그인 인터페이스 정의 (Symbol로 계약 표현)
const PLUGIN_NAME = Symbol("plugin.name");
const PLUGIN_INIT = Symbol("plugin.init");
const PLUGIN_HOOKS = Symbol("plugin.hooks");
class PluginSystem {
#plugins = new Map();
#hooks = new Map();
register(plugin) {
const name = plugin[PLUGIN_NAME];
if (!name) throw new Error("플러그인에 PLUGIN_NAME Symbol이 없습니다");
this.#plugins.set(name, plugin);
if (typeof plugin[PLUGIN_INIT] === "function") {
plugin[PLUGIN_INIT](this);
}
const hooks = plugin[PLUGIN_HOOKS] ?? {};
for (const [event, fn] of Object.entries(hooks)) {
if (!this.#hooks.has(event)) this.#hooks.set(event, []);
this.#hooks.get(event).push(fn.bind(plugin));
}
return this;
}
trigger(event, ...args) {
const handlers = this.#hooks.get(event) ?? [];
return handlers.map(fn => fn(...args));
}
}
// 플러그인 구현
const LoggingPlugin = {
[PLUGIN_NAME]: "logging",
[PLUGIN_HOOKS]: {
"request": (url) => console.log(`[LOG] 요청: ${url}`),
"response": (data) => console.log(`[LOG] 응답: ${JSON.stringify(data).slice(0, 50)}`),
},
};
const MetricsPlugin = {
[PLUGIN_NAME]: "metrics",
requestCount: 0,
[PLUGIN_HOOKS]: {
"request": function(url) { this.requestCount++; },
},
};
const system = new PluginSystem();
system.register(LoggingPlugin).register(MetricsPlugin);
system.trigger("request", "/api/users");
// [LOG] 요청: /api/users
system.trigger("response", [{ id: 1, name: "Alice" }]);
// [LOG] 응답: [{"id":1,"name":"Alice"}]
console.log(MetricsPlugin.requestCount); // 1
고수 팁
팁 1: Symbol로 private 메서드 (# 이전 패턴)
// private 메서드를 Symbol로 숨기기 (# 미지원 환경)
const _validate = Symbol("validate");
const _format = Symbol("format");
class DataProcessor {
[_validate](data) {
return data !== null && data !== undefined;
}
[_format](data) {
return JSON.stringify(data, null, 2);
}
process(data) {
if (!this[_validate](data)) throw new Error("유효하지 않은 데이터");
return this[_format](data);
}
}
const dp = new DataProcessor();
console.log(dp.process({ name: "Alice" }));
// dp[_validate] — 외부에서 Symbol 접근 불가 (Symbol을 모르면)
팁 2: Symbol.asyncIterator로 비동기 이터러블
class AsyncDataSource {
constructor(data) {
this.data = data;
}
[Symbol.asyncIterator]() {
let index = 0;
const data = this.data;
return {
async next() {
if (index >= data.length) return { done: true, value: undefined };
await new Promise(r => setTimeout(r, 10)); // 비동기 시뮬레이션
return { done: false, value: data[index++] };
},
};
}
}
async function consumeAsync() {
const source = new AsyncDataSource([1, 2, 3, 4, 5]);
const results = [];
for await (const item of source) {
results.push(item * 2);
}
console.log(results); // [2, 4, 6, 8, 10]
}
consumeAsync();
팁 3: Symbol로 타입 태그 시스템
// 런타임 타입 정보를 Symbol로 태깅
const TYPE_TAG = Symbol.for("app.typeTag");
function tagged(Class) {
Class.prototype[TYPE_TAG] = Class.name;
return Class;
}
@tagged // TypeScript/Babel 데코레이터 문법 (또는 수동으로 tagged(class...)를 호출)
class User { constructor(name) { this.name = name; } }
// 수동 방식:
class Product { constructor(name) { this.name = name; } }
tagged(Product);
function getTag(obj) { return obj[TYPE_TAG]; }
const u = new User("Alice");
const p = new Product("노트북");
console.log(getTag(u)); // "User"
console.log(getTag(p)); // "Product"