메모리 비용 관점에서의 Proxy 객체와 공유 프로토타입 비교

작성일:2026.07.14|수정일:2026.07.14|조회수:11

메모리 비용 관점에서의 Proxy 객체와 공유 프로토타입 비교

예전에 Proxy를 이용해 브라켓 문법을 DSL로 확장하는 실험을 한 적이 있다. obj[WHERE](predicate)[VALUES]()처럼, 자바스크립트의 기본 문법은 그대로 두되 그 해석만 바꿔 작은 쿼리 엔진처럼 동작하게 만드는 글이었다. 그때는 "문법은 고정되어 있지만 해석은 바꿀 수 있다"는 메타프로그래밍의 가능성이 흥미의 중심이었고, 성능이나 메모리는 크게 고민하지 않았다. 장난감 예제니까.

그러다 최근에 TanStack Table V9가 메모리 사용량을 90% 줄인 방법을 다룬 글을 읽었다. 핵심은 놀랍도록 단순했다. 인스턴스마다 같은 메서드를 복제하지 말고, 공유 프로토타입에 한 번만 두라는 것. 그 글을 읽는 내내 머릿속에 떠오르는 코드가 있었는데, 바로 내가 예전에 작성한 바로 그 Proxy DSL이었다.

Proxy DSL이 가지고 있던 구조적 비용

내가 예전에 만든 createQueryable은 이런 모양이었다.

TS
function createQueryable<T>(obj: Record<string, T>) {
  return new Proxy(obj, {
    get(target: Record<string, T>, prop: string | symbol, receiver: unknown) {
      if (prop === WHERE) {
        return (predicate: (value: T, key: string) => boolean) => {
          const filtered = Object.entries(target)
            .filter(([key, value]) => predicate(value, key))
            .reduce((acc, [key, value]) => {
              acc[key] = value;
              return acc;
            }, {} as Record<string, T>);
          return createQueryable(filtered);
        };
      }
      if (prop === VALUES) {
        return () => Object.values(target);
      }
      return Reflect.get(target, prop, receiver);
    }
  });
}

이 코드를 다시 보니, TanStack 글이 지적했던 비용이 그대로 들어 있었다. 체인이 N단계일 때마다 새로운 Proxy 인스턴스, 새로운 handler 객체, 새로운 get 트랩 클로저, 그리고 매 접근마다 새로운 화살표 함수가 만들어진다. WHERE가 반환하는 화살표는 target을 자기 스코프에 닫고 있고, VALUES의 화살표도 마찬가지다. 클로저 스코프가 인스턴스마다 유지되는, TanStack Table V8이 가지고 있던 바로 그 구조였다.

문제는 이 비용이 데이터가 커질 때 눈에 띈다는 점이다. 장난감 예제에서는 아무 문제가 없다. 그런데 "수천 개의 객체를 이 DSL로 감싸서 다룬다"고 상상하면, 인스턴스마다 생기는 함수 객체와 클로저가 쌓이기 시작한다. TanStack이 100만 행에서 4GB에 도달했다고 한 그 그래프와 정확히 같은 형태의 확장이, 내 DSL에서도 일어날 수 있다.

TanStack이 발견한 것: 공유 프로토타입

TanStack Table V9의 리팩터링은 한 문장으로 요약된다. 모든 행, 열, 셀 객체에 직접 할당하던 메서드를, 프로토타입에 한 번만 만들어 두라는 것. 그러면 인스턴스는 고유 데이터만 가지고, 메서드는 this를 통해 자기 인스턴스에 접근한다. 클로저로 인스턴스를 캡처할 필요가 없다.

JS
function getRowPrototype(table) {
  if (!table._rowPrototype) {
    table._rowPrototype = { table };
    const features = Object.values(table._features);
    for (let i = 0; i < features.length; i++) {
      features[i].assignRowPrototype?.(table._rowPrototype, table);
    }
  }
  return table._rowPrototype;
}

export const constructRow = (table, id, original, rowIndex, depth, subRows, parentId) => {
  const row = Object.create(getRowPrototype(table));
  row._valuesCache = {};
  row.id = id;
  row.index = rowIndex;
  row.original = original;
  // ... 고유 데이터만 할당
  return row;
};

이 구조에서 메서드는 행마다 새로 만들어지지 않는다. 프로토타입에 한 번 만들어지고, 모든 행이 그것을 공유한다. 행별 상태는 클로저가 아니라 행 객체 자신에게 있고, 메서드는 this로 그 상태에 접근한다.

