본문으로 건너뛰기
Advertisement

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"
Advertisement