useSyncExternalStore라는 훅을 아시나요

BiBi·2023년 12월 5일
6

React

목록 보기
2/2
post-thumbnail

훅을 보기전에 먼저 알면 좋은 개념들

Concurrent Feature

여기서 Concurrentasynchronous rendering으로 React 18에서 등장한 비동기 렌더링 기능이다.

image

렌더링 도중 유저 인터렉션이 일어났을때 유연하게 렌더링 우선순위를 바꿔 사용자에게 최상의 인터렉션 경험을 제공할 수 있다.
React 18 은 fiber 라는 엔진을 개선하여 자체적인 스케줄러를 가지게 되었다. priority queue, 다중 버퍼링 등으로 렌더링을 스케줄링하는 알고리즘이 자체적으로 구현되어있다.

동시성을 구현하는 기술


example

Concurrent Mode로 렌더링 하는 도중에 다른 작업으로 넘어갔을때

  • 해당 작업이 렌더링 중인 상태가 아닌 다른 상태를 업데이트하는 작업이라면?
    React는 일단 나중에 업데이트할 수 있도록 queue에 넣는다. React는 다시 렌더링을 시작하면 업데이트가 관련이 없는 것을 확인하고 현재 렌더링을 완료하고, 다른 업데이트가 예약되어 있지 않다면 들어온 두번째 업데이트에 대해 작업한다.
  • 해당 작업이 렌더링 중인 상태를 업데이트하는 작업이라면?
    React는 똑같이 queue에 넣지만, 렌더링을 시작할때 동일한 상태에 대한 새로운 업데이트가 있다는 것을 알 수 있다. 따라서 이미 렌더링 하던것을 버리고 새로운 업데이트 작업을 시작한다.

useDeferredValue

useDeferredValue
UI의 일부 업데이트를 지연시킬 수 있는 React 훅이다.

startTransition

startTransition
UI를 block하지 않고 state를 업데이트 하는 react의 api이다.

Pasted Graphic 10

등의 react에서 제공하는 concurrent feature가 있다. 이를 통해 웹 사용자의 심리를 사용해 렌더링 우선순위를 정할 수 있다.




Tearing

What is tearing?
image

의도치않게 동일한 상태에 대해 여러가지의 UI 형태가 나타나는 것tearing이라고 한다.

자바스크립트는 단일 스레드이기 때문에 일반적으로 웹개발에서는 이 문제가 발생하지 않았지만, React 18 이후부터 concurrent rendering 때문에 이 문제가 등장한다. 즉 StartTransition이나 Suspense와 같은 concurrent feature을 사용할때 렌더링 도중 우선순위가 높은 UI 변경에게 스레드를 위임하기 때문에 이러한 문제가 일어날 수 있다.

예시영상


External Store를 한정으로 생각할 때

동기 렌더링

img
동기렌더링은 렌더링이 끝날때까지 external store값이 바뀌지 않고 렌더링이 끝난 이후에 업데이트가 가능하므로 UI가 일관적이다. 따라서 tearing이 일어나지 않는다.

비동기 렌더링

img
비동기 렌더링에서 외부 스토어의 데이터가 'blue’라면 처음에 렌더링한 색상은 파란색이다. 렌더링 도중 React가 스토어를 업데이트하면 데이터는 'red'으로 업데이트된다. 이후, React는 업데이트된 값 'red'를 활용하여 렌더링을 계속한다다. 결과적으로 tearing이 일어난다.


Internal store, external store의 tearing

위에서 external store 한정으로 생각한 이유

Internal store

useState, useReducer, context, props

Internal store의 상태를 변경할때는 React가 즉각적으로 상태를 변경하지 않고 큐를 업데이트하고 렌더링을 스케줄한다. 렌더링을 시작하면 내부적인 알고리즘과 비교 연산을 통해 큐 전체에서 어떤 값을 참조할지 결정하고 이때문에 렌더링에서 tearing이 발생하지 않는다.

External store

자체적으로 상태관리 툴을 만들어 리액트 훅과 연동시킨 상태관리 라이브러리들 Redux, mobs, zustand, jotai, recoil. 전역변수, 모듈범위 변수, DOM 상태

External store의 상태를 사용한다면 React가 알지 못하는 사이에 외부 데이터 저장소를 렌더링 중에 업데이트하기 때문에 React의 내부적인 알고리즘을 사용하지 못한다. 따라서 external store를 제공하는 라이브러리들은 concurrent feature를 자체적으로 대비하여야 한다.




useMutableSource

useMutableSource

외부 소스에서 데이터를 안전하고 효율적으로 읽는 훅.

외부 저장소 라이브러리의 tearing 문제를 해결하기 위해 useMutableSource 훅이 추가되었다.


useMutableSource의 문제점

useMutableSource and selector stability
useMutableSource → useSyncExternalStore

문제점 1. Selectors no longer need to be memoized

useMutableSource는 내장된 selector API를 제공하지 않기 때문에 selector를 자체적으로 만들경우, 이 selectormemorization되지 않은 상태라면 매번 값이 변경될때마다 store를 다시 구독해야 했다.
selector를 보통 컴포넌트 단위에서 inline으로 작성하기 때문에 컴포넌트가 리렌더링 될때마다 store도 매번 다시 구독하게 된다.
따라서 사용자가 작성한 모든 selector 함수가 안정적인 참조를 위해 useCallbackuseMemo로 감싸야했다.

selector : 스토어에 있는 여러 값들 중 필요한 값만 불러오거나 원본 상태 값은 그대로 두고 상태의 특정 값만 계산해서 사용하게 도와주는 함수. 이 함수는 해당 하위 집합이 변경된 경우에만 React가 다시 렌더링할 필요가 있다는 신호를 보낸다.

개발적으로 불편하기도 하고, 성능이슈도 발생하고 React 문서에서 useCallback과 useMemo는 성능 최적화를 위해서만 존재한다고 강조하고 있기 때문에 의도된 목적과는 다른 사용으로 볼 수 있다.

문제점 2. Concurrent reads, synchronous updates

useMutableSource → useSyncExternalStore

위 내용에 있는 내용인데 제가 이해를 못했어요... 어쨋든...

Concurrent Mode에서 startTransition으로 래핑되었더라도 가끔 UI의 일부가 fallback으로 대체되어 보인다는 문제가 발생했다.

처음에 생각한 지원전략 방식이 잘못되었다는 것을 인지하고, 결과적으로 useMutableSource 훅이 재설계되고 이름이 useSyncExternalStore로 변경되었다.




useSyncExternalStore

useSyncExternalStore

external store를 subscribe하는 리액트 훅.
외부 상태관리 라이브러리들이 tearing 문제에 대비하기 위해 추가된 훅으로 일반 사용자들이 사용할 일은 거의.. 없다. 그리고 React에서 가장 긴 훅임.

형태

Pasted Graphic 1
  • subscribe : callback 인수를 받아 스토어를 subscribe하는 함수. store가 바뀌면 제공된 callback을 호출하고 컴포넌트가 리렌더링 된다. 그리고 subscription을 정리하는 함수를 반환해야 한다.
  • getSnapshot : 컴포넌트에 필요한 스토어 데이터의 snapshot을 리턴하는 함수. 구독 값이 마지막 시간 이후 변경되었는지, 렌더링되었는지 확인하는 데 사용된다. 저장소가 변경되거나, 반환된 값이 다른 경우 컴포넌트가 리렌더링 된다. 이 결과가 참조적으로 안정적이어야 하기 때문에 문자열이나 숫자와 같은 불변 값이거나 캐시/메모이제이션된 객체여야 한다다.
  • getServerSnapshot : (optional) 스토어 데이터의 초기 snapshot을 리턴하는 함수. 서버렌더링과 클라이언트에서 서버렌더링 컨텐츠의 hydration 도중에만 사용됨. Server snapshot은 클라이언트와 서버 사이에서 동일해야 하며, 일반적으로 serialize되어 서버에서 클라이언트로 전달됨. 이 인수를 생략하면 서버에서 컴포넌트를 렌더링할때 오류가 발생한다.
  • return : 렌더링 로직에서 상요할수있는 스토어의 현재 스냅샷