이걸 읽고 나서, 같은 통찰을 내 DSL에도 적용할 수 있다는 걸 깨달았다. Symbol 키를 이용한 브라켓 DSL의 설계는 Proxy에 종속된 게 아니라, "브라켓 안의 값을 어떻게 해석할 것인가"라는 더 추상적인 아이디어에 기반하고 있었기 때문이다. Proxy는 그 아이디어를 구현한 한 방식일 뿐이었다.

Proxy를 버리고 프로토타입으로

바꾼 코드는 이런 형태가 되었다.

TS
const queryablePrototype = {
  [WHERE]<T>(this: Queryable<T>, predicate: (value: T, key: string) => boolean): Queryable<T> {
    const out = Object.create(queryablePrototype) as Queryable<T>;
    for (const key in this) {
      if (!Object.prototype.hasOwnProperty.call(this, key)) continue;
      if (predicate(this[key], key)) out[key] = this[key];
    }
    return out;
  },
  [VALUES]<T>(this: Queryable<T>): T[] {
    return Object.values(this);
  },
  // SORT, MAP, SELECT, LIMIT, GROUP_BY, COUNT, FIRST ...
};

Object.freeze(queryablePrototype);

function createQueryable<T>(obj: Record<string, T>): Queryable<T> {
  const instance = Object.create(queryablePrototype) as Queryable<T>;
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      instance[key] = obj[key];
    }
  }
  return instance;
}

프로토타입에 Symbol 키로 메서드를 두니, 브라켓 문법은 그대로 유지된다. users[WHERE](pred)는 여전히 동작한다. 다만 WHERE가 가리키는 함수가 매 접근마다 새로 만들어지는 화살표가 아니라, 프로토타입에 존재하는 공유 메서드가 된다. this가 인스턴스를 가리키고, 인스턴스의 own property가 곧 쿼리 대상 데이터다.

WHERE 안에서 결과를 만들 때도 Object.create(queryablePrototype)로 직접 인스턴스를 생성한다. 예전엔 out = {}에 데이터를 채운 뒤 createQueryable(out)으로 다시 복사하는 이중 작업이 있었는데, 이걸 한 번으로 줄였다. 빈 프로토타입 객체에 own property만 채우면 그것이 곧 새 Queryable이 된다.

벤치마크를 돌려보니

힙 델타로 측정하기

처음에는 heap delta를 측정하면 깔끔하게 차이가 날 거라고 생각했다. 실제로 돌려보니 결과가 생각보다 일관적이지 않았다.

proxy → proto(절감률)
N=1K0.57 → 0.40(30.5%)
N=50K51.73 → 16.32(68.5%)
N=200K92.35 → 61.98(32.9%)

대체로 prototype 버전이 가볍지만, 구간에 따라 역전이 있었다. 원인은 V8 엔진의 동작 방식에 있었다. Object.create(prototype)로 만든 객체에 own property를 10~20개 채우면, V8 엔진은 그 객체를 dictionary mode로 저장한다. 반면 Proxy 객체는 단순히 target과 handler를 참조만 하므로, own property가 많지 않은 작은 데이터셋에서는 오히려 Proxy가 더 가벼울 수 있다. heap delta는 GC 타이밍에도 영향을 받아서, 같은 코드를 두 번 돌려도 결과가 미묘하게 달라졌다.

힙 바이트 수로 비교하면 노이즈가 너무 많았다. 더 직접적인 지표가 필요했다.

함수 객체 수를 직접 세기

Proxy 버전에서 obj[WHERE]에 접근하면, get 트랩이 매번 새로운 화살표 함수를 반환한다. obj[VALUES]도 마찬가지다. 반면 프로토타입 버전에서 obj[WHERE]는 프로토타입 체인을 타고 올라가서 항상 같은 함수 객체를 찾는다.

이걸 직접 측정하는 벤치마크를 만들었다. N개의 독립적인 Queryable을 만들고, 각각에 대해 도달 가능한 모든 함수 객체의 수를 센다.

NProxy distinct fnsPrototype distinct fns
102021
10020021
1,0002,00021
10,00020,00021
100,000200,00021

Proxy 버전은 N에 비례해서 함수 객체가 늘어난다. 10만 개의 Queryable이 살아 있으면 20만 개의 함수 객체가 힙에 존재한다. 반면 프로토타입 버전은 N이 늘어나도 21개로 고정이다. 10개의 DSL 메서드와 Object.prototype에서 오는 11개의 내장 메서드, 그것이 전부다.

이 차이는 heap delta로 측정했을 때보다 훨씬 선명하다. 바이트 수는 V8 엔진의 내부 표현 방식이나 GC 타이밍에 흔들리지만, "살아 있는 함수 객체가 몇 개인가"는 결정론적이다. TanStack 글이 "같은 메서드를 수백만 개 인스턴스가 각자 복제하지 말라"고 했을 때, 그 말이 구체적으로 의미하는 바가 이 숫자다.

