Skip to content

Chapter 10. '좋아요' 낙관적 업데이트와 React 19.3 useOptimistic & Zustand 롤백 ​

"좋아요 하트를 눌렀을 때 0.5초 동안 아무 반응이 없다면 한국인 사용자는 이미 버튼을 5번 연타하고 있다. React 19.3의 useOptimistic과 Zustand 스냅샷 롤백을 양방향으로 구축하고, 탭을 닫아도 작성 중인 글을 지키는 영속화 미들웨어를 장착하자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
낙관적 업데이트(Optimistic UI)와 롤백서버 응답 전에 화면부터 +1 반영하고 실패 시 원래 값으로 복구10.1~10.2
Zustand 스냅샷 백업 & pendingLikeIds (Set)스토어 레벨에서 이전 값을 기억해 롤백하고 중복 연타를 차단하는 잠금 기법10.2 [코드 10-1]
React 19.3 useOptimistic & useTransition비동기 Transition 진행 중에만 임시 낙관적 상태를 선언적으로 투영하는 훅10.3 🆕 박스
MSW setupServer, HttpResponse.error()가짜 서버로 네트워크 단절 상황을 재현하여 롤백을 자동 검증10.4 [코드 10-3]
Zustand persist 미들웨어 (useDraftStore)작성 중인 글 초안을 localStorage에 자동 동기화해 새로고침 날아감 방어10.5 [코드 10-4]

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch09.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 이 장에서 손대는 파일: frontend/src/stores/usePostStore.ts, frontend/src/components/LikeButton.tsx(새로 작성), frontend/src/stores/useDraftStore.ts(새로 작성), frontend/src/BoardApp.tsx, frontend/tests/unit/usePostStore.test.ts(새로 작성)


10.1 [좋아요] 버튼에 숨겨진 답답함과 양방향 설계 ​

동료의 유용한 기술 글을 보고 [❤️ 좋아요] 버튼을 누를 때, 전통적인 비관적(Pessimistic) 업데이트는 다음과 같이 동작합니다:

  1. 사용자가 [좋아요] 클릭
  2. 서버 응답 대기 동안 UI가 멈춰 있음 (300~500ms)
  3. 서버에서 200 OK가 도착하면 그제서야 숫자가 5에서 6으로 바뀜

네트워크가 느린 환경에서는 사용자가 "버튼이 안 눌렸나?" 싶어서 버튼을 광클하게 되고, 이로 인해 중복 요청과 경쟁 상태(Race Condition)가 발생합니다.

낙관적 업데이트는 개발자의 '선조치 후보고' 기술입니다. 일단 사용자 화면에는 하트 숫자(+1)를 시원하게 먼저 찍어주고, 뒤편에서 백엔드와 통신합니다. 물론 서버 통신이 실패하면 빛의 속도로 원래 숫자로 되돌리는(롤백) 안전장치가 필수입니다.

실무 React 19.3 + Zustand 아키텍처에서는 두 계층의 무기를 조화롭게 사용합니다:

  • 컴포넌트 UI 계층 (useOptimistic + startTransition): 버튼 자체의 즉각적인 시각 피드백(❤️ +1, isPending 표시)과 트랜지션 종료 시의 자동 복귀를 선언적으로 처리합니다.
  • 전역 스토어 계층 (usePostStore.toggleLikeOptimistic): 테이블 행, 인기글 Top 3 배너, 상세 모달 등 여러 컴포넌트가 공유하는 posts 배열을 즉시 갱신하고, 연타 방지 잠금(pendingLikeIds)과 실패 시 스냅샷 롤백을 수행합니다.

10.2 [실습 1] usePostStore.ts에 스냅샷 롤백과 연타 방지 잠금 추가 ​

9장에서 만든 usePostStore.ts에 동시성 안전한 toggleLikeOptimistic 액션과 진행 상태 조회 함수를 추가합니다.

