- Published on
React 19 useOptimistic로 렌더링 폭주 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 반응을 기다리지 않고 UI를 먼저 바꾸는 낙관적 업데이트(optimistic update)는 체감 속도를 크게 끌어올립니다. 하지만 구현 방식이 나쁘면 상태가 이중으로 움직이면서 같은 화면이 여러 번 다시 그려지고, 이벤트가 연쇄적으로 발생해 렌더링 폭주(render storm)로 이어질 수 있습니다. 특히 다음 조합에서 문제가 자주 터집니다.
- 입력창에서 매 키 입력마다 서버 액션을 호출하거나, 호출 결과를 다시 로컬 상태에 반영하는 구조
- 리스트에서 토글을 연속 클릭할 때
pending상태와 실제 서버 결과가 서로 덮어쓰는 구조 useEffect로 서버 결과를 받으면 다시setState를 하고, 그setState가 또 요청을 트리거하는 구조
React 19의 useOptimistic은 이런 문제를 "낙관 상태는 낙관 상태로만 관리"하도록 강제하는 도구에 가깝습니다. 핵심은 단순합니다.
- 진짜 소스 오브 트루스(source of truth)는 서버/부모 state
- 낙관 상태는 UI 렌더링을 위한 임시 오버레이(overlay)
- 서버 결과가 오면 낙관 오버레이는 자연스럽게 사라지고, 실제 데이터로 수렴
아래에서는 useOptimistic의 동작 원리, 렌더링 폭주가 생기는 전형적인 안티패턴, 그리고 실무에서 바로 써먹을 수 있는 패턴을 예제로 정리합니다.
렌더링 폭주는 왜 생기나
렌더링 폭주는 대개 "상태 업데이트가 중복"되거나 "상태 업데이트가 다른 상태 업데이트를 트리거"할 때 생깁니다.
예를 들어 좋아요 버튼을 생각해보면,
- 클릭 시 로컬에서
setLiked(true) - 동시에 서버 요청
- 서버 응답을 받으면 다시
setLiked(serverLiked)
이 자체는 정상처럼 보이지만, 리스트가 길고 각 아이템이 무겁거나, 클릭이 연속으로 들어오면 다음 문제가 생깁니다.
- 클릭마다 로컬 업데이트로 렌더 1회
- 응답마다 서버 반영으로 렌더 1회
- 응답 순서가 뒤틀리면(네트워크 지연) 이전 응답이 최신 UI를 덮어쓰기
- 이를 막으려
useEffect에서 보정 로직을 넣으면 또 렌더가 늘어남
React 19의 useOptimistic은 "낙관 업데이트"를 별도 레이어로 분리해 서버 결과(state)와 낙관 결과(optimistic state)가 서로 싸우지 않게 합니다.
useOptimistic의 핵심 개념
useOptimistic은 대략 아래 형태로 사용합니다.
- 첫 번째 인자: 기준이 되는 상태(대개 서버에서 내려온 데이터 또는 부모 state)
- 두 번째 인자: 낙관 액션을 적용하는 reducer
- 반환값:
[optimisticState, addOptimistic]
중요한 점은 optimisticState가 "진짜 state"가 아니라는 것입니다.
- 기준 상태가 바뀌면(서버 결과가 도착하면) 낙관 상태는 그 기준 위에서 다시 계산됩니다.
- 즉, 낙관 업데이트를 여러 번 적용해도 기준이 갱신되면 자동으로 정리됩니다.
이 구조 덕분에, 낙관 업데이트를 위해 별도의 useState를 만들고 서버 응답 때 또 setState를 하면서 생기는 이중 렌더링/경합을 크게 줄일 수 있습니다.
안티패턴: 낙관 상태와 실제 상태를 같은 useState에 섞기
아래는 흔히 보는 형태입니다.
import { useEffect, useState } from "react";
type Item = { id: string; liked: boolean };
export function LikeButtonBad({ item }: { item: Item }) {
const [liked, setLiked] = useState(item.liked);
useEffect(() => {
setLiked(item.liked);
}, [item.liked]);
async function onToggle() {
setLiked((v) => !v); // 낙관 업데이트
const res = await fetch(`/api/items/${item.id}/like`, { method: "POST" });
const data = (await res.json()) as { liked: boolean };
setLiked(data.liked); // 서버 결과 반영
}
return (
<button onClick={onToggle} aria-pressed={liked}>
{liked ? "Liked" : "Like"}
</button>
);
}
문제는 다음과 같습니다.
item.liked가 바뀔 때마다useEffect가 실행되어 또 렌더를 유발- 연속 클릭 시 응답 순서가 뒤틀리면 마지막 클릭이 아닌 값으로 되돌아갈 수 있음
- 리스트에서 이 컴포넌트가 수십 개면 클릭 한 번에 많은 하위 렌더가 발생
패턴 1: useOptimistic으로 UI 오버레이 분리
아래는 같은 요구사항을 useOptimistic으로 구성한 예시입니다.
import { useOptimistic, useTransition } from "react";
type Item = { id: string; liked: boolean };
type OptimisticAction = { type: "toggle" };
export function LikeButton({ item }: { item: Item }) {
const [isPending, startTransition] = useTransition();
const [optimisticItem, addOptimistic] = useOptimistic<Item, OptimisticAction>(
item,
(state, action) => {
if (action.type === "toggle") {
return { ...state, liked: !state.liked };
}
return state;
}
);
function onToggle() {
addOptimistic({ type: "toggle" });
startTransition(async () => {
await fetch(`/api/items/${item.id}/like`, { method: "POST" });
// 서버 결과는 상위에서 item이 갱신되며 내려온다고 가정
// 여기서 별도의 setState로 liked를 맞추지 않는다.
});
}
return (
<button onClick={onToggle} aria-pressed={optimisticItem.liked} disabled={isPending}>
{optimisticItem.liked ? "Liked" : "Like"}
</button>
);
}
포인트는 3가지입니다.
- UI는
optimisticItem만 본다 - 서버 요청은
startTransition으로 감싸 "긴급하지 않은 업데이트"로 취급한다 - 서버 결과로 UI를 맞추려고 로컬
setState를 하지 않는다
이렇게 하면 클릭 이벤트로 인한 렌더는 "낙관 1회"로 끝나고, 서버 결과가 도착했을 때는 상위에서 내려오는 item이 바뀌며 자연스럽게 수렴합니다.
패턴 2: 리스트에서 낙관 업데이트를 로컬로만 누적하지 말기
리스트 전체를 낙관적으로 업데이트해야 할 때가 많습니다. 예를 들어 댓글 목록에서 특정 댓글을 즉시 삭제한 것처럼 보이게 만들고 싶다면, 각 아이템에 useState를 두기보다 리스트 단위로 useOptimistic을 적용하는 편이 안전합니다.
import { useOptimistic, useTransition } from "react";
type Comment = { id: string; body: string };
type Action =
| { type: "remove"; id: string }
| { type: "add"; tempId: string; body: string };
export function Comments({ comments }: { comments: Comment[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticComments, addOptimistic] = useOptimistic<Comment[], Action>(
comments,
(state, action) => {
switch (action.type) {
case "remove":
return state.filter((c) => c.id !== action.id);
case "add":
return [{ id: action.tempId, body: action.body }, ...state];
default:
return state;
}
}
);
function onDelete(id: string) {
addOptimistic({ type: "remove", id });
startTransition(async () => {
await fetch(`/api/comments/${id}`, { method: "DELETE" });
// 실제 comments는 상위에서 re-fetch 또는 캐시 갱신으로 업데이트된다고 가정
});
}
return (
<section>
<ul>
{optimisticComments.map((c) => (
<li key={c.id}>
{c.body}
<button onClick={() => onDelete(c.id)} disabled={isPending}>
Delete
</button>
</li>
))}
</ul>
</section>
);
}
이 방식의 장점은 다음과 같습니다.
- 낙관 업데이트가 리스트 전체의 단일 reducer로 관리되어 예측 가능
- 아이템별
useEffect동기화가 사라져 렌더링 경로가 단순해짐 - 서버 결과는 상위 데이터 갱신 한 번으로 전체가 정리됨
패턴 3: 연속 입력에서 "요청 폭주"와 "렌더 폭주"를 분리해서 생각하기
렌더링 폭주는 UI 상태 설계 문제지만, 입력창에서는 네트워크 요청 폭주도 같이 옵니다. 이때 useOptimistic은 "입력값" 자체를 낙관적으로 다루기보다, 서버에 반영되는 결과물만 낙관 처리하는 쪽이 좋습니다.
예를 들어 "닉네임 저장" 같은 폼에서는 입력은 로컬 useState로 충분하고, 저장 버튼을 눌렀을 때만 useOptimistic을 적용합니다.
import { useOptimistic, useState, useTransition } from "react";
type Profile = { nickname: string };
type Action = { type: "setNickname"; nickname: string };
export function NicknameEditor({ profile }: { profile: Profile }) {
const [draft, setDraft] = useState(profile.nickname);
const [isPending, startTransition] = useTransition();
const [optimisticProfile, addOptimistic] = useOptimistic<Profile, Action>(
profile,
(state, action) => {
if (action.type === "setNickname") return { ...state, nickname: action.nickname };
return state;
}
);
function onSave() {
addOptimistic({ type: "setNickname", nickname: draft });
startTransition(async () => {
await fetch(`/api/profile`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nickname: draft })
});
});
}
return (
<div>
<p>Public nickname: {optimisticProfile.nickname}</p>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button onClick={onSave} disabled={isPending}>Save</button>
</div>
);
}
이렇게 하면 타이핑은 가볍게 로컬에서만 처리되고, 저장 시점에만 낙관 레이어가 개입합니다. 결과적으로 "키 입력마다 렌더가 무거워지는" 문제를 피할 수 있습니다.
실패 처리: 낙관 업데이트 롤백을 어떻게 보나
useOptimistic은 낙관 레이어를 제공하지만, 실패 시 UX를 자동으로 해결해주진 않습니다. 실무에서는 다음 전략 중 하나를 선택합니다.
- 서버가 실패하면 상위 데이터 갱신이 일어나지 않으므로 기준 상태가 그대로 유지되고, 낙관 상태는 다음 렌더 사이클에서 자연스럽게 사라지게 설계
- 실패 토스트/배너를 띄우고 "다시 시도"를 제공
- 낙관 액션을 적용할 때 임시
tempId를 붙였다면, 실패 시 해당 항목을 제거하는 액션을 추가로 적용
예를 들어 댓글 추가에서 실패 시 임시 항목을 제거하려면 reducer에 revertTemp 같은 액션을 추가하고, 요청 실패 시 addOptimistic으로 되돌릴 수 있습니다.
type Action =
| { type: "add"; tempId: string; body: string }
| { type: "revertTemp"; tempId: string };
// reducer 내부
// case "revertTemp": return state.filter((c) => c.id !== action.tempId);
다만 이때도 원칙은 같습니다. 실제 데이터는 서버/상위 상태가 책임지고, useOptimistic은 UI를 잠깐 앞당기는 역할로 제한하는 편이 렌더링 폭주를 막는 데 유리합니다.
렌더링 폭주를 줄이는 체크리스트
아래 항목을 점검하면 useOptimistic을 도입했는데도 느린 케이스를 빠르게 정리할 수 있습니다.
- 낙관 업데이트를 위해 별도의
useState를 만들고 서버 응답 때 또setState를 하고 있지 않은가 useEffect로 "서버 결과와 로컬 상태 동기화"를 반복하고 있지 않은가- 리스트 아이템마다 낙관 상태를 따로 두어, 클릭 한 번에 많은 컴포넌트가 연쇄 업데이트되지 않는가
- 서버 요청을
startTransition으로 감싸 긴급 렌더와 분리했는가 - 낙관 reducer가 매번 새 객체/새 배열을 과도하게 만들고 있지 않은가(필요한 범위만 변경)
추가로 App Router 기반이라면, 서버 데이터 갱신 전략(캐시, revalidate, fetch 옵션)에 따라 "서버에서 내려오는 기준 상태"의 업데이트 타이밍이 달라집니다. 이 부분이 꼬이면 낙관 상태가 오래 남아 보이거나, 반대로 너무 빨리 덮어써서 깜빡임이 생길 수 있습니다. 이 주제는 Next.js App Router RSC 캐시 꼬임 해결법과 Next.js App Router TTFB 느림 - RSC 캐시·fetch 설정에서 함께 보면 원인 파악이 빨라집니다.
마무리
React 19의 useOptimistic은 낙관적 UI를 "빠르게" 만드는 도구이면서 동시에 "안전하게" 만드는 도구입니다. 렌더링 폭주는 대부분 상태를 한 곳에 섞어 넣으면서 생기는 경합과 중복 업데이트에서 시작합니다.
- 기준 상태(서버/부모)와 낙관 상태(UI 오버레이)를 분리하고
- 서버 요청은
startTransition으로 우선순위를 낮추며 - 리스트는 아이템 단위가 아니라 컬렉션 단위로 낙관 reducer를 구성
이 3가지만 지켜도 클릭 연타, 느린 네트워크, 대형 리스트 같은 스트레스 상황에서 UI가 훨씬 안정적으로 동작합니다.