useSyncExternalStore의 내부 동작 원리

우혁·2024년 9월 18일
39

React

목록 보기
8/10
post-thumbnail

useSyncExternalStore란?

React 18에서 도입된 훅으로, 외부 상태 저장소(store)를 리액트 컴포넌트와 동기화하는 데 사용된다.


주요 목적

리액트의 Concurrent 기능과 외부 상태 라이브러리 간의 호환성 문제를 해결하는 것이다.

아래와 같은 상황에서 유용하게 사용된다.

1. React 외부의 상태 관리 라이브러리와 통합하는 경우

  • Redux, MobX, Zustand 등의 라이브러리와 함께 사용할 수 있다.

2 .브라우저 API와 같은 외부 데이터 소스를 구독하는 경우

  • 네트워크 연결 상태와 같은 브라우저 API를 구독할 때 사용할 수 있다.

💡 리액트의 동시성(Concurrent) 기능이란?
React 18에서 도입된 중요한 개선 사항으로, 렌더링 프로세스를 더 효울적으로 관리하고 사용자 경험을 향상시키는 것을 목표로 한다.

  • 동시성 지원: React가 여러 작업을 동시에 처리할 수 있게 해준다.
  • 중단 가능한 렌더링: 렌더링 과정을 작은 단위로 나누어 우선순위에 따라 처리할 수 있다.
  • 사용자 경험 개선: UI의 반응성을 높이고, 대규모 데이터 처리나 복잡한 애니메이션에서도 부드러운 사용자 경험을 제공한다.

사용 방법

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출하여 외부 데이터 저장소에서 값을 읽는다.

subscribe

외부 스토어의 변화를 구독하는 함수이다.

  • 콜백함수를 인자로 받는다. 이 콜백은 스토어가 업데이트될 때 마다 호출되어야 한다.
  • 구독을 해제하는 함수를 반환해야 한다. 이는 컴포넌트가 언마운트될 때 정리(Clean-Up)를 위해 사용된다.
// subscribe 예시 코드
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  
  // 클린 업(Clean-Up)
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

getSnapshot

외부 스토어의 현재 상태의 스냅샷을 반환하는 함수이다.

  • 스토어의 현재 상태를 반환해야 한다.
  • 스토어가 변경되지 않았다면 이전과 동일한 값을 반환해야 한다.
  • 성능 최적화를 위해 결과를 메모이제이션(캐시)하는 것이 좋다.
// getSnapshot 예시 코드
function getSnapshot() {
  return navigator.onLine;
}

getServerSnapshot(선택적)

서버 사이드 렌더링(SSR) 환경에서 사용되는 초기 스냅샷을 반환하는 함수이다.

  • 서버에서 HTML을 생성할 때와 클라이언트에서 hydration 중에 실행된다.
  • 클라이언트와 서버 간의 초기 상태 불일치를 방지하는 데 중요하다.
  • 브라우저 전용 API를 사용하는 경우에 특히 유용하다.
// getServerSnapshot 예시 코드
function getServerSnapshot() {
  return true; // 서버에서는 항상 온라인 상태로 가정
}

주의사항

1. getSnapshot 함수는 성능을 위해 결과를 메모이제이션(캐시) 하는 것이 좋다.

  • 데이터가 변경되지 않았다면 이전과 동일한 객체를 반환한다.
  • 데이터가 변경되었다면 새로운 객체를 생성하여 반환한다.
let lastSnapshot = null;
let lastStoreState = null;

function getSnapshot() {
  const storeState = store.getState();
  
  if (storeState !== lastStoreState) { // 변경이 있다면
    lastSnapshot = { ...storeState }; // 새로운 객체 생성
    lastStoreState = storeState;
  }
  
  return lastSnapshot;
}

불변성을 지키면서 React가 변경 사항을 효율적으로 감지하고, 불필요한 렌더링을 방지할 수 있다.

2. useSyncExternalStore가 반환한 스토어 값을 기반으로 렌더링을 일시 중단하는 것은 권장되지 않는다.

  • 외부 스토어의 변경은 React가 제어할 수 없어 Non-Blocking Transition으로 처리할 수 없다.
  • Suspense fallback(로딩 스피너 등)이 트리거되어 사용자 경험이 저하될 수 있다.
