장용석 블로그
10 min read
Proxy와 Reflect 어디다 쓰나?

가끔 들어는 보았으나 막상 실사용은 별로 없었던 Proxy와 Reflect를 사용해보았다. Proxy와 Reflect는 ES6에서 추가된 기능이다. Proxy는 객체의 기본 동작을 가로채는 기능을 제공하고, Reflect는 객체의 기본 동작을 대신하는 메서드를 제공한다.

들어가기 전에

소소한 꾸미기를 좋아해서, 애용하던 chalk라는 라이브러리가 있었다.
혹시나 모를 수 있으니 console.log의 스펙에 대해 살짝 짚고 넘어가자.

console.log('%cHello', 'color: blue;');

이런식으로 콘솔을 작성하면, Hello라는 글자가 파란색으로 출력된다.
console.log의 스펙에는 형식 지정자에 대한 구현이 정의되어 있다.

Console Standard

No description available

https://console.spec.whatwg.org/#logger

chromium쪽 포맷팅 구현은 이쪽에 있다. 순서대로 devtools 쪽과 v8 엔진 쪽이다.

https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/console/ConsoleFormat.ts;drc=b36375ce9ad77da9c8eb0791755b4a14ca0b76de;l=40

Search and explore code

https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/console/ConsoleFormat.ts;drc=b36375ce9ad77da9c8eb0791755b4a14ca0b76de;l=40

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-console.cc;drc=b36375ce9ad77da9c8eb0791755b4a14ca0b76de;l=64?q=builtins-co&ss=chromium%2Fchromium%2Fsrc

Search and explore code

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-console.cc;drc=b36375ce9ad77da9c8eb0791755b4a14ca0b76de;l=64?q=builtins-co&ss=chromium%2Fchromium%2Fsrc

chromium 쪽에서는 non-standard한 %_ 같은 포멧팅도 있긴하다.\ 일단 다른길로 세지 않기로 하고 여기까지만 알아두자.
쨋든 console.log를 스타일링 할 수 있다는 것이 중요하다.

Console을 꾸미는 방법

그래서 console.log이야기가 왜 나왔는가?

기본 형태를 가지고 스타일링을 해보다 보면 여럿 불편한 점이 있다.

[function 1] result : 1234

이런식으로 한줄에 스타일을 나눠 적용하려면 어떻게 해야할까?

console.log('%c[function 1]', 'color: skyblue;', '%cresult : 1234', 'color: green;');

오 이렇게 하면 될까? 안타깝지만 아래와 같이 첫번째 인자에서만 형식 지정자가 적용된다. 나머지는 스타일이 아니라면 그냥 문자열로 출력된다. 이유는 위에 올린 구현을 살펴보자.

[function 1] %cresult : 1234 color: green;

원하는 대로 출력하려면 아래와 같이 써야한다.

console.log('%c[function 1] %cresult : 1234', 'color: skyblue;', 'color: green;');

혹은 ANSI_escape_code를 이용해서 스타일링을 해야한다. (이 경우는 확실히 css 에 비해 다룰 수 있는 영역이 적을 것이다. 예를 들어 배경 이미지를 넣는다던가 하는 것은 불가능하다.)

console.log('\x1B[34m[function 1]\x1B[0m\x1B[32mresult : 1234\x1B[0m')

