FSD `entities` 레이어에서 서버 상태(TanStack Query)와 클라이언트 상태(Zustand)를 분리하는 법
프론트엔드를 하다 보면 어느 순간 이런 질문에 막히게 됩니다. "이 데이터는 Zustand에 넣어야 할까, TanStack Query에 맡겨야 할까?" 저도 처음엔 명확한 기준 없이 "전역으로 쓸 것 같으면 Zustand"라는 막연한 기준으로 결정했는데, 그러다 보면 어느 순간 Zustand 스토어가 서버 데이터로 가득 차고, 캐시가 두 곳에서 따로 관리되면서 동기화 버그가 터지기 시작합니다.
그 답을 찾으면서 Feature-Sliced Design(FSD)의 entities 레이어가 좋은 출발점이 된다는 걸 알게 됐습니다. FSD를 처음 접한다면 공식 문서를 먼저 살짝 훑어보는 것도 좋은데, 간단히 말하면 "User, Product, Order 같은 도메인 개념별로 코드를 슬라이스 단위로 분리해 레이어 규칙에 따라 배치하는 아키텍처 방법론"입니다. 이 구조 안에서 api 세그먼트와 model 세그먼트의 역할을 명확히 나누면, 서버 상태와 클라이언트 상태의 경계가 자연스럽게 그어집니다. 이 글에서는 entities 레이어의 api/model 세그먼트를 기준으로 TanStack Query와 Zustand의 경계를 어떻게 나누는지, 그리고 그 경계가 왜 그 위치여야 하는지를 코드와 함께 살펴봅니다.
핵심 개념
서버 상태와 클라이언트 상태는 근본적으로 다른 동물입니다
솔직히 말하면, 상태를 한 종류로 묶어서 생각하는 것 자체가 많은 혼란의 원인입니다. 서버에서 가져온 데이터와 UI에서 만들어진 데이터는 생명주기도, 관리 방식도 전혀 다릅니다.
| 구분 | 정의 | 핵심 관심사 | 도구 |
|---|---|---|---|
| 서버 상태 | API에서 가져온 원격 데이터 | 캐싱, 무효화, 백그라운드 재요청 | TanStack Query |
| 클라이언트 상태 | 앱 내에서만 존재하는 상태 | UI 결정, 필터, 선택값 | Zustand |
서버 상태는 "내가 소유하지 않은" 데이터입니다. 서버가 바뀌면 내 캐시는 구식이 되고, 다른 탭이나 사용자가 같은 데이터를 수정할 수 있습니다. TanStack Query는 이 복잡성을 staleTime, invalidateQueries, 백그라운드 리패칭으로 처리해줍니다.
반면 클라이언트 상태는 완전히 내가 소유합니다. "유저가 어떤 항목을 선택했는가", "검색 필터에 무엇을 입력했는가" — 이건 서버와 동기화할 필요가 없습니다.
핵심 원칙: 서버 데이터를 Zustand에 복제하지 않는 것이 이 아키텍처의 절대 규칙입니다. TanStack Query가 이미 캐시를 관리하고 있는데 Zustand에 같은 데이터를 넣으면, 두 캐시를 동기화하는 책임이 고스란히 여러분에게 넘어옵니다.
FSD entities 레이어의 세그먼트 역할 분담
entities 레이어는 실세계 도메인 개념을 표현하는 레이어입니다. 각 슬라이스 안에는 세 가지 세그먼트가 있고, 상태 관리 도구는 이 구조와 자연스럽게 맞아떨어집니다.
entities/
user/
api/
userQueries.ts ← TanStack Query (queryOptions, queryFn)
userApi.ts ← fetch 함수
model/
userStore.ts ← Zustand (선택값, 필터)
types.ts
ui/
UserCard.tsx
index.ts ← public API (외부 공개 인터페이스)api세그먼트: 서버와 통신하는 모든 것.queryOptions와 실제 fetch 함수가 여기에 위치합니다.model세그먼트: 클라이언트 상태. Zustand 스토어와 타입 정의가 들어갑니다.ui세그먼트: 해당 도메인의 시각적 표현. 위 두 세그먼트를 조합해서 씁니다.
이 구분이 명확하면, 팀원 누구라도 "이 버그가 서버 데이터 문제인가, UI 상태 문제인가"를 파일 구조만 보고 빠르게 판단할 수 있습니다.
queryOptions 헬퍼 — FSD와 찰떡인 이유
TanStack Query v5에서 도입된 queryOptions() 헬퍼는 queryKey와 queryFn을 하나의 객체로 묶어줍니다. React 없이도 정의할 수 있어서 entities/*/api에 배치하기에 딱 좋습니다.
queryKey는 TanStack Query가 캐시를 식별하는 고유 키입니다. ['users', id] 같은 배열 형태로 쓰는데, 이 키가 같으면 같은 캐시를 공유하고 다르면 별도 캐시가 생깁니다. 키를 여기저기 복사해서 쓰다가 오타 하나로 캐시가 따로 놀던 경험이 있다면, 아래처럼 factory 패턴으로 한 곳에서 관리하는 게 얼마나 편한지 금방 체감할 수 있습니다.
// entities/user/api/userQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser, fetchUserList } from './userApi';
export const userQueries = {
list: () =>
queryOptions({
queryKey: ['users'],
queryFn: fetchUserList,
staleTime: 1000 * 60 * 5, // list는 자주 바뀌지 않아 5분으로 설정
}),
detail: (id: string) =>
queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
// detail은 낙관적 업데이트로 직접 조작하므로 기본 staleTime(0) 유지
}),
};list에는 staleTime을 5분으로 설정했고 detail에는 따로 지정하지 않았는데, 의도적인 차이입니다. detail 쿼리는 뒤에서 다룰 낙관적 업데이트 시 캐시를 직접 조작하기 때문에, 기본값을 유지해 서버 응답 후 즉시 재요청이 가능하도록 남겨뒀습니다.
TkDodo 패턴: TanStack Query 공식 문서 기여자인 TkDodo가 제안한 패턴으로,
queryOptions와useQuery호출을 분리해 레이어 경계를 명확히 합니다.api세그먼트에서 옵션을 정의하고,ui세그먼트나 상위 레이어에서useQuery를 호출하는 방식입니다.
실전 적용
예시 1: entities/user 슬라이스 전체 구성
실무에서 가장 자주 만나는 시나리오입니다. 유저 목록을 서버에서 가져오고, 선택된 유저와 검색 필터는 클라이언트에서 관리하는 경우입니다.
서버 상태 — api 세그먼트
// entities/user/api/userApi.ts
export async function fetchUserList(): Promise<User[]> {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
}
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}클라이언트 상태 — model 세그먼트
// entities/user/model/userStore.ts
import { create } from 'zustand';
interface UserStore {
selectedUserId: string | null;
searchFilter: string;
setSelectedUser: (id: string | null) => void;
setSearchFilter: (filter: string) => void;
}
export const useUserStore = create<UserStore>((set) => ({
selectedUserId: null,
searchFilter: '',
setSelectedUser: (id) => set({ selectedUserId: id }),
setSearchFilter: (filter) => set({ searchFilter: filter }),
}));public API — index.ts
// entities/user/index.ts
export { userQueries } from './api/userQueries';
export { useUserStore } from './model/userStore';
export type { User } from './model/types';
export { UserCard } from './ui/UserCard';외부에서는 반드시 index.ts를 통해서만 접근합니다. 내부 구조가 바뀌어도 외부 코드는 영향받지 않게 됩니다.
| 파일 | 역할 | 의존 도구 |
|---|---|---|
userApi.ts |
실제 fetch 함수 | 없음 (순수 함수) |
userQueries.ts |
queryKey + queryFn 묶음 | TanStack Query |
userStore.ts |
UI 선택 상태 저장 | Zustand |
예시 2: 낙관적 업데이트 처리 — 경계가 모호한 경우
낙관적 업데이트(Optimistic Update)는 "서버 응답 전에 미리 UI를 업데이트해두는" 패턴인데, 처음 보면 이게 서버 상태인지 클라이언트 상태인지 헷갈립니다. 결론부터 말하면, TanStack Query의 onMutate 안에서 처리하는 것이 권장됩니다.
// features/update-user/model/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userQueries } from '@/entities/user';
import { updateUser } from '@/entities/user/api/userApi';
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onMutate: async (updatedUser) => {
// v5에서 cancelQueries는 QueryFilters 형태로 전달
await queryClient.cancelQueries({
queryKey: userQueries.detail(updatedUser.id).queryKey,
});
const previousUser = queryClient.getQueryData(
userQueries.detail(updatedUser.id).queryKey
);
queryClient.setQueryData(
userQueries.detail(updatedUser.id).queryKey,
updatedUser
);
return { previousUser };
},
onError: (_err, updatedUser, context) => {
queryClient.setQueryData(
userQueries.detail(updatedUser.id).queryKey,
context?.previousUser
);
},
onSettled: (_data, _err, updatedUser) => {
queryClient.invalidateQueries({
queryKey: userQueries.detail(updatedUser.id).queryKey,
});
},
});
}낙관적 업데이트 상태는 결국 "서버 응답을 기다리는 동안의 임시 서버 상태"입니다. TanStack Query 캐시를 직접 조작하는 방식으로 처리하면 Zustand를 끌어들일 필요가 없습니다.
예시 3: features 레이어에서 두 상태를 통합하기
이 두 상태가 실제로 만나는 지점은 features 레이어(또는 그 위)입니다. entities끼리는 서로 import할 수 없다는 FSD 규칙이 있으므로, 여러 엔티티를 조합하는 로직은 반드시 상위 레이어로 올려야 합니다.
// features/user-list/ui/UserList.tsx
import { useQuery } from '@tanstack/react-query';
import { userQueries, useUserStore } from '@/entities/user';
export function UserList() {
const { searchFilter, selectedUserId, setSelectedUser } = useUserStore();
const { data: users, isLoading } = useQuery(userQueries.list());
// enabled: !!selectedUserId 가 false일 때 queryFn이 호출되지 않으므로 non-null assertion이 안전
const { data: selectedUser } = useQuery({
...userQueries.detail(selectedUserId!),
enabled: !!selectedUserId,
});
// 필터링은 클라이언트에서 파생값으로 계산 (실제 코드에선 useMemo 적용 권장)
const filtered = users?.filter((u) =>
u.name.toLowerCase().includes(searchFilter.toLowerCase())
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{filtered?.map((user) => (
<UserCard
key={user.id}
user={user}
isSelected={user.id === selectedUserId}
onClick={() => setSelectedUser(user.id)}
/>
))}
</div>
);
}여기서 핵심적인 패턴이 하나 있습니다. selectedUserId는 Zustand가 갖고 있지만, 그 ID로 실제 유저 데이터를 가져오는 건 TanStack Query입니다. Zustand는 "어떤 유저를 보고 싶은가"라는 의도만 갖고, 실제 데이터는 TanStack Query 캐시에서 꺼내는 거죠. 두 도구의 역할이 교차하는 방식이 바로 이겁니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명확한 책임 분리 | 버그가 생겼을 때 서버 문제인지 UI 상태 문제인지 파일 구조만 봐도 판단 가능 |
| 캐시 일관성 | 서버 데이터의 단일 진실 소스가 TanStack Query 캐시 하나뿐이라 동기화 버그 원천 차단 |
| 테스트 용이성 | queryOptions는 React 없이 테스트 가능, Zustand 스토어도 UI 없이 단위 테스트 가능 |
| 확장성 | 슬라이스가 자체 상태를 소유하므로 슬라이스 간 결합도가 낮고 독립적으로 발전 가능 |
| 번들 크기 | Redux 대비 훨씬 가벼운 두 라이브러리 조합으로 번들 최적화 효과 |
테스트 용이성은 직접 체감해보면 확실히 다릅니다. queryOptions는 React Context나 렌더링 없이도 queryKey와 queryFn을 검증할 수 있고, Zustand 스토어는 act() 없이 상태 변화를 단위 테스트할 수 있습니다. 저는 이 구조를 도입한 뒤 API 관련 테스트를 작성하는 시간이 눈에 띄게 줄었습니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 경계 판단 어려움 | 낙관적 업데이트처럼 서버/클라이언트 경계가 모호한 상태가 존재 | TanStack Query onMutate 안에서 캐시를 직접 조작하는 방식으로 처리 |
| 엔티티 간 의존성 금지 | FSD 규칙상 entities 슬라이스끼리 서로 import 불가 |
여러 엔티티를 조합하는 로직은 features 이상 레이어로 이동 |
| v5 캐시 공유 변경 | TanStack Query v5에서 contextSharing prop 제거 |
Module Federation 등 멀티 앱 환경에서 QueryClient 인스턴스를 명시적으로 공유 |
| 과도한 세분화 위험 | 모든 UI 상태를 Zustand로 분리하면 오히려 복잡해짐 | 단순 컴포넌트 내부 상태는 useState로 처리하는 것이 더 적합 |
용어 보충 — Module Federation: Webpack 5에서 도입된 기능으로, 여러 독립적인 빌드(마이크로 프론트엔드)가 런타임에 코드를 공유할 수 있게 해줍니다. 이 환경에서는 각 앱이 독립적인
QueryClient인스턴스를 가질 수 있어 캐시 공유 전략을 별도로 설계해야 합니다.
실무에서 가장 흔한 실수
1. 서버 데이터를 Zustand에 동기화하기
useEffect로 TanStack Query 결과를 Zustand에 복사하는 패턴입니다. 가장 흔하고 가장 치명적인 실수입니다.
// 이렇게 하면 안 됩니다
useEffect(() => {
if (data) {
setUsers(data); // 두 캐시를 동기화하는 책임이 생기는 순간
}
}, [data]);두 캐시를 동기화하는 책임이 생기고, 그 순간부터 동기화 버그와의 싸움이 시작됩니다. data를 Zustand에 복사하는 대신, data를 직접 파생값 계산에 활용하는 방향으로 접근하는 것이 권장됩니다.
2. entities에서 다른 엔티티를 직접 import하기
entities/order에서 entities/user를 import하면 FSD의 레이어 규칙이 깨집니다. 두 엔티티를 조합해야 하는 로직은 features 레이어로 올려야 합니다.
3. 단순 토글 상태도 Zustand로 분리하기
특정 UI 컴포넌트 내부에서만 쓰이는 isOpen, isExpanded 같은 상태는 useState가 훨씬 적합합니다. "전역으로 접근할 필요가 있는가"를 먼저 물어보고, 그렇지 않다면 Zustand를 꺼낼 필요가 없습니다.
마치며
이 경계를 지키면, 버그가 터졌을 때 "서버 문제인가, UI 상태 문제인가"를 고민하는 시간이 사라집니다. api 세그먼트를 열면 서버 통신 로직만 있고, model 세그먼트를 열면 UI 결정 로직만 있으니까요. 책임이 명확한 코드는 디버깅 시간뿐 아니라 팀원 온보딩 시간도 줄여줍니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 Zustand 스토어를 열어보고, 서버 API 응답 데이터가 들어 있는 필드가 있는지 확인해볼 수 있습니다.
users: User[],products: Product[]같은 필드가 보인다면, 해당 슬라이스에userQueries,productQueries형태의queryOptions를 만들어 TanStack Query로 이관하는 것을 권장합니다. -
FSD 디렉토리 구조를
entities/[도메인]/api/와entities/[도메인]/model/로 분리해볼 수 있습니다.api에는queryOptions와 fetch 함수,model에는 선택값·필터 등 순수 클라이언트 상태만 배치하는 것이 출발점입니다. -
entities/[도메인]/index.ts를 public API로 정의해 외부에서는 이 파일만 import하도록 팀 컨벤션을 만들어볼 수 있습니다.import { userQueries, useUserStore } from '@/entities/user'형태로 통일되면, 내부 구조 변경이 외부에 영향을 주지 않는 안정적인 경계가 만들어집니다.
참고 자료
- Usage with TanStack Query | Feature-Sliced Design 공식 가이드
- Usage with React Query | Feature-Sliced Design
- Layers | Feature-Sliced Design 레이어 레퍼런스
- Zustand: The Minimalist State Architecture | FSD 공식 블로그
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work
- Separating Concerns with Zustand and TanStack Query
- React Query Request Factory: Reusable Type-Safe Hooks with Axios & FSD
- Does TanStack Query Replace Client State Managers? | TanStack 공식 문서
- Rule-Based Schema-Driven Development with Orval × FSD | KINTO Tech Blog
- The Best React JS Architecture for 2026: Domain-Driven + FSD
- How We Cut 70% Bundle Size: The TanStack Query + Zustand Architecture
- State of React 2025: State Management