자바스크립트 고급 #6 Symbol과 Proxy
#5 메모리 모델 에 이어, 이번엔 자바스크립트의 좀 더 색다른 도구들을 살펴봅니다. 일상 코드에선 자주 안 쓰지만 라이브러리 안쪽에서 자주 만나는 Symbol과 Proxy입니다.
Symbol — 충돌 없는 유일한 키 #
자바스크립트의 7번째 원시 타입(기초 #2).
const id1 = Symbol('id');
const id2 = Symbol('id');
console.log(id1 === id2); // false — 같은 설명이라도 다른 심볼
console.log(typeof id1); // 'symbol'
console.log(id1.toString()); // 'Symbol(id)'
Symbol(설명)으로 만듭니다. 인자는 디버깅용 설명일 뿐 정체성이 아닙니다. 호출할 때마다 새롭고 유일한 값이 만들어집니다.
객체 키로 — 충돌 없이 #
같은 모양의 두 라이브러리가 객체에 메타데이터를 붙일 때, 키가 겹쳐서 충돌하지 않으려면.
const userInfo = Symbol('userInfo');
const obj = {};
obj[userInfo] = { id: 'u1' }; // 일반 키와 안 겹침
obj.userInfo = '문자열 키'; // 두 키는 완전히 다른 슬롯
obj[userInfo]; // { id: 'u1' }
obj.userInfo; // '문자열 키'
같은 이름이라도 Symbol 키와 문자열 키는 별개의 슬롯입니다. 라이브러리들이 자기 메타데이터를 객체에 안전하게 붙일 때 자주 씁니다.
Symbol 키는 일반적인 순회에 안 보임 #
const sym = Symbol('hidden');
const obj = {
visible: 'A',
[sym]: 'B',
};
Object.keys(obj); // ['visible']
JSON.stringify(obj); // '{"visible":"A"}'
for (const k in obj) console.log(k); // visible
Object.getOwnPropertySymbols(obj); // [Symbol(hidden)]
Reflect.ownKeys(obj); // ['visible', Symbol(hidden)]
for...in, Object.keys, JSON 모두 Symbol 키를 무시합니다. 보고 싶으면 Object.getOwnPropertySymbols 또는 Reflect.ownKeys. 숨김 메타데이터의 의도와 잘 맞습니다.
Well-known Symbol — 언어 자체가 쓰는 키 #
자바스크립트 표준이 정의한 특별한 Symbol 들이 있습니다. Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive 등.
중급 #3 이터레이터와 제너레이터 에서 본 Symbol.iterator가 가장 유명합니다.
const range = {
from: 1, to: 3,
[Symbol.iterator]() {
let n = this.from;
const last = this.to;
return {
next() {
return n <= last
? { value: n++, done: false }
: { value: undefined, done: true };
},
};
},
};
[...range]; // [1, 2, 3]
이 부분에 굳이 Symbol을 쓴 이유 — 일반 키였다면 사용자가 우연히 같은 키를 쓸 수 있었습니다. 언어 차원의 약속 키는 절대 충돌 안 나게 Symbol로 정의됐습니다.
Symbol.toPrimitive — 원시값 변환 커스텀 #
객체를 숫자/문자열 용도로 쓸 때 어떻게 변환될지 정할 수 있습니다.
const money = {
amount: 1000,
currency: '원',
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
if (hint === 'string') return `${this.amount}${this.currency}`;
return `${this.amount}${this.currency}`;
},
};
+money; // 1000 (숫자 컨텍스트 — 'number' 힌트)
`${money}`; // '1000원' (문자열 컨텍스트 — 'string' 힌트)
money + 500; // '1000원500' (default 힌트 — 보통 string)
평범한 앱 코드에서 직접 쓸 일은 거의 없지만, “이 컨텍스트에서 어떻게 변환될지” 를 정확히 제어해야 하는 라이브러리에서 가끔 만납니다.
Symbol.for — 글로벌 심볼 레지스트리 #
같은 키를 여러 모듈에서 공유하고 싶다면.
const a = Symbol.for('app/secret');
const b = Symbol.for('app/secret');
a === b; // true — 같은 키로 등록된 같은 심볼
Symbol.keyFor(a); // 'app/secret'
전역 레지스트리에 등록되는 심볼입니다. 일반적인 Symbol(...)은 매번 다른데, Symbol.for(...)는 같은 키면 같은 심볼을 돌려줍니다. 라이브러리가 모듈 경계 너머로 공유 메타데이터를 둘 때 활용합니다.
Proxy — 객체 동작 가로채기 #
Proxy는 객체에 대한 모든 동작(읽기, 쓰기, 삭제 등)을 가로챌 수 있게 해주는 메타프로그래밍 도구입니다.
const target = { a: 1, b: 2 };
const proxy = new Proxy(target, {
get(obj, key) {
console.log(`읽기: ${key}`);
return obj[key];
},
set(obj, key, value) {
console.log(`쓰기: ${key} = ${value}`);
obj[key] = value;
return true; // 성공 표시
},
});
proxy.a; // 읽기: a → 1
proxy.b = 10; // 쓰기: b = 10
new Proxy(target, handler) — handler 객체에 가로챌 동작들을 정의합니다.
가로챌 수 있는 동작들 (trap) #
| trap | 호출 시 |
|---|---|
get | 속성 읽기 (obj.a) |
set | 속성 쓰기 (obj.a = 1) |
has | 'a' in obj |
deleteProperty | delete obj.a |
ownKeys | Object.keys, Reflect.ownKeys |
apply | 함수 호출 (fn()) |
construct | new fn() |
이외에도 13가지 정도의 trap이 있습니다. 거의 모든 객체 동작을 가로챌 수 있습니다.
Proxy의 실전 활용 #
1) 기본값 객체 #
const withDefault = (defaultValue) => new Proxy({}, {
get(target, key) {
return key in target ? target[key] : defaultValue;
},
});
const counts = withDefault(0);
counts.apple = 3;
console.log(counts.apple); // 3
console.log(counts.banana); // 0 (없지만 기본값)
get에서 키 존재 여부를 검사해 기본값을 흘려줍니다.
2) 검증 — 잘못된 값 막기 #
function createValidatedUser() {
return new Proxy({}, {
set(target, key, value) {
if (key === 'age' && (typeof value !== 'number' || value < 0)) {
throw new TypeError('age는 양의 number여야 합니다');
}
target[key] = value;
return true;
},
});
}
const user = createValidatedUser();
user.name = '커티스'; // OK
user.age = 30; // OK
user.age = -1; // ✗ TypeError
user.age = '서른'; // ✗ TypeError
set trap에서 검증 후 throw 하거나 false를 반환하면 할당이 실패합니다.
3) 반응형 — 변경 추적 #
Vue, MobX 같은 라이브러리가 객체 변경을 추적하는 핵심 메커니즘입니다.
function reactive(target, onChange) {
return new Proxy(target, {
get(obj, key) {
const value = obj[key];
if (typeof value === 'object' && value !== null) {
return reactive(value, onChange); // 중첩도 반응형
}
return value;
},
set(obj, key, value) {
const old = obj[key];
obj[key] = value;
onChange({ key, old, value });
return true;
},
});
}
const state = reactive({ count: 0 }, (e) => {
console.log('변경:', e);
});
state.count++;
// 변경: { key: 'count', old: 0, value: 1 }
이 골격이 Vue의 ref/reactive의 핵심입니다. 가로챈 동작에서 의존성 추적과 알림을 더하면 진짜 반응형 시스템이 됩니다.
4) API 호출 자동 — namespace 가로채기 #
const api = new Proxy({}, {
get(target, endpoint) {
return (params) => {
return fetch(`/api/${endpoint}?${new URLSearchParams(params)}`)
.then((r) => r.json());
};
},
});
const users = await api.users({ active: true });
const posts = await api.posts({ tag: 'js' });
// 정의 안 한 모든 속성이 fetch 함수가 됨
이런 패턴은 SDK 라이브러리에서 가끔 봅니다. 짧고 인상적이지만 — 자동완성/타입스크립트 경험이 나빠지니 신중하게 써야 합니다.
Reflect — Proxy와 짝
#
Reflect는 객체의 기본 동작들을 함수로 노출한 빌트인 객체입니다. Proxy의 trap 안에서 원본 동작을 그대로 진행하고 싶을 때 짝꿍처럼 쓰여요.
const target = {};
const proxy = new Proxy(target, {
get(obj, key, receiver) {
console.log(`읽기: ${key}`);
return Reflect.get(obj, key, receiver); // 원본 동작 그대로
},
set(obj, key, value, receiver) {
console.log(`쓰기: ${key}`);
return Reflect.set(obj, key, value, receiver);
},
});trap 안에서 직접 obj[key]보다 Reflect.get(...)이 더 정확합니다. getter/setter와 receiver 처리까지 자동으로 해줍니다.
자주 쓰는 Reflect 메서드:
Reflect.get(obj, key); // obj[key]
Reflect.set(obj, key, value); // obj[key] = value
Reflect.has(obj, key); // key in obj
Reflect.deleteProperty(obj, key); // delete obj[key]
Reflect.ownKeys(obj); // 모든 자기 키 (Symbol 포함)
Reflect.construct(Cls, args); // new Cls(...args)
Reflect.apply(fn, thisArg, args); // fn.call(thisArg, ...args)
평범한 코드에서 직접 쓸 일은 적습니다. Proxy를 다룰 때 자주 짝지어 등장합니다.
Proxy의 함정 #
Proxy는 강력하지만 비용이 있습니다.
- 성능 — 모든 속성 접근에 trap 함수가 호출되니, 핵심 루프 안에서는 느릴 수 있음
- 자동완성 — 정적 분석이 어려워 IDE가 속성을 알기 힘듦
- ==== 비교 —
proxy === target은 false. 같은 객체로 다루는 코드가 깨질 수 있음 - JSON / 직렬화 — 어떤 속성이 보일지 trap에 달려있음
이런 비용 때문에 일반 앱 코드에 Proxy를 직접 사용하는 건 권장되지 않습니다. 라이브러리 안쪽에서 쓰이는 도구로 인식하면 충분합니다.
마무리 #
이번 글에서 정리한 내용:
- Symbol — 유일하고 충돌 없는 값. 객체 키로 쓰기 좋음
- 일반 순회/JSON에 Symbol 키는 안 보임 — 숨김 메타데이터에 적합
- Well-known Symbols —
Symbol.iterator등 언어 약속 Symbol.for(key)로 글로벌 레지스트리 공유- Proxy — 객체 동작(읽기/쓰기/삭제 등)을 가로채는 메타 도구
- 기본값 객체, 검증, 반응형, 동적 API 등 응용
Reflect는 Proxy와 짝 — 원본 동작 그대로 진행- Proxy 비용 — 성능, 자동완성, 직렬화
다음 글(#7 모듈 시스템 깊이)에서는 시리즈 마지막으로 — CommonJS와 ES Modules의 차이, 모듈 호이스팅, 순환 참조에서 무엇이 일어나는지를 정리합니다.