Skip to content

Chapter 08. 어디든 재사용 가능한 제네릭 BoardTable<T> 제작 ​

"7장까지 진화한 BoardApp.tsx의 JSX에는 여전히 <table> 마크업이 통째로 박혀 있다. 사내 어디든 끼워 쓸 수 있으면서도 컬럼 키 오타를 컴파일 타임에 100% 잡아내는 만능 제네릭 테이블 BoardTable<T>로 분리하자."


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

개념한 줄 요약설명 위치
제네릭 함수 컴포넌트 function BoardTable<T>(...)부모가 넘긴 items: T[]로부터 행 데이터 타입을 추론하는 컴포넌트8.2 핵심 1
keyof T & string객체 T에 실제로 존재하는 문자열 속성 이름만 허용하는 타입 제약8.2 핵심 2
렌더 프롭 (renderCell?: (row: T, value: T[keyof T]) => ReactNode)특정 열의 셀 UI를 부모가 자유롭게 커스터마이징하는 콜백 패턴8.2 🆕 박스
renderActions?: (row: T) => ReactNode데이터 컬럼을 침범하지 않고 맨 오른쪽에 동작 버튼 열을 추가하는 프롭8.2 [코드 8-1]
제네릭 컴포넌트와 React.memo 타입 보존고차 컴포넌트로 감쌀 때 제네릭 시그니처가 지워지지 않게 단언하는 기법8.2 🔬 심화

TIP

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


8.1 BoardApp.tsx에 갇혀 있는 <table> 마크업 ​

현재 BoardApp.tsx 내부에는 Post 타입에 강하게 결합된 테이블 마크업이 직접 작성되어 있습니다:

tsx
// 특정 도메인(Post)에 갇혀 있어 다른 화면에서 재사용할 수 없는 테이블
<table className="board-table">
  <thead>
    <tr><th>ID</th><th>제목</th><th>작성자</th><th>작성일</th><th>동작</th></tr>
  </thead>
  <tbody>
    {posts.map((post) => (
      <tr key={post.id}>
        <td>{post.id}</td>
        <td onClick={() => setSelectedPost(post)}>{post.title}</td>
        <td>{post.author}</td>
        <td>{post.createdAt}</td>
        <td><button onClick={() => handleDeletePost(post.id)}>삭제</button></td>
      </tr>
    ))}
  </tbody>
</table>

내일 인사팀에서 *"사원 목록(Employee: 사번, 이름, 부서, 직급) 테이블도 똑같은 디자인으로 만들어 주세요"*라고 요청한다면 어떻게 하시겠습니까? 테이블 HTML과 CSS, 로딩 스피너, 빈 데이터 안내 문구를 복사해서 EmployeeTable.tsx를 또 만드는 것은 전형적인 스파게티 코드의 시작입니다.

우리는 어떤 도메인 객체(Post, Employee, Order)가 들어오더라도 타입 안전하게 렌더링할 수 있는 **제네릭 컴포넌트 BoardTable<T>**를 만들 것입니다.

🐟 비유: 만능 붕어빵 기계(BoardTable<T>)와 속재료(T)
팥 붕어빵 틀, 슈크림 붕어빵 틀, 피자 붕어빵 틀을 제각각 만들지 않고, 쇠틀(BoardTable<T>) 하나에 속재료(T: Post, T: Employee)만 갈아 끼우는 것과 같습니다! 단, 붕어빵 틀에 쇠구슬(string[] 원시값 배열)을 부으려 하면 기계가 망가지므로, TypeScript가 *"반드시 문자열 키를 가진 객체(T extends Record<string, any>)만 넣으라"*고 사전에 막아 줍니다.


8.2 [실습] 제네릭 컴포넌트 BoardTable.tsx 구현 ​

React에서 컴포넌트는 본질적으로 Props 객체를 받아 JSX를 반환하는 함수입니다. 따라서 일반 TypeScript 제네릭 함수처럼 function BoardTable<T>(props: BoardTableProps<T>) 형태로 선언하면 완벽한 제네릭 컴포넌트가 됩니다.

