Skip to content

Chapter 09. 상태는 언제 컴포넌트를 떠나야 하는가? (Zustand 철학) ​

"8장까지 작성된 BoardApp.tsx는 다른 라우트로 이동했다가 돌아올 때마다 컴포넌트가 다시 마운트되며 0.5초 동안 로딩 스피너를 돌린다. 언제 상태를 컴포넌트 밖으로 꺼내야 하는지 판별하고, Zustand 전역 스토어로 1분 TTL 캐시를 구축하자."


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

개념한 줄 요약설명 위치
상태 분류 3단계 매트릭스로컬 UI 상태(useState) vs 라우트 상태(URL Query) vs 전역 도메인 상태(Zustand)9.2
Zustand create<State>()((set, get) => ...)Provider 없이 어디서든 구독 가능한 초경량 불변 스토어 생성 함수9.3 🆕 박스
선택자(Selector) 구독 usePostStore(s => s.posts)필요한 조각만 구독해 무관한 상태 변경 시 리렌더링을 차단하는 기법9.4
useShallow (zustand/react/shallow)여러 상태를 객체로 묶어 꺼낼 때 얕은 비교로 무한 리렌더링을 막는 훅9.4 🆕 박스
쿼리 키 기반 TTL 캐싱 & invalidateCache동일 검색 조건 재진입 시 1분간 API 호출을 생략하고, 등록/삭제 시 무효화9.3 [코드 9-1]
devtools 미들웨어Redux DevTools 확장 프로그램에서 상태 변경 이력과 액션 이름을 추적9.3 [코드 9-1]

TIP

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


9.1 BoardApp.tsx 로컬 상태의 한계와 Redux의 피로감 ​

8장까지 완성한 BoardApp.tsx 안에는 다음 로컬 상태가 들어 있습니다:

tsx
// BoardApp.tsx 내부의 로컬 상태
const [posts, setPosts] = useState<Post[]>([]);

이 상태는 BoardApp 컴포넌트가 화면에 마운트될 때 생겨나고, 다른 페이지(예: /login, /write)로 이동해 컴포넌트가 언마운트되는 순간 메모리에서 흔적도 없이 증발합니다.
그 결과 사용자가 로그인 화면에 갔다가 1초 만에 뒤로 가기를 눌러도 백엔드에 똑같은 GET /api/posts 요청을 다시 보내고 화면이 깜빡입니다. 또한 상단 헤더나 사이드바 위젯에서 "인기 게시글 Top 3"를 보여주고 싶어도 데이터가 BoardApp 안에 갇혀 있어 꺼내 쓸 방법이 없습니다.

과거 React 진영에서는 이를 해결하려고 Redux를 도입했지만, 상태 하나를 추가할 때마다 Action Type, Action Creator, Reducer, Slice, <Provider> 설정까지 수십 줄의 보일러플레이트를 작성해야 했습니다. 모던 React 생태계의 표준으로 자리 잡은 **Zustand(독일어로 '상태')**는 이 모든 번잡함을 걷어내고 단 하나의 함수(create)로 전역 스토어를 구축합니다.


9.2 상태 분류 3단계 판별 매트릭스 ​

그렇다고 모든 useState를 전역 스토어로 옮기면 스토어가 거대한 쓰레기통이 됩니다. 상태를 어디에 둘지 결정하는 실무 기준은 다음과 같습니다:

상태 유형관리 장소BoardApp.tsx 적용 예시
1. 로컬 UI 상태컴포넌트 내부 (useState, useRef)모달 열림 여부(isCreateOpen), 폼 입력 중인 임시 문자열
2. 라우트 상태URL Query (useSearchParams, 4.5~5장)검색 키워드(keyword), 선택된 카테고리(category), 페이지 번호
3. 도메인/공유 상태Zustand Store게시글 목록 캐시(posts), 작성 초안(draft), 로그인 세션(auth)

☕ 비유: 내 바지 주머니(useState) vs 회사 탕비실 냉장고(Zustand)
내가 지금 씹다 만 껌이나 잠깐 쓰는 메모지(모달 열림 여부, 인풋 포커스)까지 회사 공용 탕비실 냉장고(Zustand)에 넣어두면 냉장고가 순식간에 쓰레기통이 됩니다.
탕비실 냉장고에는 전 부서원이 함께 마실 음료수와 다과(서버에서 가져온 게시글 캐시, 로그인 세션 정보)만 보관해야 깨끗합니다. 혼자 잠깐 쓸 것은 내 바지 주머니(useState)에 두십시오!

