Published on

React 19 useOptimistic로 스피너 지옥 끝내기

Authors

대부분의 프론트엔드 앱은 비동기 요청이 늘어날수록 화면 곳곳에 스피너가 생깁니다. 버튼마다 로딩, 리스트마다 로딩, 모달마다 로딩. 문제는 스피너 자체가 아니라, 로딩 상태를 관리하는 코드가 분산되면서 UX와 유지보수성이 동시에 무너진다는 점입니다.

React 19에서 useOptimistic는 이 문제를 정면으로 다룹니다. 핵심은 간단합니다.

  • 서버 응답을 기다리기 전에 UI를 먼저 바꾼다(낙관적 업데이트)
  • 실패하면 롤백한다
  • 로딩 스피너 대신, 사용자가 체감하는 즉시성을 만든다

이 글에서는 useOptimistic를 “버튼 로딩 제거용 훅” 정도로 가볍게 쓰지 않고, 리스트 추가/삭제/토글 같은 실무 CRUD에 적용하는 패턴을 정리합니다.

또한 타입 안정성까지 챙기기 위해 TS의 satisfies를 함께 쓰는 방식을 보여줍니다. 타입 좁히기 패턴이 필요하다면 TS 5.x satisfies로 타입 좁히기 실무 패턴도 같이 보면 좋습니다.

스피너 지옥이 생기는 구조적 원인

전형적인 스피너 지옥 코드는 대략 이런 형태입니다.

  • 컴포넌트마다 isLoading 상태가 따로 존재
  • 요청이 중첩되면 어떤 로딩이 무엇을 의미하는지 알기 어려움
  • 리스트 항목 단위 로딩이 필요해지면 loadingId 같은 변종 상태가 추가
  • 결국 “UI 상태”와 “서버 상태”가 서로를 밀어내며 복잡도가 폭발

여기서 중요한 관점 전환은 다음입니다.

  • 사용자는 “로딩 중”이라는 사실보다 “내가 한 행동이 반영되었는지”를 더 중요하게 느낀다
  • 그러니 스피너를 보여주기보다, UI를 먼저 반영하고 실패 시 안내하는 편이 UX가 낫다

이 철학을 React가 공식적으로 밀어주는 도구가 useOptimistic입니다.

useOptimistic 한 줄 정의

useOptimistic는 “기본 상태(base state)” 위에 “낙관적 변경(optimistic update)”을 겹쳐서 보여주는 상태 훅입니다.

  • base state: 서버에서 확정된 데이터
  • optimistic state: 사용자의 의도를 즉시 반영한 가짜 확정 데이터

React는 base state가 바뀌면(예: 서버 응답 후 re-fetch) optimistic layer를 자연스럽게 다시 계산합니다.

언제 쓰고 언제 쓰지 말아야 하나

잘 맞는 경우

  • 좋아요 토글, 북마크 토글
  • 댓글/아이템 추가
  • 리스트에서 아이템 삭제
  • 체크박스 완료 처리

즉, “내가 방금 한 행동”이 UI에 즉시 반영되면 좋은 상호작용.

피하는 게 좋은 경우

  • 서버에서만 결정되는 값이 핵심인 경우(예: 결제 승인, 권한 부여)
  • 실패 확률이 높고 롤백 비용이 큰 경우(예: 재고 차감이 크리티컬)

이 경우는 스피너가 더 안전할 수 있습니다. 다만 완전한 스피너보다는 useTransition과 함께 “진행 중” 정도의 약한 피드백을 주는 방식이 낫습니다.

예제 1: 좋아요 토글에서 스피너 제거

아래는 useOptimistic로 좋아요 토글을 구현한 예시입니다. 버튼을 누르면 즉시 카운트와 상태가 바뀌고, 서버 호출이 실패하면 원래대로 되돌립니다.

import { useOptimistic, useState, useTransition } from "react";

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

async function apiToggleLike(postId: string): Promise<void> {
  const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  if (!res.ok) throw new Error("toggle like failed");
}

export function LikeButton(props: { postId: string; initial: LikeState }) {
  const [base, setBase] = useState<LikeState>(props.initial);
  const [isPending, startTransition] = useTransition();

  const [optimistic, applyOptimistic] = useOptimistic(
    base,
    (current, next: { type: "toggle" }) => {
      if (next.type !== "toggle") return current;
      const liked = !current.liked;
      return {
        liked,
        count: current.count + (liked ? 1 : -1)
      };
    }
  );

  const onToggle = () => {
    // 1) UI 먼저 반영
    applyOptimistic({ type: "toggle" });

    // 2) 서버 요청은 transition으로 감싸서 UI 우선권 유지
    startTransition(async () => {
      try {
        await apiToggleLike(props.postId);
        // 3) 성공하면 base를 optimistic과 맞춰 확정
        setBase(optimistic);
      } catch {
        // 4) 실패하면 base를 그대로 두면 다음 렌더에서 자동 롤백
        // 필요하면 토스트/알림
      }
    });
  };

  return (
    <button onClick={onToggle} aria-busy={isPending}>
      {optimistic.liked ? "♥" : "♡"} {optimistic.count}
    </button>
  );
}