Vue의 Scoped Slot(#cell-title="{ row }", #actions="{ row }", #empty)에 대응하는 React의 표준 설계는 **렌더 프롭(Render Props)**과 ReactNode 슬롯 프롭입니다.

[코드 8-1] 사내 공통 제네릭 테이블 (frontend/src/components/common/BoardTable.tsx) ​

tsx
// frontend/src/components/common/BoardTable.tsx
import React from 'react';

// 1. 개별 컬럼 명세 인터페이스
export interface TableColumn<T> {
  // 🌟 핵심: 객체 T에 실제 존재하는 문자열 속성 키만 허용! (오타 시 즉시 컴파일 에러)
  key: keyof T & string;
  label: string;
  width?: string;
  align?: 'left' | 'center' | 'right';
  // 특정 열의 셀 렌더링을 부모가 재정의하고 싶을 때 넘기는 렌더 프롭
  renderCell?: (row: T, value: T[keyof T]) => React.ReactNode;
}

// 2. BoardTable 컴포넌트 Props 인터페이스
export interface BoardTableProps<T> {
  items: T[];
  columns: TableColumn<T>[];
  loading?: boolean;
  // 각 행의 고유 key로 사용할 필드명 (예: 'id', 'empNo')
  rowKeyField?: keyof T;
  // 행 전체 클릭 시 이벤트 핸들러 (선택)
  onRowClick?: (row: T) => void;
  // 데이터 열을 침범하지 않고 맨 오른쪽에 '동작' 열을 그리는 렌더 프롭
  renderActions?: (row: T) => React.ReactNode;
  // 데이터가 0건일 때 표시할 커스텀 UI (기본값 제공)
  emptyContent?: React.ReactNode;
}

export function BoardTable<T extends Record<string, any>>({
  items,
  columns,
  loading = false,
  rowKeyField,
  onRowClick,
  renderActions,
  emptyContent = '등록된 데이터가 없습니다.'
}: BoardTableProps<T>) {
  // 전체 컬럼 개수 (동작 열이 있으면 +1)
  const totalColumnCount = columns.length + (renderActions ? 1 : 0);

  function getRowKey(item: T, index: number): string | number {
    if (rowKeyField !== undefined) {
      const keyVal = item[rowKeyField];
      if (keyVal !== undefined && keyVal !== null) {
        return String(keyVal);
      }
    }
    return index;
  }

  return (
    <div className="generic-table-wrapper">
      <table className="custom-table">
        <thead>
          <tr>
            {columns.map((col) => (
              <th
                key={col.key}
                style={{ width: col.width, textAlign: col.align ?? 'left' }}
              >
                {col.label}
              </th>
            ))}
            {renderActions && (
              <th style={{ width: '90px', textAlign: 'center' }}>동작</th>
            )}
          </tr>
        </thead>

        <tbody>
          {loading ? (
            <tr>
              <td colSpan={totalColumnCount} className="text-center loading-cell">
                데이터를 불러오는 중입니다...
              </td>
            </tr>
          ) : items.length === 0 ? (
            <tr>
              <td colSpan={totalColumnCount} className="text-center empty-cell">
                {emptyContent}
              </td>
            </tr>
          ) : (
            items.map((item, index) => (
              <tr
                key={getRowKey(item, index)}
                onClick={() => onRowClick?.(item)}
                className={onRowClick ? 'clickable-row' : undefined}
              >
                {columns.map((col) => {
                  const cellValue = item[col.key];
                  return (
                    <td
                      key={col.key}
                      style={{ textAlign: col.align ?? 'left' }}
                    >
                      {/* 🌟 커스텀 렌더러(renderCell)가 있으면 호출, 없으면 기본 문자열 출력 */}
                      {col.renderCell
                        ? col.renderCell(item, cellValue)
                        : String(cellValue ?? '')}
                    </td>
                  );
                })}

                {renderActions && (
                  <td
                    style={{ textAlign: 'center' }}
                    onClick={(e) => {
                      // 동작 버튼 클릭이 행 클릭(onRowClick)으로 번지지 않게 이벤트 버블링 차단!
                      e.stopPropagation();
                    }}
                  >
                    {renderActions(item)}
                  </td>
                )}
              </tr>
            ))
          )}
        </tbody>
      </table>
    </div>
  );
}

이 코드에서 반드시 기억해야 할 두 가지 핵심 원리를 짚어 봅시다.

핵심 1) <T extends Record<string, any>>: 부모가 넘긴 items로부터 T가 자동 추론된다
부모가 <BoardTable items={posts} columns={columns} />라고 쓰는 순간, TypeScript 컴파일러는 posts의 타입이 Post[]임을 보고 T = Post로 자동 확정합니다. 만약 누군가 items={['문자열1', '문자열2']}처럼 객체가 아닌 배열을 넘기면 Record<string, any> 제약에 걸려 즉시 컴파일 에러가 발생합니다.