🔬 심화(건너뛰어도 됨): React <Activity> vs Zustand vs TanStack Query
  • 단순히 숨겨진 화면의 DOM과 로컬 상태를 보존하고 싶다면 React 19.3의 <Activity mode="hidden">(13장에서 자세히 다룸)가 유용합니다.
  • 여러 컴포넌트가 공유하는 도메인 상태, 인증 세션, 임시저장 초안은 Zustand가 가장 간결하고 빠릅니다.
  • 서버 API 캐싱(백그라운드 재검증, 페이지별 독립 캐시, Garbage Collection)만을 전문으로 위임하고 싶다면 **@tanstack/react-query**가 훌륭한 대안입니다. 이번 장에서는 그 내부 핵심 원리인 **쿼리 키(Query Key) 기반 TTL 캐싱과 캐시 무효화(invalidateCache)**를 Zustand로 직접 구현하며 원리를 체득합니다.

9.3 [실습] 1분 캐싱과 DevTools가 장착된 usePostStore.ts 구축 ​

먼저 Zustand 패키지가 설치되어 있는지 확인합니다(예제 프로젝트에는 이미 포함되어 있습니다):

bash
npm install zustand

아래 스토어는 요청 파라미터(page, size, category, keyword) 조합을 직렬화해 lastQueryKey로 기록하고, 동일한 조건으로 1분(60,000ms) 이내에 재호출되면 네트워크 요청을 건너뛰고 즉시 기존 상태를 유지합니다. 반면 새 글을 등록(createPost)하거나 삭제(removePost)하면 invalidateCache()를 호출해 다음 조회 시 반드시 최신 데이터를 가져오도록 만듭니다.

[코드 9-1] 전역 게시글 스토어 (frontend/src/stores/usePostStore.ts) ​

typescript
// frontend/src/stores/usePostStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import axios from 'axios';
import type { Post, CreatePostRequest, PageResult } from '@/types/board';

export interface FetchPostsParams {
  page?: number;
  size?: number;
  category?: string;
  keyword?: string;
}

export interface PostStoreState {
  // 1. State (상태)
  posts: Post[];
  currentPost: Post | null;
  totalCount: number;
  isLoading: boolean;
  lastFetchedTime: number | null;
  lastQueryKey: string;

  // 2. Actions (액션 함수)
  fetchPosts: (params?: FetchPostsParams, force?: boolean) => Promise<void>;
  createPost: (payload: CreatePostRequest) => Promise<Post>;
  removePost: (id: number) => Promise<void>;
  invalidateCache: () => void;
  setCurrentPost: (post: Post | null) => void;
}

const CACHE_TTL_MS = 60 * 1000; // 1분 캐시 유효시간

