
장바구니와 위시리스트는 새로고침해도 남아 있어야 한다. 너무 당연한 요구라서 처음에는 Zustand의 persist를 붙이면 끝이라고 생각했다. 상태를 localStorage에 넣고, 다음에 페이지를 열면 다시 읽는다. 이름부터 persist니까 그 정도는 해주겠지 싶었다.
그런데 Next.js처럼 서버 렌더링을 하는 애플리케이션에서는 여기서 시간이 하나 더 생긴다. 서버가 HTML을 만들 때의 상태와, 브라우저가 그 HTML을 처음 붙잡고 렌더링할 때의 상태가 있다. 둘은 같아야 한다. 장바구니가 브라우저에 남아 있다는 사실은 그 다음에 반영해도 늦지 않다. 문제는 상태가 틀린 것이 아니라, 너무 일찍 맞아버리는 것이다.
서버는 장바구니를 모른다
localStorage는 브라우저에만 있다. 서버는 어떤 사용자가 어제 장바구니에 무엇을 담았는지 알 수 없고, 알 필요도 없다. 서버가 만드는 첫 HTML은 대개 빈 장바구니에서 시작한다.
서버가 만든 HTML 장바구니 0개
브라우저의 첫 렌더 장바구니 3개브라우저가 이미 저장된 값을 읽은 상태에서 첫 렌더를 하면, React가 받은 HTML과 자기가 만들려는 화면이 달라진다. 이때 hydration 경고가 나온다. 개발할 때는 경고 하나로 끝나는 것처럼 보여도, 실제로는 첫 화면이 잠깐 바뀌거나 특정 컴포넌트가 예상 밖의 상태를 기준으로 동작할 수 있다. 화면이 한 번 깜빡이는 문제보다, 서버와 클라이언트가 무엇을 믿는지 분명하지 않다는 점이 더 찝찝하다.
처음에는 이 문제를 typeof window !== 'undefined'로 막으면 되지 않을까 생각했다. 물론 서버에서 localStorage를 직접 읽다 터지는 일은 피할 수 있다. 하지만 그것만으로는 충분하지 않다. 서버에서는 초기 상태를 쓰고, 브라우저 첫 렌더에서는 저장된 상태를 쓰면 여전히 첫 화면이 갈라진다.
서버와 첫 클라이언트 렌더는 같은 상태에서 시작해야 한다. 저장된 상태는 마운트가 끝난 뒤에 읽어야 한다.
자동 복원을 끄고, 복원 시점을 직접 정한다
이번 구현에서 장바구니의 초기 상태는 항상 비어 있다.
const initialCartState = {
items: {},
}그리고 persist의 자동 hydration을 끈다.
persist<CartStore, CartState>({
name: 'commerce-cart',
version: 1,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }),
migrate: (persistedState) => migrateCart(persistedState),
skipHydration: true,
})skipHydration: true는 저장 기능을 끄는 옵션이 아니다. 저장된 상태를 읽는 책임을 미들웨어에게서 애플리케이션으로 가져오는 옵션에 가깝다. 이제 스토어는 만들어질 때 빈 상태로 시작하고, 어느 시점에 rehydrate()를 호출할지는 우리가 정한다. 그 시점은 React에서 마운트 뒤다.
'use client'
import { useEffect } from 'react'
type PersistApi = {
persist: {
rehydrate: () => Promise<void> | void
hasHydrated: () => boolean
}
}
export function useHydratePersistedStore(store: PersistApi) {
useEffect(() => {
if (store.persist.hasHydrated()) {
return
}
void store.persist.rehydrate()
}, [store])
}이 훅은 특별한 일을 하지 않는다. 특별한 일을 하지 않는다는 점이 중요하다. 서버 렌더와 첫 클라이언트 렌더는 모두 초기 상태를 사용한다. React가 화면을 붙이고 useEffect가 실행된 뒤에야 브라우저 저장소를 읽는다. 이후의 변경은 hydration 과정이 아니라 보통의 클라이언트 상태 갱신이다.
서버 HTML { items: {} }
첫 클라이언트 렌더 { items: {} }
마운트 뒤 rehydrate() localStorage의 값 복원
그 이후 Zustand 구독이 화면을 갱신이 순서가 핵심이다. persist가 있느냐 없느냐보다, 저장된 상태가 첫 렌더에 끼어드느냐가 먼저다.
hasHydrated()를 함께 보는 이유도 있다. 개발 모드의 Strict Mode나 여러 컴포넌트가 같은 스토어의 hydration을 요청하는 상황에서, 같은 저장소를 불필요하게 여러 번 읽고 싶지는 않다. 물론 rehydrate()가 여러 번 호출되어도 안전하도록 만드는 편이 좋다. 다만 “이미 복원했는가”라는 상태를 API로 드러내면 호출하는 쪽의 의도가 훨씬 명확해진다.
무엇을 저장하고 무엇을 버릴 것인가
장바구니 스토어에는 items뿐 아니라 addToCart, removeFromCart, clearCart 같은 액션도 들어 있다. 하지만 함수는 JSON으로 저장할 것이 아니다. 다음 번 실행에서 필요한 것은 사용자가 담아둔 상품 ID들이지, 지난번 런타임의 함수가 아니다.
그래서 partialize로 영속화할 부분을 좁혔다.
partialize: (state) => ({ items: state.items })이 선택은 저장 공간을 아끼는 정도의 이야기가 아니다. 스토어의 전체 상태와 디스크에 남겨도 되는 상태를 구분하는 경계다. 액션, 로딩 상태, 일시적인 UI 상태까지 한꺼번에 저장하기 시작하면, 나중에는 무엇이 어느 시점의 상태인지 추적하기 어려워진다. 특히 서버에서 받아온 데이터나 요청 중 상태는 대개 저장하면 안 된다. 캐시와 사용자 의도를 같은 서랍에 넣는 셈이 된다.
장바구니와 위시리스트는 상품 ID를 키로, true를 값으로 둔 객체로 모델링했다.
type CartItems = Partial<Record<string, true>>
type CartState = {
items: CartItems
}이 구조는 수량이나 상품 상세를 저장하려는 장바구니에는 부족할 수 있다. 하지만 “담겼는가”와 개수를 빠르게 확인하는 용도에는 맞는다. 중요한 것은 어떤 자료구조를 썼는지가 아니라, 영속화할 데이터 모델을 먼저 좁혔다는 점이다.
브라우저에 남은 JSON은 신뢰할 수 없다
localStorage에 들어 있는 값은 우리 코드가 마지막으로 쓴 값일 수도 있고, 오래된 버전의 값일 수도 있고, 사용자가 개발자 도구에서 고친 값일 수도 있다. JSON 파싱에 성공했다고 해서 현재 스토어가 받을 수 있는 상태라는 뜻은 아니다.
이번에는 Zod로 저장 상태의 모양을 확인하고, 맞지 않으면 안전한 초기 상태로 돌아가도록 했다.
const persistedCartSchema = z.object({
items: z.record(z.string(), z.literal(true)),
})
const migrateCart = (persisted: unknown): CartState => {
const parsed = persistedCartSchema.safeParse(persisted)
if (parsed.success) {
return { items: parsed.data.items }
}
return initialCartState
}version과 migrate도 같은 이유로 필요하다. 지금은 1이지만, 장바구니의 표현이 나중에 { [productId]: quantity }로 바뀔 수 있다. 그때 예전 값을 무작정 새 스토어에 합치면 오류가 늦게, 이상한 모양으로 나타난다. 저장 형식에도 버전이 있다는 사실을 코드에 남겨두는 편이 낫다.
const CART_STORAGE_VERSION = 1
persist<CartStore, CartState>({
name: 'commerce-cart',
version: CART_STORAGE_VERSION,
migrate: (persistedState) => migrateCart(persistedState),
// ...
})여기서 migration은 데이터베이스 migration만큼 거창하지 않다. 브라우저에 남긴 작은 계약을 바꾸는 일이다. 하지만 사용자의 브라우저는 생각보다 오래 살아남는다. 프로젝트가 몇 달만 지나도 이 계약은 현실적인 문제가 된다.
hydration 훅은 어디에 두어야 할까
현재는 헤더가 장바구니와 위시리스트의 개수를 보여주기 때문에, Header에서 두 스토어를 복원한다.
export function Header() {
useHydratePersistedStore(useCartStore)
useHydratePersistedStore(useWishlistStore)
const cartCount = useCartStore(cartSelectors.count)
const wishlistCount = useWishlistStore(wishlistSelectors.count)
// ...
}이건 작은 예제에서는 충분하다. 다만 헤더가 먼저 마운트된다는 사실에 hydration의 책임을 묶어두는 방식이기도 하다. 다른 화면이 헤더보다 먼저 장바구니 상태를 읽을 수 있다면, 그 화면은 복원 전의 빈 값을 보게 된다.
상태 소비 범위가 넓어질수록 스토어마다 호출하는 훅보다 앱 루트의 클라이언트 Provider, 혹은 영속 상태를 위한 별도 hydration boundary를 두는 편이 낫다. 반대로 특정 위젯 안에서만 쓰는 상태라면 그 위젯이 복원 책임을 가져도 괜찮다. 정답은 하나가 아니다. 다만 상태를 누가 언제 처음 읽는지 보고 결정해야 한다.
이 문제는 persist 옵션 하나의 문제가 아니다. 상태의 소유권과 렌더링 순서의 문제다.
@ilokesto/state에는 무엇을 가져와야 할까
이 작업을 하면서 자연스럽게 @ilokesto/state의 persist도 다시 보게 됐다. @ilokesto/state는 local, session, cookie를 지원하고, 저장값을 { state, version } 형태로 관리하며, decode와 migration으로 저장 상태를 검증한다. 저장 형식 자체는 꽤 보수적으로 다룬다.
다만 현재 persist는 스토어를 만들 때 저장소를 읽어 초기 상태를 바로 바꾼다. 서버에서는 localStorage가 없어서 안전한 초기 상태로 돌아가지만, 브라우저에서는 첫 렌더 전에 저장값을 읽을 수 있다. React SSR 환경에서는 바로 이 차이가 문제가 된다.
그래서 가져와야 할 것은 Zustand의 API 이름보다 책임의 분리다.
- 스토어 생성은 항상 결정적인 초기 상태에서 시작한다.
- 영속 상태를 읽는 일은 명시적인
rehydrate()로 분리한다. hasHydrated()로 현재 단계를 알 수 있게 한다.- React, Vue, Svelte 같은 어댑터는 각 프레임워크의 마운트 이후 생명주기에서
rehydrate()를 부른다.
아직 @ilokesto/state에 이 API가 공식적으로 들어간 것은 아니다. 다만 들어간다면 아래처럼 생기는 쪽이 자연스럽다고 생각한다.
const cart = persist(
{ items: {} },
{
local: 'commerce-cart',
decode: decodeCart,
skipHydration: true,
},
)
// React라면 useEffect 안에서, Vue라면 onMounted 안에서 호출한다.
await cart.persist.rehydrate()
if (cart.persist.hasHydrated()) {
// 저장 상태를 읽은 뒤의 화면
}여기서 rehydrate()는 React 전용 API가 아니어야 한다. 저장소에서 상태를 읽고 스토어를 갱신하는 일은 프레임워크와 무관한 코어의 책임이다. React의 useEffect, Vue의 onMounted, Svelte의 onMount는 그 일을 언제 시작할지만 결정해야 한다. 그래야 멀티 프레임워크 상태 라이브러리라는 성격도 지킬 수 있다.
skipHydration을 기본값으로 둘지, SSR 환경에서만 명시하도록 할지는 더 고민할 문제다. 모든 애플리케이션이 서버 렌더링을 하는 것은 아니고, 클라이언트 전용 앱에서는 즉시 복원이 더 자연스러울 수 있다. 그렇다고 서버와 브라우저의 초기 화면이 달라질 수 있는 동작을 아무 설명 없이 기본값으로 두는 것도 마음에 걸린다. 적어도 이 선택이 성능 옵션이 아니라 렌더링 계약이라는 점은 문서에 분명히 적어야 한다.
상태가 맞는가보다 언제 읽는가가 먼저다
persist를 붙이는 일은 대개 데이터 보존 기능을 추가하는 일로 보인다. 물론 맞다. 하지만 서버 렌더링 앱에서는 시간을 다루는 일이기도 하다. 서버가 언제 상태를 알고, 브라우저가 언제 과거의 상태를 꺼내고, 화면이 언제 그 차이를 보여줄지를 정해야 한다.
저장된 장바구니를 첫 화면에서 바로 보여주는 것이 항상 좋은 UX일 수도 있다. 그 경우에는 서버도 그 값을 알 수 있는 구조, 예를 들면 쿠키나 서버 세션을 선택해야 한다. localStorage만으로 서버와 첫 렌더를 동시에 만족시키겠다는 기대는 조금 욕심이다.
결국 중요한 것은 persist를 썼느냐가 아니다. 첫 렌더에서 어떤 상태를 약속할 것인가, 그리고 브라우저에 남은 과거를 언제 현재로 데려올 것인가다. 이 문제는 문법의 문제가 아니라 상태와 시간의 경계를 정하는 설계 문제에 가깝다.
댓글
댓글을 불러오는 중...