function MyComponent() {
  const data = useSyncExternalStore(subscribe, getSnapshot);
  const [asyncResult, setAsyncResult] = useState(null);

  useEffect(() => {
    // 외부 스토어 데이터를 기반으로 비동기 작업 수행
    async function fetchData() {
      const result = await someAsyncOperation(data);
      setAsyncResult(result);
    }
    
    fetchData();
  }, [data]);

  // asyncResult를 사용하여 렌더링
}

외부 스토어 데이터를 기반으로 한 비동기 작업은 useEffect와 같은 방식으로 처리하는 것이 좋다.

💡 Non-Blocking Transition이란?
React 18에서 도입된 개념으로, 사용자 인터페이스의 반응성을 향상시키는 기능이다.

  • 중요한 업데이트와 덜 중요한 업데이트를 구분한다.
  • 중요한 업데이트는 즉시 처리된다.
  • 덜 중요한 업데이트(Transition)는 백그라운드에서 처리된다.
  • 사용자 상호작용이 발생하면 진행 중이 Transition 업데이트를 중단하고 나중에 재개할 수 있다.
// 사용 예시
const [isPending, startTransition] = useTransition();
// 검색 기능: 입력 값 업데이트는 즉시, 검색 결과 필터링은 Transition으로 처리
const handleChange = (e) => {
  setInputValue(e.target.value); // 즉시 업데이트
  startTransition(() => {
    setSearchResults(filterResults(e.target.value)); // 백그라운드에서 처리
  });
};

useSyncExternalStore가 왜 필요할까?

아래 코드는 브라우저의 온라인 상태를 보여주는 코드이다.

import { useEffect, useCallback, useState } from 'react';
export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  const update = useCallback(() => {
    setIsOnline(navigator.onLine);
  }, [])
  
  // ❌ 이벤트 핸들러가 등록되기 전에 외부 저장소 업데이트는 감지 못함
  useEffect(() => {
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    }
  }, [update])
  
  return isOnline;
}

이 코드의 문제점은 외부 저장소는 언제든지 업데이트가 될 수 있기 때문에 useStateuseEffect 사이에 변경되었을 수도 있다는 것인데, 이벤트 핸들러가 나중에 등록되기 때문에 변경 사항을 감지할 수 없다.

useEffect보다 실행시점이 빠른 useLayoutEffectuseInsertionEffect를 사용해도 외부 저장소의 업데이트를 보장할 수 없다.

이 문제를 해결하려면 이벤트 핸들러가 등록된 후 update 함수를 호출하여 외부 스토어의 상태를 한번 더 확인해야 하지만, 여전히 위 코드에는 찢어짐(Tearing) 현상이 발생하고 있다.

찢어짐(Tearing) 현상이란?

그래픽 프로그래밍에서 전통적으로 시각적 불일치를 나타내는 데 사용되는 용어이다.

JavaScript는 단일 스레드이기 때문에 이 문제는 일반적인 웹 개발에서는 발생하지 않는다.
하지만 React 18에서는 동시 렌더링이 렌더링 중에 React가 양보하기 때문에 이 문제가 발생할 수 있다.

startTransition 또는 Supense와 같은 동시 기능을 사용할 때 React는 다른 작업을 수행하기 위해 일시 중지할 수 있다. 이러한 일시 중지 사이에 업데이트가 발생해 렌더링에 사용되는 데이터가 변경될 수 있으며, 이로 인해 동일한 UI 데이터에 대해 두 개의 다른 값이 표시될 수 있다.

💡 React 18의 동시성 렌더링(Concurrent Rendering)에서의 양보란?

  • React 18은 렌더링 작업을 작은 단위로 나누어 수행한다. 각 단위 사이에 React는 브라우저에 제어권을 양보할 수 있다.
  • React는 더 중요한 작업(ex 사용자 입력 처리)이 있는지 주기적으로 확인하여, 중요한 작업이 있으면 현제 렌더링을 일시 중지하고 그 작업을 처리한다.
  • 이러한 양보 매커니즘 덕분에 React는 긴 렌더링 작업 중에도 UI의 응답성을 유지할 수 있다.