// 🌟 TypeScript에서 미들웨어(devtools)를 함께 쓸 때는 create<State>()(...) 커링 문법을 사용합니다.
export const usePostStore = create<PostStoreState>()(
  devtools(
    (set, get) => ({
      posts: [],
      currentPost: null,
      totalCount: 0,
      isLoading: false,
      lastFetchedTime: null,
      lastQueryKey: '',

      // 캐시 강제 무효화 액션 (등록/삭제 직후 또는 수동 새로고침 시 사용)
      invalidateCache: () => {
        set(
          { lastFetchedTime: null, lastQueryKey: '' },
          false,
          'post/invalidateCache' // Redux DevTools에 찍힐 액션 이름
        );
      },

      setCurrentPost: (post) => {
        set({ currentPost: post }, false, 'post/setCurrentPost');
      },

      // 쿼리 키 + 1분 TTL 캐싱이 적용된 목록 조회 액션
      fetchPosts: async (params: FetchPostsParams = {}, force = false) => {
        const normalizedParams = {
          page: params.page ?? 1,
          size: params.size ?? 10,
          category: params.category && params.category !== 'ALL' ? params.category : undefined,
          keyword: params.keyword?.trim() ? params.keyword.trim() : undefined
        };

        const currentQueryKey = JSON.stringify(normalizedParams);
        const { lastFetchedTime, lastQueryKey, posts } = get();

        const isCacheValid =
          lastFetchedTime !== null &&
          Date.now() - lastFetchedTime < CACHE_TTL_MS &&
          lastQueryKey === currentQueryKey;

        // 🌟 동일한 페이지/검색 조건이고 1분 이내라면 네트워크 호출 생략!
        if (!force && isCacheValid && posts.length > 0) {
          console.log('⚡ [Cache Hit] 동일한 조건의 Zustand 스토어 캐시를 즉시 반환합니다:', currentQueryKey);
          return;
        }

        set({ isLoading: true }, false, 'post/fetchPosts/pending');
        try {
          const res = await axios.get<PageResult<Post>>('/api/posts', {
            params: normalizedParams
          });
          set(
            {
              posts: res.data.items ?? [],
              totalCount: res.data.total ?? 0,
              lastFetchedTime: Date.now(),
              lastQueryKey: currentQueryKey,
              isLoading: false
            },
            false,
            'post/fetchPosts/fulfilled'
          );
        } catch (error) {
          set({ isLoading: false }, false, 'post/fetchPosts/rejected');
          throw error; // 에러 알림(alert/toast)은 호출한 컴포넌트가 처리하도록 다시 던짐
        }
      },

      // 새 글 등록 액션: 서버 저장 후 목록 맨 앞에 반영하고 캐시 타임스탬프 무효화
      createPost: async (payload: CreatePostRequest) => {
        const res = await axios.post<Post>('/api/posts', payload);
        const created = res.data;
        set(
          (state) => ({
            posts: [created, ...state.posts],
            totalCount: state.totalCount + 1,
            lastFetchedTime: null, // 다음 조회 시 서버에서 정확한 페이징을 다시 받도록 무효화
            lastQueryKey: ''
          }),
          false,
          'post/createPost'
        );
        return created;
      },

      // 게시글 삭제 액션: 서버 DELETE 후 스토어 배열에서 불변 필터링
      removePost: async (id: number) => {
        await axios.delete(`/api/posts/${id}`);
        set(
          (state) => ({
            posts: state.posts.filter((p) => p.id !== id),
            totalCount: Math.max(0, state.totalCount - 1),
            currentPost: state.currentPost?.id === id ? null : state.currentPost,
            lastFetchedTime: null,
            lastQueryKey: ''
          }),
          false,
          'post/removePost'
        );
      }
    }),
    { name: 'BoardPostStore' }
  )
);

// 파생 상태(Derived Getter) 선택자 함수: 좋아요 순 상위 3개 인기글 추출
export const selectTopPopularPosts = (state: PostStoreState): Post[] =>
  [...state.posts].sort((a, b) => b.likeCount - a.likeCount).slice(0, 3);

🆕 처음 보는 문법: Zustand의 set, get, 그리고 create<T>()() 커링

  • set과 get: 스토어 생성 함수 (set, get) => ({ ... })의 두 인자입니다.
    • get()은 액션 함수 내부에서 현재 시점의 스토어 상태(const { posts, lastFetchedTime } = get())를 읽을 때 씁니다.
    • set({ isLoading: true }) 또는 set((state) => ({ totalCount: state.totalCount + 1 }))은 **불변성을 유지하며 상태를 얕게 병합(Shallow Merge)**하고 구독 중인 컴포넌트를 리렌더링합니다. React의 useState와 달리 지정하지 않은 나머지 상태(posts 등)는 자동으로 그대로 유지됩니다!
  • create<PostStoreState>()(...) (빈 괄호 두 번): TypeScript에서 devtools나 persist 같은 미들웨어가 타입을 추론할 수 있도록 Zustand v5가 표준으로 채택한 커링(Currying) 문법입니다.
  • set(새상태, false, '액션이름'): 세 번째 인자로 문자열을 넘기면 브라우저의 Redux DevTools 확장 프로그램에서 어떤 액션 때문에 상태가 바뀌었는지 시간 여행(Time-travel) 디버깅을 할 수 있습니다(두 번째 인자 false는 상태를 통째로 덮어쓰지 않고 병합하겠다는 기본값입니다).

