Skip to content

fix(persist): SSR 환경(Next.js App Router)에서 서버 크래시 및 hydration mismatch 발생 #7

Description

@ayden94

Summary

@ilokesto/statepersist 미들웨어를 Next.js App Router(SSR) 환경에서 사용할 때 두 가지 결함이 발생합니다.

  1. 서버 모듈 평가 시 localStorage/sessionStorage에 접근하려 해 ReferenceError로 크래시
  2. 클라이언트 hydration 시 getServerSnapshotgetSnapshot이 다른 값을 반환해 React hydration mismatch 경고

Context

React 18의 useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)는 SSR hydration mismatch를 막기 위해 세 번째 인자를 분리해서 받습니다. @ilokesto/state의 React adapter는 이 부분을 올바르게 구현하고 있습니다.

src/core/React/createUseState.ts:

const getServerSnapshot = useMemo(() => {
  const shallowSelector = createShallowSelector(selector);
  return () => shallowSelector(store.getInitialState());
}, [store, selector]);

store.getInitialState()setState 이후에도 변하지 않는 초기값을 반환합니다 (@ilokesto/storeinitialState 보존). 이 설계 자체는 Zustand의 api.getServerState ?? getState보다 더 안전합니다.

하지만 persist 미들웨어가 store 생성 시점에 setState로 state를 덮어써서 getSnapshot이 영속값을 반환하게 만들고, 결국 hydration mismatch가 납니다.

Reproduction

// stores/counter.ts
import { create } from '@ilokesto/state/react'
import { persist } from '@ilokesto/state/middleware'
import { pipe } from '@ilokesto/state/utils'

const counterStore = pipe
  .use(persist({ local: 'counter', decode: decodeCounter }))
  .create({ count: 0 })

export const useCounter = create(counterStore)

Next.js App Router에서 위 store를 사용하는 컴포넌트를 서버 렌더하면:

1. 서버 크래시

src/middleware/persist/persistUtils.tsreadStorageValue:

const readStorageValue = (storageType, storageKey) => {
  if (storageType === 'local') {
    return localStorage.getItem(storageKey)  // ← ReferenceError on server
  }
  if (storageType === 'session') {
    return sessionStorage.getItem(storageKey) // ← ReferenceError on server
  }
  // ...
}

typeof window === 'undefined' 가드가 없어 서버에서 바로 크래시.

2. Hydration mismatch

src/middleware/persist/index.tsapplyPersist:

const applyPersist = <T>(initialState, options): Store<T> => {
  const store = getStore(initialState);
  const baseSetState = store.setState.bind(store);
  const currentState = store.getState() as T;
  const initialValue = optionObj.storageType
    ? getSafeStorage({ ... }).state
    : currentState;

  baseSetState(initialValue);  // ← 생성 시점에 state를 영속값으로 덮어씀
  // ...
};

타임라인:

단계 서버 클라이언트 (hydration 전)
모듈 평가 localStorage 접근 → 크래시 (또는 폴백) localStorage 읽기 → 영속값 획득
applyPersist setState(fallback) setState(persistedValue)
getInitialState() { count: 0 } { count: 0 }
getState() { count: 0 } { count: 5 } ⚠️
getServerSnapshot selector({ count: 0 }) selector({ count: 0 })
getSnapshot N/A selector({ count: 5 }) ⚠️
React hydration { count: 0 } 렌더 { count: 0 } 기대하지만 store는 { count: 5 } → mismatch

getServerSnapshot은 initial로 안전하지만, getSnapshot이 이미 오염된 getState()를 반환해서 mismatch가 발생합니다.

Comparison with Zustand

Zustand persist도 동일한 근본 문제를 갖고 있지만, escape hatch를 제공합니다:

  • skipHydration: true 옵션으로 생성 시점 자동 hydration을 끌 수 있음
  • store.persist.rehydrate()로 클라이언트 마운트 후 수동 호출
  • onRehydrateStorage 콜백으로 hydration 완료 시점 통지
  • 공식 문서가 Next.js App Router용 패턴을 명시적으로 문서화

@ilokesto/state는 현재 이에 대응하는 API가 없습니다.

Suggested fix

우선순위대로 세 가지를 제안합니다.

1. readStorageValue에 SSR 가드 (최소 안전장치)

const readStorageValue = (storageType, storageKey) => {
  if (typeof window === 'undefined') return null
  if (storageType === 'local') {
    return localStorage.getItem(storageKey)
  }
  // ...
}

서버 크래시는 막지만 hydration mismatch는 남음.

2. skipHydration 옵션 + rehydrate() 메서드

persist({ local: 'cart', decode: decodeCart, skipHydration: true })

// 클라이언트에서 수동 호출
useEffect(() => {
  cartStore.persist.rehydrate()
}, [])

applyPersist에서 skipHydration: truebaseSetState(initialValue)를 스킵하고, store에 rehydrate() 메서드를 노출해 클라이언트 마운트 이후에 영속값을 적용. 이 패턴이 Zustand 공식 권장 패턴과 동일합니다.

3. onRehydrateStorage 콜백 (선택)

persist({
  local: 'cart',
  decode: decodeCart,
  skipHydration: true,
  onRehydrateStorage: () => (state, error) => { ... }
})

hydration 완료 시점을 컴포넌트에 알려 hydration 게이트를 세울 수 있게 함. 장바구니 카운트 같이 "0 → 실제값" 깜빡임이 허용되는 UX엔 불필요하지만, 다크모드 같이 초기 깜빡임이 거슬리는 경우 필요.

Additional context

  • docs/guides/lifecycle-reads-writes.mdx에서 "hydrate explicitly before rendering subscribed UI"라고 언급하지만, 그걸 할 API를 제공하지 않고 있음
  • .changeset/shallow-selector-breaking.md에서 getServerSnapshot의 stale-closure 이슈는 수정된 바 있음 — SSR 관심은 있어 보이지만 persist 쪽은 아직 미흡
  • 영속화가 필요하지 않은 일반 store는 getServerSnapshot 분리가 잘 되어있어 SSR에서 안전함. 이 이슈는 persist를 켠 store에만 해당

Acceptance criteria

  • persist store를 Next.js App Router 서버 렌더에서 사용해도 크래시하지 않는다
  • persist store를 클라이언트 hydration 시 hydration mismatch 경고가 발생하지 않는다
  • skipHydration: true 옵션을 제공하면 store 생성 시 자동 hydration을 스킵한다
  • store.persist.rehydrate()로 수동 hydration을 호출할 수 있다
  • Next.js App Router용 persist 사용 패턴을 문서에 추가한다

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions