728x90

예전 프로젝트에서 tanstack query의 in useInfiniteQuery를 활용하여 무한스크롤을 구현한 적이 있습니다.

그 당시에 굉장히 수월하게 구현할 수 있었는데, 원티드 프리온보딩에서 라이브러리 없이 Intersection Oberserver API을 활용하여 무한스크롤을 구현하라는 사전과제를 받은 김에 정리하고자 글을 작성하게 되었습니다.

 

 

 Intersection Observer API란?

MDN에 따르면 Intersection Observer API의 필요성은 아래와 같습니다.

역사적으로, 요소의 가시성 또는 관련된 두 요소 사이의 상대적 가시성을 감지하는 것은 해결책을 신뢰할 수 없고 브라우저와 사용자가 접근하는 사이트를 느리게 만드는 어려운 작업이었습니다. Web이 성숙해짐에 따라, 이러한 종류의 정보의 요구가 늘어났습니다. 교차 정보는 다음과 같은 많은 이유로 필요합니다.

 

즉, 특정 요소가 얼만큼 노출되었는지(교차 정보)를 확인할 필요성이 점점 늘어났고 이를 위해 생긴 API입니다.

이때 상호작용 요소를 메인스레드가 아닌 콜백함수에서 관리함으로써 , 브라우저는 적합하다고 판단되는 대로 교차 관리를 자유롭게 최적화할 수 있게 됩니다.

 

문법

new IntersectionObserver(callback)
new IntersectionObserver(callback, options)

각각 callback과 options의 타입을 확인하면 아래와 같습니다.

// callback의 타입
interface IntersectionObserverCallback {
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

// options의 타입
interface IntersectionObserverInit {
    root?: Element | Document | null;
    rootMargin?: string;
    threshold?: number | number[];
}

 

  • 콜백함수
    콜백 함수는 대상 요소가 지정한 가시성 임계값을 넘을 때 호출됩니다. 콜백 함수는 두 개의 매개변수를 입력받는데, 
    - entries는 콜백함수가 생길 때 발생한 정보를 담고 있습니다.
    - observer는 관측자에 대한 정보를 담고 있습니다.
  • options
    option은 콜백함수가 발생하는  조건에 대해 커스텀 할 때 쓸 수 있습니다. 선택적인 요소라서 필요할 때 적용하면 됩니다.
    - root  :  상호작용의 경우에 뷰포트 기준으로 할 때가 많습니다. 하지만 특정 모달 내부의 스크롤 등  관측 대상자의 상위 요소를 지정해줘야 할 경우에 사용합니다.
    -rootMargin : 이미지 등 미리 보여야할 경우에는 뷰포트에 도달하기 전에 사용해야할 수 있습니다. 이때 해당 관측대상 기준으로 미리 감지할 수 있는 여백의 크기를 결정합니다.
    -threshold : 관측 대성이 얼만큼의 가시성을 확보해야 상호 작용할 지 설정할 떄 사용합니다.

이를 바탕으로 무한 스크롤에 사용한 옵션의 예시는 아래와 같습니다.

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          fn();
        }
      },
      { threshold: 1.0 }
    );

 

관측 대상이 완벽하게 보일 때  fn이라는 함수를  실행하겠다 입니다.

 

 Intersection Observer를 활용하여 React에서 무한스크롤 구현하기

무한스크롤을 구현하는 방법은 여러가지가 있습니다.  스크롤 높이 기준으로 구현할 수도 있고, 특정 물체가 감지될 때마다 다음페이지를 요청하는 옵저버 방식도 있습니다.

Intersection Observer API를 통해 구현할 옵저버 방식으로 감지대상이 감지되면 다음 페이지에 대한 정보를 요청할 것 입니다.

 

우선 완성된 화면은 아래와 같습니다.  상품 리스트를 담고, 상품의 가격을 전부다 합산한 내용이 우측 상단에 표시했습니다. 그리고 하단으로 내리다 불러온 자료 끝부분에 도달하면, 새로운 요청을 합니다.

 

코드 구조는 다음과 같습니다.
App에서 무한스크롤 영역과 가격 총합을 보여주는 Header부분이 있습니다. 이때 가격을 계산하는 부분은 커스텀훅으로 따로 구현하였습니다.

import './App.css';
import useTotalPrice from './hooks/useTotalPrice';
import Header from './components/Header';
import InfinityScroll from './components/InfinityScroll';

function App() {
  const { totalPrice, updateTotalPrice } = useTotalPrice();

  return (
    <main className="wrapper">
      <Header totalPrice={totalPrice} />
      <InfinityScroll updateTotalPrice= {updateTotalPrice}/>
    </main>
  );
}