실무에서 반드시 챙겨야 할 2가지 안전장치:

  1. 연타 방지 잠금(pendingLikeIds): 이미 좋아요 요청이 날아간 게시글 ID는 Set<number>에 기록해 두고 응답이 끝날(finally) 때까지 중복 클릭을 무시합니다.
  2. 스냅샷 백업과 정확한 롤백: 요청 전 해당 게시글의 previousLikeCount를 백업해 두었다가, catch 블록에서 그 게시글의 좋아요 수만 원래 값으로 되돌린 뒤 에러를 다시 던집니다(throw error).

[코드 10-1] src/stores/usePostStore.ts에 낙관적 업데이트 액션 추가 ​

typescript
// frontend/src/stores/usePostStore.ts (9장 코드에 추가/확장되는 핵심 부분)
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import axios from 'axios';
import type { Post, CreatePostRequest, PageResult } from '@/types/board';

// 스토어 모듈 내부에서 진행 중인 좋아요 요청 ID를 잠그는 Set
const pendingLikeIds = new Set<number>();

export interface PostStoreState {
  posts: Post[];
  currentPost: Post | null;
  totalCount: number;
  isLoading: boolean;
  lastFetchedTime: number | null;
  lastQueryKey: string;
  fetchPosts: (params?: any, force?: boolean) => Promise<void>;
  createPost: (payload: CreatePostRequest) => Promise<Post>;
  removePost: (id: number) => Promise<void>;
  // 🌟 10장 추가: 낙관적 좋아요 액션 및 진행 중 여부 확인
  toggleLikeOptimistic: (postId: number) => Promise<void>;
  isLikePending: (postId: number) => boolean;
  invalidateCache: () => void;
  setCurrentPost: (post: Post | null) => void;
}

export const usePostStore = create<PostStoreState>()(
  devtools(
    (set, get) => ({
      posts: [],
      currentPost: null,
      totalCount: 0,
      isLoading: false,
      lastFetchedTime: null,
      lastQueryKey: '',

      // ... (9장의 invalidateCache, setCurrentPost, fetchPosts, createPost, removePost 동일) ...

      isLikePending: (postId: number) => pendingLikeIds.has(postId),

      // 🌟 10장 핵심: 스냅샷 백업 + 즉시 +1 + 실패 시 롤백 + 연타 방지 잠금
      toggleLikeOptimistic: async (postId: number) => {
        // 1. 중복 연타 방어: 이미 해당 게시글에 대한 요청이 진행 중이면 즉시 무시
        if (pendingLikeIds.has(postId)) return;

        const targetPost = get().posts.find((p) => p.id === postId);
        if (!targetPost) return;

        // 2. 실패 시 롤백을 위해 이전 수치 스냅샷 백업 및 잠금 설정
        const previousLikeCount = targetPost.likeCount;
        pendingLikeIds.add(postId);

        // 3. [선제적 반영] 서버 응답을 기다리지 않고 스토어 배열과 상세 모달 상태를 즉시 +1
        set(
          (state) => ({
            posts: state.posts.map((p) =>
              p.id === postId ? { ...p, likeCount: p.likeCount + 1 } : p
            ),
            currentPost:
              state.currentPost?.id === postId
                ? { ...state.currentPost, likeCount: state.currentPost.likeCount + 1 }
                : state.currentPost
          }),
          false,
          'post/toggleLike/optimistic'
        );

        try {
          // 4. 실제 백엔드 API 호출 → 성공 시 서버가 돌려준 기준 값으로 최종 보정
          const res = await axios.post<Post>(`/api/posts/${postId}/like`);
          const serverLikeCount = res.data.likeCount;

          set(
            (state) => ({
              posts: state.posts.map((p) =>
                p.id === postId ? { ...p, likeCount: serverLikeCount } : p
              ),
              currentPost:
                state.currentPost?.id === postId
                  ? { ...state.currentPost, likeCount: serverLikeCount }
                  : state.currentPost
            }),
            false,
            'post/toggleLike/confirmed'
          );
        } catch (error) {
          // 5. [롤백] 네트워크 장애나 500 에러 시 백업해 둔 previousLikeCount로 즉시 복구!
          set(
            (state) => ({
              posts: state.posts.map((p) =>
                p.id === postId ? { ...p, likeCount: previousLikeCount } : p
              ),
              currentPost:
                state.currentPost?.id === postId
                  ? { ...state.currentPost, likeCount: previousLikeCount }
                  : state.currentPost
            }),
            false,
            'post/toggleLike/rollback'
          );
          throw error; // 사용자에게 토스트/alert를 띄우는 것은 컴포넌트가 맡도록 에러 재전파
        } finally {
          // 6. 성공하든 실패하든 반드시 잠금 해제
          pendingLikeIds.delete(postId);
        }
      }
    }),
    { name: 'BoardPostStore' }
  )
);

