Skip to main content
Overview

왜 useOptimistic 훅이 낙관적 업데이트를 단순화해주지 않는가

2026년 1월 22일
6 min read

낙관적 업데이트는 React 19에서 공식 패턴이 되었지만
Concurrent React 환경에서는 오히려 직접 구현이 복잡해졌으며,
그래서 프로젝트 팀 내부에서 이 문제를 컴포넌트 레벨이 아닌 React Query에 위임하는 선택을 하게 되었습니다.

그 과정에서 제가 고민했던 배경과 실제 세부 구현 방식을 정리한 기록입니다.


낙관적 UI 업데이트란

낙관적 업데이트란 서버에 요청을 보내기 전에 UI를 먼저 업데이트하는 것을 말한다.
요청이 항상 성공한다고 가정하고, 사용자의 액션에 따른 결과를 즉시 UI에 반영하여 사용자에게 빠른 피드백을 제공할 수 있다.

좋아요 버튼, 장바구니 수량 변경 등 사용자 액션에 대한 즉각적인 피드백을 제공할 필요가 있을 때 사용된다.
일반적인 mutation 훅으로 구현할 경우,
서버 응답이 돌아오기 전까지 UI가 변하지 않는 latency(지연 시간)이 존재하기 때문이다.

React Query의 리턴값, isPending으로 서버에 요청 중일 동안
다른 UI를 보여주거나 버튼을 잠시 비활성화할 수도 있지만
클릭과 동시에 결과가 보여지는 게 사용자 입장에서 더 자연스럽다.

현재 프로젝트에서의 구현

낙관적 업데이트를 React의 렌더링 모델에 맡기지 않고, 서버 상태의 변경 이력을 기준으로 관리하기 위해
React Query의 mutation lifecycle을 사용한다.

mutationFn

찜 여부에 따라 서버에 다른 요청을 보낸다.

mutationFn: (isLiked: boolean) =>
isLiked ? deleteLikeSession(sessionId) : postLikeSession(sessionId),

onMutate

onMutate는 mutation 함수(mutationFn)가 실행되기 직전에 실행된다.
이 콜백의 반환값은 context 타입으로 지정된다.

Note (context 타입이란)

onError, onSuccess, onSettled로 전달되는 mutation 한 사이클동안 공유되는 임시 데이터 타입이다.

mutation lifecycle을 시간 순서로 보면 다음과 같은데,

  1. onMutate ← context 생성
  2. mutationFn
  3. onError / onSuccess / onSettled ← context 사용

여기서 mutationFn이 실행되기 전 상태를 이후 콜백에서 다시 쓰고 싶을 때 이걸 안전하게 전달하기 위한 통로가 context다.

예시:

  • 낙관적 업데이트 전의 캐시
  • 이전 상태 스냅샷
  • 롤백에 필요한 데이터

요청 간 충돌을 방지하기 위해 찜 요청 중 세션 정보 조회 요청이 들어올 경우 해당 요청을 취소한다.
이는 찜 요청 성공을 가정한, 즉 낙관적 업데이트가 찜 요청 이전 데이터로 덮어씌워지는 상황을 예방하기 위함이다.

await queryClient.cancelQueries({
queryKey: sessionQueries.detail(sessionId).queryKey,
})

낙관적 업데이트에 실패할 경우를 대비해 이전 데이터를 저장하고,

const previousSessionData = queryClient.getQueryData(
sessionQueries.detail(sessionId).queryKey,
)

서버 요청이 성공할 것이라고 가정해 cache를 즉시 반대로 변경하여 사용자가 클릭과 동시에 UI가 변하는 화면을 확인할 수 있도록 한다.

if (previousSessionData) {
queryClient.setQueryData(
sessionQueries.detail(sessionId).queryKey,
(oldData) => {
if (!oldData) return oldData
return {
...oldData,
liked: !isLiked,
}
},
)
}

변경 직접 데이터를 다른 콜백 함수들에게 전달하기 위해 앞서 저장한 데이터를 리턴한다.

return { previousSessionData }

onError

찜 요청을 보내던 중 오류가 발생한다면 캐시를 변경 직전의 데이터로 다시 돌려놓는 에러 처리를 해준다.

onError:(_, __, context) => {
if (context?.previousSessionData) {
queryClient.setQueryData(
sessionQueries.detail(sessionId).queryKey,
context.previousSessionData
);
}
},

onSettled

