자바스크립트와 메타프로그래밍

자바스크립트의 Proxy

2025년 12월 7일7분2

Proxy?

  • 객체에 대한 작업을 가로채서 대신 처리할 수 있는 자바스크립트의 기능임

  • 간단하게 말하면 아파트의 경비원과 같은 역할로 설명이 가능함

    • 아파트에 들어가거나 나올때는 항상 경비원을 거쳐야함

    • 방문자를 확인해서 기록하거나 필요시 출입에 대한 제한이 가능함

기본 문법

code
/**
 * 인터페이스 정의
 */
interface ProxyConstructor {
  /**
   * Creates a revocable Proxy object.
   * @param target A target object to wrap with Proxy.
   * @param handler An object whose properties define the behavior of Proxy when an operation is attempted on it.
   */
  revocable<T extends object>(target: T, handler: ProxyHandler<T>): { proxy: T; revoke: () => void };

  /**
   * Creates a Proxy object. The Proxy object allows you to create an object that can be used in place of the
   * original object, but which may redefine fundamental Object operations like getting, setting, and defining
   * properties. Proxy objects are commonly used to log property accesses, validate, format, or sanitize inputs.
   * @param target A target object to wrap with Proxy.
   * @param handler An object whose properties define the behavior of Proxy when an operation is attempted on it.
   */
  new <T extends object>(target: T, handler: ProxyHandler<T>): T;
}

/**
 * 활용 예제
 */
const target = {};
const handler = {};
const proxy = new Proxy(target, handler);

프록시 트랩

  • 핸들러에 정의하는 메소드를 트랩 이라고 부름

  • 이는 작업을 가로챈다는 의미임

get 트랩 : 속성을 읽는 경우

code
type Product = {
  name: string;
  price: number;
};

const product: Product = {
  name: "맥북 프로 16",
  price: 2_000_000,
};

const productProxy = new Proxy(product, {
  get(target, prop: keyof Product) {
    console.log(`${prop}에 접근함`);
    return target[prop];
  },
});

console.log(productProxy.name);

// name에 접근함
// 맥북 프로 16

set 트랩 : 속성을 수정하는 경우

code
type Product = {
  name: string;
  price: number;
};

const product: Product = {
  name: "맥북 프로 16",
  price: 2_000_000,
};

const productProxy = new Proxy(product, {
  set(target, prop: keyof Product, value: unknown) {
    console.log(`${prop}${value}를 할당함`);
    (target as Record<keyof Product, unknown>)[prop] = value;
    return true;
  },
});

productProxy.name = "맥미니 m4";

// name에 맥미니 m4를 할당함

has 트랩 : in 연산자를 사용하는 경우

code
type Product = {
  name: string;
  price: number;
  _serialNumber: string;
};

const product: Product = {
  name: "맥북 프로 16",
  price: 2_000_000,
  _serialNumber: "1234567890",
};

const productProxy = new Proxy(product, {
  has(target, prop: keyof Product) {
    /**
     * 프로퍼티가 _로 시작한다면 해당 속성은 숨기기
     */
    if (prop.startsWith("_")) {
      return false;
    }

    return prop in target;
  },
});

console.log("name" in productProxy); // true
console.log("_serialNumber" in productProxy); // false

예제

유효성 검사 추가하기

code
type User = {
  name: string;
  age: number;
};

const user: User = {
  name: "Dongwoo",
  age: 27,
};

const userProxy = new Proxy(user, {
  set(target, prop: keyof User, value: unknown) {
    if (prop === "age") {
      if (typeof value !== "number") {
        throw new Error("나이는 숫자로 설정해야함");
      }

      if (value < 0 || value > 100) {
        throw new Error("나이는 0~100 사이의 숫자로 설정해야함");
      }
    }

    (target as Record<keyof User, unknown>)[prop] = value;
    return true;
  },
});

userProxy.age = 28;
console.log(userProxy.age); // 28

// Error: 나이는 0~100 사이의 숫자로 설정해야함
//     at Object.set (file:///Users/imkdw/Desktop/ts-pg/dist/node/node.js:12:23)
//     at file:///Users/imkdw/Desktop/ts-pg/dist/node/node.js:21:15
//     at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
//     at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:578:26)
//     at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
userProxy.age = -1;

옵셔널 프로퍼티에 기본값 제공하기

code
type Theme = "light" | "dark";
type Language = "ko" | "en";

type Config = {
  theme?: Theme;
  language?: Language;
};

const defaultConfig: Config = {
  theme: "dark",
  language: "ko",
};

function withDefaultConfig<T extends object>(target: T, defaultConfig: Partial<T>): T {
  return new Proxy(target, {
    get(target, prop: string | symbol) {
      if (typeof prop === "symbol") {
        return (target as any)[prop];
      }

      const value = target[prop as keyof T];
      if (value === undefined || value === null) {
        return defaultConfig[prop as keyof T];
      }

      return value;
    },
  });
}

const userConfig: Config = {};
const config = withDefaultConfig(userConfig, defaultConfig);

/**
 * 초기 userConfig에는 아무런 값도 존재하지 않음
 * 하지만 Proxy를 통해 값이 없으면 defaultConfig의 값이 반환되도록 설정됨
 */
console.log(config.theme); // dark
console.log(config.language); // ko

/**
 * userConfig에 theme 값을 설정하면 해당 값을 그대로 반환함
 */
config.theme = "light";
console.log(config.theme); // light

음수 인덱스 배열 제공하기

  • 파이썬과 유사하게 음시 인덱스로 배열 끝에서부터 스캔하도록 만들 수 있음

code
function createNegativeArray<T>(array: T[]): T[] {
  return new Proxy(array, {
    get(target, prop: string) {
      const index = parseInt(prop, 10);

      if (Number.isNaN(index)) {
        return (target as any)[prop];
      }

      if (index < 0) {
        return target[target.length + index];
      }

      return target[index];
    },
  });
}

const array = createNegativeArray(["a", "b", "c", "d", "e"]);

console.log(array[0]); // 'a'
console.log(array[-1]); // 'e'
console.log(array[-2]); // 'd'

const arr = [1, 2, 3];
console.log(arr[-1]); // undefined

취소가 가능한 프록시 : Proxy.revocable

  • 프록시의 경우 한번 생성하면 영구적으로 동작함

  • 하지만 Proxy.revocable을 통해서 나중에 비활성화가 가능한 프록시를 만들 수 있음

일회용 객체 만들기

code
type Coupon = {
  code: string;
  discount: number;
  use: () => string;
};

function createOneTimeCoupon(coupon: Coupon) {
  const { proxy, revoke } = Proxy.revocable(coupon, {
    get(target, prop) {
      const value = (target as any)[prop];

      if (prop === "use" && typeof value === "function") {
        return () => {
          const result = value.call(target);
          revoke();
          return result;
        };
      }

      return value;
    },
  });

  return proxy;
}

const coupon = createOneTimeCoupon({
  code: "SAVE20",
  discount: 20,
  use: () => "쿠폰이 적용되었습니다",
});

console.log(coupon.code); // 'SAVE20'
console.log(coupon.use()); // '쿠폰이 적용되었습니다'
console.log(coupon.code); // TypeError: Cannot perform 'get' on a proxy that has been revoked

댓글 0

ME

댓글을 작성하려면 로그인이 필요합니다

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!