인프런 커뮤니티 질문&답변
17강 zustand store 서버에서 생성
해결된 질문
작성
·
11
1
서버에서 스토어의 초기값을 설정해서 클라이언트에게 넘겨주는데
결국 서버 상태를 zustand에서 관리하게 되는거 아닌가요?
이미 tanstack query가 있음에도 해당 방식을 활용하는 이유가 궁금합니다.
초기값만 서버에서 넘기고 이후는 클라이언트에서만 관리되는 상태 예시가 있을까요?
답변 1
0
안녕하세요 byeong님!
많은 분들이 "어차피 서버에서 가져온 데이터라면 TanStack Query만 사용하면 되지, 왜 굳이 Zustand에 초기값으로 주입하는 번거로운 과정을 거쳐야 할까?"라는 의문을 가지십니다. 핵심부터 말씀드리자면, 서버 데이터를 '단순히 보여주기만' 할 때는 TanStack Query가 정답입니다. 하지만 서버 데이터를 초기 '씨앗(Seed)'으로 삼아 클라이언트에서 복잡한 상호작용(예: 폼 입력, 임시 상태 저장 등)을 처리해야 할 때는, 이 데이터를 Zustand로 넘겨받아 관리하는 것이 훨씬 유리하고 안전하기 때문입니다. 상태의 성격이 '서버의 스냅샷'에서 '사용자의 상호작용'으로 넘어가는 경계선인 셈이죠.
이를 실제 코드로 구현할 때 가장 먼저 주의해야 할 점은 Next.js App Router 환경에서의 Zustand 스토어 스코프(Scope)입니다. React 렌더링이 일어나는 서버 환경(Node.js)은 여러 요청이 동일한 서버 인스턴스를 공유합니다. 따라서 일반적인 싱글 페이지 애플리케이션(SPA)처럼 컴포넌트 외부에 전역 스토어 변수를 선언하게 되면, 'A 유저의 초기값이 B 유저의 화면에 노출되는' 치명적인 보안 버그가 발생합니다.
이를 방지하려면 반드시 Context API를 활용하여 렌더링 사이클(혹은 사용자 요청)마다 독립적인 스토어 인스턴스를 새로 생성해야 합니다. 아래와 같이 바닐라 Zustand 스토어를 생성하는 팩토리 함수와, 이를 하위 컴포넌트에 공급하는 Provider 패턴을 작성하는 것이 그 표준적인 해결책입니다.
import { createStore } from 'zustand';
import { createContext, useRef, useContext, ReactNode } from 'react';
import { useStore } from 'zustand';
// 1. 상태 타입 및 스토어 생성 팩토리 함수 정의 (전역 스토어가 아님에 주의합니다)
interface ProfileState {
name: string;
setName: (name: string) => void;
}
export const createProfileStore = (initialName: string) => {
return createStore<ProfileState>()((set) => ({
name: initialName,
setName: (name) => set({ name }),
}));
};
export type ProfileStore = ReturnType<typeof createProfileStore>;
// 2. React Context 및 Provider 생성
export const ProfileContext = createContext<ProfileStore | null>(null);
export function ProfileProvider({ children, initialName }: { children: ReactNode, initialName: string }) {
const storeRef = useRef<ProfileStore>(null);
if (!storeRef.current) {
// 최초 렌더링 시에만 서버로부터 받은 초기값을 주입하여 스토어 인스턴스를 생성합니다.
storeRef.current = createProfileStore(initialName);
}
return (
<ProfileContext.Provider value={storeRef.current}>
{children}
</ProfileContext.Provider>
);
}
// 3. 컴포넌트에서 스토어를 쉽게 사용하기 위한 커스텀 훅
export function useProfileContext<T>(selector: (state: ProfileState) => T): T {
const store = useContext(ProfileContext);
if (!store) throw new Error('ProfileProvider가 필요합니다.');
return useStore(store, selector);
}
이렇게 안전한 스토어 공급망을 구축했다면, 이제 복잡한 다중 스텝 폼 시나리오를 서버 컴포넌트에서 안전하게 시작할 수 있습니다. 사용자가 프로필 수정 페이지에 진입하면, 서버 컴포넌트는 데이터베이스나 외부 API를 통해 유저의 기존 프로필 정보를 페치(Fetch)합니다. 그리고 이 데이터를 앞서 만든 Provider의 initialName으로 밀어 넣어, 클라이언트 상태의 초기값으로 설정하는 역할을 수행합니다.
import { ProfileProvider } from '@/components/ProfileProvider';
import ProfileForm from '@/components/ProfileForm';
export default async function ProfilePage() {
// 서버에서 초기 데이터를 Fetching 합니다.
// 이 단계에서는 TanStack Query의 prefetchQuery와 Hydration Boundary를 조합하여 캐시를 채울 수도 있습니다.
const response = await fetch('https://api.example.com/user/profile');
const initialData = await response.json();
return (
// 서버에서 가져온 초기값을 Provider에 주입하여 클라이언트 상태의 '씨앗'으로 삼습니다.
<ProfileProvider initialName={initialData.name}>
<ProfileForm />
</ProfileProvider>
);
}
마지막으로 클라이언트, 즉 Zustand의 역할은 사용자가 입력칸을 채우고 지우는 모든 과정을 API 통신 없이 철저히 브라우저 메모리 내에서만 가볍고 빠르게 관리하는 것입니다.
사용자가 타이핑을 할 때마다 TanStack Query의 캐시를 억지로 업데이트하는 것은 '서버 상태 동기화'라는 본래 목적과 맞지 않으며, 불필요한 성능 저하를 유발합니다. 따라서 아래 코드처럼 Zustand를 통해 즉각적인 UI 반응성을 확보하고, 모든 수정이 끝난 후 사용자가 '최종 저장' 버튼을 누를 때 TanStack Query의 Mutation을 통해 한 번에 서버로 전송하는 것이 바람직합니다.
'use client';
import { useProfileContext } from '@/components/ProfileProvider';
import { useMutation } from '@tanstack/react-query';
export default function ProfileForm() {
// 서버와의 동기화는 끊어지고, 오직 클라이언트 메모리에서만 상태를 조작합니다.
const name = useProfileContext(state => state.name);
const setName = useProfileContext(state => state.setName);
// 사용자의 상호작용이 모두 끝난 후 최종 결과물만 서버에 동기화하기 위한 Mutation입니다.
const updateProfileMutation = useMutation({
mutationFn: (newName: string) =>
fetch('/api/user/profile', { method: 'POST', body: JSON.stringify({ name: newName }) })
});
return (
<form onSubmit={(e) => {
e.preventDefault();
updateProfileMutation.mutate(name); // 폼이 제출될 때 비로소 서버와 다시 소통합니다.
}}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="이름을 입력하세요"
/>
<button type="submit" disabled={updateProfileMutation.isPending}>
최종 저장
</button>
</form>
);
}
이처럼 상품 필터링의 가격 슬라이더나 이미지 크롭 툴의 캔버스 상태처럼, 초당 수십 번씩 바뀌는 순수 UI 상태는 Zustand가 가볍고 빠르게 처리하도록 맡겨야 합니다. 현업에서 상태 관리를 설계할 때 "이 상태의 주인이 누구인가?"를 스스로에게 질문해 보세요. 내 화면에서 내가 조작하기 위한 임시 상태라면, 초기 데이터만 서버에서 받고 클라이언트로 그 통제권을 완전히 분리하는 것이 맞습니다.
또한, 단일 진실 공급원(Single Source of Truth)을 훼손하지 않기 위해 Zustand에 서버 상태를 계속 복사하여 병렬로 유지하려는 안티 패턴(Anti-pattern)을 피해야 합니다. 초기 렌더링 시점에 딱 한 번만 주입한 후, 해당 데이터의 생사결정권을 완전히 Zustand가 가지도록 독립시키는 것이 핵심입니다.
byeong님, 이 답변에 포함된 아키텍처 패턴이 실무의 그림을 명확히 그리시는 데 도움이 되셨기를 바랍니다. 상태 관리의 경계를 명확히 구분하는 이 감각은 앞으로 복잡한 웹 애플리케이션을 견고하게 설계하실 때 아주 강력한 무기가 될 것입니다!
참고해주세요!