onSettled 함수는 요청 성공/실패 여부와 상관없이 마지막에 실행된다. 이때 관련 데이터를 갱신해야 한다.
따라서, 세션 상세 캐시와 내 찜 목록 캐시를 무효화시키고 새 데이터로 갱신하여 일관성을 유지시킨다.

onSettled: () => {
queryClient.invalidateQueries({
queryKey: sessionQueries.list(),
});
queryClient.invalidateQueries({
queryKey: sessionQueries.detail(sessionId).querykey,
});
queryClient.invalidateQueries({
queryKey: userQueries.me.likeAll(),
});
},

React 19의 useOptimistic

등장 배경 - 나이브(naive)한 낙관적 업데이트

요청 전에 setState로 UI를 먼저 바꾸고, 서버 응답이 오면 그때마다 다시 setState로 맞추는 방식을 의미한다.
전형적인 나이브 코드 형태는 다음과 같다:

const [liked, setLiked] = useState(false)
async function onClick() {
// 1️⃣ UI를 먼저 바꿈
setLiked((prev) => !prev)
try {
// 2️⃣ 서버 요청
const result = await toggleLike()
// 3️⃣ 서버 응답으로 다시 동기화
setLiked(result.liked)
} catch (e) {
// 4️⃣ 실패 시 되돌림
setLiked((prev) => !prev)
}
}

이 방식은 너무 많은 전제를 암묵적으로 깔고 있다:

  • 요청은 항상 하나씩만 날아간다 (❌, 사용자는 연타한다)
  • 서버 응답은 항상 요청 순서대로 온다 (❌, 네트워크는 순서를 보장하지 않는다)
  • setState는 즉시 UI를 바꾼다 (❌, Concurrent React이므로 렌더링 지연 가능, 중단/폐기 가능)
  • 이 컴포넌트만 이 상태를 쓴다 (❌)

특히 Concurrent React에서는 상태 업데이트 순서를 단순하게 가정할 수 없기 때문에 언제 UI가 바뀌는지 또는 서버 응답이 언제 덮어쓸지 예측이 불가하다. 그래서 UI 깜빡임(flicker), 상태가 왔다갔다 하는 현상과 같은 버그가 생길수 있다.

Note (Concurrent React란)

React가 렌더링을 ‘순차적으로’가 아니라 ‘중단·재개·우선순위 조정’하면서 수행하는 실행 모델을 말한다.

React 18 이전에는 이런 흐름이어서:

  • 상태 변경 → 렌더링 시작 → 끝날 때까지 멈추지 않음 → 커밋

렌더링이 느릴 경우, 클릭·입력·스크롤 같은 유저 행동이 막히게 된다.

그래서 등장한 Concurrent Rendering(동시 렌더링) 방식은 이렇게 동작한다:

  • 상태 변경 → 렌더링 시작 → (중요한 작업 발생!) → 렌더링 중단 → 중요한 작업 먼저 처리 → 다시 돌아와서 렌더링 재개 → 필요 없으면 폐기

즉, 렌더링이 한 번에 끝내는 작업이 아니라 우선순위에 따라 여러 렌더링 작업을 번갈아 가며 진행할 수 있게 된 것이다.

  • 대표적인 우선순위: 클릭, 입력 > 페이지 전환 > 데이터 fetch 후 렌더 > transition 안의 업데이트

이 지점에서 useOptimistic가 필요해졌다.

useOptimistic

  • 트랜지션 안에서도 UI를 즉시 보여주고
  • 낙관 상태와 기준 상태를 분리하며
const [optimistic, addOptimistic] = useOptimistic(base, reducer)
// base = 서버 기준 상태
// optimistic = 임시 UI 상태
  • 롤백을 자동화한다. base state가 바뀌면 optimistic state는 자동 폐기

React 19 useOptimistic Deep Dive 글의 “First-class citizen”으로 만들었다는 표현처럼, 낙관적 업데이트 패턴이 공식적으로 인정받고 언어/프레임워크 차원에서 직접 지원받는 개념임을 알 수 있다.

useOptimistic는 낙관적 업데이트를 단순화했을까?

useOptimistic 훅을 사용해 더 단순하게 낙관적 업데이트를 구현할 수 있게 되었을까?
useOptimistic은 transition 안에서도 UI를 바로 업데이트하고, rollback을 한 번에 처리해주지만,
여러 비동기 요청 간의 순서를 판단하지는 않기 때문에 여전히 race condition을 막지 못한다.

Note (race condition란)

