이번에는 전역 상태를 라이브러리 없이 직접 만들어보는 과정에 대해 적어보려고 한다.
전역 상태를 관리하는 3가지 패턴 Flux, Atom, Proxy 중 Flux 패턴인 Zustand를 모방해서 만들어보려고 한다.
0. 전역 상태를 만든다고 할 때 쉽게 떠오르는 방법?
먼저 전역 상태를 만든다고 상상해 볼 때 가장 쉽게 떠오르는 방식으로는 아래 코드와 같이 컴포넌트 외부에 변수를 두어 내부에서 수정하는 방식을 생각해 볼 수 있다. 아래 코드를 실행했을 때 버튼을 누르면 state가 증가될까?
let state = 0;
const State = () => {
const onClick = () => {
state += 1;
};
return (
<div>
<p>{state}</p>
<button onClick={onClick}>버튼</button>
</div>
);
};
export default State;
답은 절대 그렇지 않다. 아무리 버튼을 눌러도 화면에는 0으로 고정되어 있다. JS의 state는 값이 변하고 있지만 리렌더링을 요청하지 않기 때문에 값이 바뀌어도 화면에 반영되지 않는 것이다.
React에서 컴포넌트가 리렌더링이 일어나기 위해선 다음과 같은 조건을 만족해야하는데
1. 컴포넌트의 state 변화 (클래스형은 state, 함수형은 useState의 setState 호출)
2. 전달받는 prop 값 변화
3. 부모 컴포넌트 리렌더링
그러나 위는 useState의 두 번째 setState로써 바뀐 것이 아니라 단순 JS 변수의 값이 바뀌었기 때문에 위 조건에 부합하지 않아서 리렌더링이 발생하지 않게 된다. (useState의 setState가 호출되면 리렌더링 되는 이유는 예전 포스팅인 vanila js로 useState 구현해 보기를 참고해 주세요)
이 점을 숙지한 후 전역 상태 라이브러리를 만들기 위해 필요한 요구사항을 정리해 보자.
1. 컴포넌트와 별개로 저장소를 만들어서 관리해야 한다.
2. 저장소의 상태가 바뀌면 상태를 사용하는 컴포넌트가 리렌더링이 되어야 한다.
위 요구사항을 아래 그림을 정리해 보면 이렇게 된다. 상태 저장소를 컴포넌트 외부에 두고 저장소의 상태가 바뀌면 상태를 사용하고 있는 A와 B컴포넌트는 이를 반영하고 리렌더링이 일어나야 한다. 이를 구현하기 위해서 저장소를 어떻게 관리해야 할까?
바로 상태 저장소에 상태를 사용하고 있는(구독하고 있는) 컴포넌트 정보를 저장하면 된다.
각 컴포넌트가 마운트 될 때 상태 저장소 값을 사용하고 있다면 상태 저장소에 자신의 정보를 저장한다. 그리고 상태가 변했을 때 상태 저장소에 저장된 컴포넌트들에게 상태가 바뀌었다고 알림을 주면 각 컴포넌트는 알림을 통해 새로운 상태를 받을 수 있게 된다.
그렇다면 이 내용을 기억하고 전역 상태 저장소의 작은 버전을 구현해 보자
1. 저장소 만들기 CreateStore
상태 저장소가 가져야 할 기능을 정리해 보자
1. 현재 상태를 저장해야 한다.
2. 최신의 상태를 가져올 수 있어야 한다.
3. 상태를 수정할 수 있어야 한다.
4. 리렌더링이 필요한 컴포넌트 정보를 저장한다.
이 정보를 고려해서 기능을 만들어보자.
createStore.ts
export type Listener<T> = (state: T) => void;
export type SetOrUpdater<T> = T | ((prev: T) => T);
export const createStore = <T>(initialState: T) => {
let state = initialState; // 전역 상태
// 상태가 바뀔 때 알림을 받고 싶은 컴포넌트 정보를 저장하는 집합
const listeners = new Set<Listener<T>>();
// state 클로저 생성
const getState = () => state;
// state가 바뀌면 이 함수들을 호출해서 외부 컴포넌트나 구독자가 변경을 감지할 수 있도록
const setState = (nextState: SetOrUpdater<T>) => {
const updated = typeof nextState === "function" ? (nextState as (prev: T) => T)(state) : nextState;
state = updated;
listeners.forEach((listener) => listener(state));
};
// 외부에서 상태가 변경될 때 호출될 리스너를 등록하는 함수
// 컴포넌트에서 subscribe를 통해 상태 변경을 감지
const subscribe = (listener: Listener<T>) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
};
스토어는 getState, setState, subscribe 세 가지 메서드를 제공해 준다.
먼저 let으로 state를 createStore 함수 내부에 만들어둔다. 그리고 컴포넌트 정보를 저장해 둘 listener 집합을 만든다. 여기서 집합을 쓰는 이유는 집합은 내부적으로 해시 테이블로 구성되어 있어 추가와 삭제가 모두 O(1)로 작동하기 때문에 성능에 더 유리하기 때문이다.
다음으로 getState로는 () => state로 되어있다. 이렇게 하는 이유는 스토어에서 제공하는 setState 이외에 다른 방법으로 저장소의 값을 바꾸지 못하게 하기 위함과 createStore 함수가 종료되어도 createStore 내부에 선언되어 있는 state 값을 불러올 수 있게 하기 위함이다. 즉 getState는 클로저를 만들기 위한 것이다.
다음으로 setState를 보면 새로운 상태를 받아 저장소의 상태를 바꿔주는 역할을 한다. 인자로는 useState의 setState와 유사하게 바꾸고 싶은 상태를 직접 받을 수도 있고, 이전의 상태를 기반으로 상태를 바꾸는 형태로 받을 수 있도록 했다. 그리고 listener 집합에 등록된 listener들을 실행시켜 구독하는 컴포넌트에게 상태가 변했음을 알려주게 된다.
마지막으로 subscribe를 보면 인자로 listener를 받아서 저장소의 listener 집합에 등록하고 추후에 컴포넌트가 언마운트 될 때, 저장소에서 listener를 제거할 수 있도록 정리 함수를 제공해 준다.
상태 저장소를 만들었다면 이제는 상태 저장소에 컴포넌트 정보를 등록해 주는 subscribe 함수를 호출해 주는 기능을 만들 차례이다.
2. 외부 상태를 구독하는 useStore
useStore가 가져야 할 기능으로는
1. 현재 상태를 얻어와야 한다.
2. useStore가 호출될 때 저장소의 listener 집합에 등록해야 한다.
이 정보를 토대로 기능을 만들어보자
import { useSyncExternalStore } from "react";
type Listener<T> = (state: T) => void;
type Store<T> = {
getState: () => CounterState;
setState: (nextState: SetOrUpdater<CounterState>) => void;
subscribe: (listener: Listener<CounterState>) => () => void;
}
export const createStoreHook = <T>(store: Store<T>) => {
return function useStore(): T {
return useSyncExternalStore(store.subscribe, store.getState);
};
};
생각보다 간단하게 구현할 수 있다. 이는 React 18의 useSyncExternalStore Hook 덕분인데 이 Hook은 외부 상태 저장소를 구독할 수 있게 해주는 React의 Hook이다.
아래는 useSyncExternalStore Hook의 형태이다.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
첫 번째 인자로 외부 상태를 구독하는 기능을 넣어주고, 두 번째 인자로 외부 상태의 최신 상태를 불러올 수 있는 함수를 넣어주면 useSyncExternalStore를 호출하는 컴포넌트는 우리의 외부 상태 저장소를 구독할 수 있게 된다. 참고로 세 번째는 서버 사이드 렌더링에서 상태를 관리할 때 넣어주는 옵션이라고 한다.
그래서 이 Hook을 사용해서 createStoreHook이라는 메서드를 만들고 인자로 createStore로 만든 Store를 넣어서 우리의 외부 상태 저장소에 상태를 사용하는 컴포넌트들을 구독할 수 있게 만들어줬다. 이제 useStore를 사용하는 컴포넌트들이 외부 상태 저장소에 저장되고 외부 상태가 변했을 때 구독하고 있던 컴포넌트를 리렌더링 시키는 일을 할 수 있게 됐다.
3. 저장소 생성 및 사용
자 그렇다면 이제 상태 저장소와 동기화 작업을 설계했으니 실제 사용하는 방법에 대해서 알아보면
// counter.ts
import { createStore } from "../../lib/flux/createStore";
import { createStoreHook } from "../../lib/flux/useStore";
type CounterState = { count: number };
export const counterStore = createStore<CounterState>({ count: 0 });
export const useCounter = createStoreHook(counterStore);
// Counter.tsx
import { counterStore, useCounter } from "./store/flux/counter";
export default function Counter() {
const increment = () => {
counterStore.setState((prev) => ({ ...prev, count: prev.count + 1 }));
};
return (
<div>
<h1>My State Management</h1>
<Inner />
<button onClick={increment}>+1</button>
</div>
);
}
const Inner = () => {
const count = useCounter((state) => state.count);
return <p>Count: {count}</p>;
};
이전에 선언한 createStore로 저장소를 만들고 createStoreHook으로 외부 상태를 사용하고 구독할 수 있는 Hook을 받아와서 export 해준 뒤 컴포넌트에서 useCounter를 이용해서 외부 상태 저장소의 값을 불러오면서 Inner 컴포넌트를 구독하게 된다.
그 후 counterStore의 setState로 상태를 바꾸면 전역 상태가 바뀌게 되고 구독하고 있는 컴포넌트(useCounter를 호출하는 컴포넌트들)에게 상태가 바뀌었다고 알려주게 되어 리렌더링이 일어나게 된다.
실제로 아래는 위 코드를 실행한 영상이다. 상태를 구독하는 Inner 컴포넌트만 리렌더링이 되는 모습을 확인할 수 있다.
동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.
4. 상태 저장소 내 특정 상태만 구독하기 (selector)
전역 상태 저장소의 가장 작은 버전을 만들어봤다면 이제는 추가 기능을 넣을 차례이다. 다음 기능으로는 특정 상태만 구독할 수 있는 selector 기능이다. 개인적으로 전역 상태 라이브러리를 사용하는 이유로 쉽게 상태를 다른 컴포넌트로 전달하기 위함도 있지만 저장소의 특정 상태만 구독해서 불필요한 리렌더링을 예방하는 목적도 있다고 생각한다.
이를 구현하기 위해선 useStore의 정의를 조금만 수정해 주면 된다.
export const createStoreHook = <T>(store: Store<T>) => {
return function useStore<U>(selector: (state: T) => U): U {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
};
};
useStore 훅의 인자로 selector 메서드를 받아서 특정 상태를 선택할 수 있는 함수를 외부에서 주입받아 useSyncExternalStore의 두 번째 인자인 getSnapshot에 스토어의 현재 상태를 받아오는 getState에서 특정 상태를 selector로 선택하도록 해주면 된다.
사용처를 들여다보면 이제 상태를 count1, count2로 나누고 각 상태를 구독하는 Inner1, Inner2를 만들어준 뒤에 useCounter의 인자로 각 상태를 구독하는 함수를 넣어준다 (state => state.count1), 그리고 Counter 컴포넌트에서 setState로 count1 상태를 바꿔주게 되면 count1을 구독하는 Inner1만 리렌더링이 일어나게 된다.
import { counterStore, useCounter } from "./store/flux/counter";
export default function Counter() {
const increment = () => {
counterStore.setState((prev) => ({ ...prev, count1: prev.count1 + 1 }));
};
return (
<div>
<h1>My State Management</h1>
<Inner1 />
<Inner2 />
<button onClick={increment}>+1</button>
</div>
);
}
const Inner1 = () => {
const count = useCounter((state) => state.count1);
return <p>Count1: {count}</p>;
};
const Inner2 = () => {
const count = useCounter((state) => state.count2);
return <p>Count2: {count}</p>;
};
동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.
5. Flux 패턴으로 저장소를 리팩터링 하기
이제는 Flux 패턴으로 위 저장소를 수정해 볼 시간이다. 여기서 Flux 패턴이란 Action을 Dispatcher에 전달해서 Store의 데이터를 변경한 뒤 View에 반영하는 단방향으로 상태를 관리하는 아키텍처를 의미한다.
내가 생각하는 Flux 패턴의 장점으로는 상태의 선언과 관련 액션을 한 곳에서 모두 정의하는 것이라고 생각한다. 아래 그림을 보면 수라는 스토어와 더하기, 빼기, 곱하기, 나누기 액션을 정의하고 각 컴포넌트에서 필요한 액션을 호출해 값을 바꿔준다. 이 방식은 필요한 액션을 한 곳에서 모아두기 때문에 해당 기능에 필요한 액션을 한눈에 모두 파악하기 쉽다는 장점이 있다고 생각한다.
그렇다면 이제 코드로 이 구조를 반영해 보자
createStore.ts
type Listener<T> = (state: T) => void;
type SetOrUpdater<T> = T | ((prev: T) => T);
type StoreAPI<T> = {
getState: () => T;
setState: (newState: SetOrUpdater<T>) => void;
subscribe: (listener: Listener<T>) => () => void;
};
export const createStore = <T>(
createState: (set: StoreAPI<T>["setState"], get: StoreAPI<T>["getState"]) => T
): StoreAPI<T> => {
let state: T;
const listeners = new Set<Listener<T>>();
const getState = () => state;
const setState = (nextState: SetOrUpdater<T>) => {
const updated = typeof nextState === "function" ? (nextState as (prev: T) => T)(state) : nextState;
state = updated;
listeners.forEach((listener) => listener(state));
};
const subscribe = (listener: Listener<T>) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
state = createState(setState, getState);
return { getState, setState, subscribe };
};
이전에는 createStore의 인자로 초기값인 initialValue를 넣어줬지만 지금은 createStore를 만들 때 초기 상태와 액션을 정의할 수 있도록 createState 메서드를 인자로 넣어준다. 그리고 return 이전 줄에 인자로 받은 createState를 호출해 state에 이를 반영해 주도록 바꿔준다.
이제 스토어를 생성하는 부분으로 가보면 zustand의 create와 유사한 방식이라는 것을 바로 알아차릴 수 있다.
createStore의 첫 번째 인자로 createState를 넘겨주었고 그 createState 메서드를 여기서 구현하면 된다. 여기서 우리가 원하는 초기상태 State와 Action을 정의해 주면 된다.
아래 예시는 money 상태와 증가와 감소 기능을 store 한 곳에서 정의하고 사용처에서는 increment와 decrement만 노출시켜서 단방향으로 상태가 전파되는 구조를 만들 수 있게 됐다.
import { createStore } from "../../lib/flux/createStoreSetter";
import { createStoreHook } from "../../lib/flux/useStore";
type State = {
money: number;
};
type Action = {
increment: () => void;
decrement: () => void;
};
export const moneyStore = createStore<State & Action>((set) => ({
money: 1000,
increment: () => set((state) => ({ ...state, money: state.money + 1000 })),
decrement: () => set((state) => ({ ...state, money: state.money - 1000 })),
}));
export const useMoneyStore = createStoreHook(moneyStore);
마지막으로 상태 사용처에서 useMoneyStore의 selector로 action에 접근해 스토어에서 정의한 action을 호출해 상태를 변경시키게 된다.
import { useMoneyStore } from "./store/flux/money";
export default function Money() {
const money = useMoneyStore((state) => state.money);
const increment = useMoneyStore((action) => action.increment);
const decrement = useMoneyStore((action) => action.decrement);
return (
<div>
<h1>My State Management</h1>
<p>💰 Money: {money}₩</p>
<div>
<button onClick={increment}>+1000</button>
<button onClick={decrement}>-1000</button>
</div>
</div>
);
}
더 추가할 수 있는 기능으로
지금까지 전역 상태 라이브러리의 작은 버전을 만들어보았고 특정 상태만 구독할 수 있는 selector, flux 패턴으로 상태를 관리하는 기능을 만들어보았다. 여기에 더 추가할 수 있는 기능으로는 비동기 상태를 관리하는 기능과 persist(상태를 session, local storage에 자동 저장하는) 기능을 넣어볼 수 있을 것 같다. 이는 필요에 따라 추가해서 사용해 보면 좋을 것 같다. 또한 이번 예시로는 flux 패턴으로 만들어보았지만 위를 응용해서 atom 패턴으로 만들 수도 있다. 이건 팀의 방향성에 맞추어 flux 패턴이 유리한지 atom 패턴이 유리한지 논의하고 협업하기 더 좋은 방향으로 구현하는 것이 좋지 않을까 생각한다.
지금까지 전역 상태 관리 라이브러리를 직접 만드는 과정을 적으며 느낀 점은 생각보다 구현하기 어렵지 않다는 점이었다. 물론 작은 버전이긴 하지만 외부의 상태 저장소를 만들어 상태가 바뀔 때 어떻게 리렌더링을 일으킬 수 있는지에 대해 배울 수 있었고 선택한 라이브러리의 전체 기능을 활용하지 않는다면 간단하게 만들어서 사용하는 것도 좋은 아이디어일 수 있겠다는 생각도 들었다.
'Frontend' 카테고리의 다른 글
| React에서 다른 컴포넌트로 상태를 공유하는 방법에 대해서 (0) | 2025.06.22 |
|---|---|
| Next.js의 fetch와 렌더링 방식(SSR, SSG, ISR), TanStack-Query 캐시 관리의 차이점 (0) | 2025.03.19 |
| React Context의 내부 동작 원리: 값 변경 시 하위 컴포넌트 리렌더링에 대해서 (0) | 2025.01.01 |
| 바닐라 js로 useEffect 구현하며 이해하기 (0) | 2024.12.04 |
| 바닐라 js로 useState 직접 구현하며 이해하기 (4) | 2024.11.18 |

