logoRawon_Log
홈블로그소개

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

React

SNS 서비스 구축 - Tanstack Query

Rawon
2025년 12월 15일
목차
🌐 서버 상태 관리와 Tanstack Query
JSON Server 설정
📥 데이터 조회 요청 관리
Tanstack Query 설치
useQuery 훅 사용
커스텀 훅으로 분리
🔄 캐싱 메커니즘
캐시 데이터의 상태
리패칭이 발생하는 특정 타이밍
Tanstack Query Devtools 설치
StaleTime과 Refetch 옵션 설정
🗂️ Tanstack Query 캐시의 5가지 상태
Inactive 상태
gcTime (Garbage Collection Time)
staleTime과 gcTime의 관계
글로벌 옵션 설정
✏️ 데이터 수정 요청 관리 (useMutation)
useMutation 사용
이벤트 핸들러 설정
🔄 캐시 무효화
Query Key Factory 방식
📤 응답 결과값 이용
⚡ 낙관적 업데이트
Todo 업데이트 API 함수 작성
낙관적 업데이트 구현
에러 처리 및 원상복구
🗑️ 삭제 기능 구현
초기 설정 (Mutation 커스텀 훅 생성)
캐시 데이터 수정 방식
isPending을 활용한 에러 방지
📃 캐시 정규화

목차

🌐 서버 상태 관리와 Tanstack Query
JSON Server 설정
📥 데이터 조회 요청 관리
Tanstack Query 설치
useQuery 훅 사용
커스텀 훅으로 분리
🔄 캐싱 메커니즘
캐시 데이터의 상태
리패칭이 발생하는 특정 타이밍
Tanstack Query Devtools 설치
StaleTime과 Refetch 옵션 설정
🗂️ Tanstack Query 캐시의 5가지 상태
Inactive 상태
gcTime (Garbage Collection Time)
staleTime과 gcTime의 관계
글로벌 옵션 설정
✏️ 데이터 수정 요청 관리 (useMutation)
useMutation 사용
이벤트 핸들러 설정
🔄 캐시 무효화
Query Key Factory 방식
📤 응답 결과값 이용
⚡ 낙관적 업데이트
Todo 업데이트 API 함수 작성
낙관적 업데이트 구현
에러 처리 및 원상복구
🗑️ 삭제 기능 구현
초기 설정 (Mutation 커스텀 훅 생성)
캐시 데이터 수정 방식
isPending을 활용한 에러 방지
📃 캐시 정규화

이 포스팅은 인프런의 "한 입 크기로 잘라먹는 실전 프로젝트 - sns편" 강의 내용을 바탕으로 SNS 서비스 구축 간 학습 내용을 정리한 것입니다.


🌐 서버 상태 관리와 Tanstack Query

React 앱의 상태를 분류한다고 하면 지역 상태와 전역 상태 정도로 구분할 수 있을 것입니다.

그런데 만약 서버로부터 받아오는 비동기 API 요청 관련 데이터는 어디에 저장해야 할까요?

하나의 컴포넌트가 이용한다면 지역상태, 모든 컴포넌트가 이용한다면 전역 상태를 이용하면 될 것 같습니다.

그러나 서버로 보내는 요청에는 굉장히 많은 데이터가 포함되어 있습니다.

예를 들어 로딩상태, 성공/실패 유무, 에러 객체, 캐시옵션 등등 다양한 데이터가 포함되어 있기 때문에 zustand와 같은 전역상태관리도구를 이용해서 관리를 하려고 하면 굉장히 많은 초기 state와 각각의 action 함수를 만들어주어야 하는 번거로움이 있을 것입니다.

또한, 이러한 작업을 모든 API 요청에 대해 각각 스토어를 만들고 관리해야 하기 때문에 코드가 매우 복잡해지고 무리가 발생합니다.

💡 그래서 이러한 데이터를 보통 "서버 상태" 라는 별도 유형으로 관리하게 됩니다. 앞서 본 것처럼 API 요청에 포함되는 많은 데이터들을 직접 관리하기에는 코드가 복잡해지기 때문에 Tanstack query 라는 라이브러리를 사용합니다.

굉장히 짧은 코드만으로도 API 요청을 보내는 것은 물론, 로딩상태, 에러상태 등등을 굉장히 편하게 가져올 수 있습니다.

정리하자면 리액트 앱에서의 상태는 관리해야 하는 데이터의 특징에 따라 특정 컴포넌트에서만 접근이 가능하다면 지역상태로, 반대로 모든 컴포넌트에서 접근이 가능해야 한다면 전역상태로 나누게 되는데 복잡한 데이터를 모두 포함하는 API요청과 관련된 데이터들은 서버상태로 관리합니다.

그리고 이런 서버상태 관리는 일반적으로 tanstack query라는 라이브러리를 활용합니다.

JSON Server 설정