setBase(optimistic)가 필요한가

낙관적 UI는 “겹쳐 보여주는 레이어”입니다. 서버가 성공했으면 base state도 그 결과로 업데이트해 확정해야 합니다.

실무에서는 보통 다음 중 하나를 선택합니다.

  • 성공 시 setBase로 확정
  • 성공 후 서버에서 최신 데이터를 다시 가져와 base를 갱신

후자는 데이터 정합성이 더 좋지만 네트워크 비용이 듭니다.

실패 처리는 어떻게

위 예제는 실패 시 base를 유지하므로, optimistic layer가 사라지며 UI가 원래대로 돌아옵니다.

여기에 사용자 피드백을 추가하려면 토스트를 붙입니다.

catch (e) {
  toast.error("요청에 실패했어요. 잠시 후 다시 시도해 주세요.");
}

예제 2: 댓글 추가를 “즉시 반영 + 임시 ID + 롤백”으로

리스트에 아이템을 추가할 때 스피너를 쓰는 이유는 “서버가 ID를 주기 때문”인 경우가 많습니다. 이때는 임시 ID를 만들어 optimistic list에 먼저 넣고, 서버 응답이 오면 교체합니다.

import { useOptimistic, useState, useTransition } from "react";

type Comment = {
  id: string;
  body: string;
  createdAt: string;
  pending?: boolean;
};

async function apiCreateComment(postId: string, body: string): Promise<Comment> {
  const res = await fetch(`/api/posts/${postId}/comments`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ body })
  });
  if (!res.ok) throw new Error("create comment failed");
  return res.json();
}

export function CommentBox(props: { postId: string; initial: Comment[] }) {
  const [base, setBase] = useState<Comment[]>(props.initial);
  const [isPending, startTransition] = useTransition();

  const [optimistic, applyOptimistic] = useOptimistic(
    base,
    (
      current,
      action:
        | { type: "add"; comment: Comment }
        | { type: "replace"; tempId: string; saved: Comment }
        | { type: "remove"; id: string }
    ) => {
      switch (action.type) {
        case "add":
          return [action.comment, ...current];
        case "replace":
          return current.map((c) =>
            c.id === action.tempId ? { ...action.saved, pending: false } : c
          );
        case "remove":
          return current.filter((c) => c.id !== action.id);
        default:
          return current;
      }
    }
  );

  const onSubmit = (body: string) => {
    const tempId = `temp_${crypto.randomUUID()}`;
    const temp: Comment = {
      id: tempId,
      body,
      createdAt: new Date().toISOString(),
      pending: true
    };

    applyOptimistic({ type: "add", comment: temp });

    startTransition(async () => {
      try {
        const saved = await apiCreateComment(props.postId, body);

        // optimistic 리스트에서 temp를 saved로 교체
        applyOptimistic({ type: "replace", tempId, saved });

        // base 확정(실무에서는 re-fetch로 대체 가능)
        setBase((prev) => [saved, ...prev]);
      } catch {
        // 실패하면 optimistic에서 temp 제거
        applyOptimistic({ type: "remove", id: tempId });
      }
    });
  };

  return (
    <section>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const fd = new FormData(e.currentTarget);
          const body = String(fd.get("body") || "").trim();
          if (!body) return;
          onSubmit(body);
          e.currentTarget.reset();
        }}
      >
        <input name="body" placeholder="댓글을 입력" />
        <button type="submit" aria-busy={isPending}>
          등록
        </button>
      </form>

      <ul>
        {optimistic.map((c) => (
          <li key={c.id}>
            {c.pending ? "(전송중) " : ""}
            {c.body}
          </li>
        ))}
      </ul>
    </section>
  );
}

여기서 포인트는 스피너가 아니라 “리스트에 항목이 즉시 생기는 경험”입니다. 사용자는 등록 버튼을 눌렀을 때 화면이 바로 바뀌면, 로딩을 거의 인지하지 않습니다.

실무 팁: optimistic 액션을 satisfies로 고정하기