렌더링 중 외부 상태가 변경되면, 렌더링의 일부는 이전 상태를 나머지는 새 상태를 반영할 수 있다. 이를 찢어짐(Tearing)이라고 한다.

이 문제는 React에만 국한된 것이 아니라 동시성의 필연적인 결과이다. 사용자 입력에 응답하기 위해 렌더링을 중단할 수 있으려면 렌더링하는 데이터가 변경되어 UI가 찢어지는 것(Tearing)에 대한 복원력이 있어야 한다.

찢어짐(Tearing) 예시 코드

  • startTransition(동시성 렌더링) 사용 코드
let data = 1;
function getData() {
  return data;
}

setTimeout(() => (data = 2), 100); // 100ms 후 데이터 변경

function Cell() {
  const start = Date.now();
  // 50ms 지연
  while (Date.now() - start < 50) {
    // 메인 스레드에게 제어권 양보
  }

  const data = getData();
  return <div style={{ padding: "1rem", border: "1px solid red" }}>{data}</div>;
}

export default function Tearing() {
  const [showCells, setShowCells] = useState(false);
  // startTransition를 사용함으로써 동시성 렌더링을 사용한다.
  useEffect(() => {
    startTransition(() => setShowCells(true));
  }, []);
  
  return (
    <>
      <p>startTransition(동시성 렌더링) 사용 컴포넌트</p>
      {showCells ? (
        <div style={{ display: "flex", gap: "1rem" }}>
          <Cell />
          <Cell />
          <Cell />
          <Cell />
        </div>
      ) : (
        <p>준비 중..</p>
      )}
    </>
  );
}

위 코드는 100ms 후에는 data가 2로 변하고, 각 Cell 컴포넌트들은 50ms의 지연 후에 getData를 호출하여 data 값을 가져온다.

startTransition를 사용하여 동시성 렌더링을 하기 때문에 일부 Cell 컴포넌트는 1, 다른 Cell 컴포넌트는 2를 표시하는 찢어짐(Tearing) 현상이 발생한다.

이러한 찢어짐(Tearing) 현상을 해결하기 위해 useSyncExternalStore를 사용할 수 있다.

// 첫 번째 인자 subscribe에는 일단 빈 함수 전달
const data = useSyncExternalStore(() => { return () => {} }, getData);

위와 같이 data를 가져오는 로직에 useSyncExternalStore를 사용하여 렌더링 도중 외부 스토어의 값이 변경되더라도 컴포넌트가 항상 동일한 데이터를 사용하도록 보장한다.

실제로 useSyncExternalStore를 사용할 때는 첫 번째 인자(subscribe)에 외부 스토어의 변경을 감지하는 로직을 추가하여 데이터가 변경되면 컴포넌트가 자동으로 리렌더링될 수 있게 해주어야 한다!

  • useSyncExternalStore를 사용하여 일관적인 UI 데이터 제공

내부 동작 원리

useSyncExternalStore는 2가지 일을 한다.

  1. 외부 저장소의 모든 변경 사항이 감지되는지 확인한다.

  2. 동시 모드에서 UI의 동일한 저장소에 대한 동일한 데이터가 렌더링되는지 확인한다.

초기 마운트, mountSyncExternalStore