🔧 json-server는 json형태의 파일을 이용해서 간단한 api 서버를 만들 수 있도록 도와주는 도구입니다.

db.json 파일이 리액트 앱의 파일로 간주되지 않도록 (파일 수정 시 리액트 앱이 리렌더링 되지 않도록) vite 설정을 추가합니다.

css
server: {
  watch: {
    ignored: ['**/server/**'],
  }
}

server 폴더의 하위 파일을 감지하지 않습니다.

json server 설치

plain
npm install json-server -D

json server 실행

bash
npx json-server server/db.json

📥 데이터 조회 요청 관리

Tanstack Query 설치

css
npm i @tanstack/react-query

리액트 모든 컴포넌트에서 탠스택 쿼리를 이용할 수 있도록 진입점이 되는 main.tsx에서 QueryClientProvider 컴포넌트가 App 컴포를 감싸도록 위치시킵니다.

💡 QueryClient는 탠스택 쿼리를 활용해서 관리하는 서버상태를 저장하는 보관소 역할을 합니다.

javascript
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { BrowserRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <BrowserRouter>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </BrowserRouter>,
);

useQuery 훅 사용

javascript
import TodoEditor from "@/components/todo-list/todo-editor";
import TodoItem from "@/components/todo-list/todo-item";
import { API_URL } from "@/lib/constant";
import type { Todo } from "@/types";
import { useQuery } from "@tanstack/react-query";

// 데이터를 조회하는 api 함수
async function fetchTodos() {
  const response = await fetch(`${API_URL}/todos`);
  if (!response.ok) {
    throw new Error("Failed to fetch todos");
  }
  const data: Todo[] = await response.json();
  return data;
}