핵심 2) key: keyof T & string과 renderCell: 완벽한 자동완성과 폴백(Fallback)
T가 Post로 확정되면 columns의 key 자리에는 'id' | 'title' | 'author' | 'content' | 'category' | 'createdAt' | 'viewCount' | 'likeCount'만 올 수 있습니다.
또한 특정 열(예: title)에만 renderCell: (row) => ... 함수를 제공하면 그 열만 커스텀 JSX로 그려지고, renderCell을 생략한 나머지 열은 기본값인 String(item[col.key] ?? '')가 그대로 출력됩니다.

🆕 처음 보는 문법: 렌더 프롭(Render Prop)과 e.stopPropagation()

  • 렌더 프롭 (renderCell?: (row: T, value: T[keyof T]) => React.ReactNode): 자식 컴포넌트가 데이터를 들고 부모가 넘겨준 함수를 호출해 화면 조각(ReactNode)을 받아오는 패턴입니다. 자식(BoardTable)은 테이블 뼈대와 반복문만 책임지고, 각 셀을 어떻게 꾸밀지는 부모(BoardApp)가 결정합니다.
  • e.stopPropagation(): 테이블 행(<tr>) 전체에 상세 모달을 여는 onRowClick이 걸려 있을 때, 오른쪽 끝의 [삭제] 버튼을 누르면 클릭 이벤트가 부모 <tr>까지 타고 올라가(버블링) 삭제와 동시에 상세 모달이 열리는 버그가 생깁니다. td에서 e.stopPropagation()을 호출하면 이벤트가 행으로 번지는 것을 깔끔하게 막아줍니다(Vue의 @click.stop과 동일).
🔬 심화(건너뛰어도 됨): 화살표 함수에서 <T,> 쉼표 문법과 React.memo 제네릭 보존
  1. .tsx 파일에서 화살표 함수로 제네릭 컴포넌트를 쓸 때: const BoardTable = <T>(props: Props<T>) => ...라고 쓰면 JSX 파서가 <T>를 HTML 태그의 시작으로 오해해 문법 에러를 냅니다. 화살표 함수를 고집한다면 <T,>처럼 뒤에 쉼표를 찍거나 <T extends unknown>으로 써야 합니다. 본 교재처럼 export function BoardTable<T>(...) 함수 선언문을 쓰면 그런 모호함이 아예 발생하지 않습니다.
  2. 제네릭 컴포넌트를 React.memo로 감쌀 때: React.memo(BoardTable)를 그대로 반환하면 TypeScript에서 제네릭 <T> 시그니처가 지워져 T가 unknown으로 고정되는 현상이 있습니다. 이때는 아래와 같이 원래 함수 타입으로 타입 단언을 해주면 제네릭 추론이 그대로 유지됩니다:
    tsx
    export const MemoizedBoardTable = React.memo(BoardTable) as typeof BoardTable;

