728x90

들어가며

redux는 어떻게 상태 관리를 하는가에 대해서 알아보기 위해서 createStore에 대해서 알아보려고 합니다.

createStore는 deprecated인데 왜 해당 함수에 대해서 알아보냐에 대한 답변은 아래와 같습니다.

 

The standard method for creating a Redux store. It uses the low-level Redux core 
createStore
 method internally, but wraps that to provide good defaults to the store setup for a better development experience.

 

공식 문서에 의하면 configureStore 는 middleware등 추가적인 기능을 createStore 함수를 기반으로 같이 쓸 수 있게 만든 함수이기 때문입니다.

따라서 핵심 상태 관리 원리는 여전히 createStore에 남아있고 다만 유저는 configureStore를 사용하는 것이 권장되기에 derpecated 된 것 입니다.

 

createStore 살펴보기

 

https://github.com/reduxjs/redux/blob/master/src/createStore.ts

 

코드 전문은 아래와 같습니다. 

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {}
>(
  reducer: Reducer<S, A>,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {},
  PreloadedState = S
>(
  reducer: Reducer<S, A, PreloadedState>,
  preloadedState?: PreloadedState | undefined,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext>
export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {},
  PreloadedState = S
>(
  reducer: Reducer<S, A, PreloadedState>,
  preloadedState?: PreloadedState | StoreEnhancer<Ext, StateExt> | undefined,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, UnknownIfNonSpecific<StateExt>> & NoInfer<Ext> {
  if (typeof reducer !== 'function') {
    throw new Error(
      `Expected the root reducer to be a function. Instead, received: '${kindOf(
        reducer
      )}'`
    )
  }

  if (
    (typeof preloadedState === 'function' && typeof enhancer === 'function') ||
    (typeof enhancer === 'function' && typeof arguments[3] === 'function')
  ) {
    throw new Error(
      'It looks like you are passing several store enhancers to ' +
        'createStore(). This is not supported. Instead, compose them ' +
        'together to a single function. See https://redux.js.org/tutorials/fundamentals/part-4-store#creating-a-store-with-enhancers for an example.'
    )
  }

  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState as StoreEnhancer<Ext, StateExt>
    preloadedState = undefined
  }

  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error(
        `Expected the enhancer to be a function. Instead, received: '${kindOf(
          enhancer
        )}'`
      )
    }

    return enhancer(createStore)(
      reducer,
      preloadedState as PreloadedState | undefined
    )
  }

  let currentReducer = reducer
  let currentState: S | PreloadedState | undefined = preloadedState as
    | PreloadedState
    | undefined
  let currentListeners: Map<number, ListenerCallback> | null = new Map()
  let nextListeners = currentListeners
  let listenerIdCounter = 0
  let isDispatching = false


  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = new Map()
      currentListeners.forEach((listener, key) => {
        nextListeners.set(key, listener)
      })
    }
  }

 
  function getState(): S {
    if (isDispatching) {
      throw new Error(
        'You may not call store.getState() while the reducer is executing. ' +
          'The reducer has already received the state as an argument. ' +
          'Pass it down from the top reducer instead of reading it from the store.'
      )
    }

    return currentState as S
  }

  
  function subscribe(listener: () => void) {
    if (typeof listener !== 'function') {
      throw new Error(
        `Expected the listener to be a function. Instead, received: '${kindOf(
          listener
        )}'`
      )
    }

    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
          'If you would like to be notified after the store has been updated, subscribe from a ' +
          'component and invoke store.getState() in the callback to access the latest state. ' +
          'See https://redux.js.org/api/store#subscribelistener for more details.'
      )
    }

    let isSubscribed = true

    ensureCanMutateNextListeners()
    const listenerId = listenerIdCounter++
    nextListeners.set(listenerId, listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
            'See https://redux.js.org/api/store#subscribelistener for more details.'
        )
      }

      isSubscribed = false

      ensureCanMutateNextListeners()
      nextListeners.delete(listenerId)
      currentListeners = null
    }
  }

 
  function dispatch(action: A) {
    if (!isPlainObject(action)) {
      throw new Error(
        `Actions must be plain objects. Instead, the actual type was: '${kindOf(
          action
        )}'. You may need to add middleware to your store setup to handle dispatching other values, such as 'redux-thunk' to handle dispatching functions. See https://redux.js.org/tutorials/fundamentals/part-4-store#middleware and https://redux.js.org/tutorials/fundamentals/part-6-async-logic#using-the-redux-thunk-middleware for examples.`
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. You may have misspelled an action type string constant.'
      )
    }

    if (typeof action.type !== 'string') {
      throw new Error(
        `Action "type" property must be a string. Instead, the actual type was: '${kindOf(
          action.type
        )}'. Value was: '${action.type}' (stringified)`
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = (currentListeners = nextListeners)
    listeners.forEach(listener => {
      listener()
    })
    return action
  }

  function replaceReducer(nextReducer: Reducer<S, A>): void {
    if (typeof nextReducer !== 'function') {
      throw new Error(
        `Expected the nextReducer to be a function. Instead, received: '${kindOf(
          nextReducer
        )}`
      )
    }

    currentReducer = nextReducer as unknown as Reducer<S, A, PreloadedState>

   
    dispatch({ type: ActionTypes.REPLACE } as A)
  }

 
  function observable() {
    const outerSubscribe = subscribe
    return {
     
      subscribe(observer: unknown) {
        if (typeof observer !== 'object' || observer === null) {
          throw new TypeError(
            `Expected the observer to be an object. Instead, received: '${kindOf(
              observer
            )}'`
          )
        }

        function observeState() {
          const observerAsObserver = observer as Observer<S>
          if (observerAsObserver.next) {
            observerAsObserver.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }


  dispatch({ type: ActionTypes.INIT } as A)

  const store = {
    dispatch: dispatch as Dispatch<A>,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  } as unknown as Store<S, A, StateExt> & Ext
  return store
}

 

결국 요약만 하면  currentState를 통해 상태를 기억하고  외부에서 받아온 reducer를 통해서 상태를 업데이트 합니다. 
Map으로 선언한 listener를 통해서 구독자를 관리합니다. 이후 반환값으로 상태를 얻는 getState, 구독하는 subscribe, 상태를 업데이트하기 위한 dispatch함수들을 반환합니다.

(상태 관리에 좀 더 집중하기 위해 여타 에러처리와 replaceReducer , observable에 대해서는 넘어가겠습니다.)


이제 너무 방대한 양이기에 이를 간단하게 요약해서 코어만 추출해보려고 합니다.

 

createStore 코어만 작성하기

const createStoreCore = (reducer) => {
  let state;
  let listeners = new Map();
  let listenerIdCounter = 0;

  const getState = () => state;

  const subscribe = (listener) => {
    const id = listenerIdCounter++;
    listeners.set(id, listener);
    
    return () => {
      listeners.delete(id);
    };
  };

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach((listener) => listener());
  };

  dispatch({});

  return { getState, subscribe, dispatch };
};

 

상태를 수정하는 것은 reducer를 통해서 받아서 상태가 순수할 수 있도록 하고,
listners를 통해 구독시킵니다. 이후 dispatch를 할 때 (상태가 수정될 때) 는  구독자에게 알려야 하고, 구독이 끝났을 때는 listeners에서 제거해야 합니다.

 

이전에 '리엑트 훅을 활용한 마이크로 상태 관리 책'을 통해서  확인한  zustand, jotai, valtio 등과 원리는 비슷하네요.
다만 상태는 createStore 내부에서 클로저를 통해 관리하고 있습니다.


react에서는 어떻게 리렌더링이 되는가?

redux에서는 상태 관리와 리렌더링의 주체가 관심사가 나뉘어져 있습니다.

리렌더링은 react-redux 라이브러리의 훅들을 기반으로 진행됩니다.

대부분의 상태관리 라이브러리는 외부 상태를 useSyncExternalStore 훅을 통해서 합니다.

이때 redux에서는 구 버전(useSyncExternalStore 훅이 나온 18버전 이전)의 리엑트에 대응하기 위해서 

use-sync-external-store  을 활용합니다.

 

해당 코드는 useSelector 내부에서 확인할 수 있습니다.

export const initializeUseSelector = (fn: uSESWS) => {
  useSyncExternalStoreWithSelector = fn
}

 

dispatch 시 내부에서 listener에게 상태 변화가 감지가 되고, 콜백함수로 작동하여 리렌더링을 일으킵니다.

 

 

참고문헌

 

https://redux-toolkit.js.org/api/configureStore

 

configureStore | Redux Toolkit

 

redux-toolkit.js.org

https://ko.react.dev/reference/react/useSyncExternalStore

 

useSyncExternalStore – React

The library for web and native user interfaces

ko.react.dev

https://github.com/reduxjs/react-redux

 

GitHub - reduxjs/react-redux: Official React bindings for Redux

Official React bindings for Redux. Contribute to reduxjs/react-redux development by creating an account on GitHub.

github.com

 

728x90

들어가며

Proxy에서 처음 접하게 된 것은 제로초님의 쇼츠였습니다.  객체를 가공해서 사용할 수 있게 하는 일종어가며

Proxy에서 처음 접하게 된 것은 제로초님의 쇼츠였습니다. 객체를 가공해서 사용할 수 있게 하는  미들웨어 느낌의 객체입니다. 해당 쇼츠에서는 객체가 에러를 뱉도록 가공하는 예시를 보여줬었습니다.

 

리액트 훅을 활용한 마이크로 상태관리에서 Valtio가  Proxy 기반으로 상태관리를 한다는 사실을 접하게 되고, proxy란 무엇이고 Valtio에서는 어떻게 사용하는지 자세히 알아볼겸 포스팅하게 되었습니다.

 

 

프록시란?

mdn에서 나타낸 프록시의 정의는 아래와 같습니다.

Proxy 객체를 사용하면 원래 Object 대신 사용할 수 있는 객체를 만들지만, 이 객체의 속성 가져오기, 설정 및 정의와 같은 기본 객체 작업을 재정의할 수 있습니다. 프록시 객체는 일반적으로 속성 액세스를 기록하고, 입력의 유효성을 검사하고, 형식을 지정하거나, 삭제하는 데 사용됩니다.

 

let proxy = new Proxy(target, handler)
  • target – 감싸게 될 객체로, 함수를 포함한 모든 객체가 가능함
  • handler – 동작을 가로채는 메서드인 '트랩(trap)'이 담긴 객체로, 여기서 프락시를 설정함

그리고 이때 handler에 사용할 수 있는 메서드는 아래와 같습니다.

https://ko.javascript.info/proxy#ref-1033

 

아래는 javainfo에서 보여준 get메서드를 활용한 proxy 사용 예시입니다. 

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; 
    }
  }
});

console.log( numbers[1] ); // 1
console.log( numbers[123] ); // 0

이와 같이 객체를 가공하여 추가적인 기능을 삽입할 때 사용할 수 있는 툭수한 객체라고 생각하면 됩니다.

 

이제 Valtio에 대해서 알아보고  Valtio에서는 Proxy를 어떻게 사용하는지 알아보려고 합니다.

Valtio란?

리액트 훅을 활용한 마이크로 상태관리의 저자이자 Valtio를 만든 다이시 카토는 아래와 같이 Valtio를 설명합니다.

갱신 모델의 상태 관리 라이브러리이자 상태 사용 추적 기법 기반으로 렌더링 최적화를 하는 라이브러리

 

proxy는 상태를 감지하는 변경가능한 상태를 만들고, snapshot을 통해서 변경 불가능한 객체(Object.freeze 기반)를 만든다.

import { proxy, useSnapshot } from 'valtio';

const state = proxy({
  count1: 0,
  count2: 0,
});

const Counter1 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count1;
  return (
    <>
      {snap.count1} <button onClick={inc}>+1</button>
    </>
  );
};

const Counter2 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count2;
  return (
    <>
      {snap.count2} <button onClick={inc}>+1</button>
    </>
  );
};

const ValtioCounter = () => (
  <>
    <div>
      <Counter1 />
    </div>
    <div>
      <Counter2 />
    </div>
  </>
);

