[DIP] useSyncExternalStore

react

React에서 Concurrent Mode 렌더링 동작에서 시각적 불일치 현상이 발생하는 Tearing에 대해 자료 조사한 부분을 사내 공유 받았다. TearingstartTransition, Suspense와 같은 동시성 기능 사용으로 렌더링이 진행되며 새로운 상태가 반영되는 동안, 이전 상태가 유지되는 부분들이 시각적으로 일관되지 않는 상황을 나타내는 현상이다.

React에서 useSyncExternalStore를 제공함으로써 동시성 기능으로 인한 문제(Tearing) 해결을 한 부분을 들으며, useSyncExternalStore 내부 구현이 궁금하여 내부 로직을 살펴보려고 한다.

Tearing에 대한 내용은 아래 react-18 Discussions에서 정리해 놓은 내용으로 확인할 수 있으며 Concurrent Mode 강연 영상에서도 확인할 수 있다.



useSyncExternalStore

React v19 공식 문서에서 useSyncExternalStore는 단순히 외부 store를 구독할 수 있는 React Hook으로 소개되고 있다.

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

useSyncExternalStore에 대한 기능적인 부분은 React v19 공식 문서에 정리가 잘되어있으니, 내부 로직으로 바로 넘어가 보겠다.

useSyncExternalStore-1



useSyncExternalStoreShim.js

*
v19.1.0

useSyncExternalStore 는 Client, Server, BuildInAPI로 나눠진다.


시작은 useSyncExternalStoreShim에서 시작되며 여기서 서버 환경인지의 여부와 빌트인 API로서 useSyncExternalStore가 존재하는지의 조건에 따라 사용 로직이 설정된다.