9.4 [실습] BoardApp.tsx에서 선택자(Selector)로 스토어 연결하기 ​

이제 BoardApp.tsx에서 직접 선언했던 posts, totalCount, isLoading useState를 모두 지우고 usePostStore를 연결합니다.

IMPORTANT

Zustand의 핵심 규칙: 필요한 상태만 선택자(Selector)로 콕 집어 구독하라!
const store = usePostStore()처럼 인자 없이 통째로 꺼내면, 스토어 안의 아무 상관 없는 프로퍼티(lastFetchedTime 등)가 바뀔 때도 컴포넌트가 무조건 리렌더링됩니다.
단일 값은 const posts = usePostStore((s) => s.posts)처럼 단일 선택자로 꺼내고, 여러 값을 한 번에 객체로 묶어 꺼낼 때는 zustand/react/shallow의 **useShallow**로 감싸야 매 렌더링마다 새 객체 참조 때문에 발생하는 무한 리렌더링(Maximum update depth exceeded)을 막을 수 있습니다.

[코드 9-2] BoardApp.tsx에 usePostStore 연동 전체 코드 ​

tsx
// frontend/src/BoardApp.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { BoardTable, type TableColumn } from '@/components/common/BoardTable';
import { PostCreateModal } from '@/components/PostCreateModal';
import { PostDetailModal } from '@/components/PostDetailModal';
import { usePostStore, selectTopPopularPosts } from '@/stores/usePostStore';
import { usePagination } from '@/hooks/usePagination';
import { usePostFilter } from '@/hooks/usePostFilter';
import type { Post, CreatePostRequest } from '@/types/board';