export default ValtioCounter;

 

Counter 1 2는 각각 상태 count 12 를 사용하고 있다. zustand나 redux처럼 별도의 selector를 사용하지 않기에, 리렌더링 최적화가 안될거 같지만 앞서 언급한 상태 사용 추적기법에 의해서 리렌더링이 최적화가 되어있다.

1을 눌렀을 때 2가 리렌더링 되지 않음

 

useSnapshot 훅에서  나온  스냅샷을 컴퍼넌트가 사용하게 되면 해당 컴퍼넌트가 특정 상태를 사용하는 것을 기억한다. 이후 상태가 변경하였으면 proxy에서 특정 상태의 변경을 알리고 이를 통해  useSnapshot훅이 컴퍼넌트가 사용하기 있는 객체가 변경 되었으면 리렌더링을 하는 것이다.

 

Valtio내부 코드 확인

state (proxy함수 리턴값) 와 snap을 각각 콘솔로 찍어보면 Proxy 객체임을 확인할 수 있습니다. 그리고  각각의 Handler를 보면 state에는 deleteProperty와 set이 존재하고  snap에는 get, getOwnPropertyDescription ,has , ownKeys 프로퍼티가 존재하는 것을 확인할 수 있습니다.

 

이제 각각의 코드를 확인하려고 합니다. 분량이 많아서 전체코드는 다루지 않겠습니다.

각각의 코드를 확인하기전에 proxy, useSnapshot 모두 Proxy를 다룰 때,  proxy-compare 라이브러리에 의존하고 있고, 해당 부분은 관심사의 밖이기에 간단하게 어떤 역할을 하는지만 언급하려고 합니다. 자세한 내용은 상단 링크의 레포에서 확인할 수 있습니다.

 

proxy

proxy의 경우에는  레포지토리의 src\vanilla.ts 경로에 존재합니다.  

vanilla.ts 에서는 크게 3부분으로 나누어져 있습니다. 모든 코드를 보여드리기엔 많기에 간단한 예시 정도만 보여드리려고 하고,  어떻게 상태 사용 추적이 되는지에 포커스를 두려고 합니다.

 

1. 타입선언 부분 및 기본 상수값으로 쓰는 함수들

예시)

type Primitive = string | number | boolean | null | undefined | symbol | bigint
type Op =
  | [op: 'set', path: Path, value: unknown, prevValue: unknown]
  | [op: 'delete', path: Path, prevValue: unknown]
type Listener = (op: Op, nextVersion: number) => void

const canProxyDefault = (x: unknown): boolean =>
  isObject(x) &&
  !refSet.has(x) &&
  (Array.isArray(x) || !(Symbol.iterator in x)) &&
  !(x instanceof WeakMap) &&
  !(x instanceof WeakSet) &&
  !(x instanceof Error) &&
  !(x instanceof Number) &&
  !(x instanceof Date) &&
  !(x instanceof String) &&
  !(x instanceof RegExp) &&
  !(x instanceof ArrayBuffer) &&
  !(x instanceof Promise)

 

2. 내부 함수 및 내부 변수
예시) 

// internal states
const proxyStateMap: WeakMap<ProxyObject, ProxyState> = new WeakMap()
const refSet: WeakSet<object> = new WeakSet()
const snapCache: WeakMap<object, [version: number, snap: unknown]> =
  new WeakMap()
const versionHolder = [1, 1] as [number, number]
const proxyCache: WeakMap<object, ProxyObject> = new WeakMap()

// internal functions
let objectIs: (a: unknown, b: unknown) => boolean = Object.is
let newProxy = <T extends object>(target: T, handler: ProxyHandler<T>): T =>
  new Proxy(target, handler)
let canProxy: typeof canProxyDefault = canProxyDefault
let createSnapshot: typeof createSnapshotDefault = createSnapshotDefault
let createHandler: typeof createHandlerDefault = createHandlerDefault

3. 모듈 외부로 export할 함수들

예시) proxy, snapshot 

 

 

이제 다시 proxy에 조금 더 집중해보려고 합니다. proxy 함수의 전체 코드는 아래와 같습니다.