tsx
import {useSyncExternalStore as client} from './useSyncExternalStoreShimClient'; import {useSyncExternalStore as server} from './useSyncExternalStoreShimServer'; import {isServerEnvironment} from './isServerEnvironment'; import {useSyncExternalStore as builtInAPI} from 'react'; const shim = isServerEnvironment ? server : client; export const useSyncExternalStore: <T>( subscribe: (() => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T, ) => T = builtInAPI !== undefined ? builtInAPI : shim;

여기서 확인하고 싶었던 부분은 동시성 기능으로 인한 문제(Tearing) 해결을 어떻게 해결하였는지 궁금한 부분이라 ShimClient와 리액트내 builtInAPI로 존재하는 useSyncExternalStore를 살펴보았다.



useSyncExternalStoreShimClient.js

*
v19.1.0

ShimClient 버전은 useSyncExternalStore가 존재하는 리액트 버전에서 builtInAPI가 없는 경우에 사용하기 위해 존재하는 로직으로 설명되어있다.


useSyncExternalStoreShimClient에서 중요하다고 생각되는 로직을 가져와보았다. 대부분의 로직은 주석으로 어떤 동작을 수행하는지에 대한 설명이 작성되어있다.

tsx
export function useSyncExternalStore<T>( subscribe: (() => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T, ): T { ... const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); useLayoutEffect(() => { inst.value = value; inst.getSnapshot = getSnapshot; if (checkIfSnapshotChanged(inst)) { forceUpdate({inst}); } }, [subscribe, value, getSnapshot]); ... };

로직 자체는 간단하며 구독중인 상태 정보가 변경된 경우 이전 상태와 비교 (checkIfSnapshotChanged)하고 변경이 있는 경우 forceUpadate를 호출하여 리렌더링을 진행한다.

*
checkIfSnapshotChanged : Object.is로 비교

useLayoutEffect는 동기적으로 동작하며 React 렌더링 과정에서 Render Phase 이후 Commit Phase 단계에서 처리되며, 실제 브라우저 페인트 진행 전 동작을 수행한다.


useSyncExternalStore는 렌더링이 시작되고 전달된 스냅샷을 현재 값으로 저장하고 useLayoutEffect에서 렌더링이 완료되기전 저장된 스냅샷과 현재 스냅샷을 비교하여 같은 상태가 아닌 경우 Render Phase 부터 다시 렌더링을 진행하여 정확한 값으로 렌더링 되도록 처리한다.



useSyncExternalStore as builtInAPI

builtInAPI 로직에서도 중요한 부분들만 접근하여 살펴보았다. 결국 위 로직에서 보았던 렌더링전 상황에서 상태값을 확인하고 정상적인 값으로 렌더링이 될지를 확인 후 부정확한 값이라면 실제 브라우저에 반영되기전 동기적 업데이트를 수행하여 보정하는 처리가 필요하다.


useSyncExternalStore buildInAPImount, update로 나누어진다.

tsx
const HooksDispatcherOnMount: Dispatcher = { ... useSyncExternalStore: mountSyncExternalStore, ... }; const HooksDispatcherOnUpdate: Dispatcher = { ... useSyncExternalStore: updateSyncExternalStore, ... };


mountSyncExternalStore

React 18 버전 이후 Concurrent Mode에서는 상태값에 대한 일관성있는 렌더링을 보장하는 처리가 Fiber 렌더링 시스템과 결합되어 있는것으로 확인하였다.


Render Phase 단계에서 mount, update에서 pushStoreConsistencyCheck 함수를 통해 일관성 검사를 위한 스냅샷을 스케쥴링하고, 이후 부정확한 값으로 판단되는 경우 리렌더링을 수행한다.

tsx
function mountSyncExternalStore<T>( subscribe: (() => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T, ): T { ... const rootRenderLanes = getWorkInProgressRootRenderLanes(); if (!includesBlockingLane(rootRenderLanes)) { pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); } ... };


updateSyncExternalStore

update 로직에서는 pushStoreConsistencyCheck 처리 이외에도 직접적인 스냅샷 변경 검증을 수행 후 변경이 필요한 경우 현재 작업중인 Fiber에 리렌더링을 위한 업데이트를 표시(markWorkInProgressReceivedUpdate)해 둔다. 이유는 당연하게도 mount 단계에서는 아직 비교할 이전 스냅샷이 없으므로 일관성 검사를 위한 스케쥴링만 수행하고 update 단계에서는 미리 이전 스냅샷과 비교를 통해 리렌더링에 대한 표시를 해둠으로서 일관성 검사를 위한 스케쥴링전 리렌더링 호출 처리하여 불필요한 작업이 수행되지 않도록 합니다.


tsx
function updateSyncExternalStore<T>( subscribe: (() => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T, ): T { ... const prevSnapshot = (currentHook || hook).memoizedState; const snapshotChanged = !is(prevSnapshot, nextSnapshot); if (snapshotChanged) { hook.memoizedState = nextSnapshot; markWorkInProgressReceivedUpdate(); } ... if (!isHydrating && !includesBlockingLane(renderLanes)) { pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); } ... };


pushStoreConsistencyCheck

스케쥴링이 필요한 스냅샷은 queue에 저장된다. queue에는 렌더링 시점의 스냅샷과 Commit Phase 단계전 스냅샷을 얻을 수 있도록 getSnapShot()을 저장한다.

tsx
function pushStoreConsistencyCheck<T>( fiber: Fiber, getSnapshot: () => T, renderedSnapshot: T, ): void { ... const check: StoreConsistencyCheck<T> = { getSnapshot, value: renderedSnapshot, }; ... componentUpdateQueue.stores = [check]; ... }


isRenderConsistentWithExternalStores

실제 스케쥴링된 스냅샷을 비교하는 처리는 isRenderConsistentWithExternalStores 를 통해 이루어지며, queue에 저장된 스냅샷과 getSnapShot()을 통해 얻어진 현재 스냅샷을 비교하여 일관성 검증을 수행한다.

tsx
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean { ... const getSnapshot = check.getSnapshot; const renderedValue = check.value; try { if (!is(getSnapshot(), renderedValue)) { return false; } ... }


정리

나름대로 파악한 흐름을 정리해보겠다.

  • useSyncExternalStore는 외부 store를 구독할 수 있는 React Hook으로 동시성 기능으로 인한 상태 불일치가 발생하지 않도록 도와주는 역할
  • Concurrent Mode 이전에는 useLayoutEffect를 통해 브라우저 페인트 이전에 상태 불일치를 확인하고 불일치할 경우 리렌더링 시킨다.
  • Concurrent Mode에서는 Fiber 렌더링 시스템 내 Render Phase에서 상태 불일치를 확인하고 리렌더링 시킨다.

단순하게 생각한다면 같은 말이 반복되는것 같지만… 브라우저에 상태가 반영되기 직전에 현재 렌더링할 상태를 다시 확인하고 불일치 한다면 리렌더링한다고 할 수 있을것 같다.

useSyncExternalStore-2

해당 로직을 분석하면서 들었던 생각은 예전 동시편집이 가능한 서비스를 개발 및 유지보수 할때가 떠올랐다. 여러사람이 동시에 편집하는 화면안에서 동일한 결과물을 보여주게 하기 위한 보정처리가 들어간 서비스였는데, 화면을 보여주는 대상이나 보정처리에 대한 알고리즘은 다르지만 사용자에게 정확한 결과물을 보여주기위한 노력은 같다는 생각이 들었다. 내가 만약 리액트 내에서 위와 같은 동시성 기능으로 인해 발생하는 상태 불일치에 대한 문제를 맞는다면, 이전 경험을 토대로 다른 방향으로도 가능하지 않을까 상상해본다.