export default function BoardApp() {
  // 🌟 1. 상태 구독: useShallow로 필요한 상태만 얕은 비교로 묶어서 구독
  const { posts, totalCount, isLoading, currentPost } = usePostStore(
    useShallow((state) => ({
      posts: state.posts,
      totalCount: state.totalCount,
      isLoading: state.isLoading,
      currentPost: state.currentPost
    }))
  );

  // 🌟 2. 액션 구독: 함수 참조는 변하지 않으므로 개별 선택자 또는 useShallow로 꺼냄
  const fetchPosts = usePostStore((state) => state.fetchPosts);
  const createPost = usePostStore((state) => state.createPost);
  const removePost = usePostStore((state) => state.removePost);
  const setCurrentPost = usePostStore((state) => state.setCurrentPost);

  // 🌟 3. 파생 상태: 인기 게시글 Top 3
  const topPopularPosts = usePostStore(useShallow(selectTopPopularPosts));

  // 모달 열림 여부 같은 순수 UI 상태는 컴포넌트 로컬 useState에 유지!
  const [isCreateOpen, setIsCreateOpen] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const { keyword, category, setKeyword, setCategory, resetFilter } = usePostFilter();
  const {
    currentPage,
    pageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  } = usePagination({ totalCount, initialPageSize: 10 });

  const loadPosts = useCallback(
    async (page: number, force = false) => {
      try {
        await fetchPosts({ page, size: pageSize, category, keyword }, force);
      } catch {
        alert('게시글 목록을 불러오지 못했습니다.');
      }
    },
    [fetchPosts, pageSize, category, keyword]
  );

  useEffect(() => {
    loadPosts(currentPage, false);
  }, [currentPage, loadPosts]);

  async function handleCreateSubmit(payload: CreatePostRequest) {
    setIsSubmitting(true);
    try {
      await createPost(payload);
      setPage(1);
      await loadPosts(1, true); // 새 글 등록 후 강제 최신화(force = true)
    } catch {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

  async function handleDelete(id: number) {
    if (!window.confirm('정말 삭제하시겠습니까?')) return;
    try {
      await removePost(id);
    } catch {
      alert('게시글 삭제에 실패했습니다.');
    }
  }

  const columns: TableColumn<Post>[] = [
    { key: 'id', label: 'ID', width: '60px', align: 'center' },
    { key: 'category', label: '분류', width: '90px', align: 'center' },
    {
      key: 'title',
      label: '제목',
      renderCell: (row) => (
        <div className="title-cell-content">
          <span className="post-title">{row.title}</span>
          {row.likeCount >= 5 && <span className="hot-badge">🔥 인기</span>}
        </div>
      )
    },
    { key: 'author', label: '작성자', width: '110px', align: 'center' },
    { key: 'createdAt', label: '작성일', width: '120px', align: 'center' }
  ];

  return (
    <div className="board-container">
      <header className="board-header">
        <h2>사내 기술 Q&A 게시판 (v3.1 - Zustand 캐시)</h2>
        <div className="header-actions">
          <button type="button" onClick={() => loadPosts(currentPage, true)}>
            🔄 새로고침
          </button>
          <button type="button" className="btn-primary" onClick={() => setIsCreateOpen(true)}>
            ✏️ 새 질문 등록
          </button>
        </div>
      </header>

      {/* 인기글 Top 3 미니 위젯 */}
      {topPopularPosts.length > 0 && (
        <aside className="popular-banner" aria-label="인기 게시글 Top 3">
          <strong>🏆 실시간 인기 질문:</strong>
          {topPopularPosts.map((p) => (
            <button key={p.id} type="button" className="popular-chip" onClick={() => setCurrentPost(p)}>
              {p.title} (❤️ {p.likeCount})
            </button>
          ))}
        </aside>
      )}

      <section className="filter-bar">
        <select value={category} onChange={(e) => setCategory(e.target.value)}>
          <option value="ALL">전체 카테고리</option>
          <option value="DEV">개발(DEV)</option>
          <option value="NOTICE">공지(NOTICE)</option>
          <option value="FREE">자유(FREE)</option>
        </select>
        <input
          type="search"
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
          placeholder="검색어를 입력하세요..."
        />
        <button type="button" onClick={resetFilter}>초기화</button>
      </section>

      <BoardTable
        items={posts}
        columns={columns}
        loading={isLoading}
        rowKeyField="id"
        onRowClick={(row) => setCurrentPost(row)}
        renderActions={(row) => (
          <button type="button" className="btn-delete" onClick={() => handleDelete(row.id)}>
            삭제
          </button>
        )}
      />

      <nav className="pagination-bar">
        <button type="button" disabled={!hasPrevPage} onClick={prevPage}>이전</button>
        <span>{currentPage} / {totalPages} 페이지 (총 {totalCount}건)</span>
        <button type="button" disabled={!hasNextPage} onClick={nextPage}>다음</button>
      </nav>

      <PostCreateModal
        isOpen={isCreateOpen}
        isSubmitting={isSubmitting}
        onClose={() => setIsCreateOpen(false)}
        onSubmit={handleCreateSubmit}
      />

      <PostDetailModal
        post={currentPost}
        onClose={() => setCurrentPost(null)}
        onLike={() => {}}
        onDelete={handleDelete}
      />
    </div>
  );
}

💡 [확인하기 - 라우터 이동으로 Cache Hit 검증하기]:

  1. 브라우저에서 개발자 도구(F12)의 Console과 Network 탭을 엽니다.
  2. 상단 내비게이션에서 [로그인](또는 다른 라우트) 링크를 클릭해 화면을 이동합니다(BoardApp 언마운트).
  3. 1분 이내에 브라우저 [뒤로 가기] 버튼을 눌러 게시판으로 돌아옵니다(BoardApp 재마운트).
  4. 콘솔에 ⚡ [Cache Hit] 동일한 조건의 Zustand 스토어 캐시를 즉시 반환합니다가 출력되고, Network 탭에는 /api/posts 요청이 추가로 발생하지 않으며 화면이 깜빡임 없이 0.001초 만에 즉시 나타납니다!

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

🎯 미션: 컴포넌트 바깥(일반 TS 파일)에서 Zustand 상태 읽고 액션 호출하기 ​

Zustand의 강력한 장점 중 하나는 React 컴포넌트나 훅 내부가 아닌 **일반 TypeScript 파일(예: Axios 인터셉터, 유틸리티 함수, 테스트 코드)**에서도 usePostStore.getState()로 즉시 상태와 액션을 다룰 수 있다는 점입니다.
브라우저 콘솔이나 유틸리티에서 호출할 수 있는 resetBoardStore() 함수를 작성해 보십시오(캐시 무효화 및 선택된 게시글 초기화 수행).

💡 미션 정답 코드 확인하기
typescript
// frontend/src/stores/usePostStore.ts 하단에 추가
export function resetBoardStore() {
  // 🌟 React 컴포넌트 밖에서는 훅으로 부르지 않고 .getState() 메서드를 사용합니다!
  const store = usePostStore.getState();
  store.invalidateCache();
  store.setCurrentPost(null);
}

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

핵심 3줄

  1. 모달 토글 같은 로컬 UI 상태는 useState에, 공유 가능한 검색 조건은 URL 쿼리에, 여러 화면이 공유하거나 재마운트 시 보존할 서버 데이터는 Zustand 스토어에 둡니다.
  2. usePostStore에 쿼리 키(lastQueryKey)와 타임스탬프(lastFetchedTime)를 두면 1분 이내 동일 조건 재진입 시 불필요한 API 호출을 생략할 수 있습니다.
  3. 스토어를 구독할 때는 단일 선택자 (s) => s.posts 또는 useShallow를 사용하여 무관한 상태 변경으로 인한 리렌더링을 차단합니다.

바뀐 파일

파일무엇이 바뀌었나
src/stores/usePostStore.ts1분 TTL 캐싱, fetchPosts, createPost, removePost, invalidateCache, devtools 연동
src/BoardApp.tsx로컬 posts 상태를 제거하고 usePostStore 및 인기글 Top 3 배너 연결

자주 하는 실수

증상원인해결
콘솔에 Maximum update depth exceeded 에러가 나며 화면이 멈춤usePostStore((s) => ({ posts: s.posts, totalCount: s.totalCount }))처럼 useShallow 없이 매번 새 객체를 반환함개별 선택자로 쪼개거나 useShallow((s) => ({ ... }))로 감싸기
새 글을 등록했는데 다른 페이지 갔다 오니 옛날 캐시 목록이 보임createPost나 removePost에서 캐시 무효화를 하지 않음데이터 변경 액션 시 invalidateCache() 또는 lastFetchedTime: null 처리
새로고침(F5)을 눌렀더니 Cache Hit가 안 나고 다시 API를 호출함Zustand 기본 스토어는 자바스크립트 메모리에만 존재하므로 F5 시 초기화됨정상 동작. 브라우저 새로고침 후에도 유지할 데이터는 10장의 persist 미들웨어 사용

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

  1. 모달 열림 여부(isCreateOpen)를 Zustand 스토어로 옮기지 않고 BoardApp.tsx의 useState에 남겨둔 이유는 무엇인가요?
  2. selectTopPopularPosts에서 state.posts.sort(...) 대신 [...state.posts].sort(...)처럼 배열을 복사한 뒤 정렬하는 이유는 무엇인가요?
  3. React 컴포넌트가 아닌 일반 함수(예: httpClient.ts) 안에서 usePostStore의 invalidateCache 액션을 호출하려면 어떻게 써야 하나요?
셀프 체크 정답
  1. 모달 열림 여부는 다른 화면과 공유할 필요도 없고 재마운트 후 유지될 필요도 없는 순수 로컬 UI 상태이기 때문입니다.
  2. Array.prototype.sort()는 원본 배열을 직접 변경(Mutate)하는 메서드입니다. 복사 없이 호출하면 스토어의 state.posts 원본 순서 자체가 뒤바뀌어 불변성이 깨지기 때문입니다.
  3. usePostStore.getState().invalidateCache()를 호출합니다.

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

이제 BoardApp.tsx는 동일 조건 재진입 시 깜빡임 없이 즉시 화면이 뜨는 전역 캐시 스토어를 갖추었습니다.
하지만 사용자 경험(UX) 측면에서 여전히 답답한 구석이 있습니다. 게시글의 [❤️ 좋아요] 버튼을 누를 때, 서버 응답이 올 때까지 0.3~0.5초 동안 숫자가 바뀌지 않고 멈춰 있는 현상입니다.
이어지는 Chapter 10에서는 React 19.3의 useOptimistic + startTransition과 Zustand 롤백 스냅샷을 결합하여, 클릭 즉시 숫자를 올리고 서버 에러 시 빛의 속도로 롤백하는 낙관적 업데이트와 임시저장 영속화(persist)를 구현해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub