2026. 6. 29. 01:59ㆍFrontend/React

상태 관리는 라이브러리 선택이 아니라 소유권, 렌더링 위치, 생명주기 설계다
React 프로젝트를 시작하면 상태 관리는 대체로 단순하다.
const [isOpen, setIsOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
하지만 프로젝트가 커지면 질문이 달라진다.
이 상태를 useState에 둘까?
Context에 둘까?
Zustand에 둘까?
TanStack Query에 둘까?
URL에 둘까?
Server Component에서 가져올까?
Server Action으로 바꿀까?
localStorage에 저장할까?
상태 관리가 어려워지는 이유는 상태가 많아져서만은 아니다. 서로 다른 성격의 상태를 같은 방식으로 다루기 때문이다.
React/Next.js 프로젝트에서 상태 관리를 제대로 설계하려면 먼저 라이브러리를 고르는 것이 아니라 다음 질문부터 던져야 한다.
이 상태는 누가 소유하는가?
이 상태는 어디에서 만들어지는가?
이 상태는 어디에서 읽히는가?
이 상태는 언제 낡는가?
이 상태는 언제 사라져야 하는가?
이 상태는 서버 렌더링에 필요한가, 클라이언트 상호작용에 필요한가?
React 공식 문서도 애플리케이션이 커질수록 상태 구조와 데이터 흐름을 더 의도적으로 설계해야 하며, 중복되거나 불필요한 state가 버그의 흔한 원인이 된다고 설명한다. 특히 계산 가능한 값은 state로 만들지 말고, 중복 state와 모순되는 state를 피하는 것이 중요하다.
1. React/Next.js 상태 관리는 두 개의 축으로 봐야 한다
일반 React 프로젝트에서는 주로 이런 기준으로 상태를 나눈다.
local state
shared state
server state
URL state
form state
derived state
Next.js App Router 프로젝트에서는 여기에 하나의 축이 더 추가된다.
이 상태가 Server Component 영역에 있는가?
Client Component 영역에 있는가?
Next.js App Router에서 layouts와 pages는 기본적으로 Server Components이며, 서버에서 데이터를 가져오고 UI 일부를 서버에서 렌더링할 수 있다. 반대로 state, event handler, useEffect, 브라우저 API, custom hook이 필요하면 Client Component를 사용해야 한다.
따라서 Next.js 프로젝트의 상태 관리는 다음 두 축을 함께 고려해야 한다.
1. 상태의 성격
서버 상태인가?
클라이언트 UI 상태인가?
URL 상태인가?
폼 초안 상태인가?
파생 상태인가?
2. 상태의 실행 위치
서버에서 결정되는가?
클라이언트에서만 존재하는가?
서버에서 가져와 클라이언트로 넘기는가?
클라이언트에서 다시 가져오고 갱신해야 하는가?
이 두 축을 구분하지 않으면 Server Component에 있어야 할 데이터를 client store에 넣거나, 반대로 클라이언트 UI 상태를 서버 캐시처럼 다루는 일이 생긴다.
2. 상태의 종류를 먼저 분류한다
React/Next.js 프로젝트에서 자주 만나는 상태는 크게 이렇게 나눌 수 있다.
| Local UI state | 모달 열림, 탭 선택, hover, accordion open | useState |
| Complex local state | wizard 단계, 복잡한 폼 인터랙션 | useReducer |
| Shared client state | 사이드바 접힘, command palette, 선택된 조직 | Zustand, Context |
| Server state | 사용자 정보, 주문 목록, 권한, 알림 | Server Component, TanStack Query |
| URL state | 검색어, 필터, 페이지, 정렬 | searchParams, router |
| Form draft state | 저장 전 입력값, 수정 중인 임시 데이터 | form library, useState |
| Derived state | 필터링된 목록, 총합, 표시 여부 | 렌더링 중 계산, useMemo |
| Browser state | viewport, online 여부, scroll, localStorage | custom hook, client effect |
| Mutation state | 제출 중, 저장 실패, optimistic update | Server Action, mutation hook |
이 표에서 중요한 것은 “어떤 라이브러리를 쓰느냐”가 아니다.
핵심은 각 상태가 다른 생명주기를 가진다는 점이다.
서버 상태는 stale해질 수 있다.
URL 상태는 공유와 탐색의 일부다.
폼 상태는 저장 전까지 사용자의 임시 작업 공간이다.
UI 상태는 현재 화면의 상호작용을 표현한다.
파생 상태는 저장하지 않고 계산해야 한다.
3. 상태 배치의 기본 원칙
상태를 어디에 둘지 고민될 때는 아래 순서로 판단하는 것이 좋다.
1. 다른 값으로 계산할 수 있는가?
→ state로 만들지 않는다.
2. 한 컴포넌트 안에서만 쓰는가?
→ useState 또는 useReducer.
3. 부모와 자식이 함께 써야 하는가?
→ 가장 가까운 공통 부모로 끌어올린다.
4. URL에 남아야 하는가?
→ searchParams 또는 route segment.
5. 서버가 원본인가?
→ Server Component fetch 또는 TanStack Query.
6. 여러 Client Component가 공유하는 순수 클라이언트 상태인가?
→ Context 또는 Zustand.
7. 새로고침 후에도 유지되어야 하는 개인 설정인가?
→ localStorage 연동 store.
8. 서버 변경 이후 화면과 캐시를 갱신해야 하는가?
→ Server Action + revalidation 또는 mutation + invalidation.
전역 상태 관리 라이브러리는 이 목록의 앞부분에 오지 않는다.
전역 store는 편리하지만, 너무 빨리 도입하면 상태의 소유권이 흐려진다.
4. Local state: 가장 가까운 곳에 둔다
모달 열림 여부, 탭 선택, dropdown open 같은 상태는 대부분 local state면 충분하다.
function PostCard() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<article>
<button onClick={() => setIsMenuOpen((prev) => !prev)}>
메뉴
</button>
{isMenuOpen && (
<PostMenu />
)}
</article>
);
}
이 상태를 전역 store에 넣으면 오히려 코드가 복잡해진다.
const useUiStore = create((set) => ({
postMenuOpenId: null,
openPostMenu: (id: string) => set({ postMenuOpenId: id }),
closePostMenu: () => set({ postMenuOpenId: null }),
}));
정말 여러 컴포넌트가 같은 상태를 공유해야 하는 상황이 아니라면 local state가 가장 단순하다.
상태는 필요한 곳보다 더 위로 올리지 않는다.
상태는 필요한 범위보다 더 오래 살게 만들지 않는다.
React 공식 문서는 여러 컴포넌트가 같은 state를 함께 바꿔야 할 때 가장 가까운 공통 부모로 state를 끌어올리는 방식을 설명한다. 즉, 공유가 필요하다고 곧바로 전역 store를 선택할 필요는 없다.
5. useReducer: 상태 전이가 복잡할 때 사용한다
useState가 많아지면 상태 전이가 흩어진다.
const [step, setStep] = useState(1);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [categoryId, setCategoryId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
여러 state가 하나의 흐름을 구성한다면 reducer가 더 낫다.
type PostWizardState = {
step: 1 | 2 | 3;
title: string;
content: string;
categoryId: string | null;
error: string | null;
};
type PostWizardAction =
| { type: "CHANGE_TITLE"; title: string }
| { type: "CHANGE_CONTENT"; content: string }
| { type: "SELECT_CATEGORY"; categoryId: string }
| { type: "NEXT" }
| { type: "PREV" }
| { type: "SET_ERROR"; error: string | null }
| { type: "RESET" };
const initialState: PostWizardState = {
step: 1,
title: "",
content: "",
categoryId: null,
error: null,
};
function reducer(
state: PostWizardState,
action: PostWizardAction,
): PostWizardState {
switch (action.type) {
case "CHANGE_TITLE":
return {
...state,
title: action.title,
};
case "CHANGE_CONTENT":
return {
...state,
content: action.content,
};
case "SELECT_CATEGORY":
return {
...state,
categoryId: action.categoryId,
};
case "NEXT":
return {
...state,
step: Math.min(state.step + 1, 3) as 1 | 2 | 3,
};
case "PREV":
return {
...state,
step: Math.max(state.step - 1, 1) as 1 | 2 | 3,
};
case "SET_ERROR":
return {
...state,
error: action.error,
};
case "RESET":
return initialState;
default:
return state;
}
}
사용 예시는 다음과 같다.
function PostWizard() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<form>
<input
value={state.title}
onChange={(event) => {
dispatch({
type: "CHANGE_TITLE",
title: event.target.value,
});
}}
/>
<button
type="button"
onClick={() => dispatch({ type: "NEXT" })}
>
다음
</button>
</form>
);
}
useReducer는 전역 상태 도구가 아니다.
하지만 한 컴포넌트 또는 한 기능 단위 안에서 상태 전이 규칙을 명확하게 모으는 데 유용하다.
6. Derived state: 저장하지 말고 계산한다
다음 코드는 흔한 안티패턴이다.
function ProductList({
products,
keyword,
}: {
products: Product[];
keyword: string;
}) {
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
useEffect(() => {
setFilteredProducts(
products.filter((product) => {
return product.name.includes(keyword);
}),
);
}, [products, keyword]);
return <Table rows={filteredProducts} />;
}
filteredProducts는 products와 keyword에서 계산할 수 있다.
따라서 state가 아니다.
function ProductList({
products,
keyword,
}: {
products: Product[];
keyword: string;
}) {
const filteredProducts = products.filter((product) => {
return product.name.includes(keyword);
});
return <Table rows={filteredProducts} />;
}
계산 비용이 큰 경우에만 useMemo를 고려한다.
const filteredProducts = useMemo(() => {
return products.filter((product) => {
return product.name.includes(keyword);
});
}, [products, keyword]);
React 공식 문서는 props나 기존 state로 계산 가능한 정보는 state로 넣지 않는 것을 권장한다. 중복 state는 서로 동기화해야 할 대상이 늘어나기 때문에 버그 가능성을 높인다.
7. URL state: 필터, 검색, 페이지는 URL에 둘 수 있다
검색어, 페이지 번호, 정렬 조건은 단순 UI 상태처럼 보이지만 실제로는 화면의 주소를 결정한다.
/orders?status=paid&page=2&sort=createdAt.desc
이 값들은 URL에 두는 것이 자연스러운 경우가 많다.
새로고침해도 유지되어야 한다.
링크로 공유할 수 있어야 한다.
뒤로가기/앞으로가기에 반응해야 한다.
서버 요청 조건이 된다.
Next.js App Router에서 Client Component 안에서 현재 query string을 읽을 때는 useSearchParams를 사용할 수 있다. useSearchParams는 Client Component hook이며, read-only URLSearchParams 인터페이스를 반환한다.
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function OrderFilters() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const status = searchParams.get("status") ?? "all";
function updateStatus(nextStatus: string) {
const params = new URLSearchParams(searchParams);
params.set("status", nextStatus);
params.set("page", "1");
router.replace(`${pathname}?${params.toString()}`);
}
return (
<select
value={status}
onChange={(event) => updateStatus(event.target.value)}
>
<option value="all">전체</option>
<option value="paid">결제 완료</option>
<option value="pending">대기</option>
</select>
);
}
주의할 점도 있다. 정적으로 prerender되는 route에서 Client Component가 useSearchParams를 사용하면 해당 Client Component tree가 가장 가까운 <Suspense> boundary까지 client-side rendering될 수 있으며, production build에서는 Suspense boundary가 필요할 수 있다. 서버에서 search params 기반으로 데이터를 가져와야 한다면 Page의 searchParams prop을 읽고 필요한 컴포넌트로 넘기는 방식이 더 적절할 때가 있다.
8. Server Component state: 서버에서 읽고 서버에서 렌더링한다
Next.js App Router에서는 데이터가 서버 렌더링에 필요하고 상호작용이 많지 않다면 Server Component에서 가져오는 것이 자연스럽다.
// app/orders/page.tsx
import { fetchOrders } from "@/features/orders/api";
import { OrderTable } from "@/features/orders/ui/order-table";
type Props = {
searchParams: Promise<{
status?: string;
page?: string;
}>;
};
export default async function OrdersPage({ searchParams }: Props) {
const params = await searchParams;
const status = params.status ?? "all";
const page = Number(params.page ?? "1");
const orders = await fetchOrders({
status,
page,
});
return (
<main>
<h1>주문 목록</h1>
<OrderTable orders={orders.items} />
</main>
);
}
Server Component에서 데이터를 가져오면 API key나 token 같은 민감한 값이 클라이언트 번들에 포함되지 않도록 할 수 있고, 데이터 소스 가까이에서 fetch할 수 있다. Next.js 문서도 Server Components를 데이터베이스나 API 가까이에서 데이터를 가져오거나, secrets를 클라이언트에 노출하지 않거나, 브라우저로 보내는 JavaScript 양을 줄이는 데 사용할 수 있다고 설명한다.
Next.js는 Server Components 안에서 fetch를 직접 await할 수 있으며, 서버 측 fetch에 대해 캐싱과 revalidation semantics를 확장한다. 다만 현재 문서 기준으로 Server Component의 fetch 요청은 기본적으로 캐시되지 않으며, 캐시를 원하면 별도의 cache 지시나 옵션을 설계해야 한다.
9. Client Component server state: 브라우저에서 계속 갱신해야 할 때
모든 서버 상태를 Server Component에서만 다룰 수는 없다.
다음 요구사항이 있으면 클라이언트 측 server-state cache가 필요할 수 있다.
무한 스크롤
실시간에 가까운 refetch
검색어 입력 중 즉시 요청
optimistic update
브라우저 focus 시 refetch
클라이언트 이벤트 이후 부분 갱신
pagination 상태와 캐시 유지
동일 데이터를 여러 Client Component가 공유
이때 TanStack Query 같은 server-state library가 적합하다.
TanStack Query 공식 문서는 TanStack Query를 서버와 클라이언트 사이의 비동기 작업을 관리하는 server-state library라고 설명하며, Redux, MobX, Zustand 같은 도구는 client-state library라고 구분한다. 또한 async 코드를 TanStack Query로 옮기면 전역 client state에 남는 값은 대체로 매우 작아진다고 설명한다.
"use client";
import { useQuery } from "@tanstack/react-query";
function useOrders(filters: {
status: string;
page: number;
}) {
return useQuery({
queryKey: ["orders", filters],
queryFn: () => fetchOrders(filters),
});
}
export function OrdersClientTable({
filters,
}: {
filters: {
status: string;
page: number;
};
}) {
const ordersQuery = useOrders(filters);
if (ordersQuery.isPending) {
return <p>주문을 불러오는 중입니다.</p>;
}
if (ordersQuery.isError) {
return <p>주문을 불러오지 못했습니다.</p>;
}
return <OrderTable orders={ordersQuery.data.items} />;
}
이 구조에서 중요한 것은 queryKey다.
queryKey: ["orders", filters]
query key는 서버 상태 캐시의 주소다.
필터와 페이지가 다르면 다른 서버 상태로 봐야 한다.
10. Server Component fetch와 TanStack Query 중 무엇을 선택할까
둘 중 하나만 선택해야 하는 것은 아니다.
상황에 따라 역할을 나누면 된다.
| 초기 페이지 렌더링에 반드시 필요한 데이터 | Server Component |
| SEO나 초기 HTML에 중요한 데이터 | Server Component |
| secrets/API key가 필요한 데이터 접근 | Server Component |
| 사용자 입력에 따라 자주 바뀌는 데이터 | TanStack Query |
| optimistic update가 필요한 데이터 | TanStack Query |
| focus refetch, polling, stale cache 관리 | TanStack Query |
| Server Action 이후 현재 route 재렌더링 | Server Action + revalidate |
| Client Component 내부에서 독립적으로 fetch | TanStack Query 또는 SWR |
TanStack Query를 Next.js App Router와 함께 사용할 때는 Server Components와 hydration을 함께 고려해야 한다. TanStack Query의 Advanced Server Rendering 문서는 Next.js App Router와 Server Components 환경에서 React Query를 streaming, prefetch, hydration API와 함께 사용하는 방식을 설명한다.
간단한 기준은 이렇다.
처음 보여줄 페이지의 데이터인가?
→ Server Component 우선
브라우저에서 계속 상호작용하며 갱신되는 데이터인가?
→ TanStack Query 우선
서버에서 mutation하고 route cache를 갱신할 것인가?
→ Server Action + revalidatePath/revalidateTag
클라이언트에서 mutation 상태와 optimistic update가 중요한가?
→ useMutation + invalidateQueries
11. Mutation: 서버 상태 변경은 캐시 갱신까지 포함한다
서버 데이터를 바꾸는 일은 단순히 API를 호출하는 것이 아니다.
서버 원본 변경
→ 기존 캐시가 stale해짐
→ 관련 UI 갱신 필요
Next.js에서는 Server Functions를 사용해 서버에서 mutation을 처리할 수 있다. Next.js 문서는 Server Function을 서버에서 실행되는 async function으로 설명하며, Server Action은 form submission과 mutation 처리에 사용되는 Server Function의 특정 사용 방식이라고 설명한다. Server Functions는 클라이언트에서 네트워크 요청을 통해 호출될 수 있으므로 인증과 권한 검증을 함수 안에서 반드시 수행해야 한다.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { updateOrderStatus } from "@/features/orders/server";
export async function updateOrderStatusAction(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
const orderId = String(formData.get("orderId"));
const status = String(formData.get("status"));
await updateOrderStatus({
orderId,
status,
userId: session.user.id,
});
revalidatePath("/orders");
}
revalidatePath는 특정 path의 cached data를 on-demand로 invalidate할 수 있으며, Server Functions와 Route Handlers에서 호출할 수 있고 Client Components에서는 호출할 수 없다.
Client Component에서는 action을 form에 연결할 수 있다.
"use client";
import { updateOrderStatusAction } from "@/app/actions";
export function OrderStatusForm({
orderId,
}: {
orderId: string;
}) {
return (
<form action={updateOrderStatusAction}>
<input type="hidden" name="orderId" value={orderId} />
<select name="status">
<option value="paid">결제 완료</option>
<option value="cancelled">취소</option>
</select>
<button type="submit">변경</button>
</form>
);
}
클라이언트에서 mutation 후 현재 route의 Server Components를 다시 가져오고 싶다면 router.refresh()도 사용할 수 있다. router.refresh()는 서버에 새 요청을 보내고 data request를 다시 가져와 Server Components를 재렌더링하며, 영향을 받지 않는 client-side React state나 scroll 같은 브라우저 상태는 잃지 않고 병합한다. 다만 server-side cache 자체를 invalidate하는 것은 아니므로 서버 캐시 무효화에는 revalidatePath나 revalidateTag가 필요하다.
12. Client mutation: TanStack Query로 invalidate하기
클라이언트에서 mutation 상태, optimistic update, rollback이 중요하다면 TanStack Query의 useMutation이 자연스럽다.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useUpdateOrderStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateOrderStatus,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["order", variables.orderId],
});
queryClient.invalidateQueries({
queryKey: ["orders"],
});
},
});
}
mutation 이후에는 “현재 컴포넌트의 state를 어떻게 바꿀까?”보다 “어떤 서버 상태 캐시가 stale해졌는가?”를 먼저 생각해야 한다.
주문 상세 변경
→ ["order", orderId] stale
주문 목록에도 상태가 표시됨
→ ["orders", filters] 계열 stale
통계 카드에도 반영됨
→ ["order-stats"] stale
서버 상태 변경은 화면 하나의 문제가 아니라 캐시 일관성 문제다.
13. Zustand: 순수 클라이언트 공유 상태에 사용한다
Zustand는 가볍고 hook 기반 API를 가진 상태 관리 도구다. 공식 문서는 Zustand를 작고 빠르며 확장 가능한 상태 관리 솔루션으로 소개한다.
하지만 Zustand에 서버 데이터를 전부 넣는 것은 권장하기 어렵다.
const useAppStore = create((set) => ({
users: [],
orders: [],
currentUser: null,
isSidebarOpen: true,
selectedOrderIds: [],
}));
이 store에는 서버 상태와 클라이언트 상태가 섞여 있다.
더 나은 구분은 다음과 같다.
type UiState = {
isSidebarCollapsed: boolean;
isCommandPaletteOpen: boolean;
selectedOrderIds: string[];
activeWorkspaceId: string | null;
toggleSidebar: () => void;
openCommandPalette: () => void;
closeCommandPalette: () => void;
selectOrder: (orderId: string) => void;
clearSelectedOrders: () => void;
};
export const useUiStore = create<UiState>((set) => ({
isSidebarCollapsed: false,
isCommandPaletteOpen: false,
selectedOrderIds: [],
activeWorkspaceId: null,
toggleSidebar: () => {
set((state) => ({
isSidebarCollapsed: !state.isSidebarCollapsed,
}));
},
openCommandPalette: () => {
set({
isCommandPaletteOpen: true,
});
},
closeCommandPalette: () => {
set({
isCommandPaletteOpen: false,
});
},
selectOrder: (orderId) => {
set((state) => ({
selectedOrderIds: state.selectedOrderIds.includes(orderId)
? state.selectedOrderIds.filter((id) => id !== orderId)
: [...state.selectedOrderIds, orderId],
}));
},
clearSelectedOrders: () => {
set({
selectedOrderIds: [],
});
},
}));
Zustand는 다음 상태에 잘 맞는다.
전역 command palette 열림 여부
사이드바 접힘 여부
선택된 행 ID 목록
현재 workspace 선택
toast queue
client-only wizard 상태
사용자 UI 설정
Next.js에서 Zustand를 사용할 때는 server/client 경계를 조심해야 한다. Zustand의 Next.js guide는 store가 요청 간 공유되지 않도록 per request로 생성되어야 하며, React Server Components가 store를 읽거나 쓰면 안 된다고 설명한다.
14. Context: 값 전달 도구이지 만능 store는 아니다
Context는 자주 바뀌지 않는 값을 하위 트리에 전달할 때 적합하다.
const ThemeContext = createContext<Theme>("light");
function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
하지만 빈번하게 바뀌는 큰 상태를 하나의 Context에 넣으면 하위 소비 컴포넌트의 렌더링 범위가 커질 수 있다.
const AppContext = createContext({
user: null,
orders: [],
filters: {},
selectedIds: [],
modal: {},
});
Context는 전역 상태 관리 라이브러리의 대체재라기보다 “트리 아래로 값을 전달하는 React의 기본 도구”에 가깝다.
사용 기준은 다음과 같다.
theme, locale, auth boundary, feature flag처럼 넓게 필요한 설정값
→ Context 적합
자주 바뀌고 선택적 구독이 필요한 클라이언트 상태
→ Zustand 같은 store 고려
서버 데이터 캐시
→ TanStack Query 또는 Server Component
15. Provider 배치: Client boundary는 최대한 좁게
Next.js App Router에서 provider를 만들면 보통 Client Component가 된다.
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
TanStack Query의 Advanced Server Rendering 문서도 QueryClientProvider가 내부적으로 useContext를 사용하므로 Next.js에서는 provider 파일에 'use client'가 필요하다고 설명한다.
루트 layout에 provider를 둘 수 있다.
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
하지만 모든 provider를 무조건 루트에 넣는 것은 좋지 않다.
전체 앱에서 필요한가?
특정 route group에서만 필요한가?
특정 기능 영역에서만 필요한가?
를 구분해야 한다.
Client boundary가 넓어질수록 클라이언트 번들에 포함되는 영역도 커질 수 있다. Next.js 문서는 Client Components를 interactivity, browser APIs, state/event handlers가 필요한 곳에 사용하고, Server Components를 통해 클라이언트로 보내는 JavaScript 양을 줄일 수 있다고 설명한다.
16. Form state: 서버 상태에서 시작하지만 서버 상태는 아니다
폼은 상태 관리에서 가장 자주 헷갈리는 영역이다.
사용자 수정 페이지를 생각해보자.
export default async function UserEditPage({
params,
}: {
params: Promise<{
userId: string;
}>;
}) {
const { userId } = await params;
const user = await fetchUser(userId);
return (
<UserEditForm
key={user.id}
initialUser={user}
/>
);
}
폼 컴포넌트는 Client Component다.
"use client";
function UserEditForm({
initialUser,
}: {
initialUser: User;
}) {
const [draft, setDraft] = useState(() => ({
name: initialUser.name,
email: initialUser.email,
}));
return (
<form>
<input
value={draft.name}
onChange={(event) => {
setDraft((prev) => ({
...prev,
name: event.target.value,
}));
}}
/>
<input
value={draft.email}
onChange={(event) => {
setDraft((prev) => ({
...prev,
email: event.target.value,
}));
}}
/>
</form>
);
}
여기서 initialUser는 서버 상태의 스냅샷이다.
하지만 draft는 서버 상태가 아니다.
initialUser
= 서버가 소유한 데이터의 스냅샷
draft
= 사용자가 저장 전까지 편집 중인 클라이언트 임시 상태
따라서 refetch가 발생했다고 무조건 draft를 덮어쓰면 안 된다.
useEffect(() => {
setDraft({
name: user.name,
email: user.email,
});
}, [user]);
이런 코드는 사용자가 입력 중인 값을 갑자기 날릴 수 있다.
폼 상태의 질문은 따로 있다.
서버 데이터가 바뀌면 입력 중인 값을 덮어쓸 것인가?
다른 사용자를 편집하면 form을 초기화할 것인가?
저장 성공 후 draft를 유지할 것인가, 초기화할 것인가?
취소 버튼은 서버 원본으로 되돌릴 것인가?
17. 실무 구조 예시: Next.js 주문 관리 대시보드
주문 관리 대시보드를 설계한다고 가정해보자.
요구사항은 다음과 같다.
주문 목록 조회
상태 필터
검색
페이지네이션
주문 상세 패널
주문 선택
주문 상태 변경
컬럼 표시 설정
사이드바 접힘
상태를 분류하면 다음과 같다.
| 주문 목록 | 서버 상태 | Server Component 또는 TanStack Query |
| 주문 상세 | 서버 상태 | Server Component 또는 TanStack Query |
| 상태 필터 | URL 상태 | searchParams |
| 검색어 | URL 상태 또는 local input 후 debounce | |
| 페이지 번호 | URL 상태 | |
| 선택된 주문 ID | 클라이언트 UI 상태 | |
| 상세 패널 열림 | 클라이언트 UI 상태 | |
| 주문 상태 변경 | mutation | |
| 사이드바 접힘 | shared client state | |
| 컬럼 표시 설정 | client preference, localStorage 가능 |
구조는 다음처럼 나눌 수 있다.
app/orders/page.tsx
→ Server Component
→ searchParams 읽기
→ 초기 주문 목록 fetch
→ Client Shell에 전달
features/orders/ui/orders-client-shell.tsx
→ Client Component
→ 선택 상태, 패널 열림 관리
→ 필터 UI, 테이블 상호작용
features/orders/model/use-orders-query.ts
→ TanStack Query를 쓰는 경우 query hook
features/orders/actions.ts
→ Server Action을 쓰는 경우 mutation 처리
stores/ui-store.ts
→ 사이드바, command palette, 전역 UI 상태
예시 코드는 다음과 같다.
// app/orders/page.tsx
import { fetchOrders } from "@/features/orders/server/fetch-orders";
import { OrdersClientShell } from "@/features/orders/ui/orders-client-shell";
type Props = {
searchParams: Promise<{
status?: string;
page?: string;
}>;
};
export default async function OrdersPage({
searchParams,
}: Props) {
const params = await searchParams;
const filters = {
status: params.status ?? "all",
page: Number(params.page ?? "1"),
};
const initialOrders = await fetchOrders(filters);
return (
<OrdersClientShell
filters={filters}
initialOrders={initialOrders}
/>
);
}
// features/orders/ui/orders-client-shell.tsx
"use client";
import { useState } from "react";
export function OrdersClientShell({
filters,
initialOrders,
}: {
filters: {
status: string;
page: number;
};
initialOrders: OrdersResponse;
}) {
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
return (
<section>
<OrderFilterBar filters={filters} />
<OrderTable
orders={initialOrders.items}
selectedOrderId={selectedOrderId}
onSelect={(orderId) => {
setSelectedOrderId(orderId);
setIsDetailOpen(true);
}}
/>
{isDetailOpen && selectedOrderId && (
<OrderDetailPanel
orderId={selectedOrderId}
onClose={() => setIsDetailOpen(false)}
/>
)}
</section>
);
}
이 구조에서는 역할이 분명하다.
Server Component
= URL 조건에 맞는 초기 서버 데이터 확보
Client Shell
= 선택, 패널, 사용자 상호작용 관리
URL
= 필터와 페이지의 출처
Mutation
= 서버 데이터 변경과 캐시 갱신
Store
= 여러 화면이 공유하는 순수 클라이언트 UI 상태
18. 상태 관리 안티패턴
안티패턴 1. 모든 것을 하나의 전역 store에 넣는다
const useAppStore = create((set) => ({
user: null,
orders: [],
filters: {},
selectedOrderId: null,
isModalOpen: false,
formDraft: {},
isLoading: false,
error: null,
}));
서버 상태, URL 상태, UI 상태, 폼 상태가 섞인다.
이런 store는 시간이 갈수록 원본이 무엇인지 알기 어려워진다.
안티패턴 2. Server Component 데이터를 다시 client store에 복사한다
"use client";
function AppInitializer({
user,
}: {
user: User;
}) {
useEffect(() => {
useUserStore.setState({
user,
});
}, [user]);
return null;
}
이 패턴이 필요한 경우도 있지만, 기본값이 되어서는 안 된다.
서버에서 가져온 데이터가 단순히 화면 렌더링에 필요하다면 props로 전달하면 된다.
안티패턴 3. query data를 useState로 다시 복사한다
const { data } = useUser(userId);
const [user, setUser] = useState(data);
이 코드는 query data가 늦게 도착하거나 refetch될 때 쉽게 어긋난다.
폼 draft처럼 의도적으로 분리하는 경우가 아니라면 query data를 그대로 읽는 편이 낫다.
안티패턴 4. URL 상태를 store에만 저장한다
const useOrderStore = create((set) => ({
page: 1,
status: "all",
}));
검색 결과, 페이지 번호, 필터 조건이 URL에 없으면 새로고침과 공유 링크, 브라우저 history가 불편해진다.
안티패턴 5. mutation 후 관련 캐시를 갱신하지 않는다
await updateOrderStatus(orderId, status);
setIsModalOpen(false);
서버 데이터가 바뀌었는데 목록, 상세, 통계 캐시는 그대로일 수 있다.
queryClient.invalidateQueries({
queryKey: ["orders"],
});
queryClient.invalidateQueries({
queryKey: ["order", orderId],
});
또는 Server Action 안에서 revalidatePath나 revalidateTag를 사용한다.
안티패턴 6. Client Component를 너무 크게 만든다
"use client";
export default function EntirePage() {
// 모든 페이지 로직
}
페이지 전체를 Client Component로 만들면 서버에서 처리할 수 있는 데이터 fetching과 렌더링도 클라이언트 쪽으로 밀린다.
상호작용이 필요한 작은 부분만 Client Component로 분리하는 편이 좋다.
19. 추천 디렉터리 구조
정답은 아니지만, 기능 단위로 상태 위치를 나누면 관리가 쉬워진다.
src/
app/
orders/
page.tsx
loading.tsx
actions.ts
features/
orders/
api/
fetch-orders.ts
update-order.ts
model/
use-orders-query.ts
use-update-order-status.ts
order.types.ts
ui/
order-table.tsx
order-filter-bar.tsx
order-detail-panel.tsx
orders-client-shell.tsx
shared/
stores/
ui-store.ts
hooks/
use-media-query.ts
lib/
query-client.ts
대략적인 기준은 다음과 같다.
app/
= routing, Server Components, Server Actions, route-level data
features/*/api
= 서버 요청 함수
features/*/model
= query hook, mutation hook, reducer, feature state
features/*/ui
= 화면 컴포넌트
shared/stores
= 여러 기능이 공유하는 순수 클라이언트 상태
shared/hooks
= 브라우저 상태, 재사용 가능한 client hook
서버 상태와 클라이언트 상태를 디렉터리 차원에서도 분리해두면 “이 상태가 어디서 왔는가”를 추적하기 쉬워진다.
20. 실무 판단 기준
새 상태가 생길 때 아래 질문을 순서대로 던져보자.
1. 이 값은 서버가 원본인가?
→ Server Component 또는 TanStack Query.
2. 이 값은 URL에 남아야 하는가?
→ searchParams.
3. 이 값은 저장 전 사용자의 임시 입력인가?
→ form state.
4. 이 값은 다른 값으로 계산 가능한가?
→ derived value.
5. 이 값은 한 컴포넌트 안에서만 쓰이는가?
→ useState.
6. 상태 전이가 복잡한가?
→ useReducer.
7. 멀리 떨어진 Client Component들이 공유해야 하는가?
→ Context 또는 Zustand.
8. 새로고침 후에도 유지되어야 하는 개인 설정인가?
→ persisted client store.
9. mutation 이후 서버 캐시를 갱신해야 하는가?
→ invalidateQueries, revalidatePath, revalidateTag.
10. Server Component에서 충분한가?
→ Client Component로 옮기지 않는다.
마무리
React/Next.js 프로젝트의 상태 관리는 더 이상 “Redux냐 Zustand냐”의 문제가 아니다.
App Router 이후에는 서버와 클라이언트의 경계까지 함께 설계해야 한다.
Server Component
= 서버에서 읽고 렌더링할 데이터
Client Component
= 상호작용, 이벤트, local state, browser API
TanStack Query
= 브라우저에서 관리해야 하는 서버 상태 캐시
Zustand
= 여러 Client Component가 공유하는 순수 클라이언트 상태
URL
= 탐색과 공유가 필요한 상태
Form state
= 저장 전 사용자 입력 초안
Derived state
= 저장하지 않고 계산할 값
좋은 상태 관리는 강력한 store 하나로 모든 문제를 해결하는 것이 아니다.
상태의 소유권을 분리하고, 각 상태가 살아야 할 위치와 사라져야 할 시점을 명확히 하는 것이다.
React/Next.js 프로젝트에서 상태 관리를 잘한다는 것은 결국 다음을 잘 구분한다는 뜻이다.
서버가 알아야 하는 것
브라우저만 알면 되는 것
URL에 남아야 하는 것
사용자가 임시로 수정 중인 것
계산하면 되는 것
캐시를 갱신해야 하는 것
이 구분이 명확해질수록 상태 관리 코드는 작아지고, 데이터 흐름은 단순해지고, 버그의 원인은 추적하기 쉬워진다.
참고 자료
- React 공식 문서, Managing State / Choosing the State Structure.
- Next.js 공식 문서, Server and Client Components.
- Next.js 공식 문서, Fetching Data / fetch API reference.
- Next.js 공식 문서, Mutating Data / revalidatePath / useSearchParams / useRouter.
- TanStack Query 공식 문서, Server State와 Client State 구분 / Advanced Server Rendering.
- Zustand 공식 문서, Introduction / Next.js guide.
'Frontend > React' 카테고리의 다른 글
| React 생명주기 깊이 이해하기: Mount, Update, Unmount를 넘어 Effect의 생명주기까지 (0) | 2022.12.24 |
|---|---|
| Next.js가 서버 사이드 렌더링 React 앱의 미래인 5가지 이유 (0) | 2022.11.11 |
| [React] Custom Hook - 리액트의 관심사 분리 (0) | 2022.11.02 |
| [React] Context API (0) | 2022.10.31 |
| [React] 리액트 앱에서 렌더링 최적화하기 (0) | 2022.10.30 |