State Management — Build the Library Yourself
A Store in 200 Lines
한국어 원문으로 표시합니다.
목표
Redux·Zustand·Jotai 가 공통으로 푸는 문제를 직접 풀어 봅니다. 파일 하나, 200줄이 안 됩니다.
만들 것
/root/work/state/store.mjs 에서 createStore 를 export 합니다.
export function createStore(initial) {
return { getState, dispatch, subscribe, select }
}
| 함수 | 계약 |
|---|---|
getState() |
현재 상태를 즉시 돌려준다 |
dispatch(fn) |
fn(현재) 의 반환값이 새 상태. 원본을 고치지 않는다 |
subscribe(l) |
해제 함수를 반환한다 |
select(sel) |
sel(현재) 를 돌려주되, 상태가 그대로면 다시 계산하지 않는다 |
채점 방식
채점기가 여러분의 store.mjs 를 import 해서 계약을 하나씩 확인합니다.
브라우저도 프레임워크도 쓰지 않습니다.
node --version # v22
node store.mjs # 문법 확인용 (아무것도 출력 안 해도 됩니다)
단계
getState·dispatch- 제자리 수정 금지
subscribe→ 해제 함수- 같은 참조면 알리지 않기
select— 파생은 계산- 메모이제이션
- 마이크로태스크 배치
- 순회 중 해제 안전성
- 정리 →
09-notes.md
참고
- npm install 은 안 됩니다. 네트워크가 없고, 필요하지도 않습니다.
- 상태 모양은 자유입니다. 채점기는
{ n: 0, items: [] }같은 평범한 객체를 넣고 계약만 확인합니다.
스토어의 뼈대
/root/work/state/store.mjs 에서 createStore(initial) 를 export 하세요. 돌려주는 객체에 getState() 와 dispatch(fn) 이 있어야 하고, dispatch 는 fn(현재상태) 의 반환값을 새 상태로 삼습니다.
mkdir -p /root/work/state. ESM 이므로 export function createStore(initial) { ... }. 확장자가 .mjs 라 package.json 은 필요 없습니다. dispatch 안에서 상태를 제자리 수정하지 마세요 — 다음 단계가 그걸 봅니다.
제자리 수정을 막는다
dispatch 가 넘겨받은 상태 객체를 절대 고치지 않도록 하세요. 채점기가 dispatch 전후로 원본 객체를 비교합니다.
Object.freeze 로 스스로를 지키는 것도 좋은 방법입니다 — 실수로 고치면 조용히 무시되는 대신 오류가 납니다. 상태 갱신은 항상 새 객체({...s, n: s.n+1})로 하세요.
구독과 해제
subscribe(listener) 를 추가하고 해제 함수를 반환하세요. 해제한 뒤에는 다시 불리면 안 됩니다.
반환값이 함수여야 합니다. 해제는 몇 번 불러도 안전해야 합니다(두 번 부르면 다른 리스너가 지워지는 구현이 흔한 함정입니다).
안 바뀌었으면 알리지 않는다
dispatch(s => s) 처럼 같은 참조가 돌아오면 리스너를 부르지 않게 하세요.
if (next === prev) return 한 줄입니다. 이게 없으면 상태는 그대로인데 렌더가 돌고, 그 렌더가 다시 dispatch 하면 무한 루프가 됩니다.
파생 상태는 계산한다
select(selector) 를 추가하세요. store.select(s => s.items.length) 가 항상 현재 상태 기준으로 값을 돌려줘야 합니다. 파생값을 상태에 저장하면 안 됩니다.
상태에 count 같은 필드를 넣어 두고 싶어지지만, 그러면 둘이 어긋나는 순간이 옵니다. 채점기가 items 만 바꾼 뒤 파생값이 따라오는지 봅니다.
같은 입력이면 다시 계산하지 않는다
select 에 메모이제이션을 넣으세요. 상태가 바뀌지 않았는데 같은 selector 로 여러 번 물으면 selector 함수가 한 번만 실행돼야 합니다.
마지막 상태 참조와 마지막 결과를 selector 별로 기억합니다(Map 이나 WeakMap). 여기서 불변성이 값어치를 합니다 — 상태가 불변이라 참조 비교 하나로 '안 바뀌었다'를 알 수 있습니다.
한 틱에 세 번 바꿔도 렌더는 한 번
연속된 dispatch 를 마이크로태스크 하나로 묶으세요. dispatch(a); dispatch(b); dispatch(c) 뒤 리스너는 정확히 한 번 불려야 하고, 그때 상태는 세 번 모두 반영된 최종값이어야 합니다.
queueMicrotask 와 scheduled 플래그 하나면 됩니다. React 18 의 automatic batching 이 이것입니다. 주의: getState() 는 배치와 무관하게 즉시 최신값을 줘야 합니다 — dispatch 자체를 미루면 안 됩니다.
순회 중에 해제해도 안전하게
리스너 안에서 구독을 해제해도 다른 리스너가 건너뛰어지지 않게 하세요.
[...listeners].forEach(...) — 복사본을 순회합니다. 한 줄이지만 이게 없으면 '가끔 하나가 안 불린다'는, 재현율 낮고 원인 찾기 어려운 버그가 됩니다.
라이브러리가 왜 그렇게 생겼는지
09-notes.md 에 세 줄 이상. 불변성이 무엇을 O(1) 로 만들었는지, 파생 상태를 저장하면 무엇이 어긋나는지, 배치가 없으면 무엇이 낭비되는지.
본문에 불변, 파생, 배치 가 들어가야 합니다. 이 셋이 Redux·Zustand·Vue 가 서로 다르게 생겼어도 똑같이 푸는 문제입니다.