State Management — Build the Library Yourself
Immutability Is a Performance Device, Not a Preference
한국어 원문으로 표시합니다.
한 줄 요약
상태 라이브러리가 불변 업데이트를 강요하는 이유는 순수함이 아름다워서가 아니라, 바뀌었는지를 === 하나로 판단하기 위해서다.
문제부터
화면에 상태를 그리는 앱에는 질문이 하나 있다.
무엇이 바뀌었으니 무엇을 다시 그려야 하는가?
답을 구하는 방법은 셋뿐이다.
- 전부 다시 그린다 — 정확하지만 느리다
- 깊은 비교를 한다 — 정확하지만 상태가 커지면 비교가 더 비싸다
- 바뀐 것은 새 객체가 되도록 강제한다 — 비교가
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 = false
function 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 모양이 아니다. 위의 여섯 가지를 어떻게 처리하는지다. 직접 한 번 만들어 보면 문서의 문장들이 전부 이유를 갖게 된다.