본문 바로가기

FrontEnd

React 18의 useSyncExternalStore, Tearing 현상은 무엇인가?

반응형

React18의 Tearing현상과 이를 해결하기 위한 useSyncExternalStore에 대해 알아봅시다.

원문 : https://blog.saeloun.com/2021/12/30/react-18-usesyncexternalstore-api

 

Meet the new hook useSyncExternalStore, introduced in React 18 for external stores

Ruby on Rails and ReactJS consulting company. We also build mobile applications using React Native

blog.saeloun.com

useSyncExternalStore API에 대해 알아보기 전에
이 새로운 훅을 이해하기에 유용한 용어에 익숙해지도록 하겠습니다.

Concurrent rendering and startTransition API

동시성은 작업의 우선 순위를 지정하여 여러 작업을 동시에 실행하는 메커니즘입니다.
이 개념은 Dan Abramov가 전화 통화에 비유하여 쉽게 설명합니다.

set~함수를 통한 상태 업데이트를 리액트가 지연 처리할 수 있으며, 때로 렌더링 결과를 버릴 수 있음(프레임 드랍)

startTransition API를 사용하여 렌더링하는 동안 앱의 응답성을 유지하도록 선택할 수 있습니다.
즉, React는 렌더링을 일시 중지 할 수 있습니다. 이를 통해 브라우저는 그 사이의 이벤트를 처리할 수 있습니다.
이전 게시물의 startTransition API에 대한 자세한 내용을 확인하세요.
외부 저장소는 우리가 구독할 수 있는 것입니다.
외부 저장소의 예로는 Redux 저장소, Zustand 저장소, 전역 변수, 모듈 범위 변수, DOM 상태 등이 있습니다.

Internal stores

내부 저장소에는 props, context, useState, useReducer가 포함됩니다.

Tearing

Tearing은 시각적(UI) 불일치를 나타냅니다. UI가 동일한 상태에 대해 여러 형태를 나타냄을 의미합니다.
React 18 이전에는 이 문제가 발생하지 않았습니다.
그러나 React 18에서는 렌더링 중에 React가 일시 중지(suspend)됩니다.
즉 concurrent 렌더링이 이 문제를 유발할 수 있습니다.
이러한 일시 중지 사이에 업데이트는 렌더링에 사용되는 데이터와 관련된 변경 사항을 가져올 수 있습니다.
UI가 동일한 데이터에 대해 두 개의 다른 값을 표시하도록 합니다.
 
WG discussion of tearing <- 옆의 디스커션에서 논의된 내용을 살펴봅시다.
컴포넌트는 색상을 가져오기 위해 일부 외부 저장소에 액세스해야 합니다.
 
 
동기 렌더링을 사용하면 UI에서 렌더링되는 색상이 일관됩니다.
동기 렌더링은 티어링을 유발하지 않음

concurrent(동시) 렌더링에서 스토어의 데이터 'blue'를 이용해 처음에 렌더링한 색상은 파란색입니다.
렌더링 도중 React가 스토어를 업데이트하면 데이터는 'red'으로 업데이트됩니다.
React는 업데이트된 값 red를 활용하여 렌더링을 계속합니다.
이는 '티어링'으로 알려진 UI의 불일치를 유발합니다.

동시성 렌더링에 의한 티어링 발생

 

이 문제를 해결하기 위해 React 팀은
변경 가능한 외부 소스에서 데이터를 안전하고 효율적으로 읽을 수 있도록
useMutableSource 훅을 추가했습니다.
 
그러나 리액트 워킹 그룹(react Working Group)의 구성들은
기존 오픈소스 라이브러리의 구현에서
useMutableSource를 채택하기 어렵게 만드는 기존 API 컨트랙트의 결함(flaws with the existing API contract)을 보고했습니다.