주의사항

  • getSnapshot이 반환하는 store snapshot은 immutable 해야 한다. 기본 store가 mutable data를 가지고 있다면, data가 변경된 경우 새로운 immutable snapshot을 리턴해야 한다. 그렇지 않으면 캐시된 마지막 snapshot을 반환한다.
  • 리렌더링되는 동안 다른 subscribe 함수가 전달되면, react는 새로 전달된 subscribe 함수를 사용하여 store를 다시 구독한다. 따라서 컴포넌트 외부에서 구독을 선언하면 이를 방지할 수 있다.
  • Non-blocking transition update중에 store가 변경되면, react는 해당 업데이트를 blocking으로 수행하도록 한다. 구체적으로, 모든 transition update에 대해 react는 DOM에 변경사항을 적용하기 직전에 getSnapshot을 한번더 호출한다.처음 호출했을 때와 다른 값이 반환된다면, react는 업데이트를 처음부터 다시 시작하고, 이번엔 blocking update로 적용하여 화면의 모든 컴포넌트가 동일한 버전의 store를 반영하도록 한다.
  • useSyncExternalStore가 반환한 store 값을 기반으로 렌더링을 일시중단하는것이 권장되지 않는다. 이유는 외부 스토어에 대한 변형을 non-blocking transition updates로 표시할 수 없기 때문에… 가장 가까운 suspense fallback을 트리거하여 화면에서 이미 렌더링된 콘텐츠를 loading spinner로 대체하여 일반적으로 UX가 좋지 않기 때문이다. 아래는 그 예시이다.
    Pasted Graphic 4

내부 동작

How useSyncExternalStore() works internally in React?

useSyncExternalStore는 내부적으로 두가지 이슈를 해결한다.

  1. Tearing under concurrent mode
    렌더링이 끝나고 commit이 되기전에, 일관성 체크를 스케줄링하면서 concurrent mode에서 리렌더링이 강제로 수행되므로 UI에 일관되지 않은 데이터가 그려지지 않게 한다.

  2. undetected external store changes
    변경사항이 있는지 확인하고, 변경된 경우 concurrent mode에서 다시 리렌더링하도록 스케줄링한다.


열린 사용법

useSyncExternalStore - The underrated React API
useSyncExternalStore로 만들어보는 전역상태관리 라이브러리
React State Management Without Dependencies

앞으로의 React가 나아가는 방향의 큰 축이 동시성 렌더링인 만큼 갑자기 동시성 기능을 추가해야 할지도 모르니까 사용법을 알아두면 좋음.

그리고 영영 동시성을 적용하지 않는다고 하더라도 해당 훅을 사용하면 복잡한 상태값을 통째로 참조해서 발생하는 불필요한 리렌더링 문제도 해결할 수 있다. (원래는 useState와 useEffect를 써서 다소 복잡하게 구현할 수 있지만 useSyncExternalStore를 사용하면 간단하게 해결이 가능하다.)
즉, 외부 상태관리 라이브러리에서 tearing 해결을 하기 위한 목적이 아니라, 순수 react에서 전역 상태 관리를 쉽게 하려는 목적으로도 사용할 수 있다.


자체 애플리케이션 코드에서 useSyncExternalStore 훅 사용하기

React-Router의 useLocation()은 굉장히 많은 attribute(pathname, hasn, search 등등) 을 가진 객체를 반환하지만 주로.. 이걸 모두 사용하지는 않는다. 하지만 이 훅을 호출하기만 하면 이러한 attribute중 하나라도 업데이트되면 렌더링이 트리거 된다.

여기서 React-Router는 v5를 사용한다.

function CurrentPathname() {
  const { pathname } = useLocation();
  return <div>{pathname}</div>;
}

function CurrentHash() {
  const { hash } = useLocation();
  return <div>{hash}</div>;
}

function Links() {
  return (
    <div>
      <Link to="#link1">#link1</Link>
      <Link to="#link2">#link2</Link>
      <Link to="#link3">#link3</Link>
    </div>
  );
}

function App() {
  return (
    <div>
      <CurrentPathname />
      <CurrentHash />
      <Links />
    </div>
  );
}

위의 코드에서 link를 클릭하면 hash attribute를 사용하지 않아도 CurrentPathName 컴포넌트는 리렌더링 된다.

hash : 주소의 # 문자열 뒤의 값

Screenshot 2023-12-05 at 23 02 13


여기서 useSyncExternalStore를 사용하면 리렌더링을 막을 수 있다. (브라우저의 기록도 external store로 간주되기 때문에 사용 가능)

아래 3개의 값을 사용해서 간단하게 useHistorySelector를 만들 수 있다.

  • useHistory : 브라우저 history에 접근
  • history.listen(callback) : history update를 구독
  • history.location : 현재 location의 snapshot에 접근
function useHistorySelector(selector) {
  const history = useHistory();
  return useSyncExternalStore(history.listen, () =>
    selector(history)
  );
}

