다크 모드
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) 업데이트는 다음과 같이 동작합니다:
- 사용자가 [좋아요] 클릭
- 서버 응답 대기 동안 UI가 멈춰 있음 (300~500ms)
- 서버에서
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가지 안전장치:
- 연타 방지 잠금(
pendingLikeIds): 이미 좋아요 요청이 날아간 게시글 ID는Set<number>에 기록해 두고 응답이 끝날(finally) 때까지 중복 클릭을 무시합니다. - 스냅샷 백업과 정확한 롤백: 요청 전 해당 게시글의
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 참고).
- React 19에서
[코드 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} />
}💡 [확인하기 - 오프라인 모드로 롤백 직접 체험하기]:
- 브라우저 개발자 도구(F12) → Network 탭을 엽니다.
- 상단 Throttling 드롭다운(
No throttling)을 **Slow 3G**로 바꾸고[❤️ 5]버튼을 누릅니다. 서버 응답이 오기 전에 숫자가 즉시6으로 올라가고, 응답 대기 중에 아무리 연타해도 숫자가 더 오르지 않는 것을 확인합니다.- 이번에는 드롭다운을 **
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줄
- 낙관적 업데이트는 "스냅샷 백업 → 잠금(
pendingLikeIds) → 즉시 +1 반영 → API 호출 → 실패 시 백업 값으로 롤백 →finally에서 잠금 해제" 순서로 구현합니다. - React 19.3의
useOptimistic은 반드시startTransition내부에서 호출하며, 비동기 작업 동안 즉각적인 UI 반응성을 제공합니다. - 작성 중인 글 초안은 Zustand
persist미들웨어로 격리된useDraftStore에 두어, 새로고침이나 탭 닫힘에도 안전하게 보존합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/stores/usePostStore.ts | pendingLikeIds 잠금 및 toggleLikeOptimistic 액션 추가 |
src/components/LikeButton.tsx | React 19.3 useOptimistic + useTransition 기반 좋아요 버튼 |
src/stores/useDraftStore.ts | Zustand persist 미들웨어 기반 글 초안 자동 저장 스토어 |
tests/unit/usePostStore.test.ts | MSW를 이용한 낙관적 증가 및 에러 롤백 단위 테스트 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
콘솔에 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를 띄우게 함 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 좋아요 수가 5인 글에서 Offline 상태로 버튼을 누르면
likeCount는 어떤 순서로 바뀌고,pendingLikeIds는 언제 비워지나요? - 왜
usePostStore전체에persist미들웨어를 걸지 않고useDraftStore를 별도로 분리했나요? - [코드 10-3] 테스트에서
const pendingPromise = ...바로 다음 줄의expect(...).toBe(6)이await없이도 통과하는 이유는 무엇인가요?
셀프 체크 정답
5→6(즉시 선제 반영) → 네트워크 에러 발생 →5(스냅샷 롤백) 순서로 바뀌며,pendingLikeIds는finally블록에서 함수가 종료되기 직전에 비워집니다.usePostStore에는isLoading이나 서버 게시글 목록이 들어 있는데, 이를 그대로localStorage에 영속화하면 로딩 도중(isLoading: true) 새로고침했을 때 로딩이 영원히 풀리지 않거나 서버와 어긋난 옛 목록이 고착될 수 있기 때문입니다.async함수라도 첫 번째await(await axios.post(...))를 만나기 전까지의 코드(잠금·백업·set으로 +1)는 호출 즉시 동기적으로 실행되기 때문입니다.
다음 챕터로 이어지는 질문
이제 게시판은 즉각적인 사용자 경험(UX)과 초안 영속성까지 갖추었습니다.
그런데 로그인하지 않은 사용자가 주소창에 /write를 직접 입력해 새 글 작성 페이지로 진입하려 한다면 어떻게 막아야 할까요? 모든 페이지 컴포넌트마다 useEffect에서 로그인 여부를 검사하는 코드를 복붙해야 할까요?
이어지는 Chapter 11에서는 React Router v7의 <ProtectedRoute> 래퍼 컴포넌트와 returnUrl 안전 복귀 파이프라인을 구축해 보겠습니다.