기존 라이브러리들은 렌더링 시 ref를 이전 렌더링에서 사용한 데이터로 참조하는 패턴을 사용하고 있었지만,
리액트 코어 팀은 ref를 공유 데이터, 전역변수와 같이 불안전한 데이터로 생각하고 있었습니다.
그래서 렌더링 중간에 ref를 읽고 쓰는 것은 당연히 버그가 생기는게 맞다고 생각했었습니다.

  • 렌더링 중에 ref 값을 읽거나 쓰는 것은 지연 초기화 패턴(the lazy initialization pattern을 구현하는 경우에만 안전합니다.
  • ref는 다른 것과 마찬가지로 변경 가능한 소스(mutable source)이므로 다른 타입의 읽기는 안전하지 않습니다(unsafe).
  • 지연 초기화 패턴이 아닌 writing는 사실상 부작용(side effects)이 있기 때문에 안전하지 않습니다(unsafe).

이는 수 많은 기존 오픈소스 라이브러리 메인테이너들에과 사용자에게 어려움을 줄 것이었으므로,
많은 논의 끝에 useMutableSource 훅이 재설계되고 이름이 useSyncExternalStore로 변경되었습니다.
useSyncExternalStore는 이름처럼 동기적으로 렌더링하게 되었고,
이는 기존 오픈소스 라이브러리들에 큰 영향을 끼치지 않게 되었습니다.

Understanding useSyncExternalStore hook

React 18에서 사용할 수 있는 새로운 useSyncExternalStore 훅을 사용하면 저장소의 값을 적절하게 구독할 수 있습니다.
The new useSyncExternalStore hook
 

useMutableSource → useSyncExternalStore · Discussion #86 · reactwg/react-18

Since experimental useMutableSource API was added, we’ve made changes to our overall concurrent rendering model that have led us to reconsider its design. Members of this Working Group have also re...

github.com

마이그레이션을 단순화하기 위해 React는 새로운 패키지 use-sync-external-store를 제공합니다.

To help simplify the migration, React provides a new package use-sync-external-store

 

use-sync-external-store

Backwards compatible shim for React's useSyncExternalStore. Works with any React that supports hooks.. Latest version: 1.2.0, last published: 2 months ago. Start using use-sync-external-store in your project by running `npm i use-sync-external-store`. Ther

www.npmjs.com

이 패키지의 shim(/shim)는 어떤 리액트의 버전과도 잘 동작합니다.

import {useSyncExternalStore} from 'react';

// or

// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

//Basic usage. getSnapshot must return a cached/memoized result
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

useSyncExternalStore 훅은 두 개의 함수를 필요로 합니다.

  • 콜백 함수를 등록하는 'subscribe' 함수
  • 'getSnapshot'은 구독 값이 마지막 시간 이후 변경되었는지, 렌더링되었는지 확인하는 데 사용됩니다.
    • 문자열이나 숫자와 같은 변경할 수 없는 값이거나 캐시/메모된 객체여야 합니다.
    • 그런 다음 훅에서 변경할 수 없는 값을 반환합니다.

getSnapshot 결과를 자동으로 메모하는 API 버전입니다.

import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector';

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual
);

 

Daishi Kato의 React 18 for External Store Libraries talk 강연에서 논의된 예를 확인해 보겠습니다.

import React, { useState, useEffect, useCallback, startTransition } from "react";

// library code

const createStore = (initialState) => {
  let state = initialState;
  const getState = () => state;
  const listeners = new Set();
  const setState = (fn) => {
    state = fn(state);
    listeners.forEach((l) => l());
  }
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  return {getState, setState, subscribe}
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()));
  useEffect(() => {
    const callback = () => setState(selector(store.getState()));
    const unsubscribe = store.subscribe(callback);
    callback();
    return unsubscribe;
  }, [store, selector]);
  return state;
}

//Application code

const store = createStore({count: 0, text: 'hello'});

const Counter = () => {
  const count = useStore(store, useCallback((state) => state.count, []));
  const inc = () => {
    store.setState((prev) => ({...prev, count: prev.count + 1}))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  );
}

const TextBox = () => {
  const text = useStore(store, useCallback((state) => state.text, []));
  const setText = (event) => {
    store.setState((prev) => ({...prev, text: event.target.value}))
  }
  return (
    <div>
      <input value={text} onChange={setText} className='full-width'/>
    </div>
  );
}

const App = () => {
  return(
    <div className='container'>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}
코드의 어딘가에서 startTransition을 사용하면 티어링이 발생합니다.
Tearing 문제를 해결하기 위해 이제 useSyncExternalStore API를 사용할 수 있습니다.
useEffect 및 useState 훅 대신 useSyncExternalStore를 사용하도록 라이브러리의 useStore 훅을 수정하겠습니다.
import { useSyncExternalStore } from 'react';

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector]))
  )
}

 

외부 저장소에서 useSyncExternalStore 훅으로 마이그레이션하는 것은 쉽고 잠재적인 문제를 피하기 위해 권장됩니다.

모든 인터페이스가 동일하기 때문에, 다른 라이브러리로 마이그레이션 하는것도 쉬울듯... 

어떤 라이브러리들이 concurrent rendering의 영향을 받을까요?

  • 렌더링하는 동안 변경 가능한 외부 데이터에 액세스하지 않고 React 프롭, 상태 또는 컨텍스트를 사용하여 정보만 전달하는 컴포넌트 및 사용자 정의 훅이 있는 라이브러리는 영향을 받지 않습니다.
  • 데이터 가져오기, 상태 관리 또는 스타일링(Redux, MobX, Relay)을 처리하는 라이브러리가 영향을 받습니다.
    • React 외부에 상태를 저장하기 때문입니다.
    • 동시 렌더링을 사용하면 React가 알지 못하는 사이에 이러한 외부 데이터 저장소를 렌더링 중에 업데이트할 수 있습니다.
useSyncExternalStore 훅에 대해 좀 더 자세히 알아보려면 아래 링크를 읽어보세요

 

반응형