버튼을 누르고 서버 응답을 기다리는 동안 UI가 멈춰 있으면 사용자는 불안해한다. "클릭이 안 된 건가?" 하고 다시 누르기도 한다. 낙관적 업데이트는 이 문제를 해결한다. 서버 응답을 기다리지 않고 UI를 먼저 바꾸는 것이다.
낙관적 업데이트의 핵심 구조
React Query(TanStack Query)에서 낙관적 업데이트는 useMutation의 콜백 조합으로 구현한다.
useMutation({
mutationFn: (data) => api.update(data),
onMutate: async (variables) => {
// 1. 진행 중인 쿼리 취소 (경쟁 상태 방지)
await queryClient.cancelQueries({ queryKey: ['items'] });
// 2. 이전 상태 저장 (롤백용)
const previousData = queryClient.getQueryData(['items']);
// 3. 캐시를 낙관적으로 업데이트
queryClient.setQueryData(['items'], (old) => ({
...old,
...variables
}));
// 4. 롤백에 사용할 컨텍스트 반환
return { previousData };
},
onSuccess: (response) => {
// 서버 응답으로 정확한 데이터 동기화
queryClient.setQueryData(['items'], response);
},
onError: (error, variables, context) => {
// 실패 시 이전 상태로 롤백
if (context?.previousData) {
queryClient.setQueryData(['items'], context.previousData);
}
},
});
흐름을 정리하면 이렇다:
- onMutate: 서버 요청 전에 실행. UI를 먼저 바꾸고, 롤백용 데이터를 저장한다.
- onSuccess: 서버 응답이 오면 실행. 응답 데이터로 캐시를 정확하게 맞춘다.
- onError: 실패하면 실행. 저장해둔 데이터로 롤백한다.
여기까지는 공식 문서에도 나오는 기본 패턴이다. 실제 프로덕션에서는 두 가지 상황에서 복잡해진다.
패턴 1: 무한 스크롤과 결합
프로젝트 목록처럼 무한 스크롤을 쓰는 화면에서 낙관적 업데이트를 적용하려면 InfiniteData 타입을 다뤄야 한다.
// 일반 쿼리 데이터 구조
{ data: [...] }
// 무한 스크롤 데이터 구조
{
pages: [
{ data: [...], nextCursor: 'abc' }, // 첫 페이지
{ data: [...], nextCursor: 'def' }, // 두 번째 페이지
{ data: [...], nextCursor: null }, // 마지막 페이지
],
pageParams: [undefined, 'abc', 'def']
}
중첩 구조라서 업데이트할 때 pages 배열을 순회해야 한다.
export const useUpdateProject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) =>
api.patch(`/projects/${id}`, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// 무한 스크롤: 여러 쿼리가 있을 수 있어서 getQueriesData 사용
const previousData = queryClient.getQueriesData<
InfiniteData<ProjectListResponse>
>({ queryKey: projectKeys.lists() });
// pages 배열 내부까지 순회하며 업데이트
queryClient.setQueriesData<InfiniteData<ProjectListResponse>>(
{ queryKey: projectKeys.lists() },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
data: page.data.map((p) =>
p.id === id ? { ...p, ...data } : p
),
})),
};
}
);
return { previousData };
},
onError: (error, variables, context) => {
// 롤백: 저장된 모든 쿼리 데이터 복원
if (context?.previousData) {
context.previousData.forEach(([queryKey, oldData]) => {
queryClient.setQueryData(queryKey, oldData);
});
}
},
});
};
주의할 점이 두 가지 있다.
getQueryData vs getQueriesData: 무한 스크롤에서는 필터나 정렬 조건에 따라 같은 lists() 키 아래 여러 쿼리가 존재할 수 있다. getQueriesData는 매칭되는 모든 쿼리의 [queryKey, data] 배열을 반환한다. 롤백할 때도 이 배열을 순회해야 한다.
삭제 연산: 업데이트는 map으로 해당 항목만 바꾸면 되지만, 삭제는 filter로 제거해야 한다.
// 삭제 시 낙관적 업데이트
queryClient.setQueriesData<InfiniteData<ProjectListResponse>>(
{ queryKey: projectKeys.lists() },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
data: page.data.filter((p) => p.id !== id), // filter 사용
})),
};
}
);
패턴 2: 클라이언트 상태 관리와 통합
Figma 이미지처럼 캔버스에 즉시 표시해야 하는 경우, React Query 캐시만으로는 부족하다. Zustand 같은 클라이언트 상태 관리와 통합해야 한다.
이때 핵심 문제는 ID가 없다는 것이다. 서버에 생성 요청을 보내기 전이라 실제 ID가 없다. 임시 ID를 만들어서 해결한다.
export const useAddFigmaItem = (projectId: string) => {
const addLayer = useLayerStore((s) => s.addLayer);
const removeLayer = useLayerStore((s) => s.removeLayer);
const items = useLayerStore((s) => s.items);
return useMutation({
mutationFn: (data) =>
api.post(`/projects/${projectId}/figmas`, data),
onMutate: async (variables) => {
// 임시 ID 생성
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Zustand store에 즉시 추가
addLayer({
id: tempId,
name: variables.name || 'Loading...',
imageUrl: '', // 아직 없음
// ...기타 속성
});
return { tempId };
},
onSuccess: (response, variables, context) => {
const { tempId } = context;
// 임시 레이어의 현재 위치 보존 (사용자가 드래그했을 수 있음)
const currentPosition = items[tempId]?.position;
// 임시 레이어 제거
removeLayer(tempId);
// 실제 데이터로 다시 추가
addLayer({
id: response.id, // 서버에서 받은 실제 ID
name: response.name,
imageUrl: response.imageUrl,
position: currentPosition, // 위치는 보존
// ...기타 속성
});
},
onError: (error, variables, context) => {
// 실패 시 임시 레이어 제거
if (context?.tempId) {
removeLayer(context.tempId);
}
},
});
};
이 패턴의 핵심은 tempId → realId 교체 과정이다.
onMutate에서temp-xxxID로 UI에 즉시 표시onSuccess에서 서버가 준 실제 ID로 교체onError에서는 임시 데이터만 제거하면 됨 (원래 없던 데이터니까)
주의할 점: ID 교체 시 사용자가 그 사이에 조작한 상태(위치, 선택 등)를 보존해야 한다. 위 코드에서 currentPosition을 따로 저장하는 이유다.
삭제와 수정은 더 단순하다
생성과 달리, 삭제와 수정은 이미 ID가 있어서 tempId 패턴이 필요 없다.
// 삭제: 원본을 저장하고, 실패 시 복원
export const useDeleteFigmaItem = (projectId: string) => {
const items = useLayerStore((s) => s.items);
const addLayer = useLayerStore((s) => s.addLayer);
const removeLayer = useLayerStore((s) => s.removeLayer);
return useMutation({
mutationFn: (figmaId) =>
api.delete(`/projects/${projectId}/figmas/${figmaId}`),
onMutate: async (figmaId) => {
const previousItem = items[figmaId]; // 원본 저장
removeLayer(figmaId); // 즉시 제거
return { previousItem };
},
onError: (error, figmaId, context) => {
if (context?.previousItem) {
addLayer(context.previousItem); // 복원
}
},
});
};
// 수정: 이전 값만 저장
export const useUpdateFigmaItem = (projectId: string) => {
const items = useLayerStore((s) => s.items);
const updateLayer = useLayerStore((s) => s.updateLayer);
return useMutation({
mutationFn: ({ id, name }) =>
api.patch(`/projects/${projectId}/figmas/${id}`, { name }),
onMutate: async ({ id, name }) => {
const previousName = items[id]?.name; // 이전 값 저장
updateLayer(id, { name }); // 즉시 업데이트
return { id, previousName };
},
onError: (error, variables, context) => {
if (context?.previousName) {
updateLayer(context.id, { name: context.previousName }); // 복원
}
},
});
};
두 패턴을 나누는 기준
| 상황 | 패턴 | 이유 |
|---|---|---|
| 목록 조회 (무한 스크롤) | React Query 캐시 | 서버 상태와 동기화가 핵심 |
| 실시간 UI 반영 (캔버스) | Zustand + tempId | 즉각적인 시각적 피드백이 핵심 |
| 단순 CRUD | React Query 캐시 | 복잡도 낮게 유지 |
둘 다 쓰는 경우도 있다. 예를 들어 Figma 이미지는 캔버스(Zustand)와 사이드바 목록(React Query) 양쪽에 표시된다. 이때는 Zustand 업데이트 후 invalidateQueries로 목록도 갱신한다.
낙관적 업데이트의 본질은 "성공을 가정하고 먼저 움직이되, 실패하면 되돌린다"이다.
무한 스크롤에서는 InfiniteData의 중첩 구조를 다뤄야 하고, 실시간 UI에서는 tempId로 ID 부재 문제를 해결해야 한다. 두 패턴 모두 onMutate에서 이전 상태를 저장하고, onError에서 복원하는 구조는 같다.
어떤 패턴을 쓸지는 "이 데이터가 서버 상태인가, 클라이언트 UI 상태인가"로 판단하면 된다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > React' 카테고리의 다른 글
| React 조건부 렌더링 최적화: visibility 기반 캐싱으로 모드 전환 개선하기 (0) | 2026.02.26 |
|---|---|
| Next.js에서 Prisma 연결 풀 최적화하기 (0) | 2026.02.12 |
| NextAuth.js 세션 전략: DB에서 JWT로 전환하여 400ms → 5ms 달성하기 (0) | 2026.02.03 |
| Next.js로만 백엔드, 프론트엔드 구축하기 (0) | 2026.01.24 |
| Zustand로 Undo/Redo 구현하기 (0) | 2026.01.13 |