구조를 바꾸면 생기는 단점

메모리를 아끼면 대가가 있다. TanStack 글이 명시적으로 다룬 트레이드오프가 내 DSL에도 그대로 나타났다. 가장 먼저 깨지는 것은 메서드를 구조 분해하는 코드다.

TS
const { [WHERE]: where } = users;
where(u => u.age >= 20);  // TypeError: Cannot read properties of undefined

프로토타입 메서드는 this에 의존한다. 인스턴스에서 떼어 내면 this를 잃고, ES 모듈의 strict mode에서는 thisundefined가 되어 에러가 난다. 예전 Proxy 버전에서는 화살표 함수가 target을 클로저로 잡고 있었기 때문에 이런 식으로 떼어 써도 동작했다. 그게 사라진 것이다.

두 번째는 메서드가 own property가 아니라는 점이다. 이건 단점이기도 하고 장점이기도 하다. 데이터와 DSL 명령이 own과 inherited라는 선으로 깔끔하게 분리된다. Object.entries(this)를 쓰면 데이터만 순회하고, 메서드는 프로토타입 체인에서 별도로 조회된다. 쿼리가 데이터를 오염시키지 않는다. 다만 얕은 복사를 하면 메서드가 떨어져 나가니, Queryable을 복사하는 코드가 있다면 주의해야 한다.

TS
Object.keys(queryable);   // 데이터 키만 나온다 — DSL 명령이 보이지 않는다
{ ...queryable };         // 데이터는 복사되지만 메서드는 떨어진다
JSON.stringify(queryable); // 데이터만 직렬화된다

세 번째는 프로토타입을 frozen으로 만들었다는 점이다. 실수로 공유 메서드를 덮어쓰는 것을 막기 위해 Object.freeze(queryablePrototype)을 했다. 그래서 인스턴스별로 메서드를 다르게 동작시키려면 프로토타입을 건드리는 게 아니라 predicate를 다르게 줘야 한다. 유연성이 줄어든 대신, 공유 상태가 우연히 오염될 일은 없다.

문법은 그대로, 의미는 같고, 비용은 줄었다

이 리팩터링에서 흥미로웠던 점은, 원본 글의 핵심 주장이 손상되지 않았다는 것이다. "브라켓 문법을 유지하되 해석을 바꾼다"는 아이디어는 Proxy에 종속된 게 아니었다. Symbol 키가 프로토타입 메서드로도 똑같이 작동하기 때문이다. 겉으로 보이는 DSL 문법은 완전히 동일하다.

TS
users
  [WHERE](u => u.age >= 20)
  [SORT]((a, b) => a.age - b.age)
  [VALUES]();

이 코드는 Proxy 버전이든 프로토타입 버전이든 똑같이 동작한다. 다른 점은 내부에서 만들어지는 함수 객체의 수뿐이다. 10만 개의 Queryable이 살아 있을 때, Proxy 버전은 20만 개의 함수 객체를 힙에 올리고, 프로토타입 버전은 21개만 올린다.

TanStack 글을 읽기 전까지는, Proxy 기반 메타프로그래밍의 비용을 "런타임 오버헤드"라고 모호하게 넘겼다. 맞는 말이긴 하지만 정확하지 않았다. 진짜 비용은 Proxy 자체가 아니라, 매 접근마다 새로운 클로저를 만들어내는 구조에 있었다. 프로토타입으로 옮기면 클로저가 사라지는 게 아니라, 클로저가 필요 없어진다. this가 인스턴스에 닿기 때문이다. 이게 TanStack이 발견한 구조적 통찰이고, DSL에도 똑같이 적용되는 부분이었다.

그래서 언제 쓸 것인가

이 패턴이 모든 상황에 좋은 건 아니다. 소규모 데이터를 다루는 장난감 코드에서는 Proxy의 오버헤드가 체감되지 않는다. 메모리 절감이 의미를 갖는 건, 같은 DSL로 감싼 객체가 수천 개 이상 공존할 때다. 라이브러리 수준의 추상화를 만들고, 그것이 많은 인스턴스 위에서 동작해야 한다면, 프로토타입 기반 접근이 구조적으로 유리하다.

트레이드오프도 분명하다. 메서드를 구조 분해해서 쓰는 패턴이 코드 베이스에 있다면 깨진다. 얕은 복사로 Queryable을 넘기는 코드도 주의해야 한다. 이런 제약이 감당 가능한 라이브러리 내부 API라면, 메모리 이점이 그 대가를 충분히 갚는다.