10.3 [실습 2] React 19.3 useOptimistic과 startTransition을 결합한 LikeButton.tsx ​

이제 개별 버튼 컴포넌트에서 React 19.3의 useOptimistic과 useTransition을 사용해 비동기 액션 진행 상태(isPending)와 선언적 낙관적 상태를 연결합니다.

🆕 처음 보는 문법: React 19.3 useOptimistic과 useTransition

  • const [optimisticLikes, addOptimisticLike] = useOptimistic(실제값, (현재값, 추가값) => 새값):
    • 첫 번째 인자는 부모나 스토어에서 내려온 **진짜 상태(Source of Truth)**입니다.
    • 비동기 작업이 진행되는 동안에만 addOptimisticLike(1)로 계산된 임시 값을 화면에 보여주고, 비동기 작업(Transition)이 끝나면 자동으로 최신 실제값을 따라갑니다.
  • const [isPending, startTransition] = useTransition():
    • React 19에서 startTransition(async () => { ... })은 비동기 함수를 직접 인자로 받을 수 있습니다!
    • 주의: addOptimisticLike는 반드시 startTransition 콜백(또는 Form Action) 내부에서 호출해야 합니다. 일반 동기 클릭 핸들러에서 바로 부르면 React가 경고를 출력합니다(부록 C FAQ 10 참고).

[코드 10-2] 낙관적 좋아요 버튼 (frontend/src/components/LikeButton.tsx) ​

tsx
// frontend/src/components/LikeButton.tsx
import React, { useOptimistic, useTransition } from 'react';
import { usePostStore } from '@/stores/usePostStore';

export interface LikeButtonProps {
  postId: number;
  likeCount: number;
}

export function LikeButton({ postId, likeCount }: LikeButtonProps) {
  const [isPending, startTransition] = useTransition();
  const toggleLikeOptimistic = usePostStore((s) => s.toggleLikeOptimistic);
  const isLikePending = usePostStore((s) => s.isLikePending);

  // 🌟 React 19.3 공식 useOptimistic 훅
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likeCount,
    (currentCount: number, delta: number) => currentCount + delta
  );

  function handleLikeClick(e: React.MouseEvent<HTMLButtonElement>) {
    e.stopPropagation(); // 테이블 행 클릭(상세 모달 열기)으로 번지지 않게 차단
    if (isPending || isLikePending(postId)) return; // 중복 연타 방어

    startTransition(async () => {
      // 1. UI 트랜지션 레벨에서 즉시 +1 투영
      addOptimisticLike(1);

      try {
        // 2. 스토어 낙관적 액션 호출 (전역 캐시 동기화 및 실패 시 스냅샷 롤백)
        await toggleLikeOptimistic(postId);
      } catch {
        // 3. 에러 발생 시 스토어는 이미 롤백을 마쳤고, 트랜지션도 종료되며 원래 likeCount로 복귀!
        alert('네트워크 장애로 좋아요 처리에 실패하여 원래대로 롤백되었습니다.');
      }
    });
  }

  return (
    <button
      type="button"
      className={`btn-like ${isPending ? 'is-pending' : ''}`}
      onClick={handleLikeClick}
      disabled={isPending}
      aria-busy={isPending}
    >
      ❤️ {optimisticLikes}
    </button>
  );
}