export function proxy<T extends object>(baseObject: T = {} as T): T {
  if (!isObject(baseObject)) {
    throw new Error('object required')
  }
  const found = proxyCache.get(baseObject) as T | undefined
  if (found) {
    return found
  }
  let version = versionHolder[0]
  const listeners = new Set<Listener>()
  const notifyUpdate = (op: Op, nextVersion = ++versionHolder[0]) => {
    if (version !== nextVersion) {
      version = nextVersion
      listeners.forEach((listener) => listener(op, nextVersion))
    }
  }
  let checkVersion = versionHolder[1]
  const ensureVersion = (nextCheckVersion = ++versionHolder[1]) => {
    if (checkVersion !== nextCheckVersion && !listeners.size) {
      checkVersion = nextCheckVersion
      propProxyStates.forEach(([propProxyState]) => {
        const propVersion = propProxyState[1](nextCheckVersion)
        if (propVersion > version) {
          version = propVersion
        }
      })
    }
    return version
  }
  const createPropListener =
    (prop: string | symbol): Listener =>
    (op, nextVersion) => {
      const newOp: Op = [...op]
      newOp[1] = [prop, ...(newOp[1] as Path)]
      notifyUpdate(newOp, nextVersion)
    }
  const propProxyStates = new Map<
    string | symbol,
    readonly [ProxyState, RemoveListener?]
  >()
  const addPropListener = (prop: string | symbol, propValue: unknown) => {
    const propProxyState =
      !refSet.has(propValue as object) && proxyStateMap.get(propValue as object)
    if (propProxyState) {
      if (import.meta.env?.MODE !== 'production' && propProxyStates.has(prop)) {
        throw new Error('prop listener already exists')
      }
      if (listeners.size) {
        const remove = propProxyState[2](createPropListener(prop))
        propProxyStates.set(prop, [propProxyState, remove])
      } else {
        propProxyStates.set(prop, [propProxyState])
      }
    }
  }
  const removePropListener = (prop: string | symbol) => {
    const entry = propProxyStates.get(prop)
    if (entry) {
      propProxyStates.delete(prop)
      entry[1]?.()
    }
  }
  const addListener = (listener: Listener) => {
    listeners.add(listener)
    if (listeners.size === 1) {
      propProxyStates.forEach(([propProxyState, prevRemove], prop) => {
        if (import.meta.env?.MODE !== 'production' && prevRemove) {
          throw new Error('remove already exists')
        }
        const remove = propProxyState[2](createPropListener(prop))
        propProxyStates.set(prop, [propProxyState, remove])
      })
    }
    const removeListener = () => {
      listeners.delete(listener)
      if (listeners.size === 0) {
        propProxyStates.forEach(([propProxyState, remove], prop) => {
          if (remove) {
            remove()
            propProxyStates.set(prop, [propProxyState])
          }
        })
      }
    }
    return removeListener
  }
  let initializing = true
  const handler = createHandler<T>(
    () => initializing,
    addPropListener,
    removePropListener,
    notifyUpdate,
  )
  const proxyObject = newProxy(baseObject, handler)
  proxyCache.set(baseObject, proxyObject)
  const proxyState: ProxyState = [baseObject, ensureVersion, addListener]
  proxyStateMap.set(proxyObject, proxyState)
  Reflect.ownKeys(baseObject).forEach((key) => {
    const desc = Object.getOwnPropertyDescriptor(
      baseObject,
      key,
    ) as PropertyDescriptor
    if ('value' in desc && desc.writable) {
      proxyObject[key as keyof T] = baseObject[key as keyof T]
    }
  })
  initializing = false
  return proxyObject
}

 

 

1. 유효성 검사 및 캐시 여부 확인

proxy의 시작부분에서 우선 proxy의 매개변수로 들어오는 값이 객체인줄 확인하고, 캐쉬 여부를 합니다. 동일한 객체에 대해서 캐시를 한 경우에는   캐시 된 값을 얼리리턴합니다.

  if (!isObject(baseObject)) {
    throw new Error('object required')
  }
  const found = proxyCache.get(baseObject) as T | undefined
  if (found) {
    return found
  }

 

2. 버전 관리 및 리스너 등록을 위한 변수 선언

  let version = versionHolder[0]
  const listeners = new Set<Listener>()
  let checkVersion = versionHolder[1]

version은 객체의 현재 버전을 저장하는 변수로, 상태가 업데이트 될 경우 버전이 증가합니다. 이후 checkversion 변수를 활용하여 상태 변경 여부를 확인하고 필요한 경우에만 동기화합니다.

 

listeners는 해당 상태 업데이트시 등록된 리스너에게 알리기 위해서 사용하는 변수입니다.

 

3. 프록시 생성 

let initializing = true
  const handler = createHandler<T>(
    () => initializing,
    addPropListener,
    removePropListener,
    notifyUpdate,
  )
  const proxyObject = newProxy(baseObject, handler)
  proxyCache.set(baseObject, proxyObject)
  const proxyState: ProxyState = [baseObject, ensureVersion, addListener]
  proxyStateMap.set(proxyObject, proxyState)
  Reflect.ownKeys(baseObject).forEach((key) => {
    const desc = Object.getOwnPropertyDescriptor(
      baseObject,
      key,
    ) as PropertyDescriptor
    if ('value' in desc && desc.writable) {
      proxyObject[key as keyof T] = baseObject[key as keyof T]
    }
  })
  initializing = false
  return proxyObject
}

 

이후 새 프록시 객체를  생성하고,  캐쉬 및 상태관리를 합니다. proxy내부 하위 객체에 대해서도 Reflect객체를 활용하여 proxy로 만들고  최종적으로는 프록시 객체를 반환합니다.

 

결국 프록시를 생성하는 것은 알겠고, 캐시를 통해 효율적 상태관리를 하고, 하위 객체에 대한 등록도 하는 것은 알겠는데

proxy를 만들면서 어떤식으로 객체를 핸들링하는지 파악하려면, handler에 사용된 함수들에 대해서 알 필요가 있습니다.

 

4. creatHandler함수

아래는 creatHandler 에 등록 creatHandlerDefault 함수입니다.