결국 이 실험이 보여주는 건, 문법의 해석을 바꾸는 일과 메모리 모델을 설계하는 일이 분리될 수 있다는 점이다. 예전 글에서는 "문법은 고정되어 있지만 해석은 바꿀 수 있다"고 썼다. 이번에는 "해석을 바꾸는 방식은 여러 가지고, 그 선택이 메모리 비용을 결정한다"를 덧붙일 수 있을 것 같다. Proxy든 프로토타입이든 DSL의 의미는 같다. 다만 그 아래에서 몇 개의 함수가 살아 있을지는, 구조를 어떻게 잡느냐에 달려 있다.

부록

전체 구현

앞에서는 WHERE와 VALUES만 예로 들었는데, 실제로는 좀 더 많은 메서드를 붙였다. 이 글을 읽다가 "과연 다른 연산도 똑같이 짤 수 있을까?" 하는 사람이 있을지도 모르겠다. 혹시 궁금할 사람이 있을 수 있어서, 전체 DSL의 구현 코드를 남긴다.

WHERE는 조건에 맞는 항목만 걸러내고, SORT는 비교 함수로 정렬한 뒤 다시 객체로 만든다. MAP은 각 값을 변환하고, SELECT는 특정 키만 골라낸다. LIMIT은 상위 n개만 남기고, GROUP_BY는 키 함수로 묶은 뒤 각 그룹을 다시 Queryable로 만든다. COUNT와 FIRST는 스칼라 값을 반환해서 체인의 끝이나 중간에 끼울 수 있고, VALUES는 마지막에 배열로 펼쳐준다. 전부 Object.create(queryablePrototype)로 결과를 감싸기 때문에, 어떤 연산을 해도 다음 단계에서 또 [WHERE][SORT]를 붙일 수 있다.

TS
const WHERE = Symbol('where');
const SORT = Symbol('sort');
const MAP = Symbol('map');
const SELECT = Symbol('select');
const LIMIT = Symbol('limit');
const GROUP_BY = Symbol('group_by');
const COUNT = Symbol('count');
const FIRST = Symbol('first');
const VALUES = Symbol('values');

interface Queryable<T> {
  [WHERE]: (predicate: (value: T, key: string) => boolean) => Queryable<T>;
  [SORT]: (compareFn: (a: T, b: T) => number) => Queryable<T>;
  [MAP]: <U>(fn: (value: T, key: string) => U) => Queryable<U>;
  [SELECT]: (...keys: string[]) => Queryable<Partial<T>>;
  [LIMIT]: (n: number) => Queryable<T>;
  [GROUP_BY]: (keyFn: (value: T, key: string) => string) => Record<string, Queryable<T>>;
  [COUNT]: () => number;
  [FIRST]: () => T | undefined;
  [VALUES]: () => T[];

  [key: string]: T;
}

const queryablePrototype = {
  [WHERE]<T>(this: Queryable<T>, predicate: (value: T, key: string) => boolean): Queryable<T> {
    const out = Object.create(queryablePrototype) as Queryable<T>;
    for (const key in this) {
      if (!Object.prototype.hasOwnProperty.call(this, key)) continue;
      if (predicate(this[key], key)) out[key] = this[key];
    }
    return out;
  },
  [SORT]<T>(this: Queryable<T>, compareFn: (a: T, b: T) => number): Queryable<T> {
    const entries = Object.entries(this)
      .sort(([, a], [, b]) => compareFn(a as T, b as T));
    const out = Object.create(queryablePrototype) as Queryable<T>;
    for (const [key, value] of entries) out[key] = value as T;
    return out;
  },
  [MAP]<T, U>(this: Queryable<T>, fn: (value: T, key: string) => U): Queryable<U> {
    const out = Object.create(queryablePrototype) as Queryable<U>;
    for (const key in this) {
      if (!Object.prototype.hasOwnProperty.call(this, key)) continue;
      out[key] = fn(this[key], key);
    }
    return out;
  },
  [SELECT]<T>(this: Queryable<T>, ...keys: string[]): Queryable<Partial<T>> {
    const out = Object.create(queryablePrototype) as Queryable<Partial<T>>;
    for (const key of keys) {
      if (Object.prototype.hasOwnProperty.call(this, key)) out[key] = this[key];
    }
    return out;
  },
  [LIMIT]<T>(this: Queryable<T>, n: number): Queryable<T> {
    const entries = Object.entries(this).slice(0, n);
    const out = Object.create(queryablePrototype) as Queryable<T>;
    for (const [key, value] of entries) out[key] = value as T;
    return out;
  },
  [GROUP_BY]<T>(this: Queryable<T>, keyFn: (value: T, key: string) => string): Record<string, Queryable<T>> {
    const groups = Object.create(null) as Record<string, Queryable<T>>;
    for (const key in this) {
      if (!Object.prototype.hasOwnProperty.call(this, key)) continue;
      const groupKey = keyFn(this[key], key);
      if (!groups[groupKey]) groups[groupKey] = Object.create(queryablePrototype) as Queryable<T>;
      groups[groupKey][key] = this[key];
    }
    return groups;
  },
  [COUNT]<T>(this: Queryable<T>): number {
    let n = 0;
    for (const key in this) {
      if (Object.prototype.hasOwnProperty.call(this, key)) n++;
    }
    return n;
  },
  [FIRST]<T>(this: Queryable<T>): T | undefined {
    for (const key in this) {
      if (Object.prototype.hasOwnProperty.call(this, key)) return this[key];
    }
    return undefined;
  },
  [VALUES]<T>(this: Queryable<T>): T[] {
    return Object.values(this);
  },
};