export default App;

 

이제 저희의 관심사는 InfinityScroll에 대해서 알아보겠습니다.

import { MockData } from '../const/mock';
import useFetchData from '../hooks/useFetchData';
import useInfiniteScroll from '../hooks/useInfiniteScroll';
import CardList from './CardList';
import Spinner from './Spinner';

const InfinityScroll: React.FC<{
  updateTotalPrice: (datas: MockData[]) => void;
}> = ({ updateTotalPrice }) => {
  const { list, isLastPage, isLoading, fetchData } =
    useFetchData(updateTotalPrice);
  const observerRef = useInfiniteScroll(fetchData, isLoading, isLastPage);

  return (
    <>
      <CardList list={list} />
      {isLoading && <Spinner />}
      {!isLastPage ? (
        <div ref={observerRef} className="observer" />
      ) : (
        <div className="end">마지막 페이지 입니다</div>
      )}
    </>
  );
};

export default InfinityScroll;

 

카드는 배열을 매개변수로 넘기면 되고, 로딩중일때는 로딩처리, 마지막 페이지일 때는 마지막 페이지라 알려주고 아닌 경우에는 oberserRef를 할당항 옵저버 div가 렌더링되도록 하였습니다.

 

useFetchData 훅을 통해서 마지막 페이지인지, 반환된 배열은 어떤건지, 로딩처리, 데이터 요청 시 사용하는 함수가 반환됩니다. 

그리고 useInifinieScroll훅에 요청함수와 로딩중인지, 마지막 페이지인지를 전달해주고 ref를 반환받고 있습니다.

 

결국 useInfiniteScroll이 어떻게 구현되어있는지만 확인하면 됩니다.

import { useEffect, useRef } from 'react';

function useInfiniteScroll(
  callback: () => void,
  isLoading: boolean,
  isLastPage: boolean
) {
  const observerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (isLoading || isLastPage) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          callback();
        }
      },
      { threshold: 1.0 }
    );

    const currentRef = observerRef.current;

    if (currentRef) {
      observer.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [isLoading, isLastPage, callback]);

  return observerRef;
}

export default useInfiniteScroll;

해당 훅에서는 로딩이나 마지막 페이지일때는 useEffect가 작동하지 않게 하였습니다. 

observerRef를 선언한 후에, useEffect 내부에서는 Intersectionobserver를 통해서 대상 옵저버 div가 완전히 보일 때 콜백함수 (매개변수로 넘겨준 콜백함수(fetchData함수))를 실행하도록 하였습니다. 

이후 관측대상이 존재한다면 (observerRef.current가 참이라면) 관측을 시작하고, 컴퍼넌트가 업데이터 되거나 언마운트될 떄 관측을 취소하도록 하였습니다.

 

그리고  반환된 observerRef는 InfinityScroll 컴퍼넌트의 관측자 div의 ref에 할당되어있습니다.

 

 

참고문헌 및 링크

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.

developer.mozilla.org

 

https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver

 

IntersectionObserver() - Web API | MDN

IntersectionObserver() 생성자는 새로운 IntersectionObserver 객체를 생성하고 반환합니다.

developer.mozilla.org

 

 

 

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

다이시 카토가 만든 전역 상태 관리 라이브러리 중 하나인 Jotai에서는 WeakMap을 통해서 atom을 관리합니다. 이에 WeakMap이 무엇이고 왜 사용했는지에 대해 알아보려고 합니다.

 

우선 조타이 라이브러리의 공식 docs에서 언급한 왜 WeakMap을 사용했는지 입니다.

Let's start with an easy example. An atom is just a function that will return a configuration object. We are using WeakMap to map atom with their state.
WeakMap doesn't keep its keys in memory, so if an atom is garbage collected, its state will be garbage collected too. This helps avoid memory leaks.

 

요약하면 아래와 같습니다.

1. 조타이에서는 atom으로 상태관리를 하는데 atom은 함수이다.

2. WeakMap은 키를 메모리에 유지하지 않기 때문에 가비지 콜렉터가 atom을 수집하면 상태도 제거해준다.

 

즉, 손쉽게 메모리 누수를 방지하기 위해서 조타이에서는 WeakMap을 사용합니다. 그렇다면 왜 WeakMap은 무엇일까요?

 

WeakMap에 대해서

WeakMap은 이름부터 JS의 Map과 관계가 있어보입니다. 따라서 우선 Map에 대해서 알아보려고 합니다.

 

MDN문서에 설명하는 Map은 아래와 같습니다.