ANSI_escape_code 에 대한 자세한 것은 위키를 참고(https://en.wikipedia.org/wiki/ANSI_escape_code)\ ascii 형태의 경우 노드 환경에서는 인자를 나눠서 써도 적용가능하다. 브라우저에서 실행한다면 뒷부분은 문자열로 나올 것이다.

console.log('\x1B[34m[function 1]\x1B[0m','\x1B[32mresult : 1234\x1B[0m')

두 방법 다 편해보이지는 않지만
두 방법중에는 첫번째 방법이 좀더 가독성 좋고, 두번째 방법 한줄에 다 적용하다보니 가독성은 떨어지지만, 구조를 만들기에는 좋아보인다.

이럴 때 chalk를 사용하면 편하다.

import chalk from 'chalk';

console.log(chalk.skyblue('[function 1]') + chalk.green('result : 1234'));

// 이런식으로 체인형태로 여러 스타일을 적용할 수 있다.
console.log(chalk.blue.bgWhite.bold('Hello world!'));

간단한 콘솔 스타일링 라이브러리고 위의 예시처럼 체이닝을 통해 스타일을 적용할 수 있다.

여기서 인상적이였던 포인트는, 체이닝 방식이다.

이걸 한번 구현해보자.

Chalk 구현

스타일 정의

위에서 살펴본 스타일링 방식 중 기능 상으로는 ANSI_escape_code 정도면 충분할 것 같다.
먼저 스타일에 대한 정의를 해보자.

const styles = {
  // 색상
  black: "\x1b[30m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
  blue: "\x1b[34m",
  magenta: "\x1b[35m",
  cyan: "\x1b[36m",
  white: "\x1b[37m",
  // 배경
  bgBlack: "\x1b[40m",
  bgRed: "\x1b[41m",
  bgGreen: "\x1b[42m",
  bgYellow: "\x1b[43m",
  bgBlue: "\x1b[44m",
  bgMagenta: "\x1b[45m",
  bgCyan: "\x1b[46m",
  bgWhite: "\x1b[47m",
  // 스타일
  bold: "\x1b[1m",
  italic: "\x1b[3m",
  underline: "\x1b[4m",
  // 리셋
  reset: "\x1b[0m",
}

요구사항 정의

구현을 하기전 우리가 원하는 입력 방식과 그에 따른 결과물을 살펴보자.

// 1. 1개의 스타일만 적용
console.log(c.blue("Hello")) // blue색으로 출력

// 실제로는 이렇게 출력된다.
console.log("\x1B[34mHello\x1B[0m");
// {\x1B[34m} Hello {\x1B[0m}
// {스타일(색상 blue)} Hello {리셋}

// 2. 여러 스타일 적용
console.log(c.blue.bgWhite.bold("Hello world!"));
console.log("\x1B[34m\x1B[47m\x1B[1mHello\x1B[0m");
// {\x1B[34m} {\x1B[47m} {\x1B[1m} Hello {\x1B[0m}
// {스타일(색상 blue)} {스타일(배경 white)} {스타일(볼드)} Hello {리셋}

체이닝 할 때마다 새로운 스타일이 한겹씩 추가되어야한다. 어떻게 구현할까?
잠시 스크롤을 멈추고 생각해보자.

생각해 보았는가?
그럼 구현해보자.

먼저 요구사항을 다시 정리해보자.

  1. 인자로 출력할 문자열을 받아서 스타일을 적용한 문자열을 반환한다.
  2. 체이닝을 통해 스타일을 적용할 수 있어야한다.
  3. 체이닝을 할 때마다 새로운 스타일이 추가되어야한다.
  4. 마지막에 리셋 스타일이 추가되어야한다.

스타일 적용 함수 구현

문자열을 받아서 스타일을 적용한 문자열을 반환하는 함수를 만들어야한다. 먼저 아래와 같이 하나의 스타일을 적용하는 함수를 만들어보자.

c.blue("Hello") // \x1B[34mHello\x1B[0m

c.blue는 아래와 같은 기능을 할 것이다. 정의되어있는 스타일에서 blue를 찾아서 적용하고, 리셋을 추가한다.

c.blue = (text) => {
  return `${styles.blue}${text}${styles.reset}`;
}

c.{style} 형태로 스타일을 적용하려면 어떻게 해야할까?
c라는 객체에 프로퍼티로 함수들이 정의 되어있으면 가능할 것 같다.
그럼 c라는 객체를 만들어보자.

const c = {
  blue: (text) => {
    return `${styles.blue}${text}${styles.reset}`;
  }
  // ...
}

이렇게 하면 c.blue를 통해 스타일을 적용할 수 있다.
하지만 조금 불합리해보인다. 이미 style에 관한 정보가 styles에 정의되어있는데, c에도 같은 정보가 중복되어 정의되어있는 것이다.

c 객체에 style을 접근 할때마다 알아서 맞는 스타일을 찾아서 적용해 주면 될 것 같다.
이를 구현하기 위해서는 Proxy를 이용한다.

Proxy - JavaScript | MDN

Proxy 객체를 사용하면 한 객체에 대한 기본 작업을 가로채고 재정의하는 프록시를 만들 수 있습니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Proxy
Proxy - JavaScript | MDN

Proxy에는 get 트랩이라는 것이 있다. 이 트랩은 프로퍼티에 접근할 때마다 호출되는 트랩이다.

const c = new Proxy({}, {
  get: function(target, prop) {
    // ...
  }
});

c라는 객체에 접근할 때마다 get 트랩이 호출된다.
c.blue에 접근하면 get 트랩이 호출되고, prop에는 blue가 전달된다.
이를 이용해서 styles에 있는 스타일을 찾아서 적용해보자.

const c = new Proxy({}, {
  get: function(target, prop) {
    return function(text) {
      if (prop in styles) { // styles에 있는 스타일이면 적용
        return `${styles[prop]}${text}${styles.reset}`;
      }
      return text;
    };
  }
});

console.log(c.blue("Hello"));
Hello

위와 같이 c 객체를 구현해 볼 수 있다. 직접 한번 실행해보자.
c.blue를 통해 blue색으로 출력되고 c.red를 통해 red색으로 출력된다.

1단계는 완료되었다.
다음은 체이닝을 구현해보자.

체이닝 구현

이 구현에서의 체이닝은 일종의 재귀적인 구조를 가지고 있다.
함수를 호출할 때마다 새로운 함수를 반환하고, 마지막에 최종적으로 누산된 스타일의 문자열을 반환한다.

이전에 만든 코드는 단순 문자열을 반환하는 함수였다.
이를 함수를 반환하는 함수로 바꾸어보자.

재귀적으로 만들기위해 먼저 제일 끝에 실행될 기본 함수를 만들어보자.

const styleFunction = (text) => {
  return `${currentStyle}${text}${styles.reset}`;
};

스타일을 물고 다니다가 최종적으로 구성된 스타일을 반환하는 함수이다.
이 함수에 Proxy를 적용하여 체이닝을 구현해보자. 이 함수의 프로퍼티에 접근하는 경우는 새 스타일을 붙여서 새로운 styleFunction을 반환해본다.

new Proxy(styleFunction, {
  get(target, prop) {
    if (prop in styles) {
      return createStyleFunction(currentStyle + styles[prop]);
    }
    return target[prop];
  },
});

초기값을 설정해주기 위해 createStyleFunction이라는 함수를 만들어보자.

function createStyleFunction(currentStyle = "") {
  const styleFunction = (text) => {
    return `${currentStyle}${text}${styles.reset}`;
  };

  return new Proxy(styleFunction, {
    get(target, prop) {
      if (prop in styles) {
        return createStyleFunction(currentStyle + styles[prop]);
      }
      return target[prop];
    },
  });
}

export const c = createStyleFunction();

console.log(c.blue.bgWhite("Hello"));

최종적으로는 이런 형태로 구현된다.

동작

완성되었으니, 동작을 살펴봅시다.

console.log(c.blue.bgWhite("Hello"));
  1. c.blue 프로퍼티에 접근하면 get 트랩이 호출된다.
  2. createStyleFunction 함수가 호출되고, currentStylestyles.blue가 추가된 styleFunction이 반환된다.
  3. 다시 반환된 Proxy처리된 c.bluebgWhite 프로퍼티에 접근하면 get 트랩이 다시 호출된다.
  4. createStyleFunction 함수가 호출되고, currentStylestyles.bgWhite가 추가된 styleFunction이 반환된다.
  5. 이떄 currentStyle은 styles.blue + styles.bgWhite가 된다.
  6. “Hello”가 위에서 반환된 styleFunction에 전달되고, 최종적으로 styles.blue + styles.bgWhite + “Hello” + styles.reset이 반환된다.

이렇게 체이닝을 통해 스타일을 적용할 수 있다.

중간 정리

Proxy 자체에 대해 깊이 알아보기 보단 사용 케이스를 통해 알아보았다.
위의 구현에서 처럼 Proxy는 기본 동작을 가로채는 기능을 제공한다고 보면 이해가 쉽다.
가로챈다는 것은, 원본이되는 것이 존재하고 이를 확장혹은 override하는 개념으로도 볼 수 있다.
그렇기에 체이닝과 같은 기능을 구현할 때 유용하게 사용할 수 있었다.

Reflect

확장, 상속의 개념에 있어서 ReflectProxy와 함께 사용하면 더욱 강력한 기능을 제공한다.
Proxy와 Reflect는 1:1 대응되는 메서드를 가지고 있다.
Proxy가 가로채는 동작을 대신하는 메서드를 제공한다면, Reflect는 원본 동작을 대신하는 메서드를 제공한다.
안전하게 원본 동작을 돌려주는 역할을 한다고 보면 된다.

간단한 예시를 통해 Reflect를 알아보자.

예시 1 - ORM 라이브러리

ORM 라이브러리를 만든다고 가정해보자. 위에서 습득한 체이닝 기술까지 이용해서 SQL문을 만드는 쿼리 빌더를 만들어보자.
우선 이런형태로 사용해보고 싶다. (TypeORM 같은 라이브러리들의 querybuilder를 떠올려보자.)

const query = createQueryBuilder()
  .select('name', 'email')
  .from('users')
  .where('age > 18')
  .status('active')  // 동적으로 생성된 WHERE 절
  .createdAt('2024-08-04')  // 또 다른 동적 WHERE 절
  .toSQL();


console.log(query);
// SELECT name, email FROM users WHERE age > 18 AND 
// status = 'active' AND createdAt = '2024-08-04'

선언적으로 동작을 정의하고, SQL문을 만들어내는 형태이다.
기본적인 SQL문에 대한 정의가 있을 것이고, 이를 확장하는 메서드를 추가 할 것이다.

먼저 베이스가 되는 QueryBuilder를 만들어보자.

각 기본 메서드들을 정의 해두고 체이닝을 위해서 QueryBuilder의 인스턴스를 반환하도록 만들어 보았다. 그리고 toSQL 메서드를 통해 SQL문을 반환하도록 만들었다.

class QueryBuilder {
  private query: any = {};

  select(...fields: string[]): this {
    this.query.select = fields;
    return this;
  }

  from(table: string): this {
    this.query.from = table;
    return this;
  }

  where(condition: string): this {
    this.query.where = this.query.where || [];
    this.query.where.push(condition);
    return this;
  }

  toSQL(): string {
    let sql = `SELECT ${this.query.select ? this.query.select.join(', ') : '*'} FROM ${this.query.from}`;
    if (this.query.where) {
      sql += ` WHERE ${this.query.where.join(' AND ')}`;
    }
    return sql;
  }
}

기본적인 SQL문을 만들어내는 QueryBuilder를 만들었다.
이제 Proxy를 이용해서 동적으로 WHERE절을 생성하는 기능을 추가해보자.


function createQueryBuilder(): QueryBuilderProxy {
  const builder = new QueryBuilder();
  
  const handler: ProxyHandler<QueryBuilder> = {
    get(target: QueryBuilder, prop: string | symbol, receiver: any): any {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      }

      // 동적 WHERE 절 생성
      return (value: any) => {
        target.where(`${prop.toString()} = '${value}'`);
        return receiver;
      };
    }
  };
  
  return new Proxy(builder, handler) as QueryBuilderProxy;
}