const createHandlerDefault = <T extends object>(
  isInitializing: () => boolean,
  addPropListener: (prop: string | symbol, propValue: unknown) => void,
  removePropListener: (prop: string | symbol) => void,
  notifyUpdate: (op: Op) => void,
): ProxyHandler<T> => ({
  deleteProperty(target: T, prop: string | symbol) {
    const prevValue = Reflect.get(target, prop)
    removePropListener(prop)
    const deleted = Reflect.deleteProperty(target, prop)
    if (deleted) {
      notifyUpdate(['delete', [prop], prevValue])
    }
    return deleted
  },
  set(target: T, prop: string | symbol, value: any, receiver: object) {
    const hasPrevValue = !isInitializing() && Reflect.has(target, prop)
    const prevValue = Reflect.get(target, prop, receiver)
    if (
      hasPrevValue &&
      (objectIs(prevValue, value) ||
        (proxyCache.has(value) && objectIs(prevValue, proxyCache.get(value))))
    ) {
      return true
    }
    removePropListener(prop)
    if (isObject(value)) {
      value = getUntracked(value) || value
    }
    const nextValue =
      !proxyStateMap.has(value) && canProxy(value) ? proxy(value) : value
    addPropListener(prop, nextValue)
    Reflect.set(target, prop, nextValue, receiver)
    notifyUpdate(['set', [prop], value, prevValue])
    return true
  },
})

매개변수로는 4가지를 받습니다

 

  • isInitializing
    객체가 초기화 상태인지 확인하는 함수
  • addPropListener
    특정 속성에 리스너를 추가하는 함수입니다.속성의 변경을 감지하고, 구독자에게 알림을 보내기 위해 사용
  • removePropListener
    속성에 연결된 리스너를 제거하는 함수
  • notifyUpdate
    속성 값이 변경되었을 때 변경 사항을 알리는 함수로 변경된 속성의 이름과 값, 이전 값 등을 구독자에게 전달함

 

 

또한 앞서 console.log에서 확인했듯이 creatHandler는  set 와 deleteProperty  두가지 핸들러 메서드를 반환하는 것을 확인할 수 있습니다.

이제 각각의 핸들러 메서드에서 어떤 작업이 진행되는지 알아봅시다.

  • deleteProperty
    1. 삭제하려는 속성의 기존 값을 Reflect.get으로 가져옴
    2. 그 다음, 해당 속성에 연결된 리스너를 제거함
    3. Reflect.deleteProperty를 통해 속성을 삭제
    4. 속성이 성공적으로 삭제되면, notifyUpdate를 호출하여 삭제 작업을 listener에게 알림
    5.삭제가 여부를 반환합니다.
  •  set
    1. 속성의 이전 값이 존재하는지 확인하고 초기화 중이 아니라면, Reflect.has로 해당 속성이 이미 있는지 확인하고, 이전 값을 가져옵니다.
    2.만약 이전 값과 새 값이 동일하거나, 새 값이 이미 캐시된 프록시 값과 동일하다면 true를 반환하고 설정 작업을 종료
    3. 새로운 값이 설정될 속성에 대해 기존 리스너를 제거함
    4.  새 값이 객체일 경우, getUntracked를 호출해 프록시되지 않은 원본 객체를 가져오거나, 새 값이 프록시 가능한 경       우 proxy()를 사용해 새로운 프록시로 변환.
    5. addPropListener를 통해 새 값에 대한 리스너를 추가
    6. Reflect.set을 호출하여 속성에 새로운 값을 설정
    7.  notifyUpdate를 호출해 속성 값이 변경되었음을 알림

결국 proxy는 캐시를 활용한 최적화에 대한 작업과 쓰기 등록 시 구독자에게 알려준다는 사실을 알았습니다. 하지만 

컴퍼넌트가 해당 객체의 어떤 속성을 사용하는지 어떻게 알고 리렌더링 시키는지에 대해서 알기 위해선 useSnapshot훅을 분석할 필요성이 있습니다.

 

useSnapshot

import {
  useCallback,
  useDebugValue,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useSyncExternalStore,
} from 'react'
import {
  affectedToPathList,
  createProxy as createProxyToCompare,
  isChanged,
} from 'proxy-compare'
import { snapshot, subscribe } from './vanilla.ts'
import type { Snapshot } from './vanilla.ts'


export function useSnapshot<T extends object>(
  proxyObject: T,
  options?: Options,
): Snapshot<T> {
  const notifyInSync = options?.sync
  // per-proxy & per-hook affected, it's not ideal but memo compatible
  const affected = useMemo(
    () => proxyObject && new WeakMap<object, unknown>(),
    [proxyObject],
  )
  const lastSnapshot = useRef<Snapshot<T>>()
  let inRender = true
  const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback() // Note: do we really need this?
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject)
      try {
        if (
          !inRender &&
          lastSnapshot.current &&
          !isChanged(
            lastSnapshot.current,
            nextSnapshot,
            affected,
            new WeakMap(),
          )
        ) {
          // not changed
          return lastSnapshot.current
        }
      } catch {
        // ignore if a promise or something is thrown
      }
      return nextSnapshot
    },
    () => snapshot(proxyObject),
  )
  inRender = false
  useLayoutEffect(() => {
    lastSnapshot.current = currSnapshot
  })
  if (import.meta.env?.MODE !== 'production') {
    condUseAffectedDebugValue(currSnapshot as object, affected)
  }
  const proxyCache = useMemo(() => new WeakMap(), []) // per-hook proxyCache
  return createProxyToCompare(currSnapshot, affected, proxyCache, targetCache)
}

 

proxy를 캐시하는 내용과 유효성 검사등의 내용에 대해서는 생략하겠습니다.

내부에서 어떻게 proxy의 변경을 감지하고 useSnapshot 훅의 리턴값을 사용만 하면 특정 상태에 대한 구독이 되는지에 대해서 알아보려고 합니다.