낙관적 업데이트는 액션 타입이 늘어나기 쉽습니다. 이때 액션 객체를 여기저기서 만들다 보면 오타가 런타임 버그로 이어집니다.

TS의 satisfies를 사용하면 액션 생성 시점에 타입을 강제할 수 있습니다.

type Action =
  | { type: "add"; comment: { id: string; body: string; createdAt: string; pending?: boolean } }
  | { type: "remove"; id: string };

const action = {
  type: "remove",
  id: "temp_123"
} satisfies Action;

이 패턴은 액션이 커질수록 효과가 큽니다. 관련 심화는 TS 5.x satisfies로 타입 좁히기 실무 패턴에서 더 자세히 다뤘습니다.

서버 액션과 함께 쓸 때의 설계 포인트

React 19와 Next.js 환경에서는 서버 액션을 통해 데이터 변경을 수행하는 경우가 많습니다. 이때도 useOptimistic의 역할은 동일합니다.

  • UI는 즉시 바뀐다
  • 서버 액션은 백그라운드에서 실행된다
  • 실패 시 롤백과 에러 메시지를 제공한다

중요한 건 optimistic state를 “진짜 데이터”로 착각하지 않는 것입니다.

  • optimistic는 화면을 빠르게 만들기 위한 레이어
  • 정합성의 최종 권위는 서버

따라서 성공 후에는 다음 중 하나로 base를 확정해야 합니다.

  • 서버 액션이 돌려준 결과로 base 업데이트
  • revalidate 또는 재조회로 base 업데이트

흔한 함정 5가지

1) optimistic를 source of truth로 쓰기

optimistic는 임시 레이어입니다. 이를 기준으로 다른 비즈니스 로직을 굴리면 실패 롤백 시 연쇄 버그가 납니다.

2) 실패 시 사용자에게 아무 피드백도 주지 않기

UI가 되돌아가는 것만으로는 사용자가 “내가 뭘 잘못했나”라고 느낄 수 있습니다. 최소한 토스트나 인라인 에러를 제공하세요.

3) 중복 클릭으로 optimistic가 여러 번 쌓이기

토글류는 특히 위험합니다. isPending 동안 버튼을 disable하거나, 액션 큐를 두는 방식이 필요합니다.

<button onClick={onToggle} disabled={isPending}>
  ...
</button>

4) 리스트 key를 인덱스로 두기

optimistic에서 임시 항목을 넣고 교체하는 순간, 인덱스 key는 UI 글리치를 만듭니다. 반드시 안정적인 id를 key로 사용하세요.

5) 서버가 거절하는 정책을 optimistic에서 무시하기

예를 들어 좋아요는 1초에 10번 이상 누르면 서버가 rate limit을 걸 수 있습니다. optimistic는 “정책을 숨기는 도구”가 아니라 “즉시성을 주는 도구”입니다. 정책 위반 시에는 명확히 안내해야 합니다.

스피너를 완전히 없애야 하나

그럴 필요는 없습니다. 다음처럼 역할을 분리하는 게 좋습니다.

  • 버튼 클릭 즉시 반영 가능한 작업: useOptimistic 우선
  • 시간이 오래 걸리고 중간 상태가 의미 있는 작업(업로드, 결제 등): 진행률 UI나 명시적 로딩
  • 화면 진입 시 최초 데이터 로딩: skeleton UI

스피너는 “최후의 수단”으로 남기고, 사용자 행동에 대한 즉시 피드백은 optimistic로 해결하는 전략이 효과적입니다.

성능 관점에서 사용자 경험을 수치로 추적해야 한다면, 인터랙션 지연과 Long Task를 함께 점검하는 것도 도움이 됩니다. 관련해서는 Chrome INP 악화? Long Task 원인추적·해결도 참고할 만합니다.

마무리: useOptimistic는 상태관리 기법이 아니라 UX 기법이다

useOptimistic를 도입하면 코드에서 isLoading이 줄어드는 건 부수효과에 가깝습니다. 진짜 이점은 다음입니다.

  • 사용자의 행동이 즉시 화면에 반영된다
  • “기다림” 대신 “진행”을 보여준다
  • 실패 시에도 예측 가능한 롤백 흐름을 갖는다

실무 적용 순서는 보통 이렇게 가면 안전합니다.

  1. 토글류(좋아요, 북마크)부터 optimistic 적용
  2. 리스트 추가/삭제로 확장(임시 ID 패턴)
  3. 실패 케이스 UX를 통일(토스트, 인라인 에러)
  4. 성공 후 base 확정 전략을 일관되게 정리(응답 반영 또는 재조회)

이 흐름대로만 적용해도, “스피너 지옥”은 확실히 끝이 보입니다.