[React] useSyncExternalStore 외부 스토어 그게 뭔데

Tags
React
Created
June 10, 2024

Background

문제 발생

KanbanWave 프로젝트를 진행하면서, 외부 스토리지(localStorage)에 저장된 데이터가 변경될 때 UI에 즉시 반영되지 않는 문제가 발생했다. 구체적으로는 사용자가 데이터를 업데이트한 후에도 화면에 변경 사항이 반영되지 않았다. (대충 새로고침을 해야 업데이트된 내용을 볼 수 있었다는 말 ,,,) 나는 컴포넌트와 외부 스토리지 간의 상태를 동기화하는 방법이 필요했다.

해결 방법 탐색

이 문제를 해결하기 위해 여러 가지 방법을 고민하던 중, 개발자 친구가 "useSyncExternalStore를 사용해보는 건 어때?" 라는 제안을 해주었다. 처음 들어봤다. 공식 문서를 살펴보니 외부 저장소와 컴포넌트 간의 상태 동기화를 지원하는 React 훅이라는 것을 알게 되었다.

상태 관리 라이브러리 없이 컴포넌트 간에 정보를 전달할 수 있는 간단한 방법이 무엇인지 떠오르지 않던 찰나에 나타난 소중한 기능이었다. ✨

특히, React 18에서 도입된 Concurrent Mode와 관련된 Tearing 이슈를 효과적으로 해결할 수 있다는 점에서 useSyncExternalStore가 더욱 유용해 보였다. 이와 관련된 자세한 내용은 [React] React 18의 Concurrent Mode와 Tearing 이슈에서 참고하면 된다.

image

useSyncExternalStore

useSyncExternalStore는 React 18에서 도입된 훅으로, 컴포넌트가 외부 상태의 변화를 구독하고 해당 상태가 변경될 때마다 컴포넌트를 리렌더링하는 데 사용된다.

아래 코드는 useSyncExternalStore 훅을 사용하여 React 컴포넌트에서 외부 데이터 저장소(todoStore)를 구독하고, 그 데이터를 읽어오는 예제이다.

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  // ...
}
  • todoStore: 외부 데이터 저장소를 나타낸다. 예를 들어, 현재 할 일 목록(todos)을 관리하는 상태 관리 객체일 수 있다. 이 객체는 컴포넌트가 이 데이터에 접근하고 구독할 수 있도록 필요한 메서드(subscribe, getSnapshot)를 제공한다.
  • useSyncExternalStore: 이 훅을 사용하여 TodosApp 컴포넌트는 todoStore의 상태를 구독하고, 그 상태가 변경될 때마다 컴포넌트를 다시 그린다.
  • todos: todoStore.getSnapshot이 반환하는 현재 상태를 가지고 있다. 이 변수는 이후 컴포넌트에서 할 일 목록을 렌더링하는 데 사용된다.

우선, 위에서 알 수 있듯이 중요한 개념들이 나온다. useSyncExternalStore 훅과 관련된 개념들을 쉽게 이해할 수 있는 표현으로 정리해봤다. 그리고 실제 코드에서 적용된 과정들을 기록해보겠다.

개념

subscribe

컴포넌트는 외부 저장소에 상태 변경 알림을 요청한다. subscribe를 통해 "저 데이터가 바뀌면 나한테 알려줘!" 라고 구독하는 것이다. 이때 자신의 콜백 함수를 전달한다. 외부 저장소는 이 콜백 함수를 저장해두었다가, 상태가 변경될 때마다 해당콜 콜백 함수가 호출되면서 알림을 받게 된다.

📢 컴포넌트 입장
📢 외부 저장소 입장
나는 외부 저장소의 데이터가 바뀔 때마다 그걸 알고 바로 화면을 다시 그려줘야 하거든? 그러니까 subscribe를 통해 외부 저장소를 구독할게. 그래야 상태가 바뀌면 콜백 함수를 통해 알림을 받고, 최신 데이터로 리렌더링 할 수 있으니까!
나는 여러 컴포넌트에게 상태를 제공하고 있어. 컴포넌트가 subscribe를 통해 나에게 변경 사항 알림을 요청하면, 나는 그 컴포넌트의 콜백 함수를 저장해. 그리고 상태가 바뀌면 저장된 콜백 함수들을 실행해서 모든 컴포넌트에 상태 변화를 알릴 거야.

