LabHub
배우기 러닝패스 코스

状態管理 — ライブラリを自分で作ってみる

不変性は好みではなく性能の装置だ

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

상태 라이브러리가 불변 업데이트를 강요하는 이유는 순수함이 아름다워서가 아니라, 바뀌었는지를 === 하나로 판단하기 위해서다.

比較図: 바뀌었는지를 === 하나로 판단하기 위해서・무엇이 바뀌었으니 무엇을 다시 그려야 하는가?・전부 다시 그린다・깊은 비교를 한다

문제부터

화면에 상태를 그리는 앱에는 질문이 하나 있다.

무엇이 바뀌었으니 무엇을 다시 그려야 하는가?

답을 구하는 방법은 셋뿐이다.

  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 = 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 모양이 아니다. 위의 여섯 가지를 어떻게 처리하는지다. 직접 한 번 만들어 보면 문서의 문장들이 전부 이유를 갖게 된다.