프론트엔드 개발을 하다 보면 수많은 상태 관리 라이브러리 중 뭘 선택해야 할지 혼란스럽다. 왜 React Query와 함께 Zustand나 Redux 같은 상태 관리 라이브러리를 추가로 사용해야 할까?
이 의문은 사실 '상태'라는 것에 두 가지 다른 종류가 있다는 사실을 이해하면 명확해진다.
하지만 이 두 상태의 차이를 제대로 이해하지 못하면, 코드는 점점 스파게티가 되어가고 상태 업데이트 로직은 복잡해지며 성능은 저하된다.
이 글에서는 서버 상태와 클라이언트 상태의 차이를 명확히 하고, 리액트 쿼리 + 상태관리 라이브러리(zustand? RTK?)를 고민하며 먼저 어떤 상태가 있는지 정의하고 관찰한다음, 예시도 보여주겠다.
서버 상태와 클라이언트 상태는 웹 애플리케이션에서 데이터를 관리하는 두 가지 다른 개념이다. 각각의 개념은 역할과 책임이 다르며, 서로 다른 용도와 특징을 가지고 있다.
웹 애플리케이션의 백엔드에 저장돼 있는 데이터
사용자의 브라우저나 애플리케이션 메모리에 로컬로 저장되는 데이터
즉, 서버 상태는 직접 제어할 수 없고, 특별한 장비(API)가 있어야만 접근할 수 있다. 반면, 클라이언트 상태는 비교적 작고 제한적이지만, 우리가 완전히 통제할 수 있고 필요에 따라 즉시 변경할 수 있다.
이 차이점 때문에 각 상태 유형에 최적화된 서로 다른 접근법이 필요하다. 이 차이를 구분해줄 Hook이나 컴포넌트를 만들어 각각 상태에 대해 처리해줘도 되지만, 그 접근법 중 라이브러리를 쓰는 것이 제일 편하다. 바로 React Query, Zustand(Redux)다.
React Query는 서버 상태 관리에 특화가 돼 있다.
Zustand/Redux는 클라이언트 상태 관리에 적합하다.
즉, 서로 다른 문제를 해결하는 도구라고 생각하면 된다.
이렇게 한다면 아래와 같은 이점이 있다.
물론 단점도 있다. 규모가 작은 웹 앱이라면, 오버헤드가 커지고 학습해야 할 개념이 많아져 개발 효율이 떨어질 수 있다. 두 가지 상태 관리 라이브러리를 모두 알아야 하므로 러닝 커브도 높아진다.
만일 유명한 라이브러리가 아니라 지엽적인, 프로젝트 내의 유틸 Hook/함수라면? 커브가 더 높아진다.
하지만 애플리케이션이 조금이라도 성장하게 되면, 이런 구조가 빛을 발하게 된다. 특히 여러 팀원이 함께 개발할 때 코드의 일관성과 예측 가능성이 크게 향상된다.
예시를 통해 서버 상태와 클라이언트 상태의 차이를 살펴보겠다.
서버 상태와 클라이언트 상태는 서로 다른 목적과 역할을 가지고 있으며, 애플리케이션의 특정 요구에 따라 적절하게 사용돼야 한다!
사실 이 2가지 조합을 보려고 한다.
import create from 'zustand';
interface AuthState {
isAuthenticated: boolean;
user: { username: string } | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
user: null,
login: async (username, password) => {
// 서버 요청을 통해 인증 처리
try {
// 인증 성공한 경우
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const user = await response.json();
set({ isAuthenticated: true, user });
} else {
throw new Error('Login failed');
}
} catch (error) {
console.error(error);
}
},
logout: () => {
set({ isAuthenticated: false, user: null });
},
}));
// Presenter
import { useMutation } from 'react-query';
interface LoginResponse {
token: string;
}
async function loginUser({ username, password }: { username: string, password: string }): Promise<LoginResponse> {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
function useLoginMutation() {
return useMutation<LoginResponse, Error, { username: string, password: string }>(loginUser);
}
// View: 컴포넌트 예시
import { useState } from 'react';
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const auth = useAuthStore();
const loginMutation = useLoginMutation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
loginMutation.mutate({ username, password });
};
if (auth.isAuthenticated) {
return <p>Welcome, {auth.user?.username}!</p>;
}
return (
<form onSubmit={handleSubmit}>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit" disabled={loginMutation.isLoading}>Login</button>
{loginMutation.isError && <p>Login failed</p>}
</form>
);
}
위의 코드 예시에서 useAuthStore
함수는 Zustand를 사용해 클라이언트 상태를 관리한다. isAuthenticated
는 사용자가 인증되었는지 여부를 나타내고, user
는 현재 인증된 사용자 정보를 저장한다. login
함수는 사용자 로그인을 처리하고, logout
함수는 로그아웃을 처리한다.
useLoginMutation
함수는 React Query의 useMutation
훅을 사용하여 서버 요청을 처리하는데 사용된다. loginUser
함수는 실제 서버 요청을 보내는 로직을 담당하고, 해당 함수를 useLoginMutation
훅에 전달하여 사용자 로그인 요청을 처리한다.
위의 코드 예시에서 LoginForm
컴포넌트는 로그인 폼을 나타내며, Zustand와 React Query를 사용하여 클라이언트 상태와 서버 상태를 처리한다. 사용자가 로그인되어 있는 경우에는 환영 메시지를 표시하고, 그렇지 않은 경우에는 로그인 폼을 렌더링합니다. 로그인 폼에서는 입력된 사용자 이름과 비밀번호를 사용하여 로그인 요청을 보내고, 요청이 진행 중인 동안 버튼을 비활성화합니다. 로그인 요청이 실패한 경우에는 에러 메시지를 표시합니다.
이 코드 예시는 Zustand를 사용하여 클라이언트 상태를 관리하고, React Query를 사용하여 서버 상태를 처리하는 방법을 보여줍니다. 이를 기반으로 웹 애플리케이션에서 사용자 로그인 기능을 구현할 수 있습니다.
장바구니 예시를 들어 사용 코드를 보자.
import create from 'zustand';
const useCartStore = create((set) => ({
cartItems: [],
addToCart: (item) =>
set((state) => ({ cartItems: [...state.cartItems, item] })),
removeFromCart: (itemId) =>
set((state) => ({
cartItems: state.cartItems.filter((item) => item.id !== itemId),
})),
}));
function Cart() {
const cartItems = useCartStore((state) => state.cartItems);
const addToCart = useCartStore((state) => state.addToCart);
const removeFromCart = useCartStore((state) => state.removeFromCart);
return (
<div>
<h2>장바구니</h2>
<ul>
{cartItems.map((item) => (
<li key={item.id}>
{item.name} - {item.price}원{' '}
<button onClick={() => removeFromCart(item.id)}>제거</button>
</li>
))}
</ul>
<button onClick={() => addToCart({ id: 1, name: '상품', price: 1000 })}>
장바구니에 추가
</button>
</div>
);
}
useCartStore
를 생성하고 클라이언트의 장바구니 상태를 관리 addToCart
함수는 상품을 장바구니에 추가removeFromCart
함수는 장바구니에서 상품을 제거이렇게 Zustand를 사용하여 클라이언트 상태를 관리하면 장바구니 상태를 빠르고 간단하게 업데이트할 수 있다.
// React Query (서버 상태 관리)
import { useQuery } from 'react-query';
function GetCartItem() {
const { data: products, isLoading, isError } = useQuery('products', () =>
fetch('/api/products').then((response) => response.json())
);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error fetching products</div>;
}
return (
<div>
<h2>상품 목록</h2>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - {product.price}원
</li>
))}
</ul>
</div>
);
}
useQuery
훅을 이용해 서버에서 상품 목록을 가져옴. useQuery
의 첫 번째 매개변수는 쿼리 식별자로 사용되고, 두 번째 매개변수는 데이터를 가져오는 비동기 함수로 네트워크 호출.product
리스트 Return.이를 통해 서버 상태를 관리하면 데이터를 가져오고 업데이트할 수 있다.
어떤 상태를 어디서 관리해야 할지 헷갈린다면, 아래 질문을 해봐라.
이러한 이해를 바탕으로 상태 관리 전략을 세운다면, 당신의 React 애플리케이션은 더 예측 가능하고, 유지보수하기 쉬우며, 성능이 뛰어난 구조를 갖게 될 것이다!!
웹프론트 해본지 3년은 된 것 같은데 요새는 redux보다 zustand를 더 많이 쓰나요?