우선 proxy의 핸들러 메서드를 통해서 쓰기 및 삭제 시 listener들에게 알림이 간다는 사실을 확인했습니다.

이때 리스너가 알림을 받았을 때, 리렌더링을 하게 하는 훅이 useSyncExternalStore 훅입니다. 

 

useSysncstore의 훅은 3가지의 매개변수를 받을 수 있습니다.

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
  • subscribe: 하나의 callback 인수를 받아 store에 구독하는 함수입니다. store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다. subscribe 함수는 구독을 정리하는 함수를 반환해야 합니다.
  • getSnapshot: 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수입니다. store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다. 저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링합니다.
  • optional getServerSnapshot: store에 있는 데이터의 초기 스냅샷을 반환하는 함수입니다. 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용됩니다. 서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며 일반적으로 직렬화되어 서버에서 클라이언트로 전달됩니다. 이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생합니다.

이제 다시 useSnapshot 훅을 보면 1. useCallback으로 감싸진 subscribe부분, 2. 화살표 함수로된 getSnapshot, 
3. ()=>snapshot(proxyObject)인 getServerSnapshot 을 확인할 수 있습니다.

 const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback() // Note: do we really need this?
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject)
      try {
        if (
          !inRender &&
          lastSnapshot.current &&
          !isChanged(
            lastSnapshot.current,
            nextSnapshot,
            affected,
            new WeakMap(),
          )
        ) {
          // not changed
          return lastSnapshot.current
        }
      } catch {
        // ignore if a promise or something is thrown
      }
      return nextSnapshot
    },
    () => snapshot(proxyObject),
  )

 

 

1. subscribe 함수에서 전달된 callback에서는 proxyObject의 상태 변화를 감지하고 상태가 변경될 때마다 이 callback이 호출되어 새로운 스냅샷을 가져오고 컴포넌트를 리렌더링합니다.
2. getSnapshot 함수에 전달된 화살표 함수 내부에 있는 isChanged 함수는 상태가 변경되었는지 확인하는 역할을 합니다. 만약 상태가 변하지 않았으면 리렌더링을 방지합니다.

 

이제 proxy 객체가 변화가 되었을 때, 왜 컴퍼넌트가 리렌더링하는지는 알게 되었습니다. 하지만 아직 어떻게 특정 컴퍼넌트가 객체를 사용하고 있는지에 대해서는 언급이 없었습니다.

 

해당 내용은 createProxyToCompare 이란 별칭으로 사용된  proxy-compare 라이브러리의 createProxy 함수에 있습니다.

공식 문서에서 createProxy 함수에 대한 설명은 아래와 같습니다.

createProxy  함수는 최상위에서 프록시를 생성하고, 접근할 때 중첩된 객체들도 프록시로 만들어, get/has 프록시 핸들러를 통해 어떤 프로퍼티가 접근되었는지를 추적합니다.


해당 함수에 관한 내용은 방대하기에 소스코드 링크만 남겨두고,  어떻게 proxy 핸들러에서 접근을 감지하는지에 대한 부분만 간단하게 추츨하는지 알아보기 위해  proxy의 핸들러의 내용만 추출하면 아래와 같습니다.

 

 const handler: ProxyHandler<T> = {
    get(target, key) {
      if (key === GET_ORIGINAL_SYMBOL) {
        return origObj;
      }
      recordUsage(KEYS_PROPERTY, key);
      return createProxy(
        Reflect.get(target, key),
        state[AFFECTED_PROPERTY] as Affected,
        state[PROXY_CACHE_PROPERTY],
        state[TARGET_CACHE_PROPERTY],
      );
    },
    has(target, key) {
      if (key === TRACK_MEMO_SYMBOL) {
        recordObjectAsUsed();
        return true;
      }
      recordUsage(HAS_KEY_PROPERTY, key);
      return Reflect.has(target, key);
    },
    getOwnPropertyDescriptor(target, key) {
      recordUsage(HAS_OWN_KEY_PROPERTY, key);
      return Reflect.getOwnPropertyDescriptor(target, key);
    },
    ownKeys(target) {
      recordUsage(ALL_OWN_KEYS_PROPERTY);
      return Reflect.ownKeys(target);
    },
  };
  if (isTargetCopied) {
    handler.set = handler.deleteProperty = () => false;
  }
  return [handler, state] as const;
};

 

이떄 get이 proxy를 읽을 때 핸들링하는 내용입니다.  key에 접근할 때, recordUsage를 호출해 KEYS_PROPERTY와 함께 사용 기록을 남기고, 다시 createProxy를 호출하여 그 값을 프록시로 감쌉니다. 따라서 우리가 컴퍼넌트에서 useSnapshot훅의 반환 proxy를 사용하게 되면 get 핸들러 메서드에 의해 기록되어서 추후  상태가 변경되었을 때  관련 상태를 사용한 컴퍼넌트만 리렌더링이 되게 됩니다.

 

요약

리액트 훅을 활용한 마이크로 상태관리훅을 통해서 proxy와 Valtio의 상태 사용 추적이 어떻게 일어나는지에 대해서 알아봤습니다.

proxy를 통해서 원본 객체에 핸들링 메서드를 적용할 수 있는데, Valtio에서는 이를 통해서 proxy의 상태 변화를 감지하고 이를 기반으로 불변의 snapshot을 찍습니다. 이때 상태관리하는 프록시에서는 proxy의 핸들러 메서드 중 set과 deleteProperty를 통해 변경을 listener들에게 알립니다.

 

