다크 모드
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 제네릭 보존
.tsx파일에서 화살표 함수로 제네릭 컴포넌트를 쓸 때:const BoardTable = <T>(props: Props<T>) => ...라고 쓰면 JSX 파서가<T>를 HTML 태그의 시작으로 오해해 문법 에러를 냅니다. 화살표 함수를 고집한다면<T,>처럼 뒤에 쉼표를 찍거나<T extends unknown>으로 써야 합니다. 본 교재처럼export function BoardTable<T>(...)함수 선언문을 쓰면 그런 모호함이 아예 발생하지 않습니다.- 제네릭 컴포넌트를
React.memo로 감쌀 때:React.memo(BoardTable)를 그대로 반환하면 TypeScript에서 제네릭<T>시그니처가 지워져T가unknown으로 고정되는 현상이 있습니다. 이때는 아래와 같이 원래 함수 타입으로 타입 단언을 해주면 제네릭 추론이 그대로 유지됩니다:tsxexport 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>
);
}💡 [확인하기 - 컬럼 키 오타를 타입 에러로 잡기]:
- 브라우저에서 목록이 정상적으로 표시되고, 좋아요가 5개 이상인 글에
🔥 인기배지가 붙으며, 행을 클릭하면 상세 모달이 뜨는지 확인합니다.columns배열의{ key: 'title', label: '제목' }에서'title'을'wrongKey'로 일부러 바꿔 봅니다.- 터미널에서
npx tsc --noEmit을 실행하면 다음과 같은 에러가 즉시 출력됩니다:textjQuery 시절이었다면 화면에error TS2322: Type '"wrongKey"' is not assignable to type '"id" | "title" | "author" | "content" | "category" | "createdAt" | "viewCount" | "likeCount"'.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줄
function BoardTable<T>(props: BoardTableProps<T>)로 선언한 제네릭 컴포넌트는 부모가 넘긴items={posts}로부터T를 자동 추론합니다.TableColumn<T>의key: keyof T & string제약 덕분에 존재하지 않는 필드명을 컬럼에 적으면 실행 전에 타입 에러로 잡힙니다.- 셀마다 다른 마크업이 필요할 때는
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"처럼 고유 식별자 필드를 명시 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
<BoardTable items={employees} columns={empColumns} />에서empColumns에{ key: 'title', label: '제목' }을 넣으면 왜 에러가 나나요?columns정의에서author열에는renderCell을 지정하지 않았습니다. 화면의 작성자 칸에는 무엇이 출력되며 그 이유는 무엇인가요?renderActions프롭을 넘기지 않으면 로딩 중이거나 빈 목록일 때td의colSpan값은 어떻게 계산되나요?
셀프 체크 정답
items가Employee[]이므로T는Employee로 추론됩니다.'title'은keyof Employee에 존재하지 않는 키이므로 컴파일 타임에 타입 에러가 발생합니다.item.author문자열이 그대로 출력됩니다.BoardTable내부에서col.renderCell ? col.renderCell(item, cellValue) : String(cellValue ?? '')로 기본 출력 폴백(Fallback)을 두었기 때문입니다.totalColumnCount = columns.length + (renderActions ? 1 : 0)이므로,renderActions가 없으면 정확히columns.length와 같아집니다.
다음 챕터로 이어지는 질문
이제 BoardApp.tsx의 UI는 공통 테이블 컴포넌트로 깔끔하게 모듈화되었습니다.
하지만 상태 관리 측면에서 새로운 고민이 생겼습니다:
- 상단 글로벌 내비게이션 바에서 **"현재 로드된 게시글 중 인기글 Top 3"**를 함께 보여줘야 한다면 어떻게 해야 할까요?
- 사용자가 글쓰기나 로그인 화면으로 이동했다가 돌아올 때마다
BoardApp이 언마운트 후 재마운트되면서, 똑같은 목록 API를 매번 다시 호출하고 로딩 스피너가 깜빡입니다.
이어지는 Chapter 09에서는 BoardApp.tsx의 로컬 상태 posts를 초경량 전역 스토어인 Zustand로 승격시켜, 쿼리 키 기반 1분(TTL) 캐싱과 DevTools 연동을 구현해 보겠습니다.