export default function TodoListPage() {
  // tanstack query 활용 후
  const {
    data: todos,
    isLoading,
    error,
  } = useQuery({
    queryFn: () => fetchTodos(),
    queryKey: ["todos"],
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div className="flex flex-col gap-5 p-5">
      <h1 className="text-2xl font-bold">TodoListPage</h1>
      <TodoEditor />
      {todos?.map((item) => (
        <TodoItem key={
item.id
} {...item} />
      ))}
    </div>
  );
}

usequery() 훅에 인수로 객체를 전달하는데 queryFn에는 API 함수, queryKey에는 ["..."] 와 같은 형태를 전달합니다.

그러면 useQuery 훅이 자동으로 queryFn을 컴포넌트 마운트 시 자동으로 호출하게 되고 결과값을 queryKey 이름으로 저장합니다.

반환값으로는:

  • data: api 함수 반환값 저장
  • isLoading: queryFn의 로딩상태 전달
  • error: queryFn의 에러객체를 전달

그 외에도 api 요청에 관련된 거의 모든 상태값을 반환할 수 있습니다.

⚠️ tanstack query는 기본적으로 재시도가 내장되어 있는데 재시도를 보내고 싶지 않으면 useQuery에 retry 값을 0으로 지정해주면 됩니다.

커스텀 훅으로 분리

typescript
// useQuery를 별도의 커스텀 훅으로 분리
const { data: todos, isLoading, error } = useTodosData();

use-todos.data.ts

typescript
import { fetchTodos } from "@/api/fetch-todos";
import { useQuery } from "@tanstack/react-query";

export function useTodosData() {
  return useQuery({
    queryKey: ["todos"],
    queryFn: () => fetchTodos(),
    retry: 0,
  });
}

🔄 캐싱 메커니즘

c08e434f-bd50-464c-aa70-83b582a9564c.png

bbf48fb5-3b7d-4299-a163-66a7130b6b82.png

캐시 데이터의 상태

  • fetching: 데이터를 불러오는 중일 때
  • fresh: 데이터가 신선한 상태일 때
  • stale: 데이터가 상한 상태 (staleTime: 유통기한)

이런 상태까지 변해버리게 되면 특정 타이밍에 리패치 동작을 수행합니다.

  • refetching: 데이터를 다시 불러옴

일종의 순환구조를 갖게 됩니다.

리패칭이 발생하는 특정 타이밍

🔄 리패칭 타이밍은 설정에서 on/off 가능합니다:

  1. Mount: 컴포넌트 마운트 시

  2. WindowFocus: 사용자가 이 탭에 다시 돌아왔을 때

  3. Reconnect: 인터넷 연결이 끊어졌다가 다시 연결되었을 때

  4. Interval: 특정 시간을 주기로

57f170b3-6865-46cf-b71f-04873ded7dd9.png

Tanstack Query Devtools 설치

tanstack query의 캐시들을 시각적으로 보여주는 tanstack query devtools를 설치합니다.

graphql
npm install @tanstack/react-query-devtools

main.tsx 상단에 ReactQueryDevtools를 import합니다.

StaleTime과 Refetch 옵션 설정

⚠️ tanstack query는 기본적으로 모든 캐시 데이터의 stale time을 0초로 설정해두었기 때문에 조회하자마자 바로 stale 상태가 되어버리고 특정 타이밍(mount, windowFocus, refetchInterval...)에 다시 refetching이 이루어집니다.

이러한 refetch 시점(조건)은 useQuery에서 옵션으로 on/off 할 수 있습니다.

typescript
export function useTodoDataById(id: number) {
  return useQuery({
    queryKey: ["todos", id],
    queryFn: () => fetchTodoById(id),

    refetchInterval: false,
    refetchOnMount: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
  });
}

또한 staleTime을 설정함으로써 데이터가 fresh한 상태를 오래 유지할 수 있도록 할 수 있습니다.

typescript
staleTime: 1000 * 60 * 5,

💡 실시간으로 데이터가 최신화되어야 하는 서비스가 아니라면 보통은 너무 과한 데이터 요청을 방지하기 위해 staletime을 5~30초 정도로 설정하는 것이 일반적입니다.

중요! stale 상태인 캐시데이터가 더이상 사용되지 않는 것은 아닙니다.

tanstack query는 일단 stale 상태인 캐시데이터를 사용자에게 먼저 보여주고 refetching은 백그라운드에서 수행되어 완료 후 교체하는 방식으로 동작합니다. 즉, 사용자에게 빠르게 데이터를 보여주기 위함입니다.


🗂️ Tanstack Query 캐시의 5가지 상태

  1. fetching
  2. fresh
  3. stale
  4. inactive
  5. deleted

Inactive 상태

fresh 상태에서 inactive 상태로 변경되는 시점은 해당 캐시 데이터를 활용하는 컴포넌트가 없을 때(하나도 존재하지 않을 때)입니다.

예를 들어 todos 캐시 데이터는 todo 아이템 별 상세페이지에서는 이용되지 않기 때문에 inactive 상태로 전환됩니다.

즉, inactive는 캐시 데이터가 현재 사용되지 않는 상태를 의미하며, 당연히 이러한 상태의 데이터가 많아서 좋을게 없습니다. 메모리 낭비로 이어질 수 있기 때문입니다.

gcTime (Garbage Collection Time)

그래서 gcTime 시간이 지나게 되면 deleted 상태가 되며, 메모리에서 삭제되버립니다.

gcTime은 devTools에서 query explorer에서 확인할 수 있습니다. **기본값은 300000 ms (5분)**입니다.

즉, inactive 상태에서 5분 뒤 deleted 상태로 전환됩니다.

잠시 gcTime을 5초로 설정하면 아래와 같이 설정할 수 있고 그리고 inactive 상태인 캐시데이터가 5초 뒤 메모리에서 사라지는 것은 devtools에서 확인 가능합니다.

typescript
export function useTodoDataById(id: number) {
  return useQuery({
    queryKey: ["todos", id],
    queryFn: () => fetchTodoById(id),
    staleTime: 1000 * 5,
    gcTime: 1000 * 5,
  });
}

staleTime과 gcTime의 관계

⚠️ 그리고 헷갈릴 수 있는 부분이 staleTime과 gcTime이 같이 동작하지 않을까라는 착각입니다.
예를 들어 staleTime이 10분이고 gcTime이 5초인 설정의 query를 통해 데이터를 조회해온다면 gcTime이 5초이더라도 staleTime이 10분이기 때문에 10분 뒤에나 deleted 되지 않을까? 라고 착각할 수 있습니다.

staleTime은 캐시 데이터가 얼마나 fresh한 상태를 유지할 것인지에 대한 설정입니다.

그래서 fresh한 캐시데이터라고 할지라도 현재 페이지의 어느 컴포넌트에서도 사용되지 않는 캐시데이터는 inactive 상태가 되고 gcTime 이후 삭제되버립니다.

💡 즉, staleTime과 gcTime은 각각 다르게 동작한다는 것입니다.

글로벌 옵션 설정

그리고 useQuery 내 설정한 캐시의 유효기간을 설정하는 옵션과 리페칭 타임을 결정하는 옵션들은 글로벌하게 모든 쿼리에 일괄 적용할 수도 있습니다.

typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 5,

      refetchOnMount: false,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      refetchInterval: false,
    },
  },
});

main.tsx의 queryClient 생성자에 인수로 객체 옵션을 전달하면서 defaultOptions를 설정해주면 됩니다.

물론, 특정 쿼리에는 다른 옵션을 적용하고 싶다면 해당 쿼리에 다른 옵션을 적용하면 덮어쓰기가 됩니다.


✏️ 데이터 수정 요청 관리 (useMutation)

todo 항목 생성을 위한 api 함수를 작성합니다.

typescript
import { API_URL } from "@/lib/constant";
import type { Todo } from "@/types";

export async function createTodo(content: string) {
  const response = await fetch(`${API_URL}/todos`, {
    method: "POST",
    body: JSON.stringify({
      content,
      isDone: false,
    }),
  });
  if (!response.ok) {
    throw new Error("Failed to create todo");
  }
  const data: Todo = await response.json();
  return data;
}

이제 tanstack query의 기능으로 위 비동기 요청을 관리하도록 해야 하는데 이전과 같이 useQuery를 사용할 수는 없습니다.

💡 useQuery는 단순 데이터 조회, 즉 데이터를 불러오는 요청을 관리하기 위한 훅입니다.
그래서 이렇게 데이터를 조작하는 비동기 요청을 관리하기 위해서는 useQuery 대신 useMutation이라는 훅을 사용해야 합니다.

useMutation 사용

tanstack query로부터 useMutation 훅을 호출하고 여기에 옵션 객체를 인자로 전달합니다.

옵션 객체 안의 mutationFn에는 위에서 만든 비동기 함수를 전달합니다.

그러면 useMutation훅은 mutate 라는 함수를 반환하는데 이 mutate의 역할은 단순히 mutationFn으로 설정된 비동기 요청을 실행하는 역할입니다.

너무 단순한 역할 같아 보이지만 이러한 역할이 필요한 이유가 있습니다.

💡 useMutation은 useQuery와 같이 컴포넌트가 마운트되자마자 비동기 함수를 호출하지 않습니다.
대신 원하는 타이밍에 호출할 수 있도록 조절하기 위해 mutate 함수가 필요합니다.

그러면 굳이 useMutation훅을 사용하지 않고 그냥 createTodo 와 같은 비동기 함수를 내가 원하는 시점에 호출되도록 하면 되지 않을까 라는 의문이 생기는데, 이렇게 사용하는 이유는 useMutation 훅으로 createTodo함수의 상태까지 관리하기 위함입니다.

typescript
export default function TodoEditor() {
  const { mutate } = useMutation({
    mutationFn: createTodo,
  });
  const [content, setContent] = useState("");

  const handleAddClick = () => {
    if (!content.trim()) return;
    mutate(content);
    setContent("");
  };

위와 같이 해두면 사용자가 버튼을 클릭했을 때, mutate 함수가 실행되어 인자로 받은 content를 createTodo 비동기 함수에 전달해주고 이 함수가 호출되며, 입력값이 db에 저장됩니다.

그런데 이때 만약 로딩상태를 추적하고 싶다면 mutate와 함께 반환되는 isPending을 활용하면 됩니다.

isPending은 비동기 요청의 로딩상태를 파악할 수 있습니다.

이벤트 핸들러 설정

useMutation 훅에 이벤트 핸들러를 설정하여 useMutation이 관리하고 있는 비동기 함수(요청)가 성공/실패 시의 동작을 설정할 수 있습니다.

이를 위해 useMutation 훅의 옵션 객체에는 4가지 이벤트 핸들러를 등록할 수 있습니다:

  1. onMutate: 요청이 시작(발송)되었을 때
  2. onSettled: 요청이 종료되었을 때
  3. onSuccess: 요청이 성공했을 때
  4. onError: 요청이 실패했을 때
typescript
const { mutate, isPending } = useMutation({
  mutationFn: createTodo,
  onMutate: () => {},
  onSettled: () => {},
  onSuccess: () => {},
  onError: () => {},
});

이렇게 여러 설정값이 존재하다보니 사용하는 컴포넌트 내부에서 점점 mutation 관련 코드가 길어지고 이는 가독성을 해칠 수 있어, 별도 커스텀 훅으로 분리하여 쓰는 것이 일반적입니다.

typescript
import { createTodo } from "@/api/create-todo";
import { useMutation } from "@tanstack/react-query";

export function useCreateTodoMutation() {
  return useMutation({
    mutationFn: createTodo,
    onMutate: () => {},
    onSettled: () => {},
    onSuccess: () => {},
    onError: () => {},
  });
}
typescript
const { mutate, isPending } = useCreateTodoMutation();

🔄 캐시 무효화

변경된 데이터를 화면에 즉시 반영하기 위해 페이지 전체를 새로고침하는 것이 아니라 변경된 캐시 데이터를 다시 불러와야 합니다.

즉, 캐시 데이터를 무효화 시키고 다시 refetching 되도록 해야 합니다.

이를 위해서 mutate가 관리하는 비동기 함수의 요청이 성공할 경우, queryClient를 호출하고 queryClient의 invalidateQueries 라는 메서드를 호출하여 인수로 옵션 객체를 전달하는데 무효화하기를 원하는 쿼리 키를 옵션객체 내에 설정하면 됩니다.

typescript
export function useCreateTodoMutation() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createTodo,
    onMutate: () => {},
    onSettled: () => {},
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["todos"],
      });
    },
    onError: () => {},
  });
}