function mountSyncExternalStore<T>(
  subscribe: (() => void) => () => void, // 스토어 변경을 구독하는 함수
  getSnapshot: () => T, // 현재 스토어의 상태를 가져오는 함수
  getServerSnapshot?: () => T // 서버 렌더링용 스냅샷 함수
): T {
  const fiber = currentlyRenderingFiber;
  const hook = mountWorkInProgressHook();

  let nextSnapshot;
  const isHydrating = getIsHydrating();
  if (isHydrating) {
    if (getServerSnapshot === undefined) {
      throw new Error(
        '서버 렌더링된 콘텐츠에 필요한 getServerSnapshot이 누락되었습니다. 클라이언트 렌더링으로 되돌아갑니다.'
      );
    }
    nextSnapshot = getServerSnapshot();
  } else {
    nextSnapshot = getSnapshot();
    const root: FiberRoot | null = getWorkInProgressRoot();

    if (root === null) {
      throw new Error(
        '작업 중인 루트가 예상되었습니다. 이것은 React의 버그입니다. 문제를 보고해 주세요.'
      );
    }

    const rootRenderLanes = getWorkInProgressRootRenderLanes();
    if (!includesBlockingLane(root, rootRenderLanes)) {
      pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
    }
  }

  hook.memoizedState = nextSnapshot;
  const inst: StoreInstance<T> = {
    value: nextSnapshot,
    getSnapshot,
  };
  hook.queue = inst;

  mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

  fiber.flags |= PassiveEffect;
  pushEffect(
    HookHasEffect | HookPassive,
    updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
    createEffectInstance(),
    null
  );

  return nextSnapshot;
}

1. 서버 사이드 렌더링(SSR) 처리

  • 현재 hydration중인지 확인하고, 그에 따라 적절한 스냅샷을 가져온다.
  • 서버 사이드 렌더링 시 필요한 getSeverSnapshot이 없으면 에러를 발생시킨다.
  • 있다면 서버 스냅샷을 사용한다.
let nextSnapshot;
const isHydrating = getIsHydrating();
if (isHydrating) {
  if (getServerSnapshot === undefined) {
    throw new Error(
      '서버 렌더링된 콘텐츠에 필요한 getServerSnapshot이 누락되었습니다. 클라이언트 렌더링으로 되돌아갑니다.'
    );
  }
  nextSnapshot = getServerSnapshot();
}

2. 클라이언트 렌더링 처리

  • 클라이언트 스냅샷을 가져온다.
  • 현재 렌더링의 우선 순위를 확인하고 중요한 업데이트(blocking lane)를 렌더링하지 않는 한, 일관성 검사를 예약한다.
nextSnapshot = getSnapshot();
const root: FiberRoot | null = getWorkInProgressRoot();

if (root === null) {
  throw new Error(
    '작업 중인 루트가 예상되었습니다. 이것은 React의 버그입니다. 문제를 보고해 주세요.'
  );
}