그리고 리엑트 컴퍼넌트에서는  useSnapshot 훅 내부의 useSyncExternalStore와 createProxy를 통해서 2차 가공된 proxy 객체를 얻습니다. 해당 객체는 원본 proxy가 상태 변화를 할 때마다 새로운 proxy가 형성되는 불변 객체이며 컴퍼넌트에서 가공된 proxy객체(추후 스냅샷이라 부름)를 사용할 경우  createProxy훅 내부에 있는 get 메서드에 의해서 사용이 감지되고 등록이 됩니다. 이후 proxy객체가 변경이되면 useSyncExternalStore훅에 의해서 이전 상태와 차이점이 있는지를 비교하고 있을 경우 리렌더링이 됩니다.

 

 

참고문헌 및 출처

리액트 훅을 활용한 마이크로 상태관리 (다이시 카토 지음)

https://github.com/pmndrs/valtio

 

GitHub - pmndrs/valtio: 🧙 Valtio makes proxy-state simple for React and Vanilla

🧙 Valtio makes proxy-state simple for React and Vanilla - pmndrs/valtio

github.com

 

https://ko.javascript.info/proxy#ref-1036

 

Proxy와 Reflect

 

ko.javascript.info

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Reflect

 

Reflect - JavaScript | MDN

Reflect 는 중간에서 가로챌 수 있는 JavaScript 작업에 대한 메서드를 제공하는 내장 객체입니다. 메서드의 종류는 프록시 처리기와 동일합니다. Reflect는 함수 객체가 아니므로 생성자로 사용할 수

developer.mozilla.org

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

 

Proxy - JavaScript | MDN

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

developer.mozilla.org

https://www.npmjs.com/package/proxy-compare

 

proxy-compare

Compare two objects using accessed properties with Proxy. Latest version: 3.0.0, last published: 5 months ago. Start using proxy-compare in your project by running `npm i proxy-compare`. There are 39 other projects in the npm registry using proxy-compare.

www.npmjs.com

https://ko.react.dev/reference/react/useSyncExternalStore

 

useSyncExternalStore – React

The library for web and native user interfaces

ko.react.dev

 

728x90

Zustand는 상태를 유지하는 store를 만드는데 사용되는 라이브러리입니다. 

import { create } from 'zustand';

type StoreState = {
  count: number;
  text: string;
  inc1: () => void;
};

export const useEx1Store = create<StoreState>((set) => ({
  count: 0,
  text: 'hello',
  inc1: () => set((prev) => ({ count: prev.count + 1 })),
}));

 

이때 위의 예시에서 반환되는 값 useEx1Store의 타입은 아래와 같습니다.

export interface StoreApi<T> {
    setState: SetStateInternal<T>;
    getState: () => T;
    getInitialState: () => T;
    subscribe: (listener: (state: T, prevState: T) => void) => () => void;
}

 

setState는 불변성을 활용하여 값을 설정하거나 이전 값을 활용하여 갱신이 가능합니다.  setState의 경우에는 내부적으로 Object.assign으로 구현되어 있으며 , 그렇기에 store에서 이전 상태와 새 상태를 병합하여 새 객체를 반환합니다.

getState와 getInitialstate로 상태 및 초기 상태를 얻을 수 있습니다.

마지막으로 subscribe를 통해서 구독을 하여, 상태가 변경되었을 때 콜백함수가 실행되게 할 수 있습니다. 이러한 구독을 활용하여, 리엑트에서 상태를 공유하는 다른 컴퍼넌트들이 리렌더링하도록 할 수 있습니다.

 

선택자함수를 사용하여 수동 렌더링 최적화 하기

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

function Counter() {
  const { count, inc } = useStore()
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  )
}

 

아래 코드는 zustand의 공식페이지에 나와 있는 예시 입니다. 보통 이 예시를 보고 zustand의 간단한 사용법에 혹해서 도입하는 경우가 많다고 생각합니다. 하지만 지금은 상태가 단 하나뿐이라서 큰 문제가 없지만,  만약에 처음에 보였던 예시처럼 count와 text 두 가지 상태를 공유하고 count만 변경하는 경우에 문제가 발생합니다. 이를 확인하기 위해서 아래와 같은 예시를 만들었습니다.

 

각각의 컴퍼넌트를 렌더링하는 앱

import './App.css';
import { CountChanger, ZustandEx1, ZustandEx2 } from './test/chp7/ZustandEx1';

function App() {
  return (
    <>
      <ZustandEx1 />
      <ZustandEx2 />
      <CountChanger />
    </>
  );
}

export default App;

 

선택자 사용 유무 리렌더링을 비교하기 위해서 분리함

import { useEx1Store } from './count';

export const ZustandEx1 = () => {
  const { text } = useEx1Store();
  console.log('리랜더링 체크 1');
  return (
    <>
      <div>{text}</div>
    </>
  );
};

export const ZustandEx2 = () => {
  const text = useEx1Store((state) => state.text);
  console.log('리랜더링 체크 2');
  return (
    <>
      <div>{text}</div>
    </>
  );
};

export const CountChanger = () => {
  const inc1 = useEx1Store((state) => state.inc1);
  console.log('리렌더링함?');
  return <button onClick={inc1}>Zustand Count 증가</button>;
};

 

count.ts ( zustand 코드)

import { create } from 'zustand';

type StoreState = {
  count: number;
  text: string;
  inc1: () => void;
};

export const useEx1Store = create<StoreState>((set) => ({
  count: 0,
  text: 'hello',
  inc1: () => set((prev) => ({ count: prev.count + 1 })),
}));

 

예시 페이지

 

count 값을 증가하는 함수가 존재하고, 전역 store에는 count와 text가 존재합니다.