useSyncExternalStore의 parameter

  • subscribe: history.listen을 사용해서 history 객체의 변화를 감지할 때마다 호출된다.
  • getSnapshot: selector(history)를 통해 현재 history 객체의 특정 부분(location.pathname, location.hash)을 선택적으로 반환한다.
  • getServerSnapshot: 여기서는 사용 안함.

useHistorySelector를 사용해서 컴포넌트를 수정하면

function CurrentPathname() {
  const pathname = useHistorySelector(
    (history) => history.location.pathname // pathname만 사용하는 selector
  );
  return <div>{pathname}</div>;
}

function CurrentHash() {
  const hash = useHistorySelector(
    (history) => history.location.hash // hash만 사용하는 selector
  );
  return <div>{hash}</div>;
}

더이상 link를 클릭해도 CurrentPathname 컴포넌트는 리렌더링 되지 않는다.

Screenshot 2023-12-05 at 23 19 52

React.memo 랑 뭐가 다른지?




상태관리 라이브러리의 concurrent 지원

Concurrent React for Library Maintainers
External state를 사용하면 내부 React system을 사용해서 스케줄링하는 대신 렌더링 도중 상태를 직접 변경할 수 있다. 따라서 외부 저장소에서 concurrent를 지원하려면
1) 렌더링 중에 저장소가 업데이트되었음을 React에 알려 React가 다시 렌더링할 수 있도록 하거나
2) 외부 상태가 변경되면 React가 강제로 중단하고 다시 렌더링하거나
3) 렌더링 중간에 상태가 변경되지 않고 렌더링할 수 있는 다른 솔루션을 구현할 수 있는 어떤 방법이 필요하다.

levels of Concurrent Rendering support

⚠️ 레벨1. Make it work
Tearing을 그냥 감수하기. useSubscription 훅으로 첫 렌더링이후 동기 업데이트를 실행해서 tearing을 잡는다. 렌더링 도중에 에러가 발생하면 가장 가까운 상위 레벨의 에러 바운더리에서 다시 한번 동기 렌더링을 실행한다. 이 방법은 concurrent rendering을 완전히 포기하는 것이고 flash 효과가 나타날 수도 있다.

✅ 레벨2. Make it right
현재 많은 라이브러리들의 best case. 렌더링이 오래 걸리는 것을 감수하지만 tearing을 바로 잡는 것으로 useSyncExternalStore 훅을 사용해서 rendering 도중에 일어나는 external state 변경사항을 발견하고 tearing이 발생하기 전에 렌더링을 다시 실행한다. 최적화 되지 않음.

🚀 레벨3. Make it fast
속도와 tearing을 모두 손해보지않고 사이드 이펙트를 해결하는 방법으로 아직 external state에서는 연구중인거 같다. 변경 불가능한 스냅샷을 변경 가능한 상태 저장소에 넣고 렌더링 중간에는 스토어를 변경하지 않는방법으로 구현할 수 있다는데.... useContextSelector() 훅이 현재 연구중인데 출시된다면 3단계가 가능할것이라고 예상된다.
useContextSelector

각 라이브러리의 현재 상황

Will this React global state work in concurrent rendering?
React concurrent rendering의 tearing과 branching을 테스트하는 레포

테스트 시나리오

With useTransition
Level 1
1: No tearing finally on update
2: No tearing finally on mount
Level 2
3: No tearing temporarily on update
4: No tearing temporarily on mount
Level 3
5: Can interrupt render (time slicing)
6: Can branch state (wip state)
With useDeferredValue
Level 1
7: No tearing finally on update
8: No tearing finally on mount
Level 2
9: No tearing temporarily on update
10: No tearing temporarily on mount

결과

image

Redux : useSyncExternalStore를 사용하면서 지원됨. Redux의 상태 업데이트가 완료되는 시점에 React의 렌더링이 일어남. (내부적인 스토어 구독을 구현하는 대신 useSyncExternalStore를 사용하도록 마이그레이션함. 적용PR)
Recoil : 내부적으로 React의 상태감지 기능을 사용하여 상태 업데이트가 발생하면 즉시 React의 렌더링을 트리거함. 따라서 차이가 미미하긴하지만 Recoil이 Redux보다 더 빠른 상태 업데이트와 더 높은 성능을 제공함.
Zustand : 순수 자바스크립트로 구성되어있기 때문에 use-sync-external-store 패키지를 사용해서 지원한다. (적용 PR)

profile
프론트엔드 개발자

0개의 댓글