BoardApp.tsx의 columns 배열에 좋아요 열을 추가해 LikeButton을 렌더링합니다:

tsx
// frontend/src/BoardApp.tsx 의 columns 배열에 추가
{
  key: 'likeCount',
  label: '좋아요',
  width: '100px',
  align: 'center',
  renderCell: (row) => <LikeButton postId={row.id} likeCount={row.likeCount} />
}

💡 [확인하기 - 오프라인 모드로 롤백 직접 체험하기]:

  1. 브라우저 개발자 도구(F12) → Network 탭을 엽니다.
  2. 상단 Throttling 드롭다운(No throttling)을 **Slow 3G**로 바꾸고 [❤️ 5] 버튼을 누릅니다. 서버 응답이 오기 전에 숫자가 즉시 6으로 올라가고, 응답 대기 중에 아무리 연타해도 숫자가 더 오르지 않는 것을 확인합니다.
  3. 이번에는 드롭다운을 **Offline**으로 바꾸고 [❤️ 6] 버튼을 누릅니다. 잠깐 7이 되었다가 즉시 6으로 롤백되며 안내 alert가 뜹니다! 확인 후 No throttling으로 되돌리십시오.

10.4 [실습 3] 롤백을 MSW 단위 테스트로 못 박아 두기 ​

눈으로 확인한 롤백과 연타 방지 동작을 Vitest + MSW 자동 테스트로 고정해 둡니다.

[코드 10-3] 낙관적 업데이트 롤백 테스트 (frontend/tests/unit/usePostStore.test.ts) ​

typescript
// frontend/tests/unit/usePostStore.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { usePostStore } from '@/stores/usePostStore';
import type { Post } from '@/types/board';

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

function makePost(likeCount: number): Post {
  return {
    id: 1,
    title: '롤백 테스트 게시글',
    author: '김개발',
    content: '본문 최소 10자 이상 테스트 데이터입니다.',
    category: 'DEV',
    createdAt: '2026-10-27',
    viewCount: 0,
    likeCount
  };
}

beforeEach(() => {
  // 각 테스트 전에 스토어 상태 초기화
  usePostStore.setState({
    posts: [makePost(5)],
    currentPost: null,
    totalCount: 1,
    isLoading: false,
    lastFetchedTime: null,
    lastQueryKey: ''
  });
});

describe('usePostStore.toggleLikeOptimistic', () => {
  it('응답 전에 먼저 +1 하고, 성공하면 서버가 준 값으로 최종 보정한다', async () => {
    server.use(
      http.post('/api/posts/:id/like', () => HttpResponse.json(makePost(10)))
    );

    const pendingPromise = usePostStore.getState().toggleLikeOptimistic(1);
    // 🌟 첫 await 직전 동기 구간에서 이미 5 -> 6으로 즉시 증가했는지 검증
    expect(usePostStore.getState().posts[0].likeCount).toBe(6);

    await pendingPromise;
    // 서버 응답(10)으로 최종 보정되었는지 검증
    expect(usePostStore.getState().posts[0].likeCount).toBe(10);
  });

  it('네트워크 에러가 나면 원래 숫자(5)로 정확히 롤백하고 에러를 다시 던진다', async () => {
    server.use(
      http.post('/api/posts/:id/like', () => HttpResponse.error())
    );

    const pendingPromise = usePostStore.getState().toggleLikeOptimistic(1);
    expect(usePostStore.getState().posts[0].likeCount).toBe(6);

    await expect(pendingPromise).rejects.toThrow();
    // 🌟 실패 즉시 원래 값 5로 롤백되었는지 검증!
    expect(usePostStore.getState().posts[0].likeCount).toBe(5);
  });
});