여러 비동기 작업이 동시에 실행될 때, 응답이 도착하는 순서가 보장되지 않아 UI 상태가 엉키는 현상이다.

  1. A 요청 → (응답 느림)
  2. B 요청 → (응답 빠름)
  3. 응답 B 먼저 도착 → UI 업데이트
  4. 응답 A 나중에 도착 → UI가 다시 A 결과로 덮어써짐

이처럼 원래 의도(가장 최근 요청 결과)가 아닌 뒤늦게 도착한 응답이 UI를 덮어써서 잘못된 상태가 되는 것을 말한다.

참고로, useActionStateuseOptimistic를 함께 쓰면 race condition과 에러를 어느 정도 해결할 수 있지만 여전히 이해가 쉽지 않고, 결론적으로 낙관적 UI를 단순하게 만드는 데 실패했다고 평가된다.

이 글을 쓰게 된 결정적인 계기인 useOptimistic Won’t Save You 글에서는 이렇게 주장하고 있다:

  • useOptimistic는 낙관적 UI를 자동으로 해결해주지 않는다
  • Concurrent React 환경에서는 오히려 구현 난도가 올라간다
  • race condition, transition, error handling을 직접 고려해야 한다

우리가 선택한 해결 방식

우리는 React Query의 mutation lifecycle을 사용해 서버 상태 캐시를 기준으로 UI를 먼저 반영하고, 실패 시 캐시 스냅샷으로 복구하며, invalidate를 통해 최종적으로 서버와 동기화하는 방식을 선택했다. 이는 useOptimistic을 사용했을 때 고려해야 하는 복잡한 부분을 React Query의 책임으로 넘기는 구조였다.

즉, 다음과 같은 부분을 직접 관리하지 않고:

  • transition 우선순위
  • optimistic 렌더링 타이밍
  • 요청 순서

대신 React Query의 mutation lifecycle을 사용해 복잡함을 추상화했다:

  • cancelQueries → race 방지
  • snapshot → rollback
  • invalidate → 서버 단일 진실 유지

그렇다면 useOptimistic는 언제 쓰는 게 맞을까?

useOptimistic은

  • 폼 submit 같이 요청이 겹칠 확률이 적은 ‘단발성 액션’이나 (요청이 직렬적: submit → 완료)
  • 댓글 작성, 메시지 전송, 게시글 작성과 같은 ‘임시 항목 추가’에 적합하다. (동일 리소스를 수정하지 않음)

이유는, 임시 항목의 정체성이 명확하고 서버 상태와 충돌이 없기 때문이다. 서버 응답이 오면 교체 또는 제거하면 되고, 기존 데이터를 덮어쓸 위험이 없으며, 실패 시 그냥 제거하거나 사용자에게 오류 메시지를 보여주는 등 단순하게 처리 가능하다. 즉, 정합성 문제보다 UX가 중요한 경우에 useOptimistic을 고려할 수 있다.

반대로 동일 리소스를 반복 수정하는 좋아요/팔로우 기능이나, 빠른 연속 클릭이 가능한 토글 버튼, 캐시 기반 UI(invalidate 필요)에는 적합하지 않다. 이 경우에는 우리가 사용했던 패턴처럼 React Query를 사용하는 것이 바람직하다!

정리

useOptimistic는 낙관적 UI를 단순화해주는 만능 도구가 아니다. Concurrent React 환경에서는 낙관적 업데이트 자체가 단순한 setState 문제가 아니라 렌더링 우선순위, 요청 순서, 롤백 시점까지 함께 고려해야 하는 문제로 바뀌었기 때문이다.

useOptimistic는 이러한 환경에서 “UI를 먼저 보여주고, 이후 상태를 정리하는 패턴”을 React 차원에서 공식적으로 지원하기 위해 등장했다. 하지만 race condition이나 서버 상태 정합성까지 해결해주지는 않는다.

우리 프로젝트에서는 이 복잡함을 컴포넌트 레벨에서 직접 책임지기보다, React Query의 mutation lifecycle에 위임하는 선택을 했다.

  • optimistic UI는 cache snapshot으로 처리하고
  • 실패 시 rollback 기준을 명확히 하며
  • invalidate를 통해 서버를 단일 진실(source of truth)로 유지한다

결과적으로 우리는 낙관적 UI 자체보다 “어디까지를 React의 책임으로 둘 것인가”를 선택한 셈이다. useOptimistic는 분명 의미 있는 API지만, 모든 낙관적 업데이트에 적용할 도구는 아니다. 상황에 따라, 오히려 더 높은 수준의 추상화가 동시성 환경에서 더 안전한 선택이 될 수 있다.