💡 이때, queryClient란 서버상태와 관련된 모든 데이터를 보관하는 store입니다. 여기엔 cache data와 state 등이 모두 보관되어 있습니다.

다시 돌아가서 아이템을 추가할 경우 기존 캐시데이터가 무효화되고 다시 refetching 하여 화면을 업데이트 하고 있는걸 확인할 수 있는데, 그러나 위처럼 구현하는 방식에는 한가지 문제점이 있습니다.

⚠️ todos라는 키값을 갖는 모든 캐시데이터를 무효화 하고 있는데, 이렇게 되면 전체를 조회하는 것외에도 id값을 기준으로 하나의 개별 Todo 아이템을 불러오는 ['todos', id] 도 무효화가 됩니다.
그래서 이렇게 되면 불필요한 데이터까지 무효화하는 비효율이 발생하기 때문에 쿼리키를 통합으로 관리하는 하나의 객체를 만들어서 사용하게 됩니다.

Query Key Factory 방식

lib/constants.ts에 아래와 같이 구조화 하면

typescript
export const QUERY_KEYS = {
  todo: {
    all: ["todo"],
    list: ["todo", "list"],
    detail: (id: string) => ["todo", "detail", id],
  },
};

특정 캐시데이터만 무효화 시킬 수 있습니다.

이러한 방식을 query key factory 방식이라고 부릅니다.

typescript
queryKey: QUERY_KEYS.todo.detail(id),
queryKey: QUERY_KEYS.todo.list,

📤 응답 결과값 이용

만약 무효화하려는 todos의 데이터가 무수히 많을 경우 그만큼을 다시 refetching 하다보면 서버에 부하를 줄 수도 있습니다.

이런 경우에는 캐시 데이터를 모두 무효화하는 방식보다는 mutationFn의 반환값을 이용하면 됩니다.

mutationFn의 반환값은 onSuccess 이벤트 핸들러의 매개변수로 제공이 되며, 타입 추론까지도 가능합니다.

그래서 데이터를 추가하는 경우에는 아래와 같이 queryClient의 setQueryData 메서드를 이용해서 기존 캐시 데이터에 새로 반환받은 데이터를 추가하는 방식으로 구현할 수 있습니다.

이때, setQueryData 메서드는:

  • 첫번째 인수로는 수정할 캐시 데이터의 key 값이 들어옵니다
  • 두번째 인수로는 함수형 업데이트를 위한 화살표 함수가 들어오고 해당 함수의 인수로는 첫번째 인수로 전달한 키값을 갖는 캐시 데이터가 들어옵니다
typescript
onSuccess: (newTodo) => {
  queryClient.setQueryData<Todo[]>(QUERY_KEYS.todo.list, (prevTodos) => {
    if (!prevTodos) return [newTodo];
    return [...prevTodos, newTodo];
  });
},

그러면 데이터 리패칭 없이도 새로 추가한 데이터가 화면에 바로 업데이트됩니다.

💡 참고! setQueryData메서드에 제네릭을 활용하여 Todo 타입의 배열 타입을 수정할거다라고 알려줍니다.


⚡ 낙관적 업데이트

💡 낙관적 업데이트는 네트워크 요청이 성공하기 전에 성공할거라고 가정하고 지체없이 화면을 업데이트하는 기술입니다.
사용자에게 빠른 반응속도를 제공하며, 주로 좋아요와 같은 기능에서 사용됩니다.

이러한 기술을 활용해보기 위해 todos 목록의 각 아이템마다 완료 여부를 체크하는 checkbox를 만들고 사용자가 클릭 시 isDone(완료여부)이 변경되도록 구현해보려합니다.

UI는 아래와 같습니다.

typescript
import type { Todo } from "@/types";
import { Button } from "../ui/button";
import { Link } from "react-router";

export default function TodoItem({ id, content, isDone }: Todo) {
  const handleDeleteClick = () => {};
  return (
    <div className="flex items-center justify-between border p-2">
      <div className="flex gap-5">
        <input type="checkbox" checked={isDone} />
        <Link to={`/todolist/${id}`}>{content}</Link>
      </div>
      <Button variant="destructive" onClick={handleDeleteClick}>
        삭제
      </Button>
    </div>
  );
}

Todo 업데이트 API 함수 작성

먼저, todo data를 업데이트 하는 비동기 함수를 만들어야 하는데, 보통 아이템을 수정할 때, 여러 프로퍼티 중 하나만을 수정하는 경우도 있습니다.

그래서 todo 매개변수의 타입을 모든 프로퍼티를 선택적 프로퍼티로 바꿔주는 유틸리티 타입인 Partial 타입으로 감싸서 Todo데이터의 여러 프로퍼티 중 일부만 전달할 수 있도록 합니다.

typescript
export async function updateTodo(todo: Partial<Todo>) {}