listeners

subscribe로 등록된 콜백 함수들이 listeners에 저장된다. listeners는 외부 저장소의 상태 변화에 대해 알림을 받을 콜백 함수들의 리스트(배열)이다. 쉽게 말해, "외부 저장소의 상태가 바뀌면 내가 반응할게!" 라고 말하는 친구들이다. 상태가 변경될 때마다 이 리스너들을 호출하여 변경된 내용을 컴포넌트에게 전달해준다.

📢 컴포넌트 입장
📢 외부 저장소 입장
상태가 바뀌면 즉시 알림을 받아야 하니까, 내 콜백 함수를 subscribe로 외부 저장소에 전달할 거야. 그러면 외부 저장소는 내 콜백을 listeners에 등록해줄 거고, 상태가 변하면 이 콜백이 실행돼서 화면이 자동으로 갱신될 거야!
내가 상태를 관리하고 있는데, 상태가 바뀌면 구독한 모든 컴포넌트에게 알려줘야 해. 그래서 각 컴포넌트가 전달한 콜백 함수를 listeners에 저장해두고, 상태가 변할 때 이 리스트의 콜백들을 한 번에 실행해서 모든 컴포넌트에게 알려줄 거야.

emitChanges

외부 저장소에서 상태가 변경되면 emitChanges 함수가 호출된다. "데이터가 변했어요! 이제 알림을 보내야 해요" 라고 알림을 보내는 메신저 같은 역할이다. emitChanges는 상태를 업데이트하고, listeners에 등록된 모든 리스너를 호출한다.

📢 컴포넌트 입장
📢 외부 저장소 입장
외부 저장소의 상태가 바뀌면 즉시 알림을 받아서 화면을 다시 그려야 해. 이 역할을 emitChanges가 해주는데, 상태가 변할 때마다 이 함수가 실행돼서 나를 포함한 모든 컴포넌트들에게 알림을 보내줄 거야.
상태가 바뀌면 나의 역할은 모든 컴포넌트에 알림을 보내는 거야. emitChanges는 내가 상태가 변했다고 listeners에 저장된 콜백 함수들을 호출해서, 각 컴포넌트가 새 데이터를 반영할 수 있게 돕는 역할을 해.

snapshot

상태 변경이 발생하면, 컴포넌트에 새로운 데이터를 반영하기 위해 현재 상태를 확인해야 한다. snapshot"지금 이 순간, 외부 저장소 안에 뭐가 들어있는지 보여줘!" 라고 말하는 객체이다. 마치 사진 한 장을 찍듯이, 외부 저장소에 있는 현재 데이터를 컴포넌트에게 제공한다.

getSnapshot

컴포넌트가 getSnapshot 함수를 호출한다는 것은, "지금 외부 저장소 안에 뭐가 있는지 알려줘!" 라고 말하는 것이다. 그렇다. getSnapshot을 통해 snapshot을 가져온다. 컴포넌트는 반환된 snapshot을 사용해서 현재의 상태 정보를 가져올 수 있는 것이다.

위 순서로 컴포넌트가 상태를 구독하고, 상태 변경을 감지하고, 변경된 상태를 반영하는 전체 과정을 이해할 수 있다.

사용 예제

아래 예제는 KanbanWave 프로젝트에서 사용된 코드로, 데이터를 관리하는 외부 스토리지와 React 컴포넌트를 동기화하는 방법을 보여준다.

커스텀 스토어 생성

외부 스토리지를 다루기 위한 커스텀 스토어를 생성했다. 이 스토어는 컴포넌트가 외부 상태를 구독하고, 상태 변경 시 이를 반영할 수 있게 도와준다.

