
サーバー応答(おうとう)を待(ま)たずに、成功(せいこう)を仮定(かてい)して先(さき)にUIを更新(こうしん)する技法(ぎほう)です。
ユーザーは待(ま)つ時間(じかん)なく、即座(そくざ)に変化(へんか)を確認(かくにん)できます。
서버 응답을 기다리지 않고 성공을 가정해서 먼저 UI를 업데이트하는 기법입니다.
사용자는 기다리는 시간 없이 즉시 변화를 확인할 수 있습니다.
❌ 일반적인 업데이트 (느림):
사용자 클릭
↓ (기다림...)
서버 요청
↓ (기다림...)
서버 응답
↓ (기다림...)
UI 업데이트 ← 여기서 처음 반응!
총 시간: 500ms~2초
✅ 낙관적 업데이트 (빠름):
사용자 클릭
↓ (즉시!)
UI 업데이트 ← 바로 반응!
↓ (백그라운드에서)
서버 요청 → 응답 → 검증
총 시간: 사용자 체감 0ms!
| 使用(しよう)場面(ばめん) | 理由(りゆう) | 例(れい) |
|---|---|---|
| SNS いいね | 즉각적(そっかくてき) 피드백 중요(じゅうよう) | Instagram, Twitter |
| Todo 체크박스 | 간단(かんたん)한 토글 작업(さぎょう) | Todo 앱 |
| 설정(せってい) 변경(へんこう) | 사용자(しようしゃ) 경험(けいけん) 개선(かいぜん) | 알림 ON/OFF |
types.ts
// Todo 인터페이스에 완료 상태 추가
export interface Todo {
id: string; // 고유 식별자
content: string; // 할 일 내용
isDone: boolean; // 완료 여부 (새로 추가!)
}
왜 isDone? 체크박스 상태를 저장하기 위한 필수 필드
api/update-todo.ts
import { API_URL } from "@/lib/constants";
import type { Todo } from "@/types";
/**
* Todo 업데이트 API
* @param todo - 업데이트할 Todo 객체 (일부 필드만 포함 가능)
* @returns 업데이트된 Todo 객체
*/
export async function updateTodo(todo: Partial<Todo> & { id: string }) {
// Partial<Todo>: Todo의 모든 필드가 선택적(optional)이 됨
// & { id: string }: 단, id는 반드시 필요 (필수)
// 예: { id: "1", isDone: true } ← content 없이도 OK!
const response = await fetch(`${API_URL}/todos/${todo.id}`, {
method: "PATCH", // PATCH: 일부만 수정, PUT: 전체 교체
headers: {
"Content-Type": "application/json" // JSON 형식 명시
},
body: JSON.stringify(todo), // 객체를 JSON 문자열로 변환
});
// 요청 실패 시 에러 발생
if (!response.ok) throw new Error("Update Todo Failed");
// 서버가 반환한 업데이트된 Todo
const data: Todo = await response.json();
return data;
}
핵심 포인트:
Partial<Todo>: 모든 필드가 선택적 → { isDone: true } 만으로도 업데이트 가능& { id: string }: id만 필수로 지정PATCH vs PUT: PATCH는 일부만, PUT은 전체를 교체hooks/mutations/use-update-todo-mutation.ts
import { updateTodo } from "@/api/update-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useUpdateTodoMutation() {
// QueryClient: TanStack Query의 캐시 관리자
const queryClient = useQueryClient();
return useMutation({
// mutationFn: 실제로 실행될 비동기 함수
mutationFn: updateTodo,
/**
* onMutate: mutation이 실행되기 직전에 호출
* 이 시점에 캐시를 먼저 업데이트 → 낙관적 업데이트!
* @param updatedTodo - mutate()에 전달된 인자
*/
onMutate: (updatedTodo) => {
// updatedTodo = { id: "1", isDone: true }
// 캐시 데이터를 즉시 업데이트
queryClient.setQueryData<Todo[]>(
QUERY_KEYS.todo.list, // 어떤 캐시?
(prevTodos) => { // 이전 데이터를 받아서
// 만약 데이터가 없으면 빈 배열 반환
if (!prevTodos) return [];
// map으로 배열을 순회하면서
return prevTodos.map((prevTodo) =>
// 업데이트 대상인 todo를 찾으면
prevTodo.id === updatedTodo.id
? {
...prevTodo, // 기존 필드 유지 (content 등)
...updatedTodo // 새 필드로 덮어쓰기 (isDone)
}
: prevTodo // 대상이 아니면 그대로 반환
);
}
);
},
});
}
동작 플로우:
1. 사용자가 체크박스 클릭
2. mutate({ id: "1", isDone: true }) 호출
3. onMutate 실행 → 캐시 즉시 업데이트 ⚡
4. 화면에 체크 표시 즉시 반영!
5. (백그라운드) updateTodo API 요청
6. (백그라운드) 서버 응답 대기
components/todo-list/todo-item.tsx
import { Button } from "@/components/ui/button";
import { useUpdateTodoMutation } from "@/hooks/mutations/use-update-todo-mutation";
import type { Todo } from "@/types";
import { Link } from "react-router";
export default function TodoItem({ id, content, isDone }: Todo) {
// mutation 훅 사용
const { mutate } = useUpdateTodoMutation();
/**
* 체크박스 클릭 핸들러
* isDone 상태를 토글 (true ↔ false)
*/
const handleCheckboxClick = () => {
mutate({
id, // 어떤 todo?
isDone: !isDone, // 현재 상태의 반대로 변경
});
// 이 순간 캐시가 먼저 업데이트되어 화면에 즉시 반영!
};
return (
<div className="flex items-center justify-between border p-2">
<div className="flex gap-5">
{/* 제어 컴포넌트: checked 값이 isDone과 동기화 */}
<input
onClick={handleCheckboxClick}
type="checkbox"
checked={isDone} // isDone이 true면 체크 표시
readOnly // onClick으로만 제어
/>
<Link to={`/todolist/${id}`}>{content}</Link>
</div>
<Button variant="destructive">삭제</Button>
</div>
);
}
왜 즉시 반응하는가?
onMutate에서 캐시를 먼저 업데이트
↓
React Query가 자동으로 컴포넌트 리렌더링
↓
isDone 값이 변경됨
↓
checked={isDone}으로 체크박스 UI 변경
↓
사용자는 클릭 즉시 체크 표시를 봄! ⚡
❌ 문제:
1. 사용자가 체크박스 클릭
2. onMutate에서 캐시 업데이트 → 체크 표시
3. 서버 요청 실패! (네트워크 오류, 권한 없음 등)
4. 하지만 화면에는 여전히 체크 표시...
5. 데이터 불일치 발생! 💥
hooks/mutations/use-update-todo-mutation.ts (완전판)
import { updateTodo } from "@/api/update-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useUpdateTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
/**
* onMutate: mutation 실행 직전 (가장 먼저 실행)
* 역할:
* 1. 진행 중인 쿼리 취소
* 2. 현재 캐시 데이터 백업
* 3. 캐시를 낙관적으로 업데이트
* 4. 롤백용 데이터 반환
*/
onMutate: async (updatedTodo) => {
// ===== 1단계: 진행 중인 쿼리 취소 =====
// 왜? 경합 상태(race condition) 방지
// 예: 조회 요청이 진행 중인데 수정하면 과거 데이터로 덮어쓸 수 있음
await queryClient.cancelQueries({
queryKey: QUERY_KEYS.todo.list,
});
// 이제 이 쿼리키의 모든 요청이 취소됨!
// ===== 2단계: 현재 캐시 데이터 백업 =====
// 실패 시 복구하기 위해 현재 상태 저장
const prevTodos = queryClient.getQueryData<Todo[]>(
QUERY_KEYS.todo.list
);
// prevTodos = [{ id: "1", isDone: false }, ...]
// ===== 3단계: 캐시 낙관적 업데이트 =====
queryClient.setQueryData<Todo[]>(
QUERY_KEYS.todo.list,
(prevTodos) => {
if (!prevTodos) return [];
// 배열에서 대상 todo 찾아서 업데이트
return prevTodos.map((prevTodo) =>
prevTodo.id === updatedTodo.id
? { ...prevTodo, ...updatedTodo } // 덮어쓰기
: prevTodo
);
}
);
// 이 시점에 UI가 즉시 업데이트됨!
// ===== 4단계: 롤백용 데이터 반환 =====
// 이 객체가 onError의 context로 전달됨
return { prevTodos };
},
/**
* onError: mutation 실패 시 호출
* @param error - 발생한 에러 객체
* @param variable - mutate()에 전달된 인자
* @param context - onMutate에서 반환한 값
*/
onError: (error, variable, context) => {
// context = { prevTodos: [...] } ← onMutate의 return 값
console.error("업데이트 실패:", error);
// ===== 롤백: 이전 상태로 복구 =====
if (context && context.prevTodos) {
queryClient.setQueryData<Todo[]>(
QUERY_KEYS.todo.list,
context.prevTodos // 백업해둔 이전 데이터로 복구
);
// UI가 원래 상태로 돌아감!
}
},
/**
* onSettled: 성공/실패 여부와 관계없이 최종적으로 호출
* 역할: 서버 데이터와 동기화 (재검증)
*/
onSettled: () => {
// invalidateQueries: 캐시를 stale로 만들어 재조회
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.todo.list,
});
// 서버의 최신 데이터로 다시 가져옴 → 데이터 무결성 보장!
},
});
}
✅ 성공 시:
1. 사용자 클릭
2. onMutate: 캐시 업데이트 (백업 저장)
3. → UI 즉시 변경! ⚡
4. mutationFn: 서버 요청
5. onSuccess: (생략 가능)
6. onSettled: 서버 데이터로 재검증
7. → 최종 동기화 완료 ✅
❌ 실패 시:
1. 사용자 클릭
2. onMutate: 캐시 업데이트 (백업 저장)
3. → UI 즉시 변경! ⚡
4. mutationFn: 서버 요청
5. 에러 발생! 💥
6. onError: 백업 데이터로 롤백
7. → UI 원래대로 복구 ↩️
8. onSettled: 서버 데이터로 재검증
9. 사용자에게 에러 알림
❌ cancelQueries 없이:
시간순:
00ms: 사용자가 체크박스 클릭
01ms: 조회 요청 시작 (느린 네트워크)
02ms: onMutate: isDone = true로 업데이트
03ms: 수정 요청 → 즉시 완료
...
500ms: 조회 요청 완료 (과거 데이터: isDone = false)
501ms: 캐시가 과거 데이터로 덮어써짐! 💥
→ 체크 표시 사라짐 (사용자 혼란)
✅ cancelQueries 사용:
시간순:
00ms: 사용자가 체크박스 클릭
01ms: 조회 요청 시작 (느린 네트워크)
02ms: onMutate 실행
→ cancelQueries: 진행 중인 조회 요청 취소! ✋
→ isDone = true로 업데이트
03ms: 수정 요청 → 완료
→ 체크 표시 유지 ✅
api/delete-todo.ts
import { API_URL } from "@/lib/constants";
import type { Todo } from "@/types";
/**
* Todo 삭제 API
* @param id - 삭제할 Todo의 ID
* @returns 삭제된 Todo 객체 (서버가 반환)
*/
export async function deleteTodo(id: string) {
const response = await fetch(`${API_URL}/todos/${id}`, {
method: "DELETE", // HTTP DELETE 메서드
});
if (!response.ok) throw new Error("Delete Todo Failed");
// JSON Server는 삭제된 객체를 반환함
const data: Todo = await response.json();
return data;
}
hooks/mutations/use-delete-todo-mutation.ts
import { deleteTodo } from "@/api/delete-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useDeleteTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTodo,
/**
* onSuccess: 삭제 성공 시 캐시에서도 제거
* @param deletedTodo - 서버가 반환한 삭제된 Todo
*/
onSuccess: (deletedTodo) => {
// deletedTodo = { id: "1", content: "...", isDone: true }
// 캐시 데이터에서 해당 todo 필터링
queryClient.setQueryData<Todo[]>(
QUERY_KEYS.todo.list,
(prevTodos) => {
if (!prevTodos) return [];
// filter: 조건에 맞는 것만 남김
return prevTodos.filter(
(prevTodo) => prevTodo.id !== deletedTodo.id
// 삭제된 id가 아닌 것만 남김 → 결과적으로 삭제
);
}
);
// 화면에서 해당 todo가 즉시 사라짐!
},
});
}
왜 onSuccess를 사용했나?
❌ onMutate (낙관적 업데이트):
- 삭제 실패 시 todo가 "부활"함
- 사용자에게 혼란 줄 수 있음
- 삭제는 되돌리기 어려운 작업
✅ onSuccess (응답 후 업데이트):
- 서버에서 실제로 삭제 확인 후 UI 업데이트
- 안정적이고 신뢰성 높음
- 약간의 지연(~100ms)은 허용 가능
components/todo-list/todo-item.tsx
import { Button } from "@/components/ui/button";
import { useDeleteTodoMutation } from "@/hooks/mutations/use-delete-todo-mutation";
import { useUpdateTodoMutation } from "@/hooks/mutations/use-update-todo-mutation";
import type { Todo } from "@/types";
import { Link } from "react-router";
export default function TodoItem({ id, content, isDone }: Todo) {
// ===== Mutation 훅들 =====
const {
mutate: deleteTodo, // 삭제 함수
isPending: isDeleteTodoPending // 삭제 중인지 여부
} = useDeleteTodoMutation();
const { mutate: updateTodo } = useUpdateTodoMutation();
/**
* 삭제 버튼 클릭 핸들러
*/
const handleDeleteClick = () => {
// 확인 대화상자 표시 (선택사항)
if (confirm("정말 삭제하시겠습니까?")) {
deleteTodo(id);
// isPending이 true가 됨
// → 버튼과 체크박스 비활성화
}
};
/**
* 체크박스 클릭 핸들러
*/
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} // 삭제 중이면 클릭 불가
onClick={handleCheckboxClick}
type="checkbox"
checked={isDone}
readOnly
/>
<Link to={`/todolist/${id}`}>{content}</Link>
</div>
{/* 삭제 버튼 */}
<Button
disabled={isDeleteTodoPending} // 삭제 중이면 비활성화
onClick={handleDeleteClick}
variant="destructive"
>
{isDeleteTodoPending ? "삭제 중..." : "삭제"}
</Button>
</div>
);
}
왜 disabled={isDeleteTodoPending}?
❌ disabled 없이:
사용자가 삭제 버튼 클릭
↓ (삭제 진행 중)
사용자가 체크박스 클릭
↓
업데이트 요청이 이미 삭제된 todo에 전송됨
↓
404 에러 발생! 💥
↓
사용자 혼란
✅ disabled 적용:
삭제 진행 중
↓
체크박스와 버튼 모두 비활성화
↓
추가 클릭 불가능
↓
안전하게 삭제 완료! ✅
| 방법(ほうほう) | 구현(こうげん) | 속도(そくど) | 안정성(あんていせい) | 사용(しよう) 시기(じき) |
|---|---|---|---|---|
| 캐시 무효화(むこうか) | invalidateQueries | 🐢 느림(おそ) (리페칭 필요(ひつよう)) | ⭐⭐⭐ 최고(さいこう) | 데이터 정확성(せいかくせい) 중요(じゅうよう)할 때 |
| 응답값(おうとうち) 활용(かつよう) | onSuccess + setQueryData | 🐇 빠름(はや) | ⭐⭐ 좋음(よ) | 일반적(いっぱんてき)인 CRUD |
| 낙관적(らっかんてき) 업데이트 | onMutate + onError | 🚀 초고속(ちょうこうそく) | ⭐ 보통(ふつう) | UX가 최우선(さいゆうせん)일 때 |
// ===== 방법 1: 캐시 무효화 =====
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.todo.list,
});
// 장점: 서버 데이터와 100% 동기화
// 단점: 네트워크 요청 한 번 더 (느림)
}
// ===== 방법 2: 응답값 활용 (권장) =====
onSuccess: (deletedTodo) => {
queryClient.setQueryData<Todo[]>(
QUERY_KEYS.todo.list,
(prev) => prev.filter(t => t.id !== deletedTodo.id)
);
// 장점: 빠르고 안정적
// 단점: 약간의 지연 (~100ms)
}
// ===== 방법 3: 낙관적 업데이트 =====
onMutate: (id) => {
const prev = queryClient.getQueryData(...);
queryClient.setQueryData(...); // 즉시 삭제
return { prev };
},
onError: (err, vars, context) => {
queryClient.setQueryData(..., context.prev); // 롤백
alert("삭제 실패! 다시 나타남"); // 혼란스러움!
}
// 장점: 초고속
// 단점: 실패 시 "부활" → 사용자 혼란
// ❌ 정규화 전: 데이터가 중복 저장됨
// 리스트 캐시
["todo", "list"]: [
{ id: "1", content: "운동하기", isDone: false },
{ id: "2", content: "공부하기", isDone: true },
{ id: "3", content: "청소하기", isDone: false }
]
// 상세 페이지 캐시 (중복!)
["todo", "detail", "1"]: { id: "1", content: "운동하기", isDone: false }
["todo", "detail", "2"]: { id: "2", content: "공부하기", isDone: true }
문제점:
1. 메모리 낭비: 같은 데이터가 2곳에 저장
2. 동기화 어려움: 한 곳만 업데이트하면 불일치
3. 업데이트 복잡: 리스트와 상세를 따로 업데이트해야 함
// ✅ 정규화 후: ID만 저장, 실제 데이터는 한 곳에
// 리스트 캐시: ID만 저장!
["todo", "list"]: ["1", "2", "3"]
// 개별 캐시: 실제 데이터는 여기만!
["todo", "detail", "1"]: { id: "1", content: "운동하기", isDone: false }
["todo", "detail", "2"]: { id: "2", content: "공부하기", isDone: true }
["todo", "detail", "3"]: { id: "3", content: "청소하기", isDone: false }
장점:
1. ✅ 메모리 효율: 데이터가 한 곳에만 존재
2. ✅ 자동 동기화: 한 곳만 업데이트하면 끝
3. ✅ 간단한 업데이트: 개별 캐시만 수정
4. ✅ 리페칭 최소화: 이미 있는 데이터 재사용
hooks/queries/use-todos-data.ts
import { fetchTodos } from "@/api/fetch-todos";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function useTodosData() {
const queryClient = useQueryClient();
return useQuery({
/**
* queryFn: 데이터 가져오는 함수
* 정규화 로직이 포함됨!
*/
queryFn: async () => {
// ===== 1단계: 서버에서 전체 todo 가져오기 =====
const todos = await fetchTodos();
// todos = [
// { id: "1", content: "...", isDone: false },
// { id: "2", content: "...", isDone: true },
// ...
// ]
// ===== 2단계: 각 todo를 개별 캐시에 저장 =====
todos.forEach((todo) => {
// 각 todo를 detail 캐시에 저장
queryClient.setQueryData<Todo>(
QUERY_KEYS.todo.detail(todo.id), // ["todo", "detail", "1"]
todo // { id: "1", content: "...", isDone: false }
);
});
// 이제 개별 캐시들이 생성됨:
// ["todo", "detail", "1"] = { id: "1", ... }
// ["todo", "detail", "2"] = { id: "2", ... }
// ["todo", "detail", "3"] = { id: "3", ... }
// ===== 3단계: ID 배열만 반환 (리스트용) =====
return todos.map((todo) => todo.id);
// ["1", "2", "3"]
},
// 이 쿼리의 결과는 ID 배열!
queryKey: QUERY_KEYS.todo.list,
});
}
동작(どうさ) 흐름(ながれ):
1. useTodosData() 호출
2. fetchTodos()로 서버에서 전체 데이터 가져옴
3. forEach로 각 todo를 개별 캐시에 저장
["todo", "detail", "1"] ← todo 1
["todo", "detail", "2"] ← todo 2
["todo", "detail", "3"] ← todo 3
4. ID만 추출해서 반환: ["1", "2", "3"]
5. 이 ID 배열이 ["todo", "list"] 캐시에 저장됨
6. 완료!
결과:
- ["todo", "list"] = ["1", "2", "3"]
- ["todo", "detail", "1"] = { 전체 데이터 }
- ["todo", "detail", "2"] = { 전체 데이터 }
- ["todo", "detail", "3"] = { 전체 데이터 }
pages/todo-list-page.tsx
import TodoEditor from "@/components/todo-list/todo-editor";
import TodoItem from "@/components/todo-list/todo-item";
import { useTodosData } from "@/hooks/queries/use-todos-data";
export default function TodoListPage() {
// ===== 이제 todoIds는 문자열 배열! =====
const { data: todoIds, isLoading, error } = useTodosData();
// todoIds = ["1", "2", "3"]
if (error) return <div>오류가 발생했습니다.</div>;
if (isLoading) return <div>로딩 중 입니다 ...</div>;
return (
<div className="flex flex-col gap-5 p-5">
<h1 className="text-2xl font-bold">TodoList</h1>
<TodoEditor />
{/* ID만 전달! TodoItem이 개별 캐시에서 데이터 가져옴 */}
{todoIds?.map((id) => (
<TodoItem key={id} id={id} />
// props: { id: "1" } ← 객체 전체가 아닌 ID만!
))}
</div>
);
}
components/todo-list/todo-item.tsx
import { Button } from "@/components/ui/button";
import { useDeleteTodoMutation } from "@/hooks/mutations/use-delete-todo-mutation";
import { useUpdateTodoMutation } from "@/hooks/mutations/use-update-todo-mutation";
import { useTodoDataById } from "@/hooks/queries/use-todo-data-by-id";
import { Link } from "react-router";
/**
* 이제 id만 props로 받음!
*/
export default function TodoItem({ id }: { id: string }) {
// ===== ID로 개별 캐시에서 데이터 가져오기 =====
const { data: todo } = useTodoDataById(id, "LIST");
// 내부적으로 ["todo", "detail", id] 캐시를 조회
// 이미 정규화 시 저장해둔 데이터를 재사용!
// → 추가 네트워크 요청 없음! ⚡
// 데이터가 없으면 에러 (타입 안정성)
if (!todo) throw new Error("Todo Data Undefined");
// 구조 분해로 필요한 값만 추출
const { content, isDone } = todo;
// ===== Mutation 훅들 =====
const { mutate: deleteTodo, isPending: isDeleteTodoPending } =
useDeleteTodoMutation();
const { mutate: updateTodo } = useUpdateTodoMutation();
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}
onClick={handleCheckboxClick}
type="checkbox"
checked={isDone}
readOnly
/>
<Link to={`/todolist/${id}`}>{content}</Link>
</div>
<Button
disabled={isDeleteTodoPending}
onClick={handleDeleteClick}
variant="destructive"
>
삭제
</Button>
</div>
);
}
핵심(かくしん) 변화(へんか):
❌ 정규화 전:
props = { id, content, isDone } // 전체 객체 전달
→ 부모에서 전체 데이터 가져옴
→ 자식에게 props로 전달
→ 비효율적
✅ 정규화 후:
props = { id } // ID만 전달
→ 자식이 직접 개별 캐시에서 가져옴
→ 이미 저장된 데이터 재사용
→ 효율적! ⚡
hooks/mutations/use-create-todo-mutation.ts
import { createTodo } from "@/api/create-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useCreateTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: (newTodo) => {
// newTodo = { id: "4", content: "새 할일", isDone: false }
// ===== 1단계: 개별 캐시에 저장 =====
queryClient.setQueryData<Todo>(
QUERY_KEYS.todo.detail(newTodo.id), // ["todo", "detail", "4"]
newTodo // { id: "4", content: "새 할일", isDone: false }
);
// 새 todo가 개별 캐시에 저장됨!
// ===== 2단계: 리스트에 ID 추가 =====
queryClient.setQueryData<string[]>(
QUERY_KEYS.todo.list, // ["todo", "list"]
(prevTodoIds) => {
// prevTodoIds = ["1", "2", "3"]
if (!prevTodoIds) return [newTodo.id];
// 기존 ID 배열에 새 ID 추가
return [...prevTodoIds, newTodo.id];
// ["1", "2", "3", "4"]
},
);
// 리스트에 새 ID가 추가됨!
// TodoItem이 이 ID로 개별 캐시 조회 → 자동 렌더링!
},
onError: (error) => {
window.alert(error.message);
},
});
}
왜(なぜ) 2단계(だんかい)로 나뉘나?
정규화 구조에서는:
1. 실제 데이터 = ["todo", "detail", id]에 저장
2. ID 목록 = ["todo", "list"]에 저장
추가 시:
1. 새 todo를 개별 캐시에 저장
2. 그 ID를 리스트에 추가
이렇게 하면:
- 데이터 중복 없음
- 리스트가 자동으로 업데이트됨
- TodoItem이 개별 캐시에서 데이터 조회
hooks/mutations/use-update-todo-mutation.ts
import { updateTodo } from "@/api/update-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useUpdateTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// updatedTodo = { id: "1", isDone: true }
// ===== 개별 캐시만 업데이트! =====
// 리스트는 건드릴 필요 없음 (ID는 그대로니까)
// 1. 진행 중인 쿼리 취소
await queryClient.cancelQueries({
queryKey: QUERY_KEYS.todo.detail(updatedTodo.id),
// 이제 개별 캐시의 쿼리만 취소!
});
// 2. 이전 데이터 백업
const prevTodo = queryClient.getQueryData<Todo>(
QUERY_KEYS.todo.detail(updatedTodo.id),
);
// prevTodo = { id: "1", content: "...", isDone: false }
// 3. 개별 캐시 업데이트
queryClient.setQueryData<Todo>(
QUERY_KEYS.todo.detail(updatedTodo.id), // 개별 캐시
(prevTodo) => {
if (!prevTodo) return;
// 기존 데이터에 업데이트 덮어쓰기
return {
...prevTodo, // { id: "1", content: "...", isDone: false }
...updatedTodo // { id: "1", isDone: true }
// 결과: { id: "1", content: "...", isDone: true }
};
},
);
// 개별 캐시만 업데이트됨!
// 이 캐시를 사용하는 모든 컴포넌트 자동 리렌더링!
// 4. 롤백용 데이터 반환
return { prevTodo };
},
onError: (error, variable, context) => {
// 실패 시 개별 캐시 롤백
if (context && context.prevTodo) {
queryClient.setQueryData<Todo>(
QUERY_KEYS.todo.detail(context.prevTodo.id),
context.prevTodo, // 이전 데이터로 복구
);
}
},
});
}
정규화(せいきか)의 장점(ちょうてん):
❌ 정규화 전:
업데이트 시 → 리스트 전체를 map으로 순회
→ 대상 찾아서 업데이트
→ 전체 배열 교체
→ 모든 TodoItem 리렌더링 💥
✅ 정규화 후:
업데이트 시 → 개별 캐시만 업데이트
→ 해당 TodoItem만 리렌더링 ⚡
→ 다른 TodoItem은 영향 없음
→ 성능 최적화!
hooks/mutations/use-delete-todo-mutation.ts
import { deleteTodo } from "@/api/delete-todo";
import { QUERY_KEYS } from "@/lib/constants";
import type { Todo } from "@/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useDeleteTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTodo,
onSuccess: (deletedTodo) => {
// deletedTodo = { id: "2", content: "...", isDone: true }
// ===== 1단계: 개별 캐시 완전 삭제 =====
queryClient.removeQueries({
queryKey: QUERY_KEYS.todo.detail(deletedTodo.id),
// ["todo", "detail", "2"] 캐시를 메모리에서 제거!
});
// 이제 이 todo 데이터는 존재하지 않음
// ===== 2단계: 리스트에서 ID 제거 =====
queryClient.setQueryData<string[]>(
QUERY_KEYS.todo.list,
(prevTodoIds) => {
// prevTodoIds = ["1", "2", "3"]
if (!prevTodoIds) return [];
// filter: 삭제된 ID가 아닌 것만 남김
return prevTodoIds.filter(
(id) => id !== deletedTodo.id
// "2"가 아닌 것만 → ["1", "3"]
);
},
);
// 리스트에서 ID가 제거됨!
// → TodoItem이 렌더링되지 않음 → 화면에서 사라짐!
},
});
}
삭제(さくじょ) 플로우:
1. 사용자가 삭제 버튼 클릭
2. deleteTodo(id) 호출
3. 서버에 DELETE 요청
4. 서버 응답: 삭제 완료
5. onSuccess 실행:
a. removeQueries: 개별 캐시 삭제
["todo", "detail", "2"] ❌
b. setQueryData: 리스트에서 ID 제거
["1", "2", "3"] → ["1", "3"]
6. React Query가 리렌더링 트리거
7. todoIds = ["1", "3"]
8. map으로 TodoItem 생성
→ id="2"인 TodoItem이 생성되지 않음
9. 화면에서 사라짐! ✅
// ❌ 정규화 전
{
["todo", "list"]: [
{ id: "1", content: "운동", isDone: false }, // 400 bytes
{ id: "2", content: "공부", isDone: true }, // 400 bytes
{ id: "3", content: "청소", isDone: false } // 400 bytes
],
["todo", "detail", "1"]: { id: "1", content: "운동", isDone: false }, // 400 bytes (중복!)
["todo", "detail", "2"]: { id: "2", content: "공부", isDone: true }, // 400 bytes (중복!)
["todo", "detail", "3"]: { id: "3", content: "청소", isDone: false } // 400 bytes (중복!)
}
// 총 메모리: 2400 bytes
// ✅ 정규화 후
{
["todo", "list"]: ["1", "2", "3"], // 30 bytes
["todo", "detail", "1"]: { id: "1", content: "운동", isDone: false }, // 400 bytes
["todo", "detail", "2"]: { id: "2", content: "공부", isDone: true }, // 400 bytes
["todo", "detail", "3"]: { id: "3", content: "청소", isDone: false } // 400 bytes
}
// 총 메모리: 1230 bytes (48% 절감!)
// ❌ 정규화 전: 복잡함
onSuccess: (newTodo) => {
// 리스트 업데이트
queryClient.setQueryData(["todo", "list"], (prev) =>
prev.map(t => t.id === newTodo.id ? newTodo : t)
);
// 개별 캐시도 업데이트
queryClient.setQueryData(["todo", "detail", newTodo.id], newTodo);
}
// ✅ 정규화 후: 간단함
onSuccess: (newTodo) => {
// 개별 캐시만 업데이트 (자동 반영!)
queryClient.setQueryData(["todo", "detail", newTodo.id], newTodo);
}
상황: id="2"인 todo를 업데이트
❌ 정규화 전:
1. 리스트 캐시 전체 업데이트
2. 모든 TodoItem 리렌더링 (3개)
3. Virtual DOM 비교 (3개)
4. 실제 DOM 업데이트 (1개만 변경됨)
→ 불필요한 연산 많음
✅ 정규화 후:
1. 개별 캐시만 업데이트
2. 해당 TodoItem만 리렌더링 (1개)
3. Virtual DOM 비교 (1개)
4. 실제 DOM 업데이트 (1개)
→ 최적화됨! ⚡
✅ 필수 구현 사항:
1. onMutate: 캐시 즉시 업데이트
2. cancelQueries: 경합 상태 방지
3. 이전 데이터 백업
4. onError: 롤백 처리
5. onSettled: 재검증
⚠️ 주의사항:
- 삭제는 낙관적 업데이트 지양 (부활 현상)
- 네트워크 느릴 때 테스트 필수
- 에러 처리 반드시 구현
✅ 정규화 구현:
1. 리스트: ID만 저장
2. 개별: 실제 데이터 저장
3. forEach로 개별 캐시 생성
4. ID로 조회하도록 컴포넌트 수정
✅ Mutation 수정:
1. 추가: 개별 캐시 + 리스트 ID 추가
2. 업데이트: 개별 캐시만 수정
3. 삭제: 개별 캐시 제거 + 리스트 ID 제거
⚠️ 장점:
- 메모리 효율 (중복 제거)
- 성능 향상 (부분 리렌더링)
- 코드 간소화
=== 체크박스 클릭 시 ===
1. handleCheckboxClick 실행
2. mutate({ id, isDone: !isDone })
3. onMutate:
- cancelQueries (경합 방지)
- 이전 데이터 백업
- 캐시 즉시 업데이트 ⚡
4. UI 즉시 변경! (사용자가 봄)
5. updateTodo API 요청 (백그라운드)
6. 성공:
- onSuccess (생략 가능)
- onSettled: 재검증
7. 실패:
- onError: 롤백 ↩️
- 사용자에게 알림
- onSettled: 재검증
=== 삭제 버튼 클릭 시 ===
1. handleDeleteClick 실행
2. deleteTodo(id)
3. isPending = true (버튼 비활성화)
4. deleteTodo API 요청
5. 성공:
- onSuccess 실행
- removeQueries: 개별 캐시 삭제
- setQueryData: 리스트 ID 제거
- UI에서 사라짐 ✅
6. isPending = false
이제 한 달 후에 다시 봐도 완벽하게 이해할 수 있을 것입니다! 🚀