PUBLISHED

Proxy를 이용한 브라켓 기반 DSL 설계

작성일: 2026.02.14

Proxy를 이용한 브라켓 기반 DSL 설계

자바스크립트에서 obj[key]는 가장 기본적인 문법이다. 우리는 이를 단순한 프로퍼티 조회로 받아들인다. 특정 key를 넣으면 그에 해당하는 값을 반환하는, 가장 직관적인 접근 방식이다. 그러나 Proxy를 사용하면 이 동작을 가로채고 전혀 다른 의미로 재정의할 수 있다. 문법은 그대로 두되, 그 해석을 바꾸는 것이다.
이 글의 목적은 브라켓 문법을 활용해 객체를 “조건 기반으로 조회”하는 DSL(Domain Specific Language)을 설계하는 것이다. 우리가 만들고자 하는 형태는 다음과 같다.

untitled
JS
users
  [WHERE](u => u.age >= 20)
  [VALUES]()

이 표현은 단순한 프로퍼티 접근이 아니다. 객체 내부를 순회하며 조건을 만족하는 값만 추려내고, 마지막에 이를 배열로 변환한다. 겉으로는 obj[key]이지만, 실제로는 작은 쿼리 문법처럼 동작한다.

이 설계를 성립시키기 위해서는 한 가지 전제가 필요하다. 브라켓 문법을 유지하면서도, 우리가 정의한 “메타 명령”을 손실 없이 전달할 수 있어야 한다는 점이다. 문제는 브라켓 안에 들어간 값이 내부적으로 ToPropertyKey 과정을 거친다는 것이다. 이 과정에서 객체는 문자열로 변환된다. 따라서 일반 객체나 함수는 Proxy의 get 트랩까지 원형 그대로 도달할 수 없다.

여기서 중요한 단서가 나온다. ToPropertyKey 과정에서도 변환되지 않고 그대로 유지되는 타입이 있다. 바로 Symbol이다. Symbol은 문자열로 강제 변환되지 않으며, Proxy의 get 트랩에서 정확히 구분할 수 있다. 이 특성을 이용하면 브라켓 문법을 유지한 채, DSL 명령을 안전하게 전달할 수 있다.

이제 DSL에서 사용할 메타 키와 객체를 감싸는 createQueryable 함수를 정의한다. 우선 메타 키의 모습은 아래와 같다.

untitled
JS
const WHERE = Symbol('where');
const VALUES = Symbol('values');

이 Symbol들은 실제 데이터에 대응하는 키가 아니다. 객체의 속성을 가리키기 위한 식별자가 아니라, DSL에서 특정 연산을 의미하는 명령어다. 다시 말해, 값에 접근하기 위한 key가 아니라 동작을 트리거하기 위한 신호에 가깝다.

untitled
JS
function createQueryable(obj) {
  return new Proxy(obj, {
    get(target, prop, receiver) {

      if (prop === WHERE) {
        return (predicate) => {
          const filtered = Object.entries(target)
            .filter(([key, value]) => predicate(value, key))
            .reduce((acc, [key, value]) => {
              acc[key] = value;
              return acc;
            }, {});

          return createQueryable(filtered);
        };
      }

      if (prop === VALUES) {
        return () => Object.values(target);
      }

      return Reflect.get(target, prop, receiver);
    }
  });
}

이 구조의 동작은 명확하다. WHERE가 들어오면 predicate를 인자로 받는 함수를 반환한다. 그 함수는 객체를 순회하면서 조건을 만족하는 항목만 남긴 새로운 객체를 생성하고, 그 결과를 다시 createQueryable로 감싼다. 이렇게 반환된 값은 다시 Queryable이 되므로 연산을 이어갈 수 있다. VALUES가 들어오면 현재 상태의 값들을 배열로 반환한다. 이 시점에서 쿼리 체인은 종료되고, 최종 결과가 배열 형태로 확정된다.

그 외의 문자열 키나 일반적인 프로퍼티 접근은 Reflect.get을 통해 그대로 위임된다. 즉, DSL 명령을 제외하면 기존 객체와 동일하게 동작한다.

이제 이 구조를 실제 객체에 적용해보면 다음과 같은 형태가 된다.

untitled
JS
const users = createQueryable({
  a: { age: 10, name: "A" },
  b: { age: 20, name: "B" },
  c: { age: 30, name: "C" }
});

const result =
  users
    [WHERE](u => u.age >= 20)
    [VALUES]();

console.log(result); // [
                     //   { age: 20, name: "B" },
                     //   { age: 30, name: "C" }
                     // ]

문법은 obj[key]지만, 이 코드에서 그 의미는 단순한 프로퍼티 조회가 아니다. 실제로 수행되는 것은 조건 기반 필터 연산이다. 이 지점이 Proxy 기반 메타프로그래밍의 핵심이다. WHERE가 반환하는 값은 일반 객체가 아니라 다시 Proxy로 감싼 Queryable이며, 그 덕분에 연산을 자연스럽게 이어갈 수 있다.

각 단계는 이전 상태를 기반으로 새로운 객체를 만들고, 그 결과를 다시 Queryable로 감싼다. 이 흐름이 반복되면서 구조는 점차 축소되고, 전체 코드는 작은 쿼리 엔진처럼 동작한다.

untitled
JSX
const result =
  users
    [WHERE](u => u.age >= 10)
    [WHERE](u => u.age < 30)
    [VALUES]();

이 패턴은 세 가지 개념이 결합된 결과다. 첫째는 Proxy를 이용해 연산의 의미를 동적으로 재정의한 것이다. 둘째는 Symbol을 통해 메타 명령을 안전하게 전달한 설계다. 셋째는 브라켓 문법을 새로운 관점에서 해석해 DSL로 확장한 발상이다. 중요한 점은 문법 자체를 바꾸지 않았다는 것이다. 자바스크립트가 허용하는 범위 안에서, 단지 해석 방식만 재정의했을 뿐이다.
물론 이 방식에는 비용이 따른다. 모든 접근이 Proxy를 거치기 때문에 런타임 오버헤드가 발생한다. 내부 동작이 감춰져 있어 디버깅 난이도도 올라간다. 타입스크립트 환경에서는 원하는 수준의 타입 안전성을 확보하려면 추가적인 제네릭 설계가 필요하다. 이러한 이유로 이 패턴은 일반적인 비즈니스 로직보다는 내부 DSL이나 라이브러리 수준의 추상화에 더 적합하다.

그럼에도 이 실험은 의미가 있다. 자바스크립트의 브라켓 문법은 단순한 프로퍼티 접근처럼 보이지만, Proxy를 통해 그 의미를 재정의할 수 있다. 다만 스펙의 제약을 정확히 이해해야 한다. 객체는 브라켓을 통해 그대로 전달되지 않으며, Symbol만이 안전한 메타 채널이라는 점이 설계의 핵심이다.

결국 이 코드는 작은 장난감 예제처럼 보일 수 있다. 그러나 런타임 메타프로그래밍이 어디까지 확장될 수 있는지를 보여주는 사례이기도 하다. 문법은 고정되어 있지만, 그 해석은 바꿀 수 있다. 그리고 바로 그 지점에서 DSL이 만들어진다.