이때 CountChanger는 단순히 count 값을 증가시키는 store 내부의 함수이고, ZustandEx1에서는 선택자 없이 text를 사용 Ex2에서는 선택자를 활용하여 상태 증가를 시키고 있습니다.

그리고 버튼을 누르게 되면 콘솔을 통해서 리렌더링 횟수를 확인할 수 있습니다.

 

초기에 렌더링 될 때 3가지의 콘솔이 모두 찍히고, 이후 버튼 클릭시에 count 와 전혀 무관한 것 같은 ZustandEx1이 리렌더링 됨을 확인할 수 있습니다. 이는 store를 구독하고 있기 때문에, store 상태가 변경될 때마다 리렌더링 되기 때문입니다.

 

이를 해결하기 위한 방법이 선택자 함수 입니다.

  const text = useEx1Store((state) => state.text);

store 내부에 있는 상태 중에서 사용하고 싶은 상태를 선택하게 되면 해당 값을 제외한 상태 변경에는 store를 구독했어도 리렌더링 되지 않습니다.

이러한 것을 리액트 훅을 사용한 마이크로 상태 관리 책에서는 수동 렌더링 최적화 라고 부릅니다.

 

이를 활용하면 파생 상태에 대한 리렌더링도 최적화 할 수 있습니다. 만약 count 값 2개를 합친 값의 최적화를 하는 경우에는 1 + 2 는 3입니다. 하지만 만약 값이 변경되어 0 + 3 이 되어도 파생 값은 변경되지 않았기에 리렌더링할 필요가 없습니다. 이때 아래와 같이 선택자에서 파생 값(지금은 count 와 count2의 합)을 선택하면 리렌더링이 최적화 됩니다.

  const text = useEx1Store((state) => state.count + state.count2);

 

 

교재에서는 Zustand의 장점으로는 리액트와 동일한 모델을 사용해 라이브러리의 단순성과 번들 크기가 작다는 점을 꼽고 있고, 단점으로는 선택자를 이용한 수동 렌더링 최적화로 꼽고 있습니다. 객체 참조 동등성을 이해해야 하며, 선택자 코드를 위해 보일러플레이트 코드를 많이 작성해야 한다고 합니다. 

 

참고문헌

https://zustand-demo.pmnd.rs/

 

Zustand

 

zustand-demo.pmnd.rs

공식 소스 코드

 

리액트 훅을 활용한 마이크로 상태 관리 (다이시 카토 지음)

 

 

728x90

평소에 Redux와 같은 전역상태관리 라이브러리를 사용하면서 해당 라이브러리가 어떻게 전역상태관리를 하는지에 대해서 깊게 생각해 본적이 없습니다. 이번 wanted 프리온보딩에서 관련 로직을 설명듣고, 이를 정리하기 위해서 
redux의 createStore의 코드를 다시 한번 분석해 보았습니다.

 

https://github.com/deminoth/redux/blob/master/src/createStore.ts

 

redux/src/createStore.ts at master · deminoth/redux

자바스크립트 앱을 위한 예측 가능한 상태 컨테이너. Contribute to deminoth/redux development by creating an account on GitHub.

github.com

 리덕스는 클로저를 통해서 전역 변수처럼 상태관리를 합니다.

 

자바스크립트를 공부하면 자주 접하게 되는 내용입니다.

1. 클로저란 함수와 해당 함수가 선언된 렉시컬 환경의 조합이다. 

2. 클로저는 정보 은닉을 위해서 사용된다.

 

redux에서 상태관리를 위한 store를 만들 때 createStore를 사용합니다.  관련 코드는 상위 url의 ( 42~384번째 줄까지)

 

우선 createStore의 매개변수와 타입들이 나오고

export function createStore<
  S,
  A extends Action,
  Ext extends {} = {},
  StateExt extends {} = {}
>(
  reducer: Reducer<S, A>,
  preloadedState?: PreloadedState<S> | StoreEnhancer<Ext, StateExt>,
  enhancer?: StoreEnhancer<Ext, StateExt>
): Store<S, A, StateExt> & Ext {

 


135줄에  let으로 선언된 변수들이 나옵니다.

 let currentReducer = reducer
  let currentState = preloadedState as S
  let currentListeners: Map<number, ListenerCallback> | null = new Map()
  let nextListeners = currentListeners
  let listenerIdCounter = 0
  let isDispatching = false


이때 createStore는 함수이고, 내부에 let으로 된 변수가 있다는 사실을 기억하면 됩니다.

 

createStore내부에서는 메서드를 통해서 해당 변수들을 이용 및 수정합니다.

 function dispatch(action: A) {
    if (!isPlainObject(action)) {
      throw new Error(
        `Actions must be plain objects. Instead, the actual type was: '${kindOf(
          action
        )}'. You may need to add middleware to your store setup to handle dispatching other values, such as 'redux-thunk' to handle dispatching functions. See https://redux.js.org/tutorials/fundamentals/part-4-store#middleware and https://redux.js.org/tutorials/fundamentals/part-6-async-logic#using-the-redux-thunk-middleware for examples.`
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. You may have misspelled an action type string constant.'
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = (currentListeners = nextListeners)
    listeners.forEach(listener => {
      listener()
    })
    return action
  }

 

 

이때 클로저에 의해서 비록 createStore의 실행이 끝났지만, 우리는 렉시컬 환경에 남아있는 변수들에 접근할 수 있습니다.

 

 

일반적인 전역변수를 사용할 경우 해당 변수가 오염될 수 있지만, 우리는  은닉화된 createStore의 렉시컬 환경속의 변수들을 관련 메서드를 통해 사용하기 때문에 좀 더 안전하게 사용할 수 있습니다.

+ Recent posts