2026. 6. 26. 21:08ㆍFrontend

상태 관리는 “어디에 저장할까”가 아니라 “누가 소유하는가”의 문제다
프론트엔드 애플리케이션이 작을 때 상태 관리는 단순해 보인다.
const [user, setUser] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [keyword, setKeyword] = useState("");
하지만 기능이 늘어나면 상태는 빠르게 복잡해진다.
사용자 정보, 권한, 상품 목록, 검색 필터, 페이지 번호, 모달 열림 여부, 폼 입력값, 선택된 행, 정렬 조건, 알림 배지, 로딩 상태, 에러 상태, 캐시, WebSocket 메시지, optimistic update까지 모두 “상태”라는 이름으로 섞이기 시작한다.
이때 많은 팀이 다음 질문부터 던진다.
이 상태를 useState에 둘까?
Context에 둘까?
Redux에 둘까?
Zustand에 둘까?
TanStack Query에 둘까?
URL에 둘까?
localStorage에 둘까?
하지만 이 질문은 순서가 조금 늦다.
먼저 물어야 할 질문은 이것이다.
이 상태의 진짜 소유자는 누구인가?
서버가 소유하는가?
현재 브라우저 탭이 소유하는가?
사용자의 URL이 소유하는가?
DOM이나 브라우저 환경이 소유하는가?
아니면 다른 상태로부터 계산할 수 있는 파생값인가?
상태 관리의 본질은 저장소 선택이 아니라 소유권과 생명주기 설계다.
1. 모든 상태는 같은 상태가 아니다
프론트엔드에서 다루는 상태는 크게 다음과 같이 나눌 수 있다.
서버 상태
클라이언트 상태
URL 상태
폼 초안 상태
파생 상태
브라우저/환경 상태
외부 시스템 상태
예를 들어 대시보드 화면을 생각해보자.
사용자 프로필
주문 목록
검색 필터
페이지 번호
선택된 테이블 행
사이드 패널 열림 여부
수정 폼 입력값
현재 브라우저 너비
API 로딩 상태
에러 메시지
이들은 모두 화면을 구성하는 데 필요하지만, 성격은 완전히 다르다.
| 서버 상태 | 사용자 정보, 주문 목록, 권한, 상품 재고 | 서버 |
| 클라이언트 UI 상태 | 모달 열림, 탭 선택, 사이드바 접힘 | 현재 UI |
| URL 상태 | 검색어, 필터, 페이지 번호, 정렬 조건 | URL |
| 폼 초안 상태 | 사용자가 수정 중인 이름, 설명, 가격 | 현재 입력 세션 |
| 파생 상태 | 필터링된 목록, 총합, 표시 여부 | 다른 상태로부터 계산 |
| 브라우저 상태 | viewport, scroll, online 여부 | 브라우저 환경 |
이 차이를 무시하면 모든 상태가 하나의 전역 store에 쌓인다.
처음에는 편해 보인다.
const useAppStore = create((set) => ({
user: null,
orders: [],
filters: {},
selectedOrderId: null,
isModalOpen: false,
isLoading: false,
error: null,
}));
하지만 시간이 지나면 다음 문제가 생긴다.
어떤 값이 서버 원본인지 알 수 없다.
어떤 값이 임시 UI 상태인지 알 수 없다.
어떤 값이 URL과 동기화되어야 하는지 알 수 없다.
서버 응답이 바뀌었는데 store가 낡은 값을 들고 있다.
mutation 이후 어떤 데이터를 다시 가져와야 하는지 불명확하다.
파생값을 state로 저장해 원본과 불일치한다.
React 공식 문서도 중복되거나 불필요한 state는 버그의 흔한 원인이 되므로 state 구조를 의도적으로 설계하라고 설명한다. 또한 외부 시스템과 동기화하는 경우가 아니라면 props나 state 변화에 맞춰 다시 state를 설정하는 Effect가 필요하지 않을 수 있다고 안내한다.
2. 서버 상태란 무엇인가
서버 상태는 서버가 원본을 소유하고, 클라이언트는 그 시점의 스냅샷만 가지고 있는 상태다.
예시는 다음과 같다.
사용자 프로필
게시글 목록
댓글 목록
상품 가격
상품 재고
주문 내역
알림 목록
권한 정보
조직 정보
결제 상태
서버에서 내려준 feature flag
서버 상태의 핵심 특징은 다음과 같다.
1. 클라이언트가 원본 소유자가 아니다.
2. 비동기적으로 가져와야 한다.
3. 시간이 지나면 낡을 수 있다.
4. 다른 사용자나 다른 클라이언트에 의해 바뀔 수 있다.
5. 로딩, 에러, 재시도, refetch, 캐싱 생명주기가 필요하다.
6. mutation 이후 관련 데이터를 무효화하거나 갱신해야 한다.
예를 들어 상품 재고를 클라이언트가 가지고 있다고 하자.
{
"productId": "p1",
"stock": 3
}
이 값은 클라이언트에 도착한 순간부터 이미 “과거의 스냅샷”이다. 다른 사용자가 방금 구매했다면 실제 재고는 2일 수 있다. 관리자가 재고를 수정했다면 10일 수도 있다.
즉, 서버 상태는 클라이언트 안에 있어도 클라이언트의 것이 아니다.
클라이언트에 있는 서버 상태
= 서버 원본의 캐시된 스냅샷
TanStack Query 공식 문서는 TanStack Query를 서버와 클라이언트 사이의 비동기 작업을 관리하는 서버 상태 라이브러리로 설명하며, async data에 캐시와 생명주기, fetching/refetching/mutation을 위한 선언적 API를 제공한다고 설명한다.
3. 클라이언트 상태란 무엇인가
클라이언트 상태는 현재 클라이언트 세션이 소유하는 상태다.
예시는 다음과 같다.
모달이 열려 있는가?
현재 선택된 탭은 무엇인가?
사이드바가 접혀 있는가?
테이블에서 어떤 행을 선택했는가?
드롭다운이 열려 있는가?
사용자가 현재 입력 중인 폼 값은 무엇인가?
클라이언트 전용 wizard의 현재 단계는 무엇인가?
이 상태들은 서버가 모른다.
서버가 몰라도 된다.
서버가 알아야 하는 순간은 보통 사용자가 “저장”, “제출”, “적용” 같은 행동을 했을 때다.
예를 들어 다음 값은 서버 상태가 아니다.
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
이 값은 현재 UI에서 삭제 모달을 보여줄지 결정한다.
서버에는 “이 사용자의 삭제 모달이 열려 있음”이라는 원본 데이터가 존재하지 않는다.
다음 값도 일반적으로 클라이언트 상태다.
const [selectedOrderIds, setSelectedOrderIds] = useState<string[]>([]);
선택된 행은 서버의 주문 목록과 관련이 있지만, 그 자체는 현재 화면에서 사용자가 만든 UI 상태다.
Zustand 같은 클라이언트 상태 관리 도구는 이런 UI 중심의 전역 또는 공유 상태를 다루는 데 사용할 수 있다. Zustand 공식 문서는 Zustand를 hooks 기반의 작고 빠르며 확장 가능한 상태 관리 솔루션으로 설명한다.
4. 서버 상태와 클라이언트 상태의 가장 중요한 차이
서버 상태와 클라이언트 상태의 차이는 “어디에 저장하느냐”가 아니다.
핵심 차이는 소유권, 동기화, 시간성이다.
| 원본 소유자 | 서버 | 현재 클라이언트 |
| 접근 방식 | 비동기 | 대부분 동기 |
| stale 가능성 | 높음 | 낮음 |
| 다른 주체가 변경 가능 | 가능 | 보통 불가능 |
| 캐싱 필요성 | 높음 | 상황에 따라 다름 |
| 로딩/에러 필요성 | 필요 | 보통 불필요 |
| mutation 이후 처리 | invalidation/refetch/update 필요 | 단순 setState 가능 |
| 예시 | 게시글 목록, 주문 상세 | 모달 열림, 선택 탭 |
이 차이를 한 문장으로 표현하면 다음과 같다.
서버 상태는 “원본을 빌려온 것”이고,
클라이언트 상태는 “현재 화면이 직접 만든 것”이다.
5. 가장 흔한 실수: 서버 상태를 클라이언트 store에 넣기
다음 코드는 흔히 볼 수 있는 구조다.
type User = {
id: string;
name: string;
email: string;
};
type UserStore = {
user: User | null;
isLoading: boolean;
error: Error | null;
fetchUser: (userId: string) => Promise<void>;
updateUser: (user: User) => Promise<void>;
};
const useUserStore = create<UserStore>((set) => ({
user: null,
isLoading: false,
error: null,
async fetchUser(userId) {
set({
isLoading: true,
error: null,
});
try {
const user = await api.getUser(userId);
set({
user,
isLoading: false,
});
} catch (error) {
set({
error: error as Error,
isLoading: false,
});
}
},
async updateUser(user) {
const updatedUser = await api.updateUser(user);
set({
user: updatedUser,
});
},
}));
처음에는 괜찮아 보인다.
하지만 서버 상태를 직접 클라이언트 store에 넣으면 곧 질문이 늘어난다.
이 user는 언제 stale해지는가?
다른 화면에서 user를 수정하면 여기도 갱신되는가?
동시에 fetchUser가 여러 번 호출되면 어떻게 되는가?
요청 중 컴포넌트가 사라지면 어떻게 되는가?
에러 후 재시도는 어떻게 하는가?
같은 userId를 여러 컴포넌트가 요청하면 중복 요청이 발생하는가?
updateUser 이후 관련 목록도 갱신되는가?
캐시는 언제 제거되는가?
이 질문들은 단순한 전역 store의 책임이 아니다.
서버 상태 캐시의 책임이다.
물론 Redux나 Zustand에 서버 데이터를 저장할 수는 있다. 그러나 그 순간 직접 구현해야 하는 것이 많아진다.
캐시 키
중복 요청 제거
stale 시간
refetch 정책
로딩 상태
에러 상태
mutation 상태
invalidation
optimistic update
rollback
garbage collection
그래서 서버 상태는 일반적인 클라이언트 store보다 서버 상태 전용 도구로 관리하는 편이 더 자연스럽다. Redux Toolkit의 RTK Query 역시 데이터 fetching과 caching 로직을 직접 작성하는 부담을 줄이기 위한 도구로 소개된다.
6. 서버 상태는 query로 관리한다
같은 사용자 정보를 TanStack Query 기준으로 작성하면 다음과 같다.
type User = {
id: string;
name: string;
email: string;
};
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error("사용자 정보를 불러오지 못했습니다.");
}
return response.json();
}
function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
}
사용하는 컴포넌트는 다음처럼 작성할 수 있다.
function UserProfile({ userId }: { userId: string }) {
const {
data: user,
isPending,
isError,
error,
} = useUser(userId);
if (isPending) {
return <p>사용자 정보를 불러오는 중입니다.</p>;
}
if (isError) {
return <p>{error.message}</p>;
}
return (
<section>
<h1>{user.name}</h1>
<p>{user.email}</p>
</section>
);
}
이 구조에서 user는 클라이언트 store의 원본 데이터가 아니다.
["user", userId]라는 query key에 연결된 서버 상태 캐시의 스냅샷
이다.
isPending, isError, error 역시 단순 UI 상태라기보다 서버 상태 요청의 생명주기에서 나온 메타 상태다.
서버 상태 도구의 중요한 역할은 데이터를 가져오는 것만이 아니다.
이 데이터가 어떤 key에 속하는가?
언제 stale해지는가?
어떤 컴포넌트가 관찰 중인가?
다시 가져와야 하는가?
mutation 이후 무엇을 무효화해야 하는가?
까지 관리한다.
7. mutation 이후에는 “setState”보다 “invalidation”을 먼저 생각한다
사용자 이름을 수정하는 예제를 보자.
async function updateUser(input: {
userId: string;
name: string;
}) {
const response = await fetch(`/api/users/${input.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: input.name,
}),
});
if (!response.ok) {
throw new Error("사용자 정보를 수정하지 못했습니다.");
}
return response.json();
}
mutation은 다음처럼 구성할 수 있다.
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["user", variables.userId],
});
queryClient.invalidateQueries({
queryKey: ["users"],
});
},
});
}
사용자 상세 정보를 수정하면 적어도 다음 데이터가 낡았을 가능성이 있다.
["user", userId]
["users"]
["organization-users", organizationId]
["permissions", userId]
수정 API가 성공했다는 것은 클라이언트가 들고 있던 일부 서버 상태 캐시가 더 이상 신뢰할 수 없다는 뜻이다.
그래서 mutation 이후에는 단순히 현재 화면의 user만 바꿀지 고민하기보다, 어떤 query가 낡았는지 먼저 생각해야 한다.
TanStack Query 공식 문서는 invalidateQueries가 query를 stale로 표시하고 필요할 경우 refetch하게 할 수 있으며, mutation 성공 이후 관련 query를 무효화해야 할 가능성이 높다고 설명한다.
8. 클라이언트 상태는 UI 의도와 상호작용을 표현한다
반면 모달, 패널, 선택 상태 같은 값은 query로 관리할 필요가 없다.
type UiStore = {
isSidebarCollapsed: boolean;
selectedOrderIds: string[];
openCommandPalette: () => void;
closeCommandPalette: () => void;
isCommandPaletteOpen: boolean;
};
const useUiStore = create<UiStore>((set) => ({
isSidebarCollapsed: false,
selectedOrderIds: [],
isCommandPaletteOpen: false,
openCommandPalette: () => {
set({
isCommandPaletteOpen: true,
});
},
closeCommandPalette: () => {
set({
isCommandPaletteOpen: false,
});
},
}));
이런 값들은 서버 원본이 없다.
stale이라는 개념도 거의 없다.
사용자가 모달을 열면 열린 것이고, 닫으면 닫힌 것이다.
서버 상태:
“서버의 실제 데이터가 지금도 이 값일까?”
클라이언트 상태:
“현재 UI에서 사용자가 무엇을 하려는가?”
따라서 클라이언트 상태를 다룰 때는 다음 질문이 중요하다.
이 상태가 정말 전역이어야 하는가?
가장 가까운 컴포넌트에 두면 안 되는가?
URL로 표현해야 하는가?
새로고침 후에도 유지되어야 하는가?
서버로 제출되기 전까지 임시값인가?
React 공식 문서는 여러 컴포넌트가 같은 state를 함께 바꿔야 할 때 가장 가까운 공통 부모로 state를 끌어올리는 방식을 설명한다. 즉, 모든 공유 상태가 곧바로 전역 store로 가야 하는 것은 아니다.
9. 상태 배치의 우선순위
상태를 어디에 둘지 결정할 때는 보통 다음 순서로 생각하는 것이 좋다.
1. 계산할 수 있으면 state로 만들지 않는다.
2. 한 컴포넌트만 쓰면 local state에 둔다.
3. 부모와 자식이 함께 쓰면 state를 끌어올린다.
4. URL로 표현되어야 하면 URL에 둔다.
5. 서버 원본이면 server state cache에 둔다.
6. 여러 화면의 클라이언트 UI가 공유하면 client store에 둔다.
7. 새로고침 후 유지되어야 하는 클라이언트 설정이면 storage를 고려한다.
무조건 전역 store부터 쓰면 안 된다.
무조건 query부터 쓰는 것도 아니다.
상태의 성격에 맞는 위치가 있다.
10. URL 상태는 클라이언트 상태와 서버 상태 사이의 접점이다
검색 필터와 페이지 번호는 자주 헷갈린다.
검색어
상태 필터
정렬 조건
페이지 번호
페이지 크기
이 값들은 서버에서 내려온 데이터가 아니다.
사용자가 현재 어떤 데이터를 보고 싶은지 표현하는 클라이언트의 의도다.
하지만 동시에 API 요청에 영향을 준다.
function OrdersPage() {
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get("status") ?? "all";
const page = Number(searchParams.get("page") ?? "1");
const ordersQuery = useQuery({
queryKey: ["orders", { status, page }],
queryFn: () => {
return fetchOrders({
status,
page,
});
},
});
return (
<OrderTable
orders={ordersQuery.data?.items ?? []}
status={status}
page={page}
onStatusChange={(nextStatus) => {
setSearchParams({
status: nextStatus,
page: "1",
});
}}
onPageChange={(nextPage) => {
setSearchParams({
status,
page: String(nextPage),
});
}}
/>
);
}
여기서 구분은 다음과 같다.
status, page
= URL 상태
ordersQuery.data
= 서버 상태
OrderTable의 선택된 행
= 클라이언트 UI 상태
검색 조건은 서버 상태가 아니다.
하지만 서버 상태를 가져오기 위한 query key의 일부가 된다.
이 구분을 잘못하면 다음처럼 된다.
const useOrderStore = create((set) => ({
status: "all",
page: 1,
orders: [],
}));
필터, 페이지, 서버 응답이 모두 하나의 store에 섞인다.
그러면 뒤로가기, 공유 URL, 새로고침 복원, query invalidation이 모두 복잡해진다.
URL은 상태 저장소이기도 하다.
특히 다음 조건을 만족하면 URL 상태를 우선 고려해야 한다.
새로고침 후에도 유지되어야 한다.
링크로 공유할 수 있어야 한다.
뒤로가기/앞으로가기와 연동되어야 한다.
검색 결과 페이지의 정체성을 결정한다.
11. 폼 상태는 서버 상태의 복사본이 아니다
사용자 프로필 수정 화면을 생각해보자.
function UserEditPage({ userId }: { userId: string }) {
const userQuery = useUser(userId);
if (!userQuery.data) {
return null;
}
return (
<UserEditForm
key={userQuery.data.id}
initialUser={userQuery.data}
/>
);
}
폼 컴포넌트는 서버 데이터를 초기값으로 사용한다.
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
= 클라이언트 상태
draft는 서버 상태의 단순 복사본이 아니다.
사용자가 수정 중인 임시 데이터다.
사용자가 아직 저장하지 않았다면 서버 원본과 달라지는 것이 정상이다.
따라서 refetch가 발생했다고 무조건 draft를 덮어쓰면 안 된다.
useEffect(() => {
setDraft({
name: user.name,
email: user.email,
});
}, [user]);
이런 코드는 사용자가 입력 중인 값을 갑자기 날려버릴 수 있다.
폼 상태는 다음 질문으로 설계해야 한다.
서버 데이터가 바뀌면 입력 중인 draft를 덮어쓸 것인가?
사용자에게 “서버 데이터가 변경됨”을 알려줄 것인가?
저장 성공 후에 draft를 초기화할 것인가?
다른 userId로 이동하면 form을 새로 mount할 것인가?
위 예제에서 key={userQuery.data.id}를 사용한 이유는 다른 사용자를 편집할 때 폼을 새로 mount해 draft를 초기화하기 위해서다.
12. 파생 상태는 저장하지 않는다
다음 코드는 서버 상태에서 파생된 값을 다시 state로 저장한다.
function TodoSummary() {
const { data: todos = [] } = useTodos();
const [completedTodos, setCompletedTodos] = useState<Todo[]>([]);
useEffect(() => {
setCompletedTodos(
todos.filter((todo) => todo.completed),
);
}, [todos]);
return <p>완료된 할 일: {completedTodos.length}</p>;
}
이 코드는 불필요하다.
completedTodos는 todos에서 계산할 수 있다.
function TodoSummary() {
const { data: todos = [] } = useTodos();
const completedTodos = todos.filter(
(todo) => todo.completed,
);
return <p>완료된 할 일: {completedTodos.length}</p>;
}
계산 비용이 크다면 useMemo를 사용할 수 있다.
function TodoSummary() {
const { data: todos = [] } = useTodos();
const completedTodos = useMemo(() => {
return todos.filter((todo) => todo.completed);
}, [todos]);
return <p>완료된 할 일: {completedTodos.length}</p>;
}
핵심은 이것이다.
다른 상태로부터 계산 가능한 값은 별도 state로 저장하지 않는다.
파생 상태를 저장하면 원본과 파생값의 동기화 문제가 생긴다.
todos는 바뀌었는데 completedTodos가 아직 옛 값이다.
completedTodos를 업데이트하는 Effect가 누락되었다.
불필요한 추가 렌더링이 발생한다.
React 공식 문서도 중복 state를 피하고, 외부 시스템과 동기화하는 것이 아니라면 Effect로 state를 다시 설정하지 않아도 되는 경우가 많다고 설명한다.
13. optimistic update는 클라이언트 상태인가
optimistic update는 서버 mutation이 완료되기 전에 UI를 먼저 바꾸는 기법이다.
예를 들어 좋아요 버튼을 눌렀을 때 서버 응답을 기다리지 않고 좋아요 수를 먼저 올릴 수 있다.
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: likePost,
onMutate: async ({ postId }) => {
await queryClient.cancelQueries({
queryKey: ["post", postId],
});
const previousPost = queryClient.getQueryData<Post>([
"post",
postId,
]);
queryClient.setQueryData<Post>(
["post", postId],
(oldPost) => {
if (!oldPost) {
return oldPost;
}
return {
...oldPost,
liked: true,
likeCount: oldPost.likeCount + 1,
};
},
);
return {
previousPost,
};
},
onError: (_error, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(
["post", variables.postId],
context.previousPost,
);
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: ["post", variables.postId],
});
},
});
}
이 값은 잠깐 클라이언트가 만든 예측값이다.
그렇다고 일반 클라이언트 UI 상태와 같지는 않다.
더 정확히는 다음과 같다.
optimistic update
= 서버 상태 캐시에 임시로 적용한 클라이언트 예측
최종 권위는 여전히 서버에 있다.
서버 요청이 실패하면 rollback해야 할 수 있고, 성공 후에도 refetch나 invalidation으로 서버 원본과 다시 맞춰야 한다.
TanStack Query 공식 문서는 optimistic update가 mutation 완료 전에 UI를 먼저 갱신하는 방식이며, 실패 시 refetch나 rollback이 필요할 수 있다고 설명한다.
14. 인증 상태는 어디에 속하는가
인증은 서버 상태와 클라이언트 상태가 섞여 보이는 대표적인 영역이다.
예를 들어 다음 값들을 구분해야 한다.
access token
refresh token
현재 로그인한 사용자 정보
권한 목록
로그인 모달 열림 여부
로그인 폼 입력값
이들은 모두 “인증 관련 상태”지만 같은 상태가 아니다.
| 현재 사용자 정보 me | 서버 상태 | 서버가 판단한 사용자 정보의 스냅샷 |
| 권한 목록 | 서버 상태 | 서버 정책이 원본 |
| 로그인 모달 열림 | 클라이언트 UI 상태 | 현재 화면의 UI 의도 |
| 로그인 폼 입력값 | 폼 초안 상태 | 사용자가 입력 중인 임시값 |
| access token | 인증 인프라 상태 | 저장 위치와 보안 정책이 중요 |
| isLoggedIn | 파생 상태일 수 있음 | me 또는 세션 상태에서 계산 가능 |
자주 하는 실수는 다음처럼 인증 관련 모든 것을 하나의 전역 store에 넣는 것이다.
const useAuthStore = create((set) => ({
user: null,
permissions: [],
isLoginModalOpen: false,
email: "",
password: "",
accessToken: null,
}));
이렇게 되면 서버가 소유한 정보와 UI가 소유한 정보가 섞인다.
더 나은 구분은 다음과 같다.
me query
= 서버 상태
permissions query
= 서버 상태
login modal open
= 클라이언트 UI 상태
login form draft
= 폼 상태
token
= 보안 정책에 따른 인증 인프라 상태
또한 권한 정보가 클라이언트에 있다고 해서 보안이 보장되는 것은 아니다.
클라이언트의 권한 상태는 UI 표시를 돕는 스냅샷일 뿐이고, 실제 접근 제어는 서버에서 반드시 검증해야 한다.
15. 대시보드 예제로 보는 상태 분리
B2B 주문 관리 대시보드를 가정해보자.
화면에는 다음 기능이 있다.
주문 목록 조회
상태별 필터
검색어
페이지네이션
정렬
선택된 주문 행
주문 상세 사이드 패널
주문 상태 변경 mutation
컬럼 표시 설정
상태를 분류하면 다음과 같다.
| 주문 목록 데이터 | 서버 상태 | TanStack Query / RTK Query |
| 주문 상세 데이터 | 서버 상태 | TanStack Query / RTK Query |
| 주문 상태 변경 | mutation | server state cache invalidation |
| 상태 필터 | URL 상태 | search params |
| 검색어 | URL 또는 local input state | 요구사항에 따라 결정 |
| 페이지 번호 | URL 상태 | search params |
| 정렬 조건 | URL 상태 | search params |
| 선택된 주문 ID | 클라이언트 UI 상태 | local state 또는 Zustand |
| 사이드 패널 열림 | 클라이언트 UI 상태 | local state 또는 Zustand |
| 컬럼 표시 설정 | 클라이언트 설정 상태 | localStorage 연동 store 가능 |
| 필터링된 목록 | 파생 상태 | query data에서 계산 |
구현 구조는 대략 다음과 같다.
function OrdersPage() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = {
status: searchParams.get("status") ?? "all",
page: Number(searchParams.get("page") ?? "1"),
sort: searchParams.get("sort") ?? "createdAt.desc",
};
const ordersQuery = useQuery({
queryKey: ["orders", filters],
queryFn: () => fetchOrders(filters),
});
const [selectedOrderId, setSelectedOrderId] = useState<
string | null
>(null);
const [isDetailPanelOpen, setIsDetailPanelOpen] =
useState(false);
return (
<OrderLayout>
<OrderFilterBar
filters={filters}
onChange={(nextFilters) => {
setSearchParams({
...nextFilters,
page: "1",
});
}}
/>
<OrderTable
orders={ordersQuery.data?.items ?? []}
isLoading={ordersQuery.isPending}
selectedOrderId={selectedOrderId}
onSelectOrder={(orderId) => {
setSelectedOrderId(orderId);
setIsDetailPanelOpen(true);
}}
/>
{isDetailPanelOpen && selectedOrderId && (
<OrderDetailPanel
orderId={selectedOrderId}
onClose={() => {
setIsDetailPanelOpen(false);
}}
/>
)}
</OrderLayout>
);
}
상세 패널은 자체적으로 서버 상태를 가져온다.
function OrderDetailPanel({
orderId,
onClose,
}: {
orderId: string;
onClose: () => void;
}) {
const orderQuery = useQuery({
queryKey: ["order", orderId],
queryFn: () => fetchOrder(orderId),
});
if (orderQuery.isPending) {
return <aside>주문 상세를 불러오는 중입니다.</aside>;
}
if (orderQuery.isError) {
return <aside>주문 상세를 불러오지 못했습니다.</aside>;
}
return (
<aside>
<button onClick={onClose}>닫기</button>
<h2>{orderQuery.data.orderNumber}</h2>
<p>{orderQuery.data.customerName}</p>
</aside>
);
}
이 설계에서 각 상태의 역할은 분리되어 있다.
URL
= 어떤 주문 목록을 보고 싶은가
Query
= 해당 조건에 맞는 서버 데이터 스냅샷
Local state
= 현재 어떤 주문을 선택했고 패널을 열었는가
Mutation
= 서버 데이터를 바꾸고 관련 query를 무효화
16. 서버 상태와 클라이언트 상태를 섞는 안티패턴
안티패턴 1. API 응답을 무조건 전역 store에 저장한다
set({
users: await fetchUsers(),
});
문제는 캐시 생명주기를 직접 관리해야 한다는 점이다.
대신 서버 상태 도구를 우선 고려한다.
useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
안티패턴 2. query data를 다시 useState로 복사한다
const { data: user } = useUser(userId);
const [localUser, setLocalUser] = useState(user);
이 코드는 user가 나중에 도착하거나 refetch될 때 동기화 문제가 생긴다.
복사할 필요가 없다면 그대로 사용한다.
const { data: user } = useUser(userId);
폼 초안처럼 의도적으로 분리해야 하는 경우에만 초기값으로 복사한다.
안티패턴 3. 파생값을 state로 저장한다
const [visibleItems, setVisibleItems] = useState([]);
useEffect(() => {
setVisibleItems(
items.filter((item) => item.visible),
);
}, [items]);
계산 가능한 값은 렌더링 중 계산한다.
const visibleItems = items.filter(
(item) => item.visible,
);
안티패턴 4. mutation 후 현재 화면만 수동으로 수정한다
setUser(updatedUser);
현재 화면은 바뀌었지만 다른 query는 낡아 있을 수 있다.
queryClient.invalidateQueries({
queryKey: ["user", userId],
});
queryClient.invalidateQueries({
queryKey: ["users"],
});
안티패턴 5. query key에 클라이언트 의도를 포함하지 않는다
useQuery({
queryKey: ["orders"],
queryFn: () => fetchOrders({ status, page }),
});
status와 page가 바뀌어도 같은 query key를 사용한다.
조건이 다른 데이터는 다른 key를 가져야 한다.
useQuery({
queryKey: ["orders", { status, page }],
queryFn: () => fetchOrders({ status, page }),
});
안티패턴 6. 서버 상태 캐시에 UI 상태를 넣는다
queryClient.setQueryData(["modal"], {
isOpen: true,
});
query cache는 서버 상태의 스냅샷과 그 생명주기를 관리하는 곳이다.
모달 열림 여부는 일반 클라이언트 상태다.
const [isOpen, setIsOpen] = useState(false);
17. 상태를 구분하는 질문 목록
새로운 상태를 만들기 전에 다음 질문을 던지면 좋다.
1. 이 값의 원본은 어디인가?
2. 서버가 이 값을 알고 있는가?
3. 다른 사용자나 다른 기기가 이 값을 바꿀 수 있는가?
4. 시간이 지나면 이 값이 낡을 수 있는가?
5. 로딩, 에러, 재시도가 필요한가?
6. mutation 이후 관련 데이터를 다시 가져와야 하는가?
7. URL로 공유되어야 하는가?
8. 새로고침 후 유지되어야 하는가?
9. 사용자가 저장하기 전까지의 임시값인가?
10. 다른 값으로부터 계산할 수 있는가?
11. 한 컴포넌트만 쓰는가, 여러 화면이 공유하는가?
12. 이 상태가 사라지는 시점은 언제인가?
이 질문에 답하면 저장 위치가 자연스럽게 좁혀진다.
서버가 원본이고 stale 가능성이 있다
→ server state cache
현재 UI 상호작용만 표현한다
→ local state 또는 client store
URL 공유와 history가 중요하다
→ URL state
사용자가 편집 중인 값이다
→ form draft state
계산 가능하다
→ derived value
여러 화면이 공유하는 클라이언트 설정이다
→ client store + persistence 고려
18. TanStack Query와 Zustand는 경쟁 관계가 아니다
상태 관리 도구를 고를 때 자주 나오는 질문이 있다.
TanStack Query를 쓰면 Zustand가 필요 없나요?
Zustand를 쓰면 TanStack Query가 필요 없나요?
Redux를 쓰면 Query가 필요 없나요?
이 질문은 도구를 같은 범주로 놓기 때문에 생긴다.
서버 상태 도구와 클라이언트 상태 도구는 해결하려는 문제가 다르다.
TanStack Query / RTK Query
= 서버 상태, 비동기 데이터, 캐시, mutation, invalidation
Zustand / Redux / Context
= 클라이언트 상태, UI 상태, 앱 내부 상태 공유
TanStack Query 공식 문서도 TanStack Query가 Redux, MobX, Zustand 같은 클라이언트 상태 관리자를 대체하느냐는 질문에 대해, TanStack Query는 서버 상태 라이브러리이고 Redux/MobX/Zustand 등은 클라이언트 상태 라이브러리라고 구분한다.
따라서 둘은 경쟁 관계라기보다 역할 분담 관계다.
예를 들어 다음 조합은 자연스럽다.
TanStack Query
= 사용자 목록, 주문 상세, 권한, 알림 목록
Zustand
= 사이드바 접힘, 선택된 조직, 전역 command palette 상태
URL
= 검색 필터, 페이지, 정렬
useState
= 현재 컴포넌트의 hover, input draft, modal open
useMemo
= query data에서 계산한 파생값
19. 상태 설계는 데이터 흐름 설계다
좋은 상태 설계는 다음처럼 보인다.
서버 상태
→ query cache
→ 화면에서 읽음
→ mutation
→ invalidation/refetch/update
클라이언트 상태
→ local state 또는 client store
→ 화면 상호작용 제어
URL 상태
→ query key에 반영
→ 서버 상태 요청 조건 결정
폼 상태
→ 서버 상태에서 초기화
→ 사용자가 수정
→ mutation으로 제출
→ 성공 후 query invalidation
파생 상태
→ 렌더링 중 계산
나쁜 상태 설계는 모든 것이 한 방향으로 빨려 들어간다.
모든 상태
→ global store
→ useEffect로 동기화
→ 수동 setState
→ stale 데이터
→ 중복 상태
→ 원인 불명 버그
상태가 많아서 문제가 생기는 것이 아니다.
상태의 성격이 섞여서 문제가 생긴다.
20. 실무 원칙
서버 상태와 클라이언트 상태를 구분할 때 다음 원칙을 기준으로 삼을 수 있다.
1. 서버가 원본이면 클라이언트 store에 원본처럼 저장하지 않는다
서버 데이터는 캐시된 스냅샷이다.
원본
= 서버
클라이언트
= 관찰자
2. query key는 서버 상태의 주소다
조건이 다른 데이터는 다른 query key를 가져야 한다.
["orders", { status, page, sort }]
3. mutation은 데이터 변경만이 아니라 캐시 일관성 이벤트다
mutation 성공 후 관련 query를 어떻게 처리할지 설계해야 한다.
invalidate
refetch
setQueryData
optimistic update
rollback
4. UI 상태는 서버 상태 캐시에 넣지 않는다
모달, 탭, 선택 상태는 클라이언트 상태다.
5. 폼 draft는 서버 상태와 분리한다
서버 데이터는 초기값일 뿐이다.
사용자가 수정 중인 값은 클라이언트가 소유한다.
6. URL로 표현할 수 있는 탐색 상태는 URL을 고려한다
검색, 필터, 페이지, 정렬은 URL 상태가 적합한 경우가 많다.
7. 계산 가능한 값은 저장하지 않는다
파생값은 렌더링 중 계산하거나 useMemo를 사용한다.
8. 전역 store는 마지막 선택지에 가깝다
가장 가까운 곳에 둘 수 있으면 local state가 더 단순하다.
9. 서버 상태와 클라이언트 상태는 함께 쓸 수 있다
서버 상태 도구와 클라이언트 상태 도구는 역할이 다르다.
10. 상태의 종료 시점을 설계한다
상태는 생성보다 제거가 중요하다.
컴포넌트 unmount
페이지 이동
폼 제출 성공
로그아웃
조직 변경
사용자 변경
캐시 만료
각 상태가 언제 사라져야 하는지 명확해야 한다.
마무리
서버 상태와 클라이언트 상태를 구분한다는 것은 단순히 “React Query를 쓸까, Zustand를 쓸까”를 고르는 문제가 아니다.
그보다 더 근본적인 질문이다.
이 값은 누가 소유하는가?
이 값은 언제 낡는가?
이 값은 어떻게 갱신되는가?
이 값은 언제 사라지는가?
이 값은 다른 값으로부터 계산 가능한가?
서버 상태는 서버가 소유하는 원본의 스냅샷이다.
클라이언트 상태는 현재 사용자와 현재 UI가 만들어낸 의도와 상호작용이다.
URL 상태는 화면의 탐색 조건을 외부로 드러낸다.
폼 상태는 서버 데이터에서 출발하지만 저장 전까지는 사용자의 임시 작업 공간이다.
파생 상태는 저장할 필요 없이 계산하면 된다.
상태 관리의 핵심은 더 강력한 store를 고르는 것이 아니다.
상태의 소유권을 정확히 구분하고, 각 상태가 가진 생명주기에 맞는 위치를 선택하는 것이다.
참고 자료
- React 공식 문서, Managing State / Choosing the State Structure.
- React 공식 문서, You Might Not Need an Effect / Sharing State Between Components.
- TanStack Query 공식 문서, Overview / Server State와 Client State 구분.
- TanStack Query 공식 문서, Query Invalidation / Invalidations from Mutations / Optimistic Updates.
- Redux Toolkit 공식 문서, RTK Query Overview.
- Zustand 공식 문서, Introduction.
'Frontend' 카테고리의 다른 글
| 클린 코드가 모호한 개념인 이유, 선언적 프로그래밍과 추상화 (0) | 2023.01.07 |
|---|---|
| 의존성 역전 원칙(DIP)과 의존성 주입 (0) | 2022.11.07 |
| 횡단 관심사 - 클래스로 HTTP 통신 횡단 관심사 처리 (0) | 2022.11.04 |
| 서버 사이드 렌더링(SSR)이란? - CSR vs SSR(+SSG) (0) | 2022.10.05 |
| 중요 렌더링 경로(Critical Rendering Path) 개념 정리 (0) | 2022.09.30 |