const rootRenderLanes = getWorkInProgressRootRenderLanes();
if (!includesBlockingLane(root, rootRenderLanes)) {
  pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

3. 훅 상태 설정

  • 현재 스냅샷을 훅의 상태로 저장한다.
  • 스토어 인스턴스를 새로 생성하고 훅의 큐에 할당한다.
hook.memoizedState = nextSnapshot;
const inst: StoreInstance<T> = {
  value: nextSnapshot,
  getSnapshot,
};
hook.queue = inst;

4. 구독(subscribe) 효과 설정

  • 스토어 변경을 구독하기 위한 효과를 설정한다.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

5. 업데이트 효과 설정

  • 인스턴스 필드를 업데이트하기 위한 패시브 효과를 설정한다.
  • 이 효과는 subscribe, getSnapshot 또는 value가 변경될 때 실행된다.
fiber.flags |= PassiveEffect;
pushEffect(
  HookHasEffect | HookPassive,
  updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
  createEffectInstance(),
  null
);

6. 결과 반환

  • 스냅샷을 반환한다.
return nextSnapshot;

요약하기

1. mountSyncExternalStore 함수는 외부 스토어와 React 컴포넌트를 동기화한다.

2. 이 함수는 현재 스토어의 스냅샷을 가져오고, 스토어 변경을 구독하며 데이터 일관성을 유지하기 위한 효과들을 설정한다.

3. 서버 사이드 렌더링(SSR)과 클라이언트 렌더링 상황을 모두 고려하여 동시성 렌더링 환경에서도 안정적으로 외부 데이터를 관리할 수 있게 해준다.

이 함수는 복잡한 로직을 통해 외부 스토어의 데이터를 React의 렌더링 사이클과 효과적으로 통합하여, 일관된 UI 상태를 유지하는 것이 핵심 목적이다.


리렌더링, updateSyncExternalStore

서버 사이드 렌더링(SSR)과 클라이언트 렌더링 상황에서의 스냅샷을 가져오는 로직은 mountSyncExternalStore 함수와 동일하다.

function updateSyncExternalStore<T>(
  subscribe: (() => void) => () => void, // 스토어 변경을 구독하는 함수
  getSnapshot: () => T, // 현재 스토어의 상태를 가져오는 함수
  getServerSnapshot?: () => T // 서버 렌더링용 스냅샷 함수
): T {
  const fiber = currentlyRenderingFiber; // 현재 Fiber
  const hook = updateWorkInProgressHook(); // 업데이트 중인 훅

  // 스냅샷을 가져오는 로직은 mountSyncExternalStore와 동일
  let nextSnapshot;
  const isHydrating = getIsHydrating();
  if (isHydrating) {
    if (getServerSnapshot === undefined) {
      throw new Error(
        '서버 렌더링된 콘텐츠에 필요한 getServerSnapshot이 없습니다. ' +
          '클라이언트 렌더링으로 되돌아갑니다.'
      );
    }
    nextSnapshot = getServerSnapshot();
  } else {
    nextSnapshot = getSnapshot();
  }
    
  const prevSnapshot = (currentHook || hook).memoizedState;
  const snapshotChanged = !is(prevSnapshot, nextSnapshot);
  if (snapshotChanged) {
    hook.memoizedState = nextSnapshot;
    markWorkInProgressReceivedUpdate();
  }
    
  const inst = hook.queue;
  updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
    subscribe,
  ]);

  if (
    inst.getSnapshot !== getSnapshot ||
    snapshotChanged ||
    (workInProgressHook !== null &&
      workInProgressHook.memoizedState.tag & HookHasEffect)
  ) {
    fiber.flags |= PassiveEffect;
    pushEffect(
      HookHasEffect | HookPassive,
      updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
      createEffectInstance(),
      null
    );

    const root: FiberRoot | null = getWorkInProgressRoot();

    if (root === null) {
      throw new Error(
        '작업 중인 루트가 예상되었습니다. 이는 React의 버그입니다. 문제를 보고해 주세요.'
      );
    }

    if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
      pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
    }
  }

  return nextSnapshot;
}

1. 스냅샷 변경 감지

  • 이전 스냅샷과 새 스냅샷을 비교하여 변경을 감지한다.
  • 변경이 있으면 상태를 업데이트하고, 업데이트 플래그를 설정한다.
const prevSnapshot = (currentHook || hook).memoizedState;
const snapshotChanged = !is(prevSnapshot, nextSnapshot); // is함수는 Object.is 함수와 동일하다.
if (snapshotChanged) {
  hook.memoizedState = nextSnapshot;
  markWorkInProgressReceivedUpdate();
}

2. 구독 효과 업데이트

  • 구독(subscribe) 효과를 업데이트한다.
  • mountSyncExternalStore에서는 mountEffect를 사용했지만 여기서는 updateEffect를 사용한다.
 const inst = hook.queue;
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
  subscribe,
]);

3. 조건부 효과 설정

조건 1) 스냅샷을 가져오는 함수가 변경되었을 때
조건 2) 스냅샷 값이 변경되었을 때
조건 3) 현재 작업중인 훅에 이미 효과(Effect)가 있을 때

이 조건들 중 하나라도 참이라면:

  • 컴포넌트에 패시브 효과 플래그를 설정한다.
  • updateStoreInstance함수를 사용하여 새 효과를 추가한다.
  • 이 효과는 외부 스토어의 인스턴스를 업데이트하는 역할을 한다.

이는 외부 스토어의 변경사항을 React 컴포넌트에 반영하기 위한 매커니즘이다.

if (
 inst.getSnapshot !== getSnapshot ||
 snapshotChanged ||
 (workInProgressHook !== null &&
  workInProgressHook.memoizedState.tag & HookHasEffect)
) {
 fiber.flags |= PassiveEffect;
 pushEffect(
   HookHasEffect | HookPassive,
   updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
   createEffectInstance(),
   null
 );
}