8.3 [실습] BoardApp.tsx에서 공통 BoardTable 장착하기 ​

이제 BoardApp.tsx의 투박한 <table> 태그를 걷어내고 BoardTable을 연결합니다.
이번 장부터는 테이블·전역 스토어·라우터 아키텍처에 집중하기 위해 6장의 댓글 영역을 잠시 분리해 두고, 깔끔한 게시판 본체 코드로 정리합니다.

[코드 8-2] BoardApp.tsx에 제네릭 테이블 적용 전체 코드 ​

tsx
// frontend/src/BoardApp.tsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { BoardTable, type TableColumn } from '@/components/common/BoardTable';
import { PostCreateModal } from '@/components/PostCreateModal';
import { PostDetailModal } from '@/components/PostDetailModal';
import { usePagination } from '@/hooks/usePagination';
import { usePostFilter } from '@/hooks/usePostFilter';
import type { Post, PageResult, CreatePostRequest } from '@/types/board';

export default function BoardApp() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [totalCount, setTotalCount] = useState(0);
  const [isLoading, setIsLoading] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [selectedPost, setSelectedPost] = useState<Post | null>(null);
  const [isCreateOpen, setIsCreateOpen] = useState(false);

  // 검색 필터 훅 (5장)
  const { keyword, category, setKeyword, setCategory, resetFilter } = usePostFilter();

  // 페이지네이션 훅 (5장)
  const {
    currentPage,
    pageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  } = usePagination({ totalCount, initialPageSize: 10 });

  // 🌟 제네릭 테이블 컬럼 정의: key에 Post에 없는 이름(예: 'titel')을 쓰면 즉시 컴파일 에러!
  const columns: TableColumn<Post>[] = [
    { key: 'id', label: 'ID', width: '60px', align: 'center' },
    { key: 'category', label: '분류', width: '90px', align: 'center' },
    {
      key: 'title',
      label: '제목',
      // row는 Post 타입으로 자동 추론됨!
      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' }
  ];

  async function loadPosts(page = currentPage) {
    setIsLoading(true);
    try {
      const res = await axios.get<PageResult<Post>>('/api/posts', {
        params: {
          page,
          size: pageSize,
          category: category === 'ALL' ? undefined : category,
          keyword: keyword.trim() || undefined
        }
      });
      setPosts(res.data.items ?? []);
      setTotalCount(res.data.total ?? 0);
    } catch {
      alert('게시글 목록을 불러오지 못했습니다.');
    } finally {
      setIsLoading(false);
    }
  }

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

  async function handleCreatePost(payload: CreatePostRequest) {
    setIsSubmitting(true);
    try {
      await axios.post<Post>('/api/posts', payload);
      setPage(1);
      await loadPosts(1);
    } catch {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

  async function handleDelete(id: number) {
    if (!window.confirm('정말 이 게시글을 삭제하시겠습니까?')) return;
    try {
      await axios.delete(`/api/posts/${id}`);
      await loadPosts(currentPage);
    } catch {
      alert('게시글 삭제에 실패했습니다.');
    }
  }

  async function handleLike(id: number) {
    try {
      const res = await axios.post<Post>(`/api/posts/${id}/like`);
      setPosts((prev) => prev.map((p) => (p.id === id ? res.data : p)));
      setSelectedPost((prev) => (prev?.id === id ? res.data : prev));
    } catch {
      alert('좋아요 처리에 실패했습니다.');
    }
  }

  return (
    <div className="board-container">
      <header className="board-header">
        <h2>사내 기술 Q&A 게시판 (v3.0)</h2>
        <button type="button" className="btn-primary" onClick={() => setIsCreateOpen(true)}>
          ✏️ 새 질문 등록
        </button>
      </header>

      {/* 검색 필터 바 */}
      <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>

      {/* 🌟 하드코딩된 <table> 대신 만능 제네릭 BoardTable 장착! */}
      <BoardTable
        items={posts}
        columns={columns}
        loading={isLoading}
        rowKeyField="id"
        onRowClick={(row) => setSelectedPost(row)}
        emptyContent={<span>등록된 게시글이 없습니다. 첫 질문을 남겨보세요!</span>}
        renderActions={(row) => (
          <button
            type="button"
            className="btn-delete"
            onClick={() => handleDelete(row.id)}
          >
            삭제
          </button>
        )}
      />

      {/* 페이지네이션 컨트롤 */}
      <nav className="pagination-bar" aria-label="페이지 이동">
        <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={handleCreatePost}
      />

      <PostDetailModal
        post={selectedPost}
        onClose={() => setSelectedPost(null)}
        onLike={handleLike}
        onDelete={handleDelete}
      />
    </div>
  );
}

💡 [확인하기 - 컬럼 키 오타를 타입 에러로 잡기]:

  1. 브라우저에서 목록이 정상적으로 표시되고, 좋아요가 5개 이상인 글에 🔥 인기 배지가 붙으며, 행을 클릭하면 상세 모달이 뜨는지 확인합니다.
  2. columns 배열의 { key: 'title', label: '제목' }에서 'title'을 'wrongKey'로 일부러 바꿔 봅니다.
  3. 터미널에서 npx tsc --noEmit을 실행하면 다음과 같은 에러가 즉시 출력됩니다:
    text
    error TS2322: Type '"wrongKey"' is not assignable to type '"id" | "title" | "author" | "content" | "category" | "createdAt" | "viewCount" | "likeCount"'.
    jQuery 시절이었다면 화면에 undefined나 빈칸이 찍히는 것을 운영 배포 후에야 알았겠지만, 이제는 빌드조차 되지 않습니다. 확인 후 'title'로 되돌리십시오.

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

🎯 미션: 사내 직원 목록(Employee) 데이터를 같은 BoardTable로 렌더링하기 ​

인사팀에서 요청한 사원 목록(Employee) 인터페이스가 다음과 같을 때, BoardTable을 수정하지 않고 그대로 재사용하여 사원 테이블을 렌더링하는 EmployeeDirectory.tsx 컴포넌트를 작성해 보십시오.

  • Employee 필드: empNo: string, name: string, department: 'PLATFORM' | 'AI' | 'SECURITY', isActive: boolean
  • 조건: isActive 열은 renderCell을 사용해 true면 '🟢 재직', false면 '⚪ 휴직'으로 표시할 것.
💡 미션 정답 코드 확인하기
tsx
// frontend/src/practice/EmployeeDirectory.tsx
import React from 'react';
import { BoardTable, type TableColumn } from '@/components/common/BoardTable';

export interface Employee {
  empNo: string;
  name: string;
  department: 'PLATFORM' | 'AI' | 'SECURITY';
  isActive: boolean;
}

const mockEmployees: Employee[] = [
  { empNo: 'EMP-101', name: '김시니어', department: 'PLATFORM', isActive: true },
  { empNo: 'EMP-102', name: '이주니어', department: 'AI', isActive: false }
];

export function EmployeeDirectory() {
  // 🌟 T가 Employee로 지정되어 'empNo' | 'name' | 'department' | 'isActive'만 허용됨!
  const empColumns: TableColumn<Employee>[] = [
    { key: 'empNo', label: '사번', width: '100px', align: 'center' },
    { key: 'name', label: '이름', width: '120px' },
    { key: 'department', label: '소속 부서', width: '140px', align: 'center' },
    {
      key: 'isActive',
      label: '재직 상태',
      width: '100px',
      align: 'center',
      renderCell: (row) => (
        <span>{row.isActive ? '🟢 재직' : '⚪ 휴직'}</span>
      )
    }
  ];

  return (
    <BoardTable
      items={mockEmployees}
      columns={empColumns}
      rowKeyField="empNo"
    />
  );
}

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

핵심 3줄

  1. function BoardTable<T>(props: BoardTableProps<T>)로 선언한 제네릭 컴포넌트는 부모가 넘긴 items={posts}로부터 T를 자동 추론합니다.
  2. TableColumn<T>의 key: keyof T & string 제약 덕분에 존재하지 않는 필드명을 컬럼에 적으면 실행 전에 타입 에러로 잡힙니다.
  3. 셀마다 다른 마크업이 필요할 때는 renderCell: (row, value) => ReactNode 렌더 프롭으로 부모가 UI를 주입하고, 행 클릭과 버튼 클릭이 겹치지 않도록 e.stopPropagation()으로 버블링을 제어합니다.

바뀐 파일

파일무엇이 바뀌었나
src/components/common/BoardTable.tsx제네릭 테이블 컴포넌트 신규 작성 (renderCell, renderActions, emptyContent)
src/BoardApp.tsx원시 <table> 마크업을 제거하고 <BoardTable<Post>> 및 columns 배열로 교체

자주 하는 실수

증상원인해결
[삭제] 버튼을 눌렀더니 삭제 확인창과 함께 상세 모달까지 열림버튼 클릭 이벤트가 부모 <tr>의 onRowClick으로 버블링됨renderActions를 감싸는 <td>에서 e.stopPropagation() 호출
.tsx에서 <T>(props) => ... 화살표 함수가 파싱 에러가 남JSX 파서가 <T>를 태그로 오인함function BoardTable<T>(...) 함수 선언문을 쓰거나 <T,>로 작성
rowKeyField를 생략했더니 행 삭제 후 UI 상태가 엉뚱한 행에 남음getRowKey가 배열 인덱스(index)로 폴백됨항상 rowKeyField="id"처럼 고유 식별자 필드를 명시

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

  1. <BoardTable items={employees} columns={empColumns} />에서 empColumns에 { key: 'title', label: '제목' }을 넣으면 왜 에러가 나나요?
  2. columns 정의에서 author 열에는 renderCell을 지정하지 않았습니다. 화면의 작성자 칸에는 무엇이 출력되며 그 이유는 무엇인가요?
  3. renderActions 프롭을 넘기지 않으면 로딩 중이거나 빈 목록일 때 td의 colSpan 값은 어떻게 계산되나요?
셀프 체크 정답
  1. items가 Employee[]이므로 T는 Employee로 추론됩니다. 'title'은 keyof Employee에 존재하지 않는 키이므로 컴파일 타임에 타입 에러가 발생합니다.
  2. item.author 문자열이 그대로 출력됩니다. BoardTable 내부에서 col.renderCell ? col.renderCell(item, cellValue) : String(cellValue ?? '')로 기본 출력 폴백(Fallback)을 두었기 때문입니다.
  3. totalColumnCount = columns.length + (renderActions ? 1 : 0)이므로, renderActions가 없으면 정확히 columns.length와 같아집니다.

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

이제 BoardApp.tsx의 UI는 공통 테이블 컴포넌트로 깔끔하게 모듈화되었습니다.
하지만 상태 관리 측면에서 새로운 고민이 생겼습니다:

  1. 상단 글로벌 내비게이션 바에서 **"현재 로드된 게시글 중 인기글 Top 3"**를 함께 보여줘야 한다면 어떻게 해야 할까요?
  2. 사용자가 글쓰기나 로그인 화면으로 이동했다가 돌아올 때마다 BoardApp이 언마운트 후 재마운트되면서, 똑같은 목록 API를 매번 다시 호출하고 로딩 스피너가 깜빡입니다.

이어지는 Chapter 09에서는 BoardApp.tsx의 로컬 상태 posts를 초경량 전역 스토어인 Zustand로 승격시켜, 쿼리 키 기반 1분(TTL) 캐싱과 DevTools 연동을 구현해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub