React Query와 Zustand
발표 목표: useState만으로 상태를 관리하던 초기 앱의 문제점을 알아보고, Zustand와 React Query를 도입하여 어떻게 코드 품질과 사용자 경험을 극적으로 개선했는지 과정을 공유합니다.
// MyDayPage.tsx (개선 전, 요약)
export default function MyDayPage() {
// 1. 날짜 상태 관리
const [selectedDate, setSelectedDate] = useState(new Date());
// 2. 할 일 목록 관리
const [tasks, setTasks] = useState(initialTasks);
// 3. 자식에게 props로 전달
return (
<MobileLayout>
<DateHeader
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
/>
{/* tasks.must.map(...) */}
</MobileLayout>
);
}
① 클라이언트 상태 (Client State): 오직 내 앱(브라우저)에만 존재하는 상태.
② 서버 상태 (Server State): 서버 데이터베이스에서 가져와야 하는 상태.
"아주 작고 간단한 전역 상태 보관함" 이라고 생각하시면 됩니다. Redux처럼 복잡한 설정 없이, 어떤 컴포넌트에서든 쉽게 꺼내 쓸 수 있는 공유 상자 같은 것입니다.
useState로 날짜를 관리하고, props로 자식에게 전달.
// store/useDateStore.ts
export const useDateStore = create<DateState>((set) => ({
selectedDate: new Date(),
setSelectedDate: (date) => set({ selectedDate: date }),
}));
// features/myday/components/DateHeader.tsx
export default function DateHeader() {
const { selectedDate, setSelectedDate } = useDateStore();
// ...
}
"데이터를 다루는 아주 유능한 개인 비서" 입니다. fetch나 axios처럼 단순히 데이터를 요청만 하는 도구가 아닙니다. 데이터를 가져오고, 캐싱(기억)하고, 로딩/에러 상태를 관리하고, 화면을 자동으로 업데이트하는 등 서버 데이터와 관련된 모든 귀찮은 일을 대신 해줍니다.
useState와 (상상 속의) useEffect로 API 데이터를 관리. 로딩/에러 처리, 캐싱, 업데이트 모두 수동으로 구현해야 함.
useState 3개(data, loading, error)와 useEffect 1개, 총 4개가 필요했던 데이터 가져오기.
// MyDayPage.tsx
import { useQuery } from '@tanstack/react-query';
import { getTasksByDate } from '@/lib/api/tasks';
// ...
const { data, isLoading, isError } = useQuery({
queryKey: ['tasks', selectedDate], // 데이터의 "이름표"
queryFn: () => getTasksByDate(selectedDate), // 실제 데이터를 가져오는 함수
});
// DateHeader.tsx
function DateItem({ date, onSelect }) {
const queryClient = useQueryClient();
const prefetchTasks = () => {
queryClient.prefetchQuery(...); // 마우스 올리면 미리 가져오기
};
return <div onMouseEnter={prefetchTasks} onClick={onSelect} />;
}
// MyDayPage.tsx
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: updateTaskStatus, // "어떻게" 데이터를 수정할지 알려줌
// 여기가 마법의 핵심: 낙관적 업데이트(Optimistic Update)
onMutate: async (variables) => {
// 1. UI를 즉시 업데이트 (사용자는 바로 반응을 본다)
queryClient.setQueryData(queryKey, newOptimisticData);
return { previousTasks }; // 2. 실패 시 되돌릴 원본 데이터 저장
},
onError: (err, variables, context) => {
// 3. 만약 실패하면, 저장해 둔 원본 데이터로 UI를 롤백
queryClient.setQueryData(queryKey, context.previousTasks);
},
onSettled: () => {
// 4. 성공하든 실패하든, 마지막에 서버와 데이터를 최종 동기화
queryClient.invalidateQueries({ queryKey });
},
});
onMutate는 useMutation의 옵션 중 하나로, 실제 서버 요청 함수(mutationFn)가 실행되기 바로 직전에 호출되는 함수입니다.
가장 중요한 역할은, "서버의 응답을 기다리지 않고, 성공할 거라고 '낙관'하며 UI를 미리 업데이트하는 것" 입니다.
사용자가 느린 네트워크 환경에 있더라도, 앱이 즉각적으로 반응하는 것처럼 느끼게 만드는 '마법'의 원천이죠.
우리가 작성한 onMutate 코드를 한 줄 한 줄 분해해서 그 의도를 파헤쳐 보겠습니다.
// onMutate는 mutation 함수가 받을 변수(variables)를 인자로 받습니다.
// 여기서는 { priority, id, done } 객체입니다.
onMutate: async (variables) => {
// ----------------------------------------------------
// 1단계: 충돌 방지 및 데이터 스냅샷
// ----------------------------------------------------
console.log('1. 이전 쿼리 취소');
await queryClient.cancelQueries({ queryKey });
console.log('2. UI 즉시 업데이트 (setQueryData)');
const previousTasks = queryClient.getQueryData<Tasks>(queryKey);
// ----------------------------------------------------
// 2단계: UI 즉시 업데이트 (가짜 성공)
// ----------------------------------------------------
if (previousTasks) {
// (여기서 캐시 데이터를 직접 조작하여 UI를 미리 변경)
queryClient.setQueryData<Tasks>(queryKey, newTasks);
}
// ----------------------------------------------------
// 3단계: 롤백을 위한 데이터 반환
// ----------------------------------------------------
console.log('3. 이전 데이터 저장 (롤백 대비)');
return { previousTasks };
},
['tasks', 오늘날짜]
라는 이름표를 가진, 현재 진행 중인 모든 데이터 요청(useQuery)을 취소시킵니다.마치 잘 짜인 팀플레이 같습니다.
콜백 | 역할 분담 | 주고받는 것 (context) |
---|---|---|
onMutate | "일단 UI 바꿔! 그리고 혹시 모르니 원본 데이터 챙겨놔!" | 원본 데이터를 context에 담아 onError에게 전달 |
onError | "(실패 시) 이런! onMutate가 챙겨준 원본 데이터로 되돌리자!" | onMutate가 전달한 context를 받아 사용 |
onSettled | "(성공/실패 무관) 모든 게 끝났으니, 최종적으로 서버랑 동기화하자!" | onMutate가 전달한 context를 받아 사용할 수 있음 |
DateHeader에서 prefetchQuery 메소드를 사용했습니다.
// DateHeader.tsx
const queryClient = useQueryClient();
const prefetchTasks = () => {
queryClient.prefetchQuery({ // 마우스를 올리면
queryKey: ['tasks', date], // 해당 날짜의 데이터를
queryFn: () => getTasksByDate(date), // 미리 가져온다!
});
};
구분 | Before (useState) | After (Zustand + React Query) |
---|---|---|
코드 복잡도 | 높음 (수많은 상태, useEffect) | 낮음 (선언적, 역할 분리) |
Props Drilling | 문제 발생 | 해결 (전역 스토어) |
로딩 처리 | 수동 구현 (지루한 텍스트) | 자동 + 스켈레톤 UI + Prefetching (세련된 경험) |
업데이트 처리 | 수동 구현 (느리고 답답함) | 자동 + 낙관적 업데이트 (즉각적인 반응) |
개발자 경험 | 좋지 않음 | 매우 좋음 |
사용자 경험 | 끔찍함 | 매우 뛰어남 |
이 정교한 메커니즘 덕분에, 우리는 사용자에게는 즉각적인 반응성을 제공하면서도, 내부적으로는 데이터의 안정성과 정합성을 완벽하게 유지할 수 있는 것입니다.