4. 일관성 검사 예약

  • 클라이언트 사이드 렌더링 중이고, 현재 렌더링이 중요한 업데이트(blocking lane)를 포함하지 않는다면 실행된다.
  • pushStoreConsistencyCheck 함수를 호출하여 일관성 검사를 예약한다.
  • 이 검사는 렌더링 과정 중 외부 스토어의 값이 변경되었는지 확인한다.
if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
  pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

5. 결과 반환

  • 스냅샷을 반환한다.
return nextSnapshot;

요약하기

1. 외부 스토어의 최신 스냅샷을 가져오고, 이전 스냅샷과 비교하여 변경 사항을 감지한다.

2. 변경이 감지되면 컴포넌트 상태를 업데이트하고, 스토어 구독(subscribe)을 위한 효과를 설정한다.

3. 필요한 경우 추가적인 일관성 검사를 예약하고, 최종적으로 최신 스냅샷을 반환하여 컴포넌트가 항상 최신 데이터로 렌더링되도록 보장한다.

이 함수는 React의 동시성 모드에서 외부 데이터 소스와 컴포넌트 상태를 효율적으로 동기화하는 핵심 매커니즘을 구현한다.


일관성 검사 플래그 설정

function pushStoreConsistencyCheck<T>(
  fiber: Fiber, // 현재 작업중이 Fiber
  getSnapshot: () => T, // 현재 스토어 상태를 가져오는 함수
  renderedSnapshot: T // 렌더링에 사용된 스냅샷
): void {
  fiber.flags |= StoreConsistency; 
    
  const check: StoreConsistencyCheck<T> = {
    getSnapshot,
    value: renderedSnapshot,
  };
    
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue : any);

  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
 	currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.stores = [check];
  } else {
    const stores = componentUpdateQueue.stores;

    if (stores === null) {
      componentUpdateQueue.stores = [check];
    } else {
      stores.push(check);
    }
  }
}

1. Fiber 플래그 설정

  • 현재 Fiber에 StoreConsistency 플래그를 추가한다.
  • 이 컴포넌트에 스토어 일관성 검사가 필요함을 나타낸다.
fiber.flags |= StoreConsistency; 

2. 검사 객체 생성

  • 일관성 검사를 위한 객체를 생성한다.
const check: StoreConsistencyCheck<T> = {
  getSnapshot, // 최신 스토어의 상태를 가져오는 함수
  value: renderedSnapshot, // 렌더링에 사용된 스냅샷 값
};

3. 업데이트 큐 확인

  • 현재 렌더링 중인 Fiber의 업데이트 큐를 가져온다.
let componentUpdateQueue: null | FunctionComponentUpdateQueue =
  (currentlyRenderingFiber.updateQueue : any);

4. 업데이트 큐 생성 또는 업데이트

  • 업데이트 큐가 없으면 새로 생성하고 check 객체를 추가한다.
  • 업데이트 큐가 있지만 stores 배열이 없으면 새로 생성한다.
  • stores 배열이 있으면 check 객체를 추가한다.
if (componentUpdateQueue === null) {
  componentUpdateQueue = createFunctionComponentUpdateQueue();
  currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
  componentUpdateQueue.stores = [check];
} else {
  const stores = componentUpdateQueue.stores;

  if (stores === null) {
    componentUpdateQueue.stores = [check];
  } else {
    stores.push(check);
  }
}

요약하기

1. 스토어 일관성 검사를 위한 정보를 컴포넌트의 업데이트 큐에 추가한다.

2. 이를 통해 React는 렌더링 과정에서 외부 스토어의 변경을 감지하고 적절히 대응할 수 있다.

3. 동시성 모드에서 데이터의 일관성을 유지하는 데 중요한 역할을 한다.


일관성 검사 체크, 호출