Object.freeze(queryablePrototype);

function createQueryable<T>(obj: Record<string, T>): Queryable<T> {
  const instance = Object.create(queryablePrototype) as Queryable<T>;
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      instance[key] = obj[key];
    }
  }
  return instance;
}

이 구조에서도 모든 메서드는 프로토타입에 한 번만 존재한다. 인스턴스 수가 늘어나도 함수 객체의 총량은 늘어나지 않는다. 체인이 길어져도 매 단계마다 새로운 화살표 함수가 생기는 일은 없다.

사용 사례

앞에서 코드만 보여줬는데, 실제로 데이터를 갖고 쓰면 어떤 모양이 되는지 궁금할 수 있을 것 같다. 간단한 예시를 몇 가지 남긴다.

먼저 기본적인 필터링과 정렬. users는 이름을 키로 하는 객체고, 각 값은 이름과 나이와 부서를 담고 있다.

TS
const users = createQueryable({
  alice:   { name: 'Alice',   age: 25, dept: 'Engineering' },
  bob:     { name: 'Bob',     age: 30, dept: 'Design' },
  charlie: { name: 'Charlie', age: 20, dept: 'Engineering' },
  diana:   { name: 'Diana',   age: 35, dept: 'Design' },
});

// 25살 이상만 걸러내고 이름만 꺼낸다
const adultNames = users
  [WHERE](u => u.age >= 25)
  [MAP](u => u.name)
  [VALUES]();
// ['Alice', 'Bob', 'Diana']

WHERE 다음에 MAP을 붙이면 필터링된 결과의 값만 변환할 수 있다. MAP도 프로토타입 메서드라서 체인이 이어진다. 마지막에 VALUES로 배열로 펼치지 않으면 여전히 Queryable 객체다.

GROUP_BY는 키 함수의 결과로 객체를 묶는다. 각 그룹은 다시 Queryable이기 때문에, 묶은 뒤에도 [WHERE][MAP]을 붙일 수 있다.

TS
const byDept = users[GROUP_BY](u => u.dept);
// {
//   Engineering: Queryable { alice: {...}, charlie: {...} },
//   Design:      Queryable { bob: {...}, diana: {...} }
// }

const engAdults = byDept.Engineering
  [WHERE](u => u.age >= 25)
  [VALUES]();
// [{ name: 'Alice', age: 25, dept: 'Engineering' }]

LIMIT와 SELECT는 좀 더 좁은 결과를 뽑을 때 쓴다.

TS
const youngest = users
  [SORT]((a, b) => a.age - b.age)
  [LIMIT](2)
  [SELECT]('name', 'age')
  [VALUES]();
// [{ name: 'Charlie', age: 20 }, { name: 'Alice', age: 25 }]

SELECT는 키를 여러 개 받아서 각 객체에서 해당 필드만 남긴다. LIMIT은 정렬 뒤에 붙여서 상위 n개만 가져오는 식으로 쓰면 된다. COUNT와 FIRST는 체인 중간에 끼워서 조건을 만족하는 항목의 수나 첫 번째 항목만 빠르게 확인할 수 있다.

TS
users[WHERE](u => u.dept === 'Engineering')[COUNT](); // 2
users[WHERE](u => u.age >= 30)[FIRST](); // { name: 'Bob', age: 30, dept: 'Design' }

이런 식으로 전체 코드는 하나의 프로토타입에만 존재하고, 실제 데이터는 각 인스턴스의 own property에만 남는다. 체인이 길어져도 함수 객체는 새로 생기지 않는다.

댓글

댓글을 불러오는 중...