export const makeKanbanwaveStore = (storage: KanbanwaveStorage) => {
  let snapshot = {
    getBoards: wrap(storage.getBoards),
    getBoardContent: wrap(storage.getBoardContent),
    getCard: wrap(storage.getCard)
  };
  let listeners: Array<() => void> = [];
  • snapshot 객체는 외부 스토리지의 메서드(getBoards, getBoardContent, getCard)를 래핑하여 현재 상태를 저장한다.
  • 컴포넌트가 스토어를 구독할 때 listeners 배열에 리스너 함수가 추가된다. 상태 변경 시 호출될 리스너 함수들이 저장된다.
const emitChanges = (changes: Array<keyof typeof snapshot>) => {
  snapshot = { ...snapshot };
  changes.forEach(key => {
    const fn = wrap(storage[key]) as (typeof snapshot)[typeof key];
    snapshot[key] = fn as any;
  });
  for (let listener of listeners) {
    listener();
  }
};
  • emitChanges: 외부 스토리지의 데이터가 변경될 때 호출되는 함수다. 이 함수는 주어진 changes 배열에 포함된 키에 해당하는 snapshot 값을 업데이트하고, 모든 리스너에게 알림을 보내 변경 사항을 반영한다.
return {
  subscribe(listener: () => void) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(it => it !== listener);
    };
  },
  getSnapshot() {
    return snapshot;
  },
  async createBoard(...args: Parameters<typeof storage.createBoard>) {
    await storage.createBoard(...args);
    emitChanges(['getBoards']);
  },
  {
    ...
  },
  async deleteCard(...args: Parameters<typeof storage.deleteCard>) {
    await storage.deleteCard(...args);
    emitChanges(['getBoardContent', 'getCard']);
  },
};
};
  • subscribe(listener): 리스너를 등록하는 함수다. 컴포넌트가 이 스토어를 구독할 때 호출되며, 등록된 리스너를 제거하는 함수도 함께 반환한다.
  • getSnapshot: snapshot을 반환한다. 구독된 컴포넌트가 최신 상태를 가져올 수 있다.
  • createBoard: 외부 스토리지에서 보드를 생성하는 비동기 메서드다. 데이터를 변경한 후 emitChanges를 호출해 변경된 상태를 반영한다.

리액트 컴포넌트에서의 활용

이제, useSyncExternalStore를 사용해 React 컴포넌트가 이 스토어를 구독하고, 데이터 변경 시 UI가 자동으로 업데이트되도록 설정할 수 있다.

export const useKanbanwaveStore = () => {
  const context = useContext(KanbanStorageContext);
  const store = context.store;
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
  • KanbanStorageContext로부터 store를 가져오고, useSyncExternalStore를 통해 컴포넌트가 스토어를 구독하도록 설정한다. 이때, store.subscribestore.getSnapshot을 전달해 상태를 구독하고, 최신 스냅샷을 얻는다.
  return useMemo(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { subscribe, getSnapshot, ...methods } = store;
    return {
      ...snapshot,
      ...methods
    };
  }, [snapshot, store]);
};
  • useMemo: 이 훅은 스토어의 메서드들과 현재 스냅샷을 메모이제이션해서 반환한다. 불필요한 재렌더링을 방지한다.

Context Provider 설정

마지막으로, KanbanStorageProvider를 이용해 useKanbanwaveStore 훅이 작동할 수 있는 환경을 만들었다.

type KanbanStorageProviderProps = {
  children?: ReactNode;
  storage: KanbanwaveStorage;
};

const KanbanStorageProvider = ({ storage, children }: KanbanStorageProviderProps) => {
  const store = useMemo(() => makeKanbanwaveStore(storage), [storage]);
  const contextValue = useMemo(() => ({ store }), [store]);
  
  return (
    <KanbanStorageContext.Provider value={contextValue}>
      {children}
    </KanbanStorageContext.Provider>
  );
};
  • KanbanStorageProviderProps: storage는 외부 스토리지 인스턴스다.
  • useMemo: useMemo를 사용해 makeKanbanwaveStore 함수가 불필요하게 다시 호출되지 않도록 한다. 이 훅을 사용하면 storage가 변경되지 않는 한, 동일한 스토어 인스턴스를 재사용하게 된다.
  • KanbanStorageContext.Provider: Context API를 사용해 KanbanStorageContext를 설정하고, 그 안에 포함된 모든 컴포넌트가 이 스토어를 사용할 수 있다.