export function performConcurrentWorkOnRoot(
  root: FiberRoot,
  didTimeout: boolean
): RenderTaskFn | null {
  if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
    resetNestedUpdateFlag();
  }

  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error('Should not already be working.');
  }

  const originalCallbackNode = root.callbackNode;
  const didFlushPassiveEffects = flushPassiveEffects();
  if (didFlushPassiveEffects) {
    if (root.callbackNode !== originalCallbackNode) {
      return null;
    }
  }


  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
  );
  if (lanes === NoLanes) {
    return null;
  }

  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes) &&
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);

  if (exitStatus !== RootInProgress) {
    let renderWasConcurrent = shouldTimeSlice;
    do {
      if (exitStatus === RootDidNotComplete) {
        markRootSuspended(root, lanes, NoLane);
      } else {
        const finishedWork: Fiber = (root.current.alternate: any);
        if (
          renderWasConcurrent &&
          !isRenderConsistentWithExternalStores(finishedWork)
        ) {
          exitStatus = renderRootSync(root, lanes);
          renderWasConcurrent = false;
          continue;
        }

        if (exitStatus === RootErrored) {
          const lanesThatJustErrored = lanes;
          const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
            root,
            lanesThatJustErrored
          );
          if (errorRetryLanes !== NoLanes) {
            lanes = errorRetryLanes;
            exitStatus = recoverFromConcurrentError(
              root,
              lanesThatJustErrored,
              errorRetryLanes
            );
            renderWasConcurrent = false;
            if (exitStatus !== RootErrored) {
              continue;
            }
          }
        }
        if (exitStatus === RootFatalErrored) {
          prepareFreshStack(root, NoLanes);
          markRootSuspended(root, lanes, NoLane);
          break;
        }

        root.finishedWork = finishedWork;
        root.finishedLanes = lanes;
        finishConcurrentRender(root, exitStatus, finishedWork, lanes);
      }
      break;
    } while (true);
  }

  ensureRootIsScheduled(root);
  return getContinuationForRoot(root, originalCallbackNode);
}

렌더링 로직이 포함되어 있어 코드가 길지만 일관성 검사에 관련된 로직만 보면 그렇게 길지 않다.

if (exitStatus !== RootInProgress) {
  let renderWasConcurrent = shouldTimeSlice;
  do {
    // ... 다른 코드 생략 ...

    const finishedWork: Fiber = (root.current.alternate: any);
    if (
      renderWasConcurrent &&
      !isRenderConsistentWithExternalStores(finishedWork)
    ) {
      exitStatus = renderRootSync(root, lanes);
      renderWasConcurrent = false;
      continue;
    }

    // ... 다른 코드 생략 ...

  } while (true);
}

1. 동시성 렌더링 확인

  • renderWasConcurrent 변수는 렌더링이 동시적으로 수행되었는지를 나타낸다.
  • 초기 값은 shouldTimeSlice로 설정되며, 이는 시간 분할 렌더링을 사용했는지를 나타낸다.

2. 일관성 검사 조건

  • renderWasConcurrent가 true일 때만 일관성 검사를 수행한다.
  • 이는 동시성 렌더링에서만 외부 스토어와의 불일치가 발생할 수 있기 때문이다.

3. 일관성 검사 수행

  • isRenderConsistentWithExternalStores(finishedWork) 함수를 호출하여 실제 일관성 검사를 수행한다.
  • 이 함수는 렌더링된 트리(finishedWork)를 검사하여 외부 스토어와의 일관성을 확인한다.

4. 불일치 발견 시 처리

  • 만약 isRenderConsistentWithExternalStores 함수가 false를 반환하여 불일치를 발견하면
  • exitStatus = renderRootSync(root, lanes)를 호출하여 동기적으로 다시 렌더링한다.
  • renderWasConcurrent를 false로 설정하여 다음 반복에서 일관성 검사를 건너뛰게 한다.
  • continue를 사용하여 루프를 계속하고 새로운 렌더링 결과를 처리한다.

일관성 검사