ECMAScript 6에서 값들을 매핑하기 위한 새로운 데이터 구조를 소개 하고 있다. 그중 하나인 Map객체는 간단한 키와 값을 서로 연결(매핑)시켜 저장하며 저장된 순서대로 각 요소들을 반복적으로 접근할 수 있도록 한다.

 

Object와 Map 비교
1. Object의 키는 Strings이며, Map의 키는 모든 값을 가질 수 있다.
2. Object는 크기를 수동으로 추적해야하지만, Map은 크기를 쉽게 얻을 수 있다.
3. Map은 삽입된 순서대로 반복된다.
4. 객체(Object)에는 prototype이 있어 Map에 기본 키들이 있다. (이것은 map = Object.create(null) 를 사용하여 우회할 수 있다. )

 

자바스크립트에서는 key,value가 쌍으로 이루어진 자료구조에는 Object, Map, WeakMap 3가지가 중요합니다.

앞서 말했듯이 조타이는 atom이라는 함수를 키로 상태를 관리합니다. 그리고 함수는 객체이기에, Map이나 WeakMap이 적합합니다. 

 

그렇다면  WeakMap은 어떤 차이점이 있을까요?

Mdn에서 말하는 WeakMap의 장점은 아래와 같습니다.

  • 가비지 컬렉션을 방지하지 않으므로 키 객체에 대한 참조가 결국 사라집니다.
  • 키 객체가 WeakMap 밖의 다른 곳에서 참조되지 않으면 그 값의 가비지 컬렉션도 허용합니다.

그리고 자바스크립트 엔진의 가비지 컬렉터의 특징을 아래 예시를 통해 확인해보고자 합니다.

자바스크립트 엔진은 도달 가능한 (그리고 추후 사용될 가능성이 있는) 값을 메모리에 유지합니다.
출처 : https://ko.javascript.info/garbage-collection
let object = { name: 'Garbage' };

let collector = [object];

object = null; // 참조를 null로 덮어씀

console.log(JSON.stringify(collector[0])); // {"name":"Garbage"}

let obj2 = { name: 'Trash' };

let weakMap = new WeakMap();

weakMap.set(obj2, 'die');

obj2 = null; // 참조를 덮어씀

console.log(typeof weakMap.get(obj2)); //undefined

따라서 아래의 예시를 통해서 값을 확인해보면, 첫 번째 예시의 object의 경우 collector에 의해 참조되고 있기 때문에 도달할 수 있다고 평가되어 지고 값이 유지되어 있음을 볼 수 있습니다. 하지만 weakMap의 키로 사용된 obj2의 경우에는 참조로 덮어씌워 졌을 때, 가비지 컬렉터에 의해 수거됩니다. 그리고 WeakMap에서 키가 수거된 경우에는 자연스럽게 값도 같이 가비지 컬렉터에 의해 수거됐음을 확인할 수 있습니다.

 

 Jotai에서 WeakMap

다이시 카토에 의하면 Jotai도 구독 모델 기반의 라이브러리입니다.

아래는  Jotai의 createStore입니다. WeakMap을 사용하는 것을 확인할 수 있네요.

export const createStore = (): Store => {
  const atomStateMap = new WeakMap()
  const getAtomState = <Value>(atom: Atom<Value>) => {
    let atomState = atomStateMap.get(atom) as AtomState<Value> | undefined
    if (!atomState) {
      atomState = { d: new Map(), p: new Set(), n: 0 }
      atomStateMap.set(atom, atomState)
    }
    return atomState
  }
  return buildStore(getAtomState)
}

 

이 소스코드에서 알 수 있듯이 WeakMap을 통해서 아톰의 상태를 저장하고, 이미 존재하는 값이라면 해동 값을 가져옵니다.

 

이를 통해서 만약 React에서 Jotai를 통해 상태관리를 할 경우에, 해당 컴퍼넌트가 unmount되어 더 이상 atom이 참조되고 있지 않다면, 가비지 컬렉터에 의해 atom이 수거되어 메모리 누수를 방지하는 장점을 얻습니다.

 

 

 

 

 

참고 문헌 및 출처

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

 

https://ko.javascript.info/weakmap-weakset

 

위크맵과 위크셋

 

ko.javascript.info

 

https://ko.javascript.info/garbage-collection

 

가비지 컬렉션

 

ko.javascript.info

 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Keyed_collections

 

키 기반 컬렉션 - JavaScript | MDN

이번 장에서는 입력된 키값을 기준으로 정렬되는 데이터의 집합(자료 구조)에 대해 소개 할 것이다. Map과 Set은 입력된 순서대로 반복적으로 접근 가능한 요소들을 포함하고 있다.

developer.mozilla.org

 

 

+ Recent posts