Proxy·Reflect, 불변 자료구조, 구조적 공유, WeakMap, 메타프로그래밍으로 견고한 데이터 계층을 설계한다.
고급 타입 시스템과 불변성 설계
입문편에서 객체와 프로토타입의 기본 구조를 익혔다면, 이제는 그 위에서 데이터를 어떻게 '신뢰할 수 있게' 만들 것인지를 고민할 차례입니다. 런타임에서 객체의 동작을 가로채고, 변경 불가능한 구조를 설계하며, 메모리 누수 없이 부가 상태를 관리하는 기법은 라이브러리 개발부터 프레임워크 내부 구현까지 폭넓게 쓰입니다. 이번 레슨에서는 JavaScript 메타프로그래밍의 핵심 도구인 Proxy와 Reflect, 불변성 전략, WeakMap 기반 프라이빗 상태 관리, 그리고 well-known Symbol을 이용한 동작 커스터마이징을 깊이 있게 살펴봅니다.
학습 목표
- Proxy와 Reflect를 조합해 트랩 기반 메타프로그래밍(검증, 옵저버블, 가상 속성)을 구현할 수 있다.
Object.freeze의 한계를 이해하고 구조적 공유(structural sharing) 기반 불변 업데이트 패턴을 설계할 수 있다.- WeakMap·WeakRef·FinalizationRegistry로 메모리 친화적인 프라이빗 상태와 캐시를 구현할 수 있다.
structuredClone의 동작과 제약을 파악하고 깊은 복사·깊은 동결 전략을 올바르게 선택할 수 있다.- well-known Symbol(
Symbol.toPrimitive,Symbol.hasInstance,Symbol.species등)로 객체의 내장 동작을 커스터마이징할 수 있다.
Proxy·Reflect로 만드는 트랩 기반 메타프로그래밍
Proxy와 Reflect의 역할 분리
Proxy는 대상 객체(target)에 대한 모든 기본 연산을 가로채는 래퍼입니다. Reflect는 동일한 기본 연산을 명시적으로 실행하는 네임스페이스로, 두 API는 트랩 메서드 이름을 공유하도록 설계되었습니다. 트랩 내부에서 Reflect를 통해 원래 동작을 그대로 위임하면, 기본 불변식(invariant)을 유지하면서 부가 로직을 삽입할 수 있습니다.
const handler = {
get(target, prop, receiver) {
console.log(`[get] ${String(prop)}`);
return Reflect.get(target, prop, receiver); // ✅ receiver 전달로 this 바인딩 보존
},
set(target, prop, value, receiver) {
console.log(`[set] ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
},
};
const obj = new Proxy({ x: 1 }, handler);
obj.x; // [get] x
obj.y = 2; // [set] y = 2
receiver를 Reflect.get/set에 그대로 전달하지 않으면 getter/setter가 정의된 프로토타입 체인에서 this가 프록시가 아닌 target을 가리켜 버그가 발생합니다.
검증 트랩
런타임 타입 검증은 TypeScript가 커버하지 못하는 외부 입력(API 응답, 폼 데이터)을 방어할 때 유용합니다.
function createSchema(schema) {
return new Proxy(
{},
{
set(target, prop, value) {
const rule = schema[prop];
if (!rule) throw new TypeError(`알 수 없는 속성: ${prop}`);
if (!rule(value))
throw new TypeError(`${prop}에 유효하지 않은 값: ${value}`);
return Reflect.set(target, prop, value);
},
}
);
}
const user = createSchema({
name: (v) => typeof v === "string" && v.length > 0,
age: (v) => Number.isInteger(v) && v >= 0,
});
user.name = "Alice"; // ✅
user.age = -1; // ❌ TypeError: age에 유효하지 않은 값: -1
옵저버블(Observable) 패턴
set 트랩을 이용하면 Vue 2/3 반응형 시스템과 유사한 옵저버블 객체를 만들 수 있습니다.
function reactive(target, onChange) {
return new Proxy(target, {
set(t, prop, value, receiver) {
const prev = t[prop];
const result = Reflect.set(t, prop, value, receiver);
if (result && prev !== value) onChange(prop, value, prev);
return result;
},
});
}
const state = reactive({ count: 0 }, (key, next, prev) => {
console.log(`${key}: ${prev} → ${next}`);
});
state.count = 1; // count: 0 → 1
state.count = 1; // 변화 없으므로 콜백 미발생
가상 속성(Virtual Property)
get 트랩에서 target에 존재하지 않는 속성을 동적으로 계산해 반환할 수 있습니다.
const vector = new Proxy(
{ x: 3, y: 4 },
{
get(target, prop, receiver) {
if (prop === "length")
return Math.sqrt(target.x ** 2 + target.y ** 2);
return Reflect.get(target, prop, receiver);
},
}
);
console.log(vector.length); // 5 — target에는 없는 속성
⚠️ 주의
has트랩을 구현하지 않으면"length" in vector는false를 반환합니다. 가상 속성을 완전히 지원하려면has,ownKeys,getOwnPropertyDescriptor트랩도 함께 구현해야 합니다.
불변성 전략: Object.freeze의 한계와 구조적 공유
Object.freeze의 얕음(Shallow) 문제
Object.freeze는 직속 속성의 쓰기·삭제·재정의를 막지만, 중첩 객체까지 동결하지 않습니다.
const config = Object.freeze({ db: { host: "localhost", port: 5432 } });
config.db = {}; // ❌ 무시됨 (strict mode에서는 TypeError)
config.db.port = 9999; // ✅ 여전히 변경 가능! — 얕은 동결의 함정
console.log(config.db.port); // 9999
깊은 동결이 필요하다면 재귀적으로 처리해야 합니다.
function deepFreeze(obj) {
if (obj === null || typeof obj !== "object") return obj;
Object.getOwnPropertyNames(obj).forEach((name) => deepFreeze(obj[name]));
return Object.freeze(obj);
}
const frozen = deepFreeze({ a: { b: { c: 42 } } });
frozen.a.b.c = 99; // 무시됨
console.log(frozen.a.b.c); // 42
💡 TIP
deepFreeze는 순환 참조가 있는 객체에서 무한 루프에 빠집니다. 실제 프로덕션 코드에서는WeakSet으로 방문한 노드를 추적하세요.
구조적 공유(Structural Sharing)
불변 업데이트의 핵심은 변경된 경로의 노드만 새로 생성하고, 나머지는 기존 참조를 재사용하는 것입니다. 이를 구조적 공유라고 합니다. 깊은 복사보다 메모리 효율이 높고, 이전 상태를 유지할 수 있어 실행 취소(undo)/타임 트래블 디버깅에 쓰입니다.
// 중첩 객체에서 특정 경로만 불변 업데이트
const state = { user: { profile: { name: "Alice", age: 30 }, role: "admin" } };
// ❌ 깊은 복사 — 전체를 새로 만듦
const next1 = JSON.parse(JSON.stringify(state));
next1.user.profile.age = 31;
// ✅ 구조적 공유 — 변경 경로만 새 객체 생성
const next2 = {
...state,
user: {
...state.user,
profile: { ...state.user.profile, age: 31 },
},
};
console.log(next2.user.role === state.user.role); // true — 공유
console.log(next2.user.profile === state.user.profile); // false — 새 객체
Immer류 라이브러리의 copy-on-write 원리
Immer는 produce 함수 내부에서 Proxy를 이용해 드래프트 객체를 만들고, 수정이 발생한 노드만 copy-on-write로 새 객체를 생성합니다. 사용자는 마치 뮤터블 코드처럼 작성하지만 결과는 불변 객체입니다.
// Immer의 copy-on-write를 직접 구현한 최소 예제
function produce(base, recipe) {
// 각 드래프트 노드는 원본(base)·복사본(copy)·부모 연결 정보를 가진다.
function createDraft(base, parent, parentKey) {
const state = { base, copy: null, parent, parentKey };
// 변경이 처음 발생할 때만 복사본을 만들고(copy-on-write),
// 부모 체인을 따라 올라가며 부모도 복사한 뒤 자식 참조를 새 복사본으로 갱신한다.
function ensureCopy() {
if (state.copy) return state.copy;
state.copy = Array.isArray(base) ? [...base] : { ...base };
if (state.parent) {
state.parent.ensureCopy()[state.parentKey] = state.copy;
}
return state.copy;
}
state.ensureCopy = ensureCopy;
state.proxy = new Proxy(state, {
get(s, prop) {
const source = s.copy ?? s.base; // 이미 복사됐으면 복사본을 읽는다
const val = Reflect.get(source, prop);
if (val && typeof val === "object") {
return createDraft(val, s, prop).proxy; // 중첩 객체도 드래프트로 감싸 부모 연결
}
return val;
},
set(s, prop, value) {
ensureCopy()[prop] = value;
return true;
},
});
return state;
}
const root = createDraft(base, null, null);
recipe(root.proxy);
// 루트에 복사본이 생겼다면 변경이 발생한 것이고, 없다면 원본을 그대로 반환한다.
return root.copy ?? base;
}
const state = { a: { b: 1 }, c: 2 };
const next = produce(state, (draft) => { draft.a.b = 99; });
console.log(next.a.b); // 99
console.log(next.c === state.c); // true — 구조적 공유
console.log(state.a.b); // 1 — 원본 불변
structuredClone의 동작과 제약
structuredClone은 WHATWG HTML 표준에 정의된 전역 함수로, 모던 브라우저와 Node.js 17+에서 사용 가능한 깊은 복사 API입니다. (ECMAScript 사양이 아니라 구조적 복제 알고리즘과 함께 HTML 표준에 정의됩니다.) JSON.parse(JSON.stringify(...))의 여러 한계를 해소합니다.
| 항목 | JSON 왕복 | structuredClone |
|---|---|---|
Date | ❌ 문자열로 변환 | ✅ Date 유지 |
Map / Set | ❌ {} / [] 로 손실 | ✅ 유지 |
ArrayBuffer / TypedArray | ❌ 손실 | ✅ 복사 |
| 순환 참조 | ❌ 에러 | ✅ 처리 |
| 함수 | ❌ 손실 | ❌ DataCloneError(DOMException) throw |
Symbol 키 | ❌ 손실 | ❌ 무시 |
| 프로토타입 | ❌ 손실 | ❌ Object로 평탄화 |
WeakMap / WeakRef | ❌ | ❌ |
⚠️ 주의 함수처럼 복제 불가능한 값이 포함되면
structuredClone은TypeError가 아니라DataCloneError라는 이름의DOMException을 throw합니다. 따라서 예외를 분기할 때는catch (e) { if (e instanceof DOMException) ... }처럼 처리해야 하며,e instanceof TypeError로 판별하면 잡히지 않습니다.
const original = {
date: new Date("2024-01-01"),
map: new Map([["key", 42]]),
buf: new Uint8Array([1, 2, 3]),
};
const clone = structuredClone(original);
console.log(clone.date instanceof Date); // true
console.log(clone.map.get("key")); // 42
console.log(clone.buf[0]); // 1
console.log(clone.date === original.date); // false — 독립 복사
// 전송 가능한 객체(Transferable)는 복사 대신 소유권 이전
const buffer = new ArrayBuffer(1024);
const transferred = structuredClone({ buf: buffer }, { transfer: [buffer] });
console.log(buffer.byteLength); // 0 — 원본에서 소유권 이동
⚠️ 주의
structuredClone은 클래스 인스턴스의 프로토타입을 보존하지 않습니다. 커스텀 클래스를 깊이 복사해야 한다면 직렬화/역직렬화 로직을 직접 구현하거나,lodash.cloneDeep같은 라이브러리를 사용하세요.
WeakMap·WeakRef·FinalizationRegistry로 만드는 프라이빗 상태와 캐시
WeakMap 기반 프라이빗 상태
ES2022 클래스 필드(#field)가 있기 전에는 WeakMap이 실질적인 프라이빗 상태 저장소였습니다. 키가 객체이고 약한 참조를 사용하므로, 인스턴스가 GC되면 연관 데이터도 함께 수거됩니다.
const _secret = new WeakMap();
class Account {
constructor(id, balance) {
_secret.set(this, { balance });
this.id = id;
}
deposit(amount) {
if (amount <= 0) throw new RangeError("amount must be positive");
_secret.get(this).balance += amount;
}
get balance() {
return _secret.get(this).balance;
}
}
const acc = new Account("A001", 1000);
acc.deposit(500);
console.log(acc.balance); // 1500
console.log(_secret.get(acc)); // {balance: 1500} — 모듈 외부에서는 접근 불가
WeakRef와 FinalizationRegistry로 만드는 메모이제이션 캐시
WeakRef는 대상 객체를 약하게 참조하여 GC를 막지 않습니다. FinalizationRegistry는 객체가 수거될 때 콜백을 등록할 수 있습니다. 두 API를 조합하면 메모리 부담이 적은 캐시를 구현할 수 있습니다.
const cache = new Map();
const registry = new FinalizationRegistry((key) => {
// GC 후 캐시 항목 정리
if (!cache.get(key)?.deref()) {
cache.delete(key);
console.log(`[cache] ${key} 수거됨`);
}
});
function memoize(key, factory) {
const existing = cache.get(key)?.deref();
if (existing) return existing;
const value = factory();
cache.set(key, new WeakRef(value));
registry.register(value, key);
return value;
}
// 사용 예: 무거운 DOM 노드나 대형 데이터 버퍼 캐싱
let data = memoize("bigData", () => ({ payload: new Array(10_000).fill(0) }));
console.log(cache.size); // 1
data = null; // 강한 참조 제거 → 이후 GC 시 캐시 정리
⚠️ 주의 GC 타이밍은 엔진이 결정하므로
FinalizationRegistry콜백이 즉시 호출된다고 가정해서는 안 됩니다. 정확한 자원 해제가 필요하다면try/finally나 명시적dispose패턴을 사용하세요.
Symbol 기반 메타 프로토콜로 동작 커스터마이징
입문편에서 Symbol.iterator를 다뤘으므로, 여기서는 실무에서 자주 쓰이는 다른 well-known Symbol에 집중합니다.
Symbol.toPrimitive — 타입 변환 제어
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return `${this.amount} ${this.currency}`;
return this.amount; // "default" hint
}
}
const price = new Money(1000, "KRW");
console.log(`가격: ${price}`); // "가격: 1000 KRW" (string hint)
console.log(price * 1.1); // 1100 (number hint)
console.log(price == 1000); // true (default hint)
Symbol.hasInstance — instanceof 동작 재정의
class Integer {
static [Symbol.hasInstance](instance) {
return Number.isInteger(instance);
}
}
console.log(42 instanceof Integer); // true
console.log(3.14 instanceof Integer); // false
console.log("hi" instanceof Integer); // false
Symbol.species — 파생 클래스의 생성자 제어
Array.prototype.map 등 새 컬렉션을 반환하는 메서드는 Symbol.species를 통해 반환 타입을 결정합니다.
class SafeArray extends Array {
static get [Symbol.species]() {
return Array; // map/filter 결과를 일반 Array로 반환
}
first() {
return this[0];
}
}
const safe = new SafeArray(1, 2, 3);
const mapped = safe.map((x) => x * 2);
console.log(mapped instanceof SafeArray); // false — species 덕분에 일반 Array
console.log(mapped instanceof Array); // true
💡 TIP
Symbol.species는 TC39에서 폐지(deprecation) 논의가 진행 중입니다. 새 코드에서는 메서드를 직접 오버라이드하는 방식이 더 안전합니다.
Symbol.toStringTag — Object.prototype.toString 반환값 제어
class Buffer {
get [Symbol.toStringTag]() {
return "Buffer";
}
}
const buf = new Buffer();
console.log(Object.prototype.toString.call(buf)); // [object Buffer]
console.log(buf.toString()); // [object Buffer]
Symbol.isConcatSpreadable — 배열 병합 동작 제어
const arrayLike = { 0: "a", 1: "b", length: 2, [Symbol.isConcatSpreadable]: true };
console.log([].concat(arrayLike)); // ["a", "b"]
const noSpread = [1, 2, 3];
noSpread[Symbol.isConcatSpreadable] = false;
console.log([0].concat(noSpread)); // [0, [1, 2, 3]]
요약
- Proxy + Reflect는 런타임 검증, 옵저버블 상태, 가상 속성 등 메타프로그래밍의 핵심 도구입니다. 트랩 내에서
Reflect를 통해 원래 동작을 위임해 불변식을 유지하세요. Object.freeze는 얕은 동결만 수행하므로 중첩 객체에는 재귀적deepFreeze나 구조적 공유 기반 불변 업데이트가 필요합니다.- Immer 같은 라이브러리는 Proxy의 copy-on-write로 변경된 경로만 새 객체를 생성해 불변성과 편의성을 동시에 달성합니다.
structuredClone은Date,Map,Set, 순환 참조를 올바르게 처리하지만, 함수·클래스 프로토타입·Symbol 키는 복사하지 않습니다.- WeakMap/WeakRef/FinalizationRegistry는 GC를 방해하지 않는 프라이빗 상태와 메모이제이션 캐시를 구현할 때 사용합니다.
Symbol.toPrimitive,Symbol.hasInstance,Symbol.toStringTag등 well-known Symbol로 객체의 내장 연산을 정밀하게 커스터마이징할 수 있습니다.
연습문제
-
get트랩을 이용해 존재하지 않는 속성에 접근하면undefined대신null을 반환하는safeProxy(obj)함수를 작성하세요. 단, 존재하는 속성과Symbol키는 기존 값을 그대로 반환해야 합니다.힌트
Reflect.get의 반환값이undefined인지 확인하기 전에,prop in target으로 속성이 실제로 존재하는지 먼저 판별하세요. -
다음 중첩 객체를 구조적 공유 방식으로 불변 업데이트하는
updateIn(obj, path, value)함수를 구현하세요.const state = { a: { b: { c: 1 } }, d: 2 }; const next = updateIn(state, ["a", "b", "c"], 99); // next.a.b.c === 99 // next.d === state.d (공유) // state.a.b.c === 1 (원본 불변)힌트 path 배열을 재귀적으로 순회하면서 현재 레벨의 객체를 스프레드로 복사하고, 마지막 키에만 새 값을 할당하세요.
-
WeakMap을 사용해 클래스 외부에서 접근할 수 없는 프라이빗
_log배열을 가지는Logger클래스를 작성하세요.log(message)메서드로 메시지를 추가하고,getLogs()메서드로 배열의 복사본을 반환하도록 구현하세요.힌트
WeakMap의 키는this(인스턴스), 값은{ log: [] }형태의 객체로 설정하세요. -
Symbol.toPrimitive를 구현하여 산술 연산에서는 섭씨 온도값, 문자열 변환에서는"25°C"형식으로 출력되는Temperature클래스를 작성하세요.힌트
hint매개변수의 값이"number","string","default"인 세 경우를 각각 처리하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“JavaScript 심화” 강좌에 대한 댓글입니다.