Published on

React 19 useOptimistic로 렌더링 폭주 잡기

Authors

서버 액션이나 뮤테이션을 붙인 직후, UI가 "빠르게" 반응하는 대신 "너무 자주" 렌더링되는 경험을 해본 적이 있을 겁니다. 특히 입력 이벤트마다 네트워크 요청을 쏘거나, 목록 항목을 토글하는 낙관적 업데이트를 무심코 전역 상태에 섞어 넣으면, 다음 현상이 함께 옵니다.

  • 동일한 사용자 액션에 대해 상태 업데이트가 2~3회 이상 연쇄적으로 발생
  • 요청이 겹치면서 응답 순서가 뒤집혀 UI가 되돌아가거나 깜빡임
  • 리스트 전체가 매번 재렌더링되어 입력 지연, 스크롤 끊김

React 19에서 useOptimistic는 낙관적 UI를 "별도 레이어"로 분리해, 커밋된 데이터(서버 확정)와 임시 데이터(사용자 즉시 반영)를 명확히 구분하도록 돕습니다. 이 글에서는 useOptimistic를 렌더링 폭주 관점에서 어떻게 써야 하는지, 그리고 어떤 구조가 폭주를 유발하는지 코드로 정리합니다.

관련 최적화 관점으로는 프론트엔드 레이아웃 비용을 줄이는 방법도 함께 보면 좋습니다. 예를 들어 레이아웃 스래싱이 동반되는 경우엔 CSS contain로 레이아웃 스래싱 줄이는 실전도 같이 점검해보세요.

렌더링 폭주가 생기는 전형적인 패턴

낙관적 업데이트를 구현할 때 가장 흔한 실수는 "서버 응답을 기다리기 전"과 "서버 응답을 받은 후"를 같은 상태에 섞어 넣는 것입니다.

예를 들어 체크박스 토글을 낙관적으로 반영하려고 아래처럼 작성하면, 상태가 다음처럼 흔들릴 수 있습니다.

  1. 사용자 클릭으로 setTodos 실행(낙관적)
  2. 요청 성공 후 다시 setTodos 실행(서버 반영)
  3. 동시에 다른 요청이 늦게 도착하면 2)에서 확정된 값을 다시 덮어씀

이 과정에서 리스트가 큰 경우, 매번 전체 리렌더링이 발생해 체감 성능이 크게 떨어집니다.

useOptimistic 핵심: "확정 상태" 위에 "임시 상태"를 얹는다

useOptimistic는 기본 상태(서버에서 온 값, 혹은 확정된 값)를 입력으로 받고, 거기에 "임시 패치"를 적용한 계산 결과를 반환합니다.

  • 확정 상태: 서버 응답으로만 갱신(또는 상위에서 내려옴)
  • 낙관 상태: 사용자 액션 즉시 반영, 실패 시 자연스럽게 롤백되도록 설계

즉, "서버 확정 데이터"를 더럽히지 않고도 UX를 빠르게 만들 수 있고, 상태 업데이트 횟수와 범위를 줄이기 쉬워집니다.

예제 1: Todo 토글에서 렌더링 폭주 줄이기

아래 예제는 useOptimistic로 토글을 즉시 반영하면서도, 서버 응답이 도착했을 때는 "확정 상태"만 갱신하도록 분리합니다.

'use client';

import * as React from 'react';

type Todo = {
  id: string;
  title: string;
  done: boolean;
  updatedAt: number;
};

type OptimisticPatch =
  | { type: 'toggle'; id: string; nextDone: boolean; clientTs: number }
  | { type: 'replaceAll'; todos: Todo[] };

async function updateTodoDone(id: string, done: boolean) {
  // 실제로는 fetch 호출
  await new Promise((r) => setTimeout(r, 250));
  return { id, done, serverTs: Date.now() };
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  // 1) 확정 상태(서버 기반)
  const [todos, setTodos] = React.useState<Todo[]>(initialTodos);

  // 2) 낙관 상태(확정 상태 위에 patch 적용)
  const [optimisticTodos, applyOptimistic] = React.useOptimistic(
    todos,
    (current: Todo[], patch: OptimisticPatch) => {
      if (patch.type === 'replaceAll') return patch.todos;

      if (patch.type === 'toggle') {
        return current.map((t) =>
          t.id === patch.id
            ? { ...t, done: patch.nextDone, updatedAt: patch.clientTs }
            : t
        );
      }

      return current;
    }
  );

  const onToggle = async (id: string) => {
    const current = optimisticTodos.find((t) => t.id === id);
    if (!current) return;

    const nextDone = !current.done;
    const clientTs = Date.now();

    // A) UI는 즉시 반영(낙관 업데이트)
    applyOptimistic({ type: 'toggle', id, nextDone, clientTs });

    try {
      // B) 서버 요청
      const res = await updateTodoDone(id, nextDone);

      // C) 서버 확정 데이터로만 확정 상태 갱신
      //    여기서도 "최소 변경"만 적용하면 렌더링 범위를 줄이기 쉽습니다.
      setTodos((prev) =>
        prev.map((t) =>
          t.id === id
            ? { ...t, done: res.done, updatedAt: res.serverTs }
            : t
        )
      );
    } catch (e) {
      // 실패 시: 확정 상태는 건드리지 않았으므로
      // 상위에서 todos가 그대로면 낙관 레이어가 자연스럽게 원복됩니다.
      // 필요하면 에러 토스트만 띄우면 됩니다.
      console.error(e);
    }
  };

  return (
    <ul>
      {optimisticTodos.map((t) => (
        <li key={t.id}>
          <label>
            <input
              type="checkbox"
              checked={t.done}
              onChange={() => onToggle(t.id)}
            />
            {t.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

이 구조가 "폭주"를 줄이는 이유

  • 낙관 업데이트는 applyOptimistic로만 처리되어, "확정 상태"의 변경을 최소화합니다.
  • 서버 응답이 돌아올 때도 setTodos는 해당 항목만 얕게 갱신하므로 전체 변동이 줄어듭니다.
  • 실패 시 롤백을 위해 별도 "되돌리기" 상태를 만들 필요가 줄어듭니다.

다만, 리스트가 매우 크다면 항목 컴포넌트를 분리하고 React.memo를 적용해 변경된 항목만 다시 그리게 하는 것도 중요합니다.

예제 2: 중복 클릭과 경쟁 상태(Out-of-order) 막기

렌더링 폭주의 또 다른 원인은 "연타"로 인해 동일 리소스에 대한 요청이 겹치고, 늦게 도착한 응답이 최신 상태를 덮어쓰는 경쟁 상태입니다.

이를 막으려면 최소한 다음 중 하나가 필요합니다.

  • 요청 단위로 AbortController를 두고 이전 요청 취소
  • 응답에 버전(또는 타임스탬프)을 두고 최신만 반영

아래는 "클라이언트 타임스탬프"를 이용해 최신 응답만 반영하는 예시입니다.

'use client';

import * as React from 'react';

type LikeState = { liked: boolean; version: number };

type Patch = { type: 'set'; liked: boolean; version: number };

async function apiSetLike(liked: boolean, version: number) {
  await new Promise((r) => setTimeout(r, Math.random() * 500));
  return { liked, version };
}

export function LikeButton() {
  const [state, setState] = React.useState<LikeState>({ liked: false, version: 0 });

  const [optimistic, applyOptimistic] = React.useOptimistic(
    state,
    (current: LikeState, patch: Patch) => {
      if (patch.type === 'set') return { liked: patch.liked, version: patch.version };
      return current;
    }
  );

  const inFlightVersionRef = React.useRef(0);

  const toggle = async () => {
    const nextLiked = !optimistic.liked;
    const nextVersion = optimistic.version + 1;

    // 낙관 반영
    applyOptimistic({ type: 'set', liked: nextLiked, version: nextVersion });

    // 최신 요청 버전 기록
    inFlightVersionRef.current = nextVersion;

    const res = await apiSetLike(nextLiked, nextVersion);

    // 최신 요청의 응답만 확정 상태에 반영
    if (res.version === inFlightVersionRef.current) {
      setState({ liked: res.liked, version: res.version });
    }
  };

  return (
    <button type="button" onClick={toggle}>
      {optimistic.liked ? 'Liked' : 'Like'}
    </button>
  );
}

포인트

  • useOptimistic는 UI 즉시 반응을 담당하고,
  • "서버 확정 반영"은 최신성 검증을 통과한 응답만 setState로 반영합니다.

이 패턴을 적용하면 연타로 인해 상태가 앞뒤로 흔들리며 발생하는 불필요한 렌더링과 깜빡임을 크게 줄일 수 있습니다.

렌더링 폭주를 부르는 useOptimistic 오용 3가지

1) 낙관 패치에 "전체 리스트"를 매번 새로 생성

낙관 업데이트의 reducer에서 매번 전체 배열을 재구성하면, 하위 컴포넌트 메모이제이션이 깨지고 리렌더링이 증폭됩니다.

  • 가능한 한 "변경된 항목만" 바꾸는 구조를 유지하세요.
  • 정렬, 필터 같은 고비용 연산은 useMemo나 서버에서 처리하는 것을 고려하세요.

2) 입력 이벤트마다 낙관 업데이트를 남발

검색어 입력처럼 초당 수십 번 발생하는 이벤트에 낙관 업데이트를 걸면, 당연히 렌더링이 폭주합니다.

  • 디바운스(예: setTimeout) 또는 useDeferredValue로 우선순위를 낮추세요.
  • 입력은 로컬 state로만 처리하고, 서버 동기화는 일정 간격으로 묶는 편이 안정적입니다.

3) 낙관 상태를 전역 스토어에 합쳐버리기

전역 스토어에 낙관 상태까지 합치면, 앱 전체가 "임시 변화"에 반응하게 되어 영향 범위가 커집니다.

  • useOptimistic는 가능한 한 "해당 UI 경계"에 가깝게 두세요.
  • 전역에는 확정 상태만 두고, 낙관 레이어는 컴포넌트 내부에서만 유지하는 것이 안전합니다.

서버 액션/뮤테이션과 함께 쓸 때 체크리스트

React 19와 Next.js 조합에서 서버 액션 또는 API 뮤테이션을 붙일 때, 아래를 기준으로 설계를 점검하면 렌더링 폭주를 줄이기 쉽습니다.

  1. 낙관 업데이트는 patch 기반으로 작게
    • applyOptimistic에 "의도"만 전달(예: 토글, 추가, 삭제)
  2. 확정 상태 갱신은 최소 변경만
    • 서버 응답을 그대로 덮어쓰기보다, 바뀐 필드만 반영
  3. 경쟁 상태 제어
    • 버전 비교 또는 AbortController
  4. 에러 처리의 단순화
    • 확정 상태를 건드리지 않았다면, 실패 시 롤백 로직이 거의 필요 없음

인증이 붙은 API에서 401이나 키 로테이션 이슈로 요청이 재시도되며 상태가 흔들리는 경우도 실무에서 자주 보입니다. 그런 경우엔 프론트에서 "요청이 중복되며 낙관 상태가 흔들리는" 현상이 같이 나타날 수 있으니, 토큰 검증/캐시 전략도 함께 점검해보세요: Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응

마무리: useOptimistic는 "빠른 UI"보다 "안정적인 경계"가 핵심

useOptimistic의 가치는 단순히 UI를 빨리 보이게 하는 데만 있지 않습니다. 진짜 효용은 다음 두 가지를 분리하는 데 있습니다.

  • 서버 확정 데이터의 단일한 진실(확정 상태)
  • 사용자 경험을 위한 임시 레이어(낙관 상태)

이 경계를 잘 지키면, 중복 업데이트와 경쟁 상태로 인한 렌더링 폭주를 줄이고, 실패/재시도/지연 같은 현실적인 네트워크 조건에서도 UI를 안정적으로 유지할 수 있습니다.

다음 단계로는 컴포넌트 경계에서의 메모이제이션, 리스트 가상화, 그리고 레이아웃 비용 최적화까지 함께 묶어 성능을 점검해보는 것을 권장합니다.