function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
  let node: Fiber = finishedWork; // 검사할 노드 설정

  while (true) { // Fiber 트리를 순회하기 위한 무한 루프
    if (node.flags & StoreConsistency) { // StoreConsistency 플래그 체크
      const updateQueue: FunctionComponentUpdateQueue | null =
        (node.updateQueue: any);

      if (updateQueue !== null) {
        const checks = updateQueue.stores;

        if (checks !== null) {
          for (let i = 0; i < checks.length; i++) {
            const check = checks[i];
            const getSnapshot = check.getSnapshot;
            const renderedValue = check.value;
            try {
              if (!is(getSnapshot(), renderedValue)) {  // 현재 스토어 값과 렌더링 값 비교
                return false; // 불일치 시 false
              }
            } catch (error) {
              return false;
            }
          }
        }
      }
    }

    const child = node.child; // 현재 노드의 자식이 StoreConsistency 플래그가 있다면 자식 노드로 이동
    if (node.subtreeFlags & StoreConsistency && child !== null) {
      child.return = node;
      node = child;
      continue;
    }

    if (node === finishedWork) { // 시작 노드로 돌아왔다면 모든 검사 완료
      return true;
    }

    while (node.sibling === null) { // 형제 노드로 이동
      if (node.return === null || node.return === finishedWork) { // 루트에 도달하면
        return true;
      }

      node = node.return; // 형제가 없으면 부모로 이동
    }

    node.sibling.return = node.return;
    node = node.sibling;
  }

  return true;
}

이 함수는 Fiber 트리를 깊이 우선 탐색으로 순회하면서 모든 StoreConsistency 플래그가 설정된 노드에 대해 외부 스토어와의 일관성 검사를 한다.

하나라도 불일치하는 경우 즉시 false를 반환하고, 모든 검사를 통과하면 true를 반환한다.

이를 통해 React는 동시성 모드에서 렌더링 결과가 외부 스토어와의 최신 상태와 일치하는지 확인할 수 있다.


정리하기

pushStoreConsistencyCheck() - 플래그 설정

  • Fiber 노드에 StoreConsistency 플래그를 설정하여 일관성 검사가 필요하다는걸 알린다.
  • 검사에 필요한 정보를 컴포넌트의 updateQueue에 저장한다.

performConcurrentWorkOnRoot() - 일관성 검사 체크, 호출

  • React의 동시성 렌더링의 주요 진입점이다.
  • 렌더링 과정이 완료된 후, 일관성 검사가 필요한지 확인한다.
  • 동시성 렌더링이 수행되었고, 외부 스토어와의 일관성 검사가 필요하다면 isRenderConsistentWithExternalStores 함수를 호출한다.

isRenderConsistentWithExternalStores() - 일관성 검사

  • Fiber 트리를 순회하면서 StoreConsistency 플래그가 설정된 노드를 찾는다.
  • 각 노드에 대해 getSnapshot 함수를 호출하여 현재 스토어 값을 가져오고, 이를 렌더링된 값과 비교한다.
  • 불일치가 발견되면 false를 반환하여 리렌더링이 필요함을 알린다.

1. useSyncExternalStore는 렌더링이 완료되고 커밋이 시작되기 전에 일관성 검사를 예약함으로써 찢어짐(Tearing) 문제를 해결한다. 불일치한 데이터가 UI에 그려지는 것을 방지하기 위해 동기 모드에서 강제로 재렌더링을 수행한다.

2. 외부 스토어의 변경을 감지하기 위한 매커니즘을 포함한다. 변경 사항이 있으면 동기 모드에서 리렌더링이 예약된다. 이는 커밋 이후에 발생하므로 사용자는 UI가 깜박이는 것을 볼 수 있다.

💡 동시성 모드와 동기 모드

  • 동시성 모드: React가 렌더링을 중단하고 더 중요한 업데이트를 처리할 수 있는 모드이다.
  • 동기 모드: React가 렌더링 작업을 한 번에 완료하는 방식이다. 렌더링이 시작되면 멈추지 않고 끝까지 진행된다.

🙃 도움이 되었던 자료들

useSyncExternalStore - 리액트 공식 문서
How useSyncExternalStore() works internally in React?
React 18 useSyncExternalStore에 대해서
How to use useSyncExternalStore in React 18
What is tearing?

profile
🏁

2개의 댓글

comment-user-thumbnail
2024년 9월 18일

좋은 글 감사합니다 잘 읽고 있어요 🔥🔥🔥

1개의 답글