Project-hongsta(7)

강홍규(カンホンギュ)·2026년 1월 28일

Project

목록 보기
7/9
post-thumbnail

TanStack Query - 楽観的(らっかんてき)アップデートとキャッシュ正規化(せいきか) 完全版(かんぜんばん)

🚀 楽観的(らっかんてき)アップデート (Optimistic Update) - 核心概念(かくしんがいねん)

楽観的(らっかんてき)アップデートとは?

サーバー応答(おうとう)を待(ま)たずに、成功(せいこう)を仮定(かてい)して先(さき)にUIを更新(こうしん)する技法(ぎほう)です。
ユーザーは待(ま)つ時間(じかん)なく、即座(そくざ)に変化(へんか)を確認(かくにん)できます。

서버 응답을 기다리지 않고 성공을 가정해서 먼저 UI를 업데이트하는 기법입니다.
사용자는 기다리는 시간 없이 즉시 변화를 확인할 수 있습니다.

❌ 일반적인 업데이트 (느림):
사용자 클릭 
   ↓ (기다림...)
서버 요청 
   ↓ (기다림...)
서버 응답 
   ↓ (기다림...)
UI 업데이트 ← 여기서 처음 반응!
총 시간: 500ms~2초

✅ 낙관적 업데이트 (빠름):
사용자 클릭
   ↓ (즉시!)
UI 업데이트 ← 바로 반응!
   ↓ (백그라운드에서)
서버 요청 → 응답 → 검증
총 시간: 사용자 체감 0ms!

いつ使(つか)うか?

使用(しよう)場面(ばめん)理由(りゆう)例(れい)
SNS いいね즉각적(そっかくてき) 피드백 중요(じゅうよう)Instagram, Twitter
Todo 체크박스간단(かんたん)한 토글 작업(さぎょう)Todo 앱
설정(せってい) 변경(へんこう)사용자(しようしゃ) 경험(けいけん) 개선(かいぜん)알림 ON/OFF

✅ Todo 체크박스 - 단계별(だんかいべつ) 구현(こうげん)

1단계: 타입 정의(ていぎ)

types.ts

// Todo 인터페이스에 완료 상태 추가
export interface Todo {
  id: string;           // 고유 식별자
  content: string;      // 할 일 내용
  isDone: boolean;      // 완료 여부 (새로 추가!)
}

왜 isDone? 체크박스 상태를 저장하기 위한 필수 필드

2단계: PATCH API 작성(さくせい)

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은 전체를 교체

3단계: Mutation 훅 - 기본(きほん)버전

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. (백그라운드) 서버 응답 대기

4단계: 컴포넌트 연결(れんけつ)

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가 중요(じゅうよう)한 이유(りゆう)

❌ cancelQueries 없이:

시간순:
00ms: 사용자가 체크박스 클릭
01ms: 조회 요청 시작 (느린 네트워크)
02ms: onMutate: isDone = true로 업데이트
03ms: 수정 요청 → 즉시 완료
      ...
500ms: 조회 요청 완료 (과거 데이터: isDone = false)
501ms: 캐시가 과거 데이터로 덮어써짐! 💥
       → 체크 표시 사라짐 (사용자 혼란)

✅ cancelQueries 사용:

시간순:
00ms: 사용자가 체크박스 클릭
01ms: 조회 요청 시작 (느린 네트워크)
02ms: onMutate 실행
      → cancelQueries: 진행 중인 조회 요청 취소! ✋
      → isDone = true로 업데이트
03ms: 수정 요청 → 완료
      → 체크 표시 유지 ✅

🗑️ Todo 삭제(さくじょ) 기능(きのう) - 상세(しょうさい) 구현(こうげん)

1단계: DELETE API

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;
}

2단계: Delete Mutation

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)은 허용 가능

3단계: 컴포넌트 - 로딩(ろーでぃんぐ) 상태(じょうたい) 관리(かんり)

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 적용:
삭제 진행 중
   ↓
체크박스와 버튼 모두 비활성화
   ↓
추가 클릭 불가능
   ↓
안전하게 삭제 완료! ✅

3가지 업데이트 방법(ほうほう) 비교(ひかく)

방법(ほうほう)구현(こうげん)속도(そくど)안정성(あんていせい)사용(しよう) 시기(じき)
캐시 무효화(むこうか)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>
  );
}

TodoItem 수정(しゅうせい)

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만 전달
→ 자식이 직접 개별 캐시에서 가져옴
→ 이미 저장된 데이터 재사용
→ 효율적! ⚡

🔄 정규화(せいきか)된 구조(こうぞう)의 Mutation 수정(しゅうせい)

추가(ついか) Mutation

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이 개별 캐시에서 데이터 조회

업데이트 Mutation

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은 영향 없음
→ 성능 최적화!

삭제(さくじょ) Mutation

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

이제 한 달 후에 다시 봐도 완벽하게 이해할 수 있을 것입니다! 🚀

profile
日本での就職を目指している26歳の韓国人開発者です。 アプリとweb開発、両方準備中で、日本語で技術概念を整理しながら日本語も一緒に勉強する予定です。 コツコツ続けるのが好きな開発者の成長記録を、一緒に見守っていただけると嬉しいです! 일본에서의 취업을 목표로 하고 있는 26살의 한국인 개발자입니다. 앱과 웹 개발, 둘 다 준비 중이며, 일본어로 기술 개념을 정리하면서 일본어도 함께 공부할 예정입니다. 꾸준히 계속하는 것을 좋아하는 개발자의 성장 기록을, 함께 지켜봐

0개의 댓글