Background
문제 발생
KanbanWave
프로젝트를 진행하면서, 외부 스토리지(localStorage)에 저장된 데이터가 변경될 때 UI에 즉시 반영되지 않는 문제가 발생했다. 구체적으로는 사용자가 데이터를 업데이트한 후에도 화면에 변경 사항이 반영되지 않았다. (대충 새로고침을 해야 업데이트된 내용을 볼 수 있었다는 말 ,,,) 나는 컴포넌트와 외부 스토리지 간의 상태를 동기화하는 방법이 필요했다.
해결 방법 탐색
이 문제를 해결하기 위해 여러 가지 방법을 고민하던 중, 개발자 친구가 "useSyncExternalStore
를 사용해보는 건 어때?" 라는 제안을 해주었다. 처음 들어봤다. 공식 문서를 살펴보니 외부 저장소와 컴포넌트 간의 상태 동기화를 지원하는 React 훅이라는 것을 알게 되었다.
상태 관리 라이브러리 없이 컴포넌트 간에 정보를 전달할 수 있는 간단한 방법이 무엇인지 떠오르지 않던 찰나에 나타난 소중한 기능이었다. ✨
특히, React 18에서 도입된 Concurrent Mode와 관련된 Tearing 이슈를 효과적으로 해결할 수 있다는 점에서 useSyncExternalStore
가 더욱 유용해 보였다. 이와 관련된 자세한 내용은 [React] React 18의 Concurrent Mode와 Tearing 이슈에서 참고하면 된다.
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.subscribe
와store.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
를 설정하고, 그 안에 포함된 모든 컴포넌트가 이 스토어를 사용할 수 있다.