Optimistic Update (1) — 즉시 반응하는 UI 만들기

INNOPER·2025년 3월 16일

1️⃣ 배경

상단 고정(Pin) 버튼 클릭 시,
UI가 바로 바뀌지 않아 느리게 느껴지는 문제가 있었습니다.
서버 응답을 기다리기 때문에 사용자는 반응이 늦다고 느낍니다.

인스타그램 하트 버튼처럼 “클릭 즉시 반응”하는 인터랙션이 필요했습니다.

window.addEventListener("scroll", updatePosition);

2️⃣ React Query의 캐시 업데이트 방식 비교

React Query는 서버 데이터 캐시를 조작하는 두 가지 방법을 제공합니다.

// 1️⃣ invalidateQueries - 캐시 무효화 후 refetch
queryClient.invalidateQueries({ queryKey: ["user"] });
// → 기존 캐시 삭제 → 서버에 다시 요청 → 새 데이터로 교체

// 2️⃣ setQueryData - 캐시 직접 수정
queryClient.setQueryData(["user"], { name: "John" });
// → 서버 요청 없이 캐시 값 직접 변경

즉,

  • invalidateQueries: 서버에서 새 데이터를 받아오기 때문에 느리지만 안전
  • setQueryData: 캐시를 즉시 바꿔서 빠르지만, 서버와 불일치 가능성 있음

토글 버튼처럼 true/false를 즉시 바꿀 때는
이미 새 상태를 알고 있기 때문에 setQueryData 가 적합합니다.


3️⃣ Optimistic Update 도입 ⚙️

Optimistic Update(낙관적 업데이트)
서버 요청이 성공할 거라고 “낙관적으로” 가정하고
UI를 먼저 업데이트하는 패턴입니다.

const togglePin = (linkId: string, currentPinState: boolean) => {
  const next = !currentPinState;

  // ✅ 즉시 UI 업데이트 (서버 응답 전)
  queryClient.setQueryData(["linkDetail", linkId, boxId], (old) =>
    old ? { ...old, data: { ...old.data, isPin: next } } : old
  );

  // ⚙️ 백그라운드에서 서버 요청
  mutate({ linkId, boxId, currentPinState });
};

👉 클릭 → 즉시 UI 변경 → 서버 요청 (백그라운드)
👉 사용자가 느끼는 지연 시간: 0ms


4️⃣ 문제 발생 💥

문제 1️⃣ — 여러 위치에서 같은 데이터 사용

Pin 버튼은 Link ListLink Detail 두 곳에서 사용됩니다.

  • LinkDetail에서만 캐시를 바꾸면
    → Detail을 닫고 List로 돌아왔을 때 List에는 반영되지 않음

문제 2️⃣ — 무한 스크롤(Infinite Query) 구조

리스트 캐시는 아래처럼 InfiniteData 형태로 저장되어 있습니다.

{
  pages: [
    { items: [
      { id: "1", type: "folder" },
      { id: "2", type: "link", isPin: false },  // 이 pin을 눌렀을 때 상태를 바꿔야 함
      { id: "3", type: "link", isPin: true },
    ]},
    { items: [
      { id: "21", type: "link", isPin: false },
      { id: "22", type: "folder" },
    ]},
    // ... 수십 개의 페이지
  ]
}

➡️ 하지만 어느 페이지에 해당 링크가 있는지 모름
따라서 모든 페이지를 순회하며 해당 item을 찾아야 함.


5️⃣ setQueriesData + exact: false 🧠

React Query v5부터는 여러 캐시를 한 번에 갱신할 수 있는
setQueriesData()가 도입되었습니다.

queryClient.setQueriesData<InfiniteData<ListResponse>>(
  {
    queryKey: ["list", boxId],  // folderId 생략
    exact: false,               // 부분 일치 허용 (fuzzy matching)
  },
  (old) =>
    old
      ? {
          ...old,
          pages: old.pages.map((page) => ({
            ...page,
            items: page.items.map((item) =>
              item.type === "link" && item.id === linkId
                ? { ...item, isPin: next } // ✅ 핀 상태만 변경
                : item
            ),
          })),
        }
      : old
);

📘 exact: false의 의미

exact: false는 query key의 앞부분만 일치하면 모두 매칭하는 기능입니다.

예시

// 캐시 상태
["user", "1"]
["user", "1", "posts"]
["user", "1", "posts", "comments"]
["user", "2"]
설정매칭되는 키설명
exact: true["user", "1"]길이와 요소가 완전히 일치해야 함
exact: false["user", "1"], ["user", "1", "posts"], ["user", "1", "posts", "comments"]앞부분만 일치하면 모두 OK

즉,

"list" 관련 캐시가 여러 개 있더라도,
["list", boxId]로 시작하는 모든 쿼리 캐시를 한 번에 갱신할 수 있습니다.


✅ 정리

항목설명
invalidateQueries캐시 무효화 후 서버에서 다시 가져옴 (느리지만 안전)
setQueryData캐시를 즉시 변경 (빠르지만 수동 관리 필요)
setQueriesData여러 캐시를 한 번에 갱신 가능
exact: falsequeryKey 앞부분만 일치해도 매칭 (부분 갱신)
Optimistic Update서버 성공을 가정하고 UI 먼저 변경

🎯 결론

  • 즉각적인 피드백이 필요한 경우 → Optimistic Update
  • 서버 데이터를 클라이언트에서 예측할 수 있을 때 → setQueryData / setQueriesData 활용
  • 무한스크롤 구조에서 여러 캐시를 함께 갱신할 때 → exact: false 필수
profile
FE 개발자 INNOPER 입니다

0개의 댓글