Proxy를 이용해서 QueryBuilder의 인스턴스에 접근할 때마다 get 트랩이 호출된다.
이를 이용해서 WHERE절을 동적으로 생성하는 기능을 추가하였다.

.status('active') 이런식으로 WHERE절을 동적으로 생성할 수 있다.

예시 2 - 상태관리

Proxyget 트랩 외에도 set 트랩도 제공한다.
그말은 set 되는 값에 대해서도 가로챌 수 있다는 것이다.
그럼 변화도 알 수 있을 터, 이를 이용해서 상태 관리도 해볼 수 있을 것이다.

간단한 상태관리툴을 만들어보자.

type Listener = () => void;

export class StateManager<T extends object> {
  private state: T;
  private listeners = new Set<Listener>();

  constructor(initialState: T) {
    this.state = new Proxy<T>(initialState, {
      get: (target: T, prop: string | symbol): any => {
        return Reflect.get(target, prop);
      },
      set: (target: T, prop: string | symbol, value: any): boolean => {
        const oldState = { ...target };
        const result = Reflect.set(target, prop, value);
        if (prop in target && oldState[prop as keyof T] !== target[prop as keyof T]) {
          this.notifyListeners();
        }
        return result;
      },
    });
  }

  getState = (): T => this.state;

  setState = <K extends keyof T>(key: K, value: T[K]): void => {
    if (this.state[key] !== value) {
      this.state = { ...this.state, [key]: value };
      this.notifyListeners();
    }
  };