터미널에서 npx vitest run usePostStore를 실행해 2 passed가 뜨는지 확인합니다.


10.5 [실습 4] 글 작성 중 탭 닫힘 방어: Zustand persist와 useDraftStore.ts ​

열심히 질문 글을 작성하다가 실수로 새로고침(F5)을 누르거나 탭을 닫으면 작성 중이던 본문이 날아갑니다.
Zustand의 공식 persist 미들웨어를 사용하여, 작성 중인 폼 초안(Draft)만 전담해 localStorage에 자동 저장하는 useDraftStore.ts를 만듭니다. (목록 캐시가 든 usePostStore는 서버 상태와 어긋날 수 있으므로 영속화하지 않고 초안 스토어만 분리합니다.)

[코드 10-4] 초안 자동 영속화 스토어 (frontend/src/stores/useDraftStore.ts) ​

typescript
// frontend/src/stores/useDraftStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { PostCategory } from '@/types/board';

export interface DraftFormState {
  title: string;
  author: string;
  category: PostCategory;
  content: string;
  updateField: <K extends 'title' | 'author' | 'category' | 'content'>(
    field: K,
    value: DraftFormState[K]
  ) => void;
  clearDraft: () => void;
}

const initialDraft = {
  title: '',
  author: '',
  category: 'DEV' as PostCategory,
  content: ''
};

export const useDraftStore = create<DraftFormState>()(
  persist(
    (set) => ({
      ...initialDraft,

      updateField: (field, value) => {
        set({ [field]: value } as Partial<DraftFormState>);
      },

      clearDraft: () => {
        set(initialDraft);
      }
    }),
    {
      // 🌟 localStorage에 저장될 고유 키 이름 (12장에서 로그아웃 시 이 키는 지우지 않고 보존함!)
      name: 'BOARD_APP_DRAFT_STORAGE',
      storage: createJSONStorage(() => localStorage)
    }
  )
);

7장에서 만든 PostCreateModal.tsx 내부의 로컬 useState(title, author, category, content)를 useDraftStore()의 상태와 updateField, clearDraft로 교체하기만 하면, 글 작성 중 브라우저를 새로고침(F5)해도 작성하던 내용이 그대로 복원됩니다!


10.6 [직접 해보기] 실습 미션 💪 ​

🎯 미션: 중복 연타 방지(pendingLikeIds) 동작을 검증하는 단위 테스트 추가하기 ​

tests/unit/usePostStore.test.ts에 세 번째 테스트 케이스를 추가해 보십시오.
좋아요 수가 5인 상태에서 첫 번째 toggleLikeOptimistic(1)이 아직 응답을 기다리는 중에 두 번째 toggleLikeOptimistic(1)을 연달아 호출했을 때, 낙관적 증가가 7이 아니라 6으로 한 번만 일어나는지 검증하십시오.

💡 미션 정답 코드 확인하기
typescript
// frontend/tests/unit/usePostStore.test.ts 의 describe 블록 안에 추가
it('요청이 진행 중일 때 같은 글을 연타해도 중복 증가하지 않는다', async () => {
  server.use(
    http.post('/api/posts/:id/like', async () => {
      await new Promise((r) => setTimeout(r, 50));
      return HttpResponse.json(makePost(6));
    })
  );

  // 첫 번째 클릭과 두 번째 광클을 동시에 호출
  const p1 = usePostStore.getState().toggleLikeOptimistic(1);
  const p2 = usePostStore.getState().toggleLikeOptimistic(1);

  // 두 번 호출했지만 잠금(pendingLikeIds) 덕분에 7이 아니라 6이어야 함!
  expect(usePostStore.getState().posts[0].likeCount).toBe(6);

  await Promise.all([p1, p2]);
  expect(usePostStore.getState().posts[0].likeCount).toBe(6);
});

