낙관적 업데이트: 먼저 바꾼 캐시는 실패할 때 어떻게 복구하는가
관심상품 추가가 즉시 성공한 것처럼 보였지만 서버 거절 뒤에도 남는 실패를 재현하고, 읽기 취소·이전 사본·임시 반영·복구·재확인의 순서를 연결한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
낙관적 업데이트는 서버 성공 전에 다음 값을 화면이나 캐시에 먼저 보여 준다. Query 캐시를 직접 바꾸는 방식이라면 진행 중인 읽기가 임시값을 덮지 않게 멈추고, 요청 전 사본을 보관하고, 실패하면 복구하고, 끝난 뒤 서버를 다시 확인해야 한다. 먼저 보여 준 값은 확정값이 아니라 실패할 수 있는 임시 제안이다.
이 글을 관통하는 상황: 서버는 거절했는데 관심상품은 켜져 있다
가짜 서버의 상품은 처음에 관심상품이 아니다. saveFavorite는 0.8초 뒤 항상 실패하도록 만들어 서버
거절을 고정한다. 먼저 잘못된 구현은 캐시를 favorite: true로 바꾸지만 요청 전 값을 보관하지 않고
실패 때 복구하지 않는다.
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
const queryClient = new QueryClient()
const productKey = ['product', 'coffee']
const SHOULD_FAIL = true
let serverProduct = { id: 'coffee', name: '원두 정기 배송', favorite: false }
async function fetchProduct() {
return { ...serverProduct }
}
async function saveFavorite({ favorite, shouldFail }) {
await new Promise((resolve) => setTimeout(resolve, 800))
if (shouldFail) {
throw new Error('관심상품 저장 거절')
}
serverProduct = { ...serverProduct, favorite }
return { ...serverProduct }
}
function ProductDetails() {
const client = useQueryClient()
const productQuery = useQuery({
queryKey: productKey,
queryFn: fetchProduct,
staleTime: Infinity,
})
const favoriteChange = useMutation({
mutationFn: saveFavorite,
onMutate: ({ favorite }) => {
client.setQueryData(productKey, (current) => ({
...current,
favorite,
}))
},
})
if (productQuery.isPending) return <p>상품을 읽는 중…</p>
if (productQuery.isError) return <p>상품을 읽지 못했습니다.</p>
return (
<main>
<h1>{productQuery.data.name}</h1>
<button
disabled={favoriteChange.isPending}
onClick={() => favoriteChange.mutate({
favorite: true,
shouldFail: SHOULD_FAIL,
})}
>
{SHOULD_FAIL
? '관심상품 추가 실패 재현'
: '관심상품 추가 성공 확인'}
</button>
<p>화면 캐시: {productQuery.data.favorite ? '추가됨' : '추가 안 됨'}</p>
<p>가짜 서버: {serverProduct.favorite ? '추가됨' : '추가 안 됨'}</p>
{favoriteChange.isError ? <p>{favoriteChange.error.message}</p> : null}
</main>
)
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductDetails />
</QueryClientProvider>
)
}버튼을 누르면 화면 캐시는 즉시 추가됨으로 바뀐다. 0.8초 뒤 오류가 보이지만 화면 캐시는 그대로고,
가짜 서버는 추가 안 됨이다. 사용자에게는 실패 메시지와 성공처럼 보이는 추가됨 표시가 동시에 남는다.
다시 실험하려면 브라우저를 전체 새로고침해 Query 캐시와 가짜 서버를 초기화한다.
낙관적 값은 확정값이 아니다
한 버튼 주변에서만 임시 상태가 필요하면 variables 방식이 더 단순하다. 상품 상세, 목록과 상단 요약이 같은 관심상품 값을 함께 보여 줘야 한다면 캐시 변경이 일관된 임시 화면을 만들 수 있다. 캐시를 바꾸는 순간 공유 범위가 넓어지는 만큼 실패 복구 책임도 함께 넓어진다.
읽기 취소와 변경 작업은 다르다
Query 함수가 TanStack Query가 제공한 AbortSignal을 실제 네트워크 요청에 전달하면 취소 신호에
응답할 수 있다. 신호를 소비했을 때 취소된 Query는 이전 상태로 돌아간다. 모든 서버 API가 취소 신호를
받았다는 이유만으로 이미 처리한 작업을 되돌리는 것은 아니다.
현재 가짜 서버의 fetchProduct는 즉시 끝나고 AbortSignal을 소비하지 않으므로 이 로컬 실험은
읽기 취소 자체를 증명하지 않는다. 실제 네트워크 통합 검증에서는 지연된 다시 읽기를 시작한 뒤
관심상품 변경을 실행해, 취소 신호가 요청에 전달되고 그 늦은 읽기 결과가 임시 캐시를 덮지 않는지
확인한다. 아래 로컬 실험이 직접 증명하는 범위는 요청 전 사본 복구와 마지막 서버 재확인이다.
요청 전 사본을 보관하고 실패하면 복구한다
앞 예제의 favoriteChange 설정을 다음 코드로 바꾼 뒤 전체 새로고침한다. 현재 예제는 버튼을
isPending 동안 비활성화해 한 번에 하나의 관심상품 변경만 허용한다. 여러 낙관적 변경이 겹치는
문제는 15편의 작업 순서 계약을 먼저 적용해야 한다.
코드를 읽기 전에 callback 지도를 먼저 맞춘다. variables는 mutate에 넘긴 { favorite, shouldFail } 입력이다. onMutate는 mutationFn 전에 실행되고, 두 번째 context는 React가
컴포넌트 트리에 값을 제공할 때 쓰는 기능이 아니라 현재 서버 변경 실행 정보와 context.client를
제공하는 TanStack Query context다.
onMutate의 반환값은 뒤 callback에서 onMutateResult로 받는다. onError는 실패했을 때 복구하고,
onSettled는 성공·실패 어느 쪽이든 끝난 뒤 실행한다. invalidateQueries는 일치하는 Query를
무효화하고 현재 화면이 구독 중인 Query를 기본적으로 다시 가져오게 한다.
const favoriteChange = useMutation({
mutationFn: saveFavorite,
onMutate: async ({ favorite }, context) => {
await context.client.cancelQueries({ queryKey: productKey })
const previousProduct = context.client.getQueryData(productKey)
context.client.setQueryData(productKey, (current) => ({
...current,
favorite,
}))
return { previousProduct }
},
onError: (_error, _variables, onMutateResult, context) => {
if (onMutateResult?.previousProduct !== undefined) {
context.client.setQueryData(
productKey,
onMutateResult.previousProduct,
)
}
},
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
return context.client.invalidateQueries({ queryKey: productKey })
},
})실행 순서에는 이유가 있다.
- 취소: 진행 중인 같은 키의 다시 읽기가 임시값을 덮는 경로를 먼저 닫는다.
- 보관: 낙관적으로 바꾸기 전 사본을
previousProduct에 남긴다. - 임시 반영: 공유 Query 캐시에 사용자가 요청한 값을 쓴다.
- 실패 복구:
onError에서 해당 작업 전에 보관한 사본을 다시 쓴다. - 서버 재확인: 성공·실패와 관계없이 관련 Query를 다시 확인한다.
버튼을 누르면 화면은 즉시 추가됨으로 바뀐다. 0.8초 뒤 서버가 거절하면 onError가 요청 전
추가 안 됨 사본을 복구한다. 이어서 다시 읽은 서버 값도 추가 안 됨이므로 화면과 원본이
일치한다.
복구 뒤에도 서버를 다시 확인한다
요청 전 사본은 “그때 클라이언트가 알고 있던 값”이지 현재 서버 원본의 증명은 아니다. 변경 작업이 성공했다면 서버가 공백을 제거하거나 표준 형식으로 바꾼 실제 값과 새 버전을 받아야 한다. 실패했더라도 다른 탭이나 기기가 그사이 원본을 바꿨을 수 있다. 복구는 화면 모순을 즉시 줄이고, 재확인은 서버를 다시 읽어 클라이언트 사본을 현재 서버 값과 맞춘다.
성공 경로도 확인한다. 앞 코드의 SHOULD_FAIL을 false로 바꾸고 전체 새로고침한 뒤
관심상품 추가 성공 확인 버튼을 누르면, 화면은 즉시 추가됨이고 서버도 0.8초 뒤 추가됨이 된다.
마지막 다시 읽기 결과도 같은 값을 유지해야 한다.
모든 낙관적 UI가 캐시 복구를 요구하지는 않는다
예를 들어 앞의 캐시 변경용 favoriteChange를 다음 별도 컴포넌트로 교체할 수 있다. 이 설정에는
onMutate, setQueryData와 실패 복구가 없다. 완료 뒤 서버를 다시 읽는 onSettled만 둔다.
function FavoriteUiOnly({ confirmedFavorite }) {
const favoriteChange = useMutation({
mutationFn: saveFavorite,
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
return context.client.invalidateQueries({ queryKey: productKey })
},
})
const visibleFavorite = favoriteChange.isPending
? favoriteChange.variables.favorite
: confirmedFavorite
return (
<section>
<button
disabled={favoriteChange.isPending}
onClick={() => favoriteChange.mutate({
favorite: true,
shouldFail: SHOULD_FAIL,
})}
>
{SHOULD_FAIL ? 'UI-only 실패 재현' : 'UI-only 성공 확인'}
</button>
<p>현재 표시: {visibleFavorite ? '추가됨' : '추가 안 됨'}</p>
{favoriteChange.isError ? (
<>
<p>{favoriteChange.error.message}</p>
<button
onClick={() => favoriteChange.mutate(favoriteChange.variables)}
>
같은 변경 다시 시도
</button>
</>
) : null}
</section>
)
}
function ProductDetails() {
const productQuery = useQuery({
queryKey: productKey,
queryFn: fetchProduct,
staleTime: Infinity,
})
if (productQuery.isPending) return <p>상품을 읽는 중…</p>
if (productQuery.isError) return <p>상품을 읽지 못했습니다.</p>
return (
<FavoriteUiOnly confirmedFavorite={productQuery.data.favorite} />
)
}이 방식은 pending 동안 variables.favorite만 한 위치에 보여 주고 Query 캐시는 낙관적으로 바꾸지
않으므로 실패 때 이전 캐시로 복구할 필요가 없다. 실패하면 임시 표시를 없애고 오류와 재시도 행동을
남긴다. 성공하면 onSettled의 서버 재확인이 끝난 뒤 confirmedFavorite이 새 Query 결과로 바뀐다.
서버 실패 뒤 사용자가 직접 재시도할 수 있는 새 목록 항목은 이런 variables 방식으로도 충분할 수 있다.
반대로 관심상품 수가 헤더와 여러 카드에 동시에 보이면 캐시 방식이 중복 상태를 줄인다. “낙관적이면
무조건 캐시를 바꾼다”가 아니라 임시값을 누가 봐야 하는지에서 선택한다.
Web과 React Native가 공유하는 경계
문제를 만났을 때의 조사 순서
- 먼저 보인 값이 서버 확정값인지 낙관적 임시값인지 UI에서 구분한다.
- 같은 키의 진행 중인 다시 읽기가 임시 캐시를 덮을 수 있는지 확인한다.
- 각 서버 변경 작업 직전의 정확한 사본을 보관하는지 확인한다.
- 실패 callback이 그 작업의 보관값으로 복구하는지 확인한다.
- 성공·실패 뒤 서버를 다시 읽어 클라이언트 사본이 현재 서버 값과 다시 맞는지 확인한다.
- 여러 변경이 겹치면 15편의 직렬화·버전 조건부터 다시 확인한다.
한 번 더 예측하고 확인한다
- 잘못된 구현에서 서버가 실패한 뒤 화면 캐시와 서버 원본이 각각 무엇인지 예측한다.
- 교정 구현에서
previousProduct를 임시 반영 뒤 읽으면 왜 복구가 실패하는지 설명한다. cancelQueries를 제거하고 이전 다시 읽기가 늦게 도착하면 어떤 값이 임시 캐시를 덮을지 예측한다.- 실패 복구 뒤
invalidateQueries가 필요한 이유를 서버의 다른 변경 가능성과 연결한다. - 임시 결과를 한 버튼만 보여 줄 때 variables 방식이 단순한 이유를 설명한다.
문제 서버가 관심상품 추가를 거절했는데 화면 캐시는 추가됨으로 남음
잘못된 판단 먼저 바꾼 캐시는 서버 성공과 같은 확정값임
관찰 오류 뒤에도 임시값이 남고 서버 원본과 화면이 갈라짐
원인 요청 전 사본과 실패 복구·서버 재확인 계약이 없음
행동 읽기 취소 → 이전 사본 보관 → 임시 반영 → 실패 복구 → 재확인
검증 실패는 이전값 복구, 성공은 새 값 유지, 둘 다 서버 재조회로 최종값 확인PR에서 바로 확인할 항목
- 낙관적 값이 서버 확정 전 임시 상태임을 UI가 표현하는가?
- 같은 키의 진행 중인 다시 읽기를 임시 캐시 변경 전에 취소하고 실제 요청의 취소 동작을 통합 검증했는가?
- 요청 전 사본을 임시 반영보다 먼저 보관하는가?
- 실패 시 해당 작업의 보관값으로 복구하는가?
- 성공과 실패 뒤 서버 원본을 다시 확인하는가?
- 겹친 변경과 오프라인 재개를 단일 메모리 사본으로 해결하려 하지 않는가?
기억할 질문은 하나다.
서버가 이 변경을 거절해도, 화면을 어떤 이전값으로 복구하고 무엇으로 최종 확인할 수 있는가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01관심상품을 즉시 추가됨으로 표시했다면 서버 저장도 성공한 것인가?
정답
아니다. 성공을 예상한 임시 표현일 뿐이다. 이 글의 캐시 변경 방식은 실패 때 이전 사본을 복구하고, variables UI 방식은 임시 표시를 없앤 뒤 오류와 재시도를 남긴다.
관련 설명 다시 읽기왜 그런가
화면 반응 시각과 서버 원본 확인 시각을 분리해야 임시값을 확정값으로 오해하지 않는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02onMutate에서 cancelQueries를 호출하면 관심상품 저장 요청도 취소되는가?
정답
아니다. 같은 Query의 진행 중인 다시 읽기가 임시 캐시를 덮지 않게 멈추는 것이며 서버 변경 작업 자체를 취소하는 호출이 아니다.
관련 설명 다시 읽기왜 그런가
읽기 Query와 쓰기 작업의 취소 대상을 구분해야 실패 복구를 생략하지 않는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03실패 시 이전 캐시를 복구했다면 서버를 다시 읽을 필요가 없는가?
정답
캐시를 직접 바꾼 방식에서는 로컬 사본만으로 서버 최종값을 확정할 수 없으므로 성공과 실패 뒤 모두 관련 Query를 무효화해 다시 확인한다.
관련 설명 다시 읽기왜 그런가
복구 사본은 요청 전 클라이언트 값이고, 그동안 서버가 다른 경로에서 바뀌었을 가능성은 남는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- Optimistic UpdatesTanStack · 공식 문서 · 확인 2026-08-09
- useMutationTanStack · 공식 문서 · 확인 2026-08-09
- Query CancellationTanStack · 공식 문서 · 확인 2026-08-09
- QueryClientTanStack · 공식 문서 · 확인 2026-08-09
- Important DefaultsTanStack · 공식 문서 · 확인 2026-08-09