Caro-Kann으로 알아보는 전역 상태 기본 원리
이 포스트의 내용 대부분은 카토 다이시의 [리액트 훅을 활용한 마이크로 상태 관리]에서도 찾아볼 수 있습니다. 전역 상태 관리 원리를 더욱 깊게 이해하고 싶으시다면 해당 서적을 여러번 읽어보시는 것을 권해드립니다.
전역 상태란 무엇인가
대부분의 프론트엔드 개발자라면 원하든 원하지 않든 전역 상태를 사용해본 경험이 있을 것이다. 이를 구현하기 위해 일반적으로 Zustand와 Jotai, Redux Toolkit 같은 라이브러리를 사용하게 된다(제발 useContext를 이런 용도로 쓰지 말아주세요). 그런데 이런 라이브러리들은 어떻게 전역 상태를 구현하고 있는 걸까? 또, 애시당초 전역 상태란 무엇일까?
근본적인 의문으로부터 이야기를 시작해보자. 리액트에서 '상태(State)'란 무엇일까. 누군가 이에 대해 "컴포넌트 내부에서 관리되며 어플리케이션의 렌더에 영향을 미치는 객체"라고 표현한 것을 읽은 적이 있다. 꽤나 괜찮은 정의이지만 한 가지 아쉬운 부분이 있다. 앞서 언급한 전역 상태 라이브러리들은 모두 컴포넌트 바깥에서 선언되며, 관리되기 때문이다. 하지만 그걸 제외한 나머지 부분은 괜찮은 관계로 리액트에서의 상태를 '어플리케이션의 랜더에 영향을 미치는 객체'라고 정의할 수 있겠다.
왜 상태가 객체인지 이해할 수 없는 사람도 있을 것이다. 아래의 경우와 같이 useState에 원시 자료형을 할당하는 경우가 많으니까. 하지만 useState가 리턴하는 것이 getter와 setter라는 사실을 떠올려보면 모든 게 명쾌해진다. 모든 상태는 하나의 값과 두 개의 메서드로 이루어진 객체이며, initValue를 사용해 값을 초기화한다.
const [ value, setValue ] = useState("")
전역 상태라는 표현에서 '상태'가 무엇인지는 알겠는데, 그렇다면 '전역'이 의미하는 바는 무엇일까. 전역이란 ─ 군대에서 뛰쳐나오는 것 외에도 ─ 특정한 컴포넌트에 구애받지 않고 애플리케이션 전체에서 접근 가능하며, 공유할 수 있도록 의도적으로 처리해둔 데이터를 의미한다.
export let bar = ""
위에서 선언한 변수 bar는 애플리케이션 전체에서 import를 통해 접근할 수 있으며, 개발자는 이 데이터를 공유할 수 있도록 의도적으로 export하여 처리하고 있다. 이것이 '전역'이라는 표현의 근본적인 의미이다. 따라서 나는 전역 상태를 '특정한 컴포넌트에 구애받지 않고 애플리케이션 전체에서 접근 가능하며, 공유할 수 있도록 의도적으로 처리해둔, 어플리케이션의 랜더에 영향을 미치는 객체'라고 정의한다.
어떻게 구현할 것인가
다시 '전역'으로 돌아가보자. 시도해본 사람이 얼마나 있을 지는 모르겠지만 위에서 선언한 bar를 import 하여 그 값을 변경하려 해보면, let으로 선언되었음에도 값을 변경할 수 없다는 에러를 마주하게 된다. 이는 ESM에서 import로 가져온 값이 모듈에서 export된 변수를 참조하기 때문이다. 참조는 읽기 전용으로 동작하며, 외부에서 해당 값을 변경하는 것을 허용하지 않는다.
따라서 일반적으로는 클래스나 팩토리 함수 사용하여 store를 생성한다. 내 경우에는 context와 함께 처리한다면 팩토리 함수를 쓰고, 그 외의 경우에는 클래스를 사용한다. 팩토리 함수로 생성된 객체는 상태와 마찬가지로 하나의 값과 두 개의 메서드로 이루어져 있으며, 이를 통해 외부에서 전역 공간의 값을 수정할 수 있도록 한다.
function createStore<T>(initValue: T) => {
const store = initValue;
const getStore = () => store;
const setStore = (value: T) => { store = value };
return [getStore, setStore] as const;
}
export const useGlobalState = createStore();
하지만 store는 아직까지 ─ 사실은 앞으로도 ─ 전역 '상태'는 아니다. 그저 전역 공간에 노출된 데이터일 뿐. 리액트에서 '상태'를 다룰 수 있는 방법은 현재로서 세 가지 뿐이다. useState, useReducer, 그리고 useSyncExteralStore 훅이 그것이다. 특히 마지막 훅의 경우 이름에서 알 수 있는 것처럼 Exteral Store를 리액트의 상태로 Sync 시켜주는 도구이다. Caro-Kann 역시 내부적으로 useSyncExteralStore를 사용하고 있고, Zustand와 Jotai도 마찬가지다.
하지만 이 포스트에서는 우선 useState를 사용하여 전역 상태를 구현해보려 한다.
발행-구독 패턴
정리해보자면 1) setStore를 했을 때 상태를 변경시키려면 useState를 사용해야 하는데 2) useState는 반드시 컴포넌트 내부에서 호출되어야 한다. 3) 하지만 setStore 자체는 전역적으로 존재하기 위해 컴포넌트 외부에 있어야 한다. 4) ??? 5) profit
이렇게 뒤엉킨 조건을 해결하기 위해 전역 상태 도구들은 발행-구독 패턴을 사용하게 된다. 발행-구독 패턴의 핵심 요소는 구독(subscribe)과 알림(notify)이다. 이 패턴은 상태 변화가 발생할 때(발행) 등록된 구독자들에게 알림을 보내고, 구독자들은 이 알림을 통해 상태를 업데이트한다.
function createStore() {
let store = initValue;
const callbacks = new Set<Function>()
const getStore = () => store
const setStore = (value: T) => { store = value, callbacks.forEach(cb => cb()) }
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { getStore, setStore, subscribe };
}
useState는 반드시 컴포넌트 내부에서 호출되어야 하지만, setter까지 그런 제약을 가지고 있지는 않다. 따라서 전역 상태 관리 도구들은 구독 과정에서 setter 함수를 callbacks에 등록(구독)하게 된다. 이를 통해 setStore가 store변경시킬 때마다 callbacks.forEact를 통해 setter 함수가 호출되고(알림), 이는 곧 상태의 변경으로 인한 리랜더링을 촉발시킨다.
컴포넌트에서는 useState를 호출하여 구독을 처리하는데, 이는 아래의 예시 코드 중 useEffect 내부에서 확인해볼 수 있다. useStore 훅은 호출되면 useState를 사용하여 '상태'를 만들고, subscribe를 호출하여 () => { setState(getStore()) }라는 익명 함수를 store에 등록한다. 전체적으로 살펴보시면 state는 모두 getStore가 리턴한 값을 바라보고 있는 것을 알 수 있다.
const useStore = ({ getStore, setStore, subscribe }) => {
const [state, setState] = useState(getStore());
useEffect(() => {
const unsubscribe = subscribe(() => {
setState(getStore());
});
setState(getStore());
return unsubscribe;
}, []);
return [state, setStore] as const;
};
useStore가 리턴하는 값이 [state, setState]가 아니라 [state, setStore]라는 점도 주목할만하다. 즉, 전역 상태 관리 도구는 우리에게 직접 상태를 변경하도록 하는 것이 아니라, store를 변경하도록 한다는 것이다. 이 과정에서 setStore는 callbacks에 들어있는 구독 함수를 모두 호출하고, 구독 함수는 setState(getStore())를 호출하며, 이로 인해 state가 변경되면서 '상태의 변경'이 리랜더링을 촉발한다.
아래는 Caro-Kann이 1.x.x 버전이던 시절을 간략하게 요약해둔 코드이다. 몇 가지 기능을 제외하기는 했지만, 아래의 코드만으로도 전역 상태 관리 기능을 수행할 수 있다.
export const create = <T>(initValue: T) => {
function createStore() {
let store = initValue;
const callbacks = new Set<Function>()
const getStore = () => store
const setStore = (value: T) => { store = value, callbacks.forEach(cb => cb()) }
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { getStore, setStore, subscribe };
}
const { getStore, setStore, subscribe } = createStore();
const useStore = () => {
const [state, setState] = useState(getStore());
useEffect(() => {
const unsubscribe = subscribe(() => {
setState(getStore());
});
setState(getStore());
return unsubscribe;
}, []);
return [state, setStore] as const;
};
return useStore
}
위의 create를 호출하면 전역 상태를 만들고, useStore를 리턴한다. useStore는 클로저를 통해 store에 접근하며, 이로 인해 store는 GC의 대상이 되지 않는다. 참고로 create와 달리 useStore는 커스텀 훅이기 때문에 컴포넌트 내부에서 호출해야 한다.
// ./hooks/useNumberState.ts
import { create } from 'caro-kann'
export useNumberState = create(0)
// ./components/someGoodComp.tsx
import { useNumberState } from "./hooks/useNumberState"
export function Comp() {
// 실제로는 [state, setStore]
const [number, setNumber] = useNumberState()
return <button onClick={() => setStore(number + 1)}>{number}</button>
}
블로그의 정보
Ayden's journal
Beard Weard Ayden