그러나 id 프로퍼티만은 반드시 존재해야 하므로 아래와 같이 **intersection 타입(교집합 타입)**을 이용해서 id 프로퍼티만은 필수적으로 string 타입으로 전달되도록 합니다.

typescript
export async function updateTodo(todo: Partial<Todo> & { id: string }) {}
typescript
import { API_URL } from "@/lib/constant";
import type { Todo } from "@/types";

export async function updateTodo(todo: Partial<Todo> & { id: string }) {
  const response = await fetch(`${API_URL}/todos/${
todo.id
}`, {
    method: "PATCH",
    body: JSON.stringify(todo),
  });
  if (!response.ok) {
    throw new Error("Failed to update todo");
  }
  const data: Todo = await response.json();
  return data;
}

위와 같이 비동기함수를 만들었다면 이제 이 비동기함수를 관리할 mutation을 커스텀 훅으로 만들어 주어야 합니다.

typescript
import { updateTodo } from "@/api/update-todo";
import { useMutation } from "@tanstack/react-query";

export function useUpdateTodoMutation() {
  return useMutation({
    mutationFn: updateTodo,
  });
}
typescript
import type { Todo } from "@/types";
import { Button } from "../ui/button";
import { Link } from "react-router";
import { useUpdateTodoMutation } from "@/hooks/mutations/use-update-todo-mutation";

export default function TodoItem({ id, content, isDone }: Todo) {
  const { mutate } = useUpdateTodoMutation();
  const handleDeleteClick = () => {};
  const handleCheckBoxClick = () => {
    mutate({ id, isDone: !isDone });
  };
  return (
    <div className="flex items-center justify-between border p-2">
      <div className="flex gap-5">
        <input
          type="checkbox"
          checked={isDone}
          onChange={handleCheckBoxClick}
        />
        <Link to={`/todolist/${id}`}>{content}</Link>
      </div>
      <Button variant="destructive" onClick={handleDeleteClick}>
        삭제
      </Button>
    </div>
  );
}

위와 같이 컴포넌트에서 커스텀 훅을 불러와서 input 태그 클릭 이벤트에서 mutate 함수를 호출하고 수정할 내용을 추가함으로써 사용자가 input 체크박스 클릭 시마다 network 요청이 보내지게 됩니다.

그러나 UI가 바로 변경되지 않고 새로고침을 해야 변경된 UI를 볼 수 있습니다.

낙관적 업데이트 구현

그래서 이런 경우 낙관적 업데이트를 통해 네트워크 요청이 완료가 되지 않았더라도 성공했을거라 가정하고 UI를 먼저 빠르게 업데이트 하도록 해보려면 mutate 함수가 실행되어 비동기 요청이 실행된 타이밍에 요청이 성공할 것이라 가정하고 캐시데이터를 업데이트 시켜주면 됩니다.

그래서 비동기 요청이 시작되었을 때 호출되는 onMutate 이벤트 핸들러에 캐시데이터를 업데이트하는 기능을 설정하면 됩니다.

그런데 onMutate 함수 내부에서는 어떤 기준으로 캐시를 업데이트해야 하는지 알 수 없는 상태입니다. 즉, 업데이트 된 값을 모릅니다.

💡 이럴 땐 onMutate 함수에 자동으로 제공되는 매개변수를 이용하면 됩니다.
이 매개변수는 무엇이냐면 mutationFn이 호출되면서 인수로 전달된 값입니다.

즉, {id, isDone: !isDone} 이러한 값이 전달됩니다.

typescript
import { updateTodo } from "@/api/update-todo";
import { QUERY_KEYS } from "@/lib/constant";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useUpdateTodoMutation() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateTodo,
    onMutate: (updateTodo) => {
      queryClient.setQueryData<Todo[]>(QUERY_KEYS.todo.list, (prevTodos) => {
        if (!prevTodos) return [];
        return 
prevTodos.map
((prevTodo) =>
          
prevTodo.id
 === 
updateTodo.id

            ? { ...prevTodo, ...updateTodo }
            : prevTodo,
        );
      });
    },
  });
}

에러 처리 및 원상복구

낙관적 업데이트를 onMutate와 같은 메서드를 활용해서 구현했는데, 만약 mutationFn의 비동기 함수 요청이 실패하는 예외 상황의 경우엔 어떻게 해야할까요?

요청이 성공할거라 기대하고 만들어둔 캐시 업데이트를 다시 원상복구 해야 합니다.

이를 위해 먼저, onError 이벤트 핸들러를 추가로 활용해야 합니다.

onError에는 예외처리를 위한 3가지 매개변수가 기본적으로 제공됩니다:

  1. error: 현재 발생한 에러정보를 담고 있는 에러 객체
  2. variable: mutationFn을 호출할 때, 인수로 전달한 값
  3. context: onMutate 이벤트 핸들러가 반환한 값

