자바스크립트 고급 #6 Symbol과 Proxy

#5 메모리 모델 에 이어, 이번엔 자바스크립트의 좀 더 색다른 도구들을 살펴봅니다. 일상 코드에선 자주 안 쓰지만 라이브러리 안쪽에서 자주 만나는 SymbolProxy입니다.

Symbol — 충돌 없는 유일한 키 #

자바스크립트의 7번째 원시 타입(기초 #2).

Symbol 만들기
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(설명)으로 만듭니다. 인자는 디버깅용 설명일 뿐 정체성이 아닙니다. 호출할 때마다 새롭고 유일한 값이 만들어집니다.

객체 키로 — 충돌 없이 #

같은 모양의 두 라이브러리가 객체에 메타데이터를 붙일 때, 키가 겹쳐서 충돌하지 않으려면.

Symbol 키
const userInfo = Symbol('userInfo');

const obj = {};
obj[userInfo] = { id: 'u1' };   // 일반 키와 안 겹침
obj.userInfo = '문자열 키';       // 두 키는 완전히 다른 슬롯

obj[userInfo];   // { id: 'u1' }
obj.userInfo;     // '문자열 키'

같은 이름이라도 Symbol 키와 문자열 키는 별개의 슬롯입니다. 라이브러리들이 자기 메타데이터를 객체에 안전하게 붙일 때 자주 씁니다.

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가 가장 유명합니다.

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 — 원시값 변환 커스텀 #

객체를 숫자/문자열 용도로 쓸 때 어떻게 변환될지 정할 수 있습니다.

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 — 글로벌 심볼 레지스트리 #

같은 키를 여러 모듈에서 공유하고 싶다면.

Symbol.for / Symbol.keyFor
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는 객체에 대한 모든 동작(읽기, 쓰기, 삭제 등)을 가로챌 수 있게 해주는 메타프로그래밍 도구입니다.

기본 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
deletePropertydelete obj.a
ownKeysObject.keys, Reflect.ownKeys
apply함수 호출 (fn())
constructnew 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 가로채기 #

속성 접근으로 동적 API
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 안에서 원본 동작을 그대로 진행하고 싶을 때 짝꿍처럼 쓰여요.

Reflect.get / set
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
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는 강력하지만 비용이 있습니다.

  1. 성능 — 모든 속성 접근에 trap 함수가 호출되니, 핵심 루프 안에서는 느릴 수 있음
  2. 자동완성 — 정적 분석이 어려워 IDE가 속성을 알기 힘듦
  3. ==== 비교proxy === target은 false. 같은 객체로 다루는 코드가 깨질 수 있음
  4. JSON / 직렬화 — 어떤 속성이 보일지 trap에 달려있음

이런 비용 때문에 일반 앱 코드에 Proxy를 직접 사용하는 건 권장되지 않습니다. 라이브러리 안쪽에서 쓰이는 도구로 인식하면 충분합니다.

마무리 #

이번 글에서 정리한 내용:

  • Symbol — 유일하고 충돌 없는 값. 객체 키로 쓰기 좋음
  • 일반 순회/JSON에 Symbol 키는 안 보임 — 숨김 메타데이터에 적합
  • Well-known Symbols — Symbol.iterator 등 언어 약속
  • Symbol.for(key)로 글로벌 레지스트리 공유
  • Proxy — 객체 동작(읽기/쓰기/삭제 등)을 가로채는 메타 도구
  • 기본값 객체, 검증, 반응형, 동적 API 등 응용
  • Reflect는 Proxy와 짝 — 원본 동작 그대로 진행
  • Proxy 비용 — 성능, 자동완성, 직렬화

다음 글(#7 모듈 시스템 깊이)에서는 시리즈 마지막으로 — CommonJS와 ES Modules의 차이, 모듈 호이스팅, 순환 참조에서 무엇이 일어나는지를 정리합니다.

X