  subscribe = (listener: Listener): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  private notifyListeners = (): void => {
    this.listeners.forEach((listener) => listener());
  };
}

StateManagerProxy를 이용해서 state를 가로채고, set 트랩을 이용해서 상태변화를 감지한다.
새로운 값이 set 될때마다 등록된 리스너들에게 알림을 보낸다.

이런 상태관리를 React에 붙여보면 어떨까?
외부 상태이니 useSyncExternalState랑 같이쓰면 좋을 것 같다.

interface GlobalState {
  count: number;
}

// 전역 상태 인스턴스 생성
const globalState = new StateManager<GlobalState>({
  count: 0,
});

export function useGlobalState<K extends keyof GlobalState>(
  key: K
): [GlobalState[K], (value: GlobalState[K]) => void] {
  const value = useSyncExternalStore(
    globalState.subscribe,
    () => globalState.getState()[key],
    () => globalState.getState()[key]
  );

  const setValue = (newValue: GlobalState[K]) => {
    globalState.setState(key, newValue);
  };

  return [value, setValue];
}

이렇게 이벤트를 구독만 잘 시켜주면 useSyncExternalState를 이용해서 React와 연동할 수 있다. 실제 사용은 아래와 같다. 잘 동작하는 것을 확인할 수 있다.

import { useGlobalState } from './store';

function Counter() {
  const [count, setCount] = useGlobalState('count');

  return (
    <div>
      Count: {count}
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

React에 종속되지 않기에 다른 프레임워크에서도 사용할 수 있을 것이다.
ValtioProxy 기반이라고 알고 있는데, 이런느낌이지 않을까 싶다.

마치며

생소 했던 ProxyReflect를 사용해보았다.
Proxy는 기본 동작을 가로채는 기능을 제공하고, Reflect는 기본 동작을 대신하는 메서드를 제공한다.
이를 이용해서 콘솔 스타일링 라이브러리ORM 라이브러리, 상태관리 라이브러리를 만들어보았다.

마무리는 멘트는 코파일럿에게 맡겨봤다. 저녁을 먹지 않아 배가 너무 고프다. 이만 글을 마치겠다.

RSS 구독