💡 context 매개변수를 이용하면 요청이 실패했을 때, 낙관적으로 업데이트한 캐시 데이터를 원상복구 할 수 있습니다.

그래서 캐시데이터를 업데이트하기 전에 미리 원상복구에 활용될 원본 데이터를 설정해두어야 합니다.

queryClient.getQueryData

typescript
export function useUpdateTodoMutation() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateTodo,
    onMutate: (updateTodo) => {
      const prevTodos = queryClient.getQueryData<Todo[]>(QUERY_KEYS.todo.list);
      queryClient.setQueryData<Todo[]>(QUERY_KEYS.todo.list, (prevTodos) => {
        if (!prevTodos) return [];
        return 
prevTodos.map
((prevTodo) =>
          
prevTodo.id
 === 
updateTodo.id

            ? { ...prevTodo, ...updateTodo }
            : prevTodo,
        );
      });

      return { prevTodos };
    },
  });
}

또한 onMutate 이벤트 핸들러에서 prevTodos를 반환하도록 해두면 onError 이벤트 핸들러의 context 매개변수로 해당 반환값이 전달됩니다.

typescript
onError: (error, variable, context) => {
  if (context && context.prevTodos) {
    queryClient.setQueryData<Todo[]>(
      QUERY_KEYS.todo.list,
      context.prevTodos,
    );
  }
},

🗑️ 삭제 기능 구현

초기 설정 (Mutation 커스텀 훅 생성)

typescript
import { deleteTodo } from "@/api/delete-todo";
import { useMutation } from "@tanstack/react-query";

export function useDeleteTodoMutation() {
  return useMutation({
    mutationFn: deleteTodo,
  });
}

아직 삭제된 아이템이 바로 화면에서 사라지지 않고 새로고침을 해야만 사라집니다.

그래서 이제부터 데이터 삭제 요청이 발송되었을 때, 캐시 데이터 무효화하거나 수정해서 삭제된 아이템이 화면에서 바로 사라지도록 해야 합니다.

캐시 데이터 수정 방식

아래 3가지 캐시 데이터 수정방식 중 삭제 시 일반적으로 많이 사용되는 방법은 두번째 방법인 수정 요청의 응답값을 활용하는 것입니다:

  1. 캐시 무효화 → invalidateQueries
  2. 수정 요청의 응답값 활용 → onSuccess
  3. 낙관적 업데이트 활용 → onMutate
typescript
import { deleteTodo } from "@/api/delete-todo";
import { QUERY_KEYS } from "@/lib/constant";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useDeleteTodoMutation() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: deleteTodo,
    onSuccess: (deletedTodo) => {
      queryClient.setQueryData<Todo[]>(QUERY_KEYS.todo.list, (prevTodos) => {
        if (!prevTodos) return [];
        return prevTodos.filter((prevTodo) => 
prevTodo.id
 !== 
deletedTodo.id
);
      });
    },
  });
}

isPending을 활용한 에러 방지

추가로 설정해주어야 할 사항이 있습니다.

⚠️ 캐시 데이터가 수정되는 시점이 onSuccess 이벤트 핸들러로, 요청이 완료된 이후의 시점이기 때문에 만약 누군가 처리 중 수행여부를 나타내는 checkbox를 클릭해버리게 되면 백엔드 서버에는 삭제가 이루어지고 있는 아이템에 추가로 수정 요청을 보내는 것이기 때문에 이런 상황을 방지하기 위해 isPending을 활용하여 잠재적 에러를 방지해야합니다.

typescript
import type { Todo } from "@/types";
import { Button } from "../ui/button";
import { Link } from "react-router";
import { useUpdateTodoMutation } from "@/hooks/mutations/use-update-todo-mutation";
import { useDeleteTodoMutation } from "@/hooks/mutations/use-delete-todo-mutation";

export default function TodoItem({ id, content, isDone }: Todo) {
  const { mutate: updateTodo } = useUpdateTodoMutation();
  const { mutate: deleteTodo, isPending: isDeleteTodoPending } =
    useDeleteTodoMutation();
  const handleDeleteClick = () => {
    deleteTodo(id);
  };
  const handleCheckBoxClick = () => {
    updateTodo({ id, isDone: !isDone });
  };
  return (
    <div className="flex items-center justify-between border p-2">
      <div className="flex gap-5">
        <input
          disabled={isDeleteTodoPending}
          type="checkbox"
          checked={isDone}
          onChange={handleCheckBoxClick}
        />
        <Link to={`/todolist/${id}`}>{content}</Link>
      </div>
      <Button
        variant="destructive"
        onClick={handleDeleteClick}
        disabled={isDeleteTodoPending}
      >
        삭제
      </Button>
    </div>
  );
}

isPending을 호출해온 뒤 checkbox와 버튼의 disabled 값에 적용합니다.


📃 캐시 정규화

캐시 정규화란 특정 그룹 내부에 아이템이 각각 포함되어 있는 것과 같이 복잡하게 되어 있는 구조를 평탄화함으로서 각 아이템에 쉽게 접근할 수 있게 하고 만약 중복된 데이터가 있다면 제거하여 데이터를 보다 효율적으로 저장하고 관리할 수 있게 하는 방식입니다.

만약 저장 및 관리하는 캐시 데이터가 무수히 많을 경우 중복된 캐시 데이터가 있다면 이를 저장, 관리하는데 많은 리소스 낭비가 발생하기 때문에 정규화를 통해 이러한 문제를 사전에 방지해야 합니다.

예를 들어 todo list 캐시 데이터 배열과 상세페이지에서 조회하는 각 아이템에 대한 캐시 데이터는 서로 중복이 발생합니다.

즉, 배열의 요소들이 각 아이템으로 캐싱되어 있기 때문 입니다.

그래서 이러한 중복된 캐시 데이터를 정규화하기 위해서

먼저, 각각의 아이템을 개별 캐시 아이템으로 평탄화해야 합니다.

그리고 나서 todo list 배열 캐시 데이터에서는 이전처럼 각 아이템(id, content, isDone …)을 저장하는게 아니라 id 값만 참조하도록 정규화를 진행합니다.

이를 구현하기 위해 todo list를 불러오는 커스텀 훅의 QueryFn에 바로 fetch 함수를 호출하는 것이 아니라 화살표 함수로 만든 뒤 내부에서 fetch 함수를 호출하고 반환받은 todo list에 map 메서드를 활용하여 각 요소를 순회하면서 각각의 아이템의 id 값을 반환하도록 설정합니다.

그러면 useQuery는 QueryFn 의 결과값을 캐시데이터로 보관하기 때문에 각 아이템의 id 값만을 모아둔 배열이 캐시 데이터로 보관 됩니다.

typescript
import { QUERY_KEYS } from "@/lib/constant";
import { useQuery } from "@tanstack/react-query";

export function useTodosData() {
  return useQuery({
    queryKey: QUERY_KEYS.todo.list,
    queryFn: async () => {
      const todos = await fetchTodos();
      return todos.map((todo) => todo.id);
    },
    retry: 0,
  });
}

44a44807-8e99-493c-a10e-adc1ad1abf9c.png

그리고 QueryFn 내부에서 각각의 아이템의 데이터를 개별적인 캐시데이터로까지 평탄화를 진행하는 방법은 아래와 같이 forEach 메서드를 활용해서 개별 캐시데이터로 보관합니다.

typescript
import { fetchTodos } from "@/api/fetch-todos";
import { QUERY_KEYS } from "@/lib/constant";
import type { Todo } from "@/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";

export function useTodosData() {
  const queryClient = useQueryClient();
  return useQuery({
    queryKey: QUERY_KEYS.todo.list,
    queryFn: async () => {
      const todos = await fetchTodos();

      todos.forEach((todo) => {
        queryClient.setQueryData<Todo>(QUERY_KEYS.todo.detail(todo.id), todo);
      });

      return todos.map((todo) => todo.id);
    },
    retry: 0,
  });
}

ae17602f-b5c1-4d85-b251-af1415e52d78.png

정규화를 마친 캐시 데이터를 리스트 페이지에서 조회할 때, 최초 한번의 요청으로 각 아이템별 캐시 데이터를 저장하는데

만약 각각의 캐시 데이터가 fresh 상태에서 stale 상태로 넘어가게 되고 이후에 다시 새로고침을 하는 등 refetching 타이밍이 되면 다시 전부 불러와지게 됩니다.

이는 많은 네트워크 요청으로 인한 서버 부하와 같은 심각한 문제를 야기할 수 있으므로

이런 경우에는 하나의 아이템을 불러오는 훅이 type이라는 매개변수를 받도록하며, 이 type이라는 매개변수는 페이지에서 활용하는 캐시 데이터가 detail인지 list인지 구분하는 역할을 하고

만약 type이 detail 일 경우에만 refetching을 하고 싶다라면

기존 staleTime, gcTime, refetchOnMount 등과 같은 옵션 대신 enabled : type === "DETAIL" 과 같이 옵션을 설정해서 특정 상황에서만 refetching 이 이루어지도록 하면 됩니다.

typescript
import { fetchTodoById } from "@/api/fetch-todo-by-id";
import { QUERY_KEYS } from "@/lib/constant";
import { useQuery } from "@tanstack/react-query";

export function useTodoDataById(id: string, type: "DETAIL" | "LIST") {
  return useQuery({
    queryKey: QUERY_KEYS.todo.detail(id),
    queryFn: () => fetchTodoById(id),
    enabled: type === "DETAIL",
  });
}