상태 관리 — 라이브러리를 직접 만들어 본다 · 스토어의 계약 · 이론
불변성은 취향이 아니라 성능 장치다
한 줄 요약
상태 라이브러리가 불변 업데이트를 강요하는 이유는 순수함이 아름다워서가 아니라, 바뀌었는지를 === 하나로 판단하기 위해서다.
문제부터
화면에 상태를 그리는 앱에는 질문이 하나 있다.
> 무엇이 바뀌었으니 무엇을 다시 그려야 하는가?
답을 구하는 방법은 셋뿐이다.
1. 전부 다시 그린다 — 정확하지만 느리다
2. 깊은 비교를 한다 — 정확하지만 상태가 커지면 비교가 더 비싸다
3. 바뀐 것은 새 객체가 되도록 강제한다 — 비교가 prev === next 한 번이다
3번이 불변성이다. 상태를 절대 제자리에서 고치지 않기로 약속하면, 참조 비교 하나로 변경 감지가 끝난다. O(1) 이다.
// ❌ 제자리 변경 — 참조가 그대로라 아무도 눈치채지 못한다state.items.push(x)// ✅ 새 배열 — 참조가 달라진다state = { ...state, items: [...state.items, x] }제자리 변경의 증상은 "가끔 화면이 안 바뀐다" 다. 데이터는 맞는데 렌더가 안 된다. 원인을 찾기 가장 어려운 종류의 버그다.
구독은 반드시 해제된다
const unsub = store.subscribe(render)// ... 컴포넌트가 사라질 때unsub()해제하지 않으면 사라진 화면이 계속 그려진다. 리스트를 열고 닫기를 반복하면 리스너가 쌓여 클릭 한 번에 수백 번 렌더가 돈다. React 의 useEffect 가 정리 함수를 반환하게 만든 이유이고, Vue 가 onUnmounted 를 준 이유다.
> 구독을 만드는 코드와 해제하는 코드는 같은 화면 안에서 보여야 한다. 멀리 떨어지면 반드시 하나가 빠진다.
안 바뀌었으면 알리지 않는다
dispatch(s => s) // 같은 참조를 돌려줬다이때 리스너를 부르면 안 된다. 부르면 "상태는 그대로인데 렌더가 도는" 낭비가 생기고, 그 렌더가 다시 dispatch 하면 무한 루프가 된다.
파생 상태를 저장하지 마라
이것이 상태 설계에서 가장 자주 하는 실수다.
// ❌ 두 개의 진실{ items: [...], count: 3 }// ✅ 하나의 진실 + 계산{ items: [...] }const count = items.length저장하면 둘이 어긋나는 순간이 반드시 온다. 어딘가에서 items 만 고치고 count 를 안 고친다. 그 버그는 "숫자가 가끔 틀리다" 로 나타나고, 재현이 안 된다.
React 문서가 useEffect 로 파생 상태를 동기화하지 말라고 못 박는 것도 같은 이유다. 계산할 수 있으면 계산한다.
그럼 계산이 비쌀 때는
메모이제이션. 입력이 그대로면 지난 결과를 그대로 돌려준다.
function memo(fn) { let lastArg, lastOut, has = false return arg => { if (has && arg === lastArg) return lastOut // 참조 비교 하나 lastArg = arg; lastOut = fn(arg); has = true return lastOut }}여기서 불변성이 다시 값어치를 한다. 입력이 불변이면 arg === lastArg 만으로 "안 바뀌었다" 를 알 수 있다. 제자리에서 고치는 상태로는 이 최적화가 성립하지 않는다.
useMemo, reselect, Vue 의 computed 가 전부 이 세 줄이다.
배치 — 한 틱에 여러 번 바꿔도 렌더는 한 번
dispatch(a); dispatch(b); dispatch(c) // 리스너는 몇 번 불려야 하나?세 번 부르면 화면이 세 번 그려진다. 중간 두 번은 사용자가 볼 일이 없는 상태다. 그래서 라이브러리들은 마이크로태스크 하나로 묶는다.
let scheduled = falsefunction notify() { if (scheduled) return scheduled = true queueMicrotask(() => { scheduled = false; listeners.forEach(l => l()) })}React 18 의 automatic batching 이 이것이다. 그 전에는 이벤트 핸들러 밖(예: setTimeout 안)의 setState 가 배치되지 않아 렌더가 여러 번 돌았다.
순회 중에 구독을 해제하면
리스너 안에서 unsub() 을 부르는 일은 흔하다(한 번만 듣고 끊기). 배열을 그대로 순회하면서 원본을 건드리면 다음 리스너를 건너뛴다.
[...listeners].forEach(l => l()) // 복사본을 순회한다한 줄이지만 이게 없으면 "가끔 하나가 안 불린다" 는 버그가 된다. 재현율이 낮아 원인을 찾는 데 며칠이 걸린다.
현장에서 — 라이브러리를 고를 때
상태 라이브러리를 갈아 끼우는 이유는 대개 "API 가 더 예뻐서" 가 아니라 위 여섯 가지 중 하나에 물려서다. 그런데 옮겨 간 쪽에서도 파생값을 상태에 저장하는 습관이나 구독을 안 끊는 습관이 남아 있으면 같은 버그가 그대로 따라온다. 도구를 바꿔서 사라지는 문제가 아니다.
라이브러리를 고르는 기준은 API 모양이 아니다. 위의 여섯 가지를 어떻게 처리하는지다. 직접 한 번 만들어 보면 문서의 문장들이 전부 이유를 갖게 된다.