10.7 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄

  1. 낙관적 업데이트는 "스냅샷 백업 → 잠금(pendingLikeIds) → 즉시 +1 반영 → API 호출 → 실패 시 백업 값으로 롤백 → finally에서 잠금 해제" 순서로 구현합니다.
  2. React 19.3의 useOptimistic은 반드시 startTransition 내부에서 호출하며, 비동기 작업 동안 즉각적인 UI 반응성을 제공합니다.
  3. 작성 중인 글 초안은 Zustand persist 미들웨어로 격리된 useDraftStore에 두어, 새로고침이나 탭 닫힘에도 안전하게 보존합니다.

바뀐 파일

파일무엇이 바뀌었나
src/stores/usePostStore.tspendingLikeIds 잠금 및 toggleLikeOptimistic 액션 추가
src/components/LikeButton.tsxReact 19.3 useOptimistic + useTransition 기반 좋아요 버튼
src/stores/useDraftStore.tsZustand persist 미들웨어 기반 글 초안 자동 저장 스토어
tests/unit/usePostStore.test.tsMSW를 이용한 낙관적 증가 및 에러 롤백 단위 테스트

자주 하는 실수

증상원인해결
콘솔에 An optimistic state update occurred outside a transition or action 경고 출력addOptimisticLike(1)를 startTransition 바깥에서 호출함반드시 startTransition(async () => { addOptimisticLike(1); ... }) 내부에서 호출
한 번 네트워크 에러가 난 뒤로 해당 글의 좋아요가 영원히 안 눌림pendingLikeIds.delete(postId)를 try 블록 안에 두어 에러 시 실행 안 됨잠금 해제는 반드시 finally 블록에서 수행
좋아요 실패 시 숫자는 돌아오는데 사용자에게 아무런 알림이 안 뜸스토어 catch 블록에서 롤백만 하고 throw error를 빼먹음롤백 후 throw error로 에러를 다시 던져 컴포넌트가 alert를 띄우게 함

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. 좋아요 수가 5인 글에서 Offline 상태로 버튼을 누르면 likeCount는 어떤 순서로 바뀌고, pendingLikeIds는 언제 비워지나요?
  2. 왜 usePostStore 전체에 persist 미들웨어를 걸지 않고 useDraftStore를 별도로 분리했나요?
  3. [코드 10-3] 테스트에서 const pendingPromise = ... 바로 다음 줄의 expect(...).toBe(6)이 await 없이도 통과하는 이유는 무엇인가요?
셀프 체크 정답
  1. 5 → 6(즉시 선제 반영) → 네트워크 에러 발생 → 5(스냅샷 롤백) 순서로 바뀌며, pendingLikeIds는 finally 블록에서 함수가 종료되기 직전에 비워집니다.
  2. usePostStore에는 isLoading이나 서버 게시글 목록이 들어 있는데, 이를 그대로 localStorage에 영속화하면 로딩 도중(isLoading: true) 새로고침했을 때 로딩이 영원히 풀리지 않거나 서버와 어긋난 옛 목록이 고착될 수 있기 때문입니다.
  3. async 함수라도 첫 번째 await(await axios.post(...))를 만나기 전까지의 코드(잠금·백업·set으로 +1)는 호출 즉시 동기적으로 실행되기 때문입니다.

다음 챕터로 이어지는 질문 ​

이제 게시판은 즉각적인 사용자 경험(UX)과 초안 영속성까지 갖추었습니다.
그런데 로그인하지 않은 사용자가 주소창에 /write를 직접 입력해 새 글 작성 페이지로 진입하려 한다면 어떻게 막아야 할까요? 모든 페이지 컴포넌트마다 useEffect에서 로그인 여부를 검사하는 코드를 복붙해야 할까요?
이어지는 Chapter 11에서는 React Router v7의 <ProtectedRoute> 래퍼 컴포넌트와 returnUrl 안전 복귀 파이프라인을 구축해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub