이번 편의 결과물: components/BookTable.jsx가 제네릭 DataTable.tsx로 리팩터링되어 책 목록뿐 아니라 다른 배열 데이터에도 그대로 재사용됩니다. · 다루는 개념: 제네릭 컴포넌트(<T,>) 문법, 컴포넌트 제네릭의 extends 제약, 제네릭 props와 렌더 프롭 조합
이 편에서 만드는 파일
bookshelf-ts/src/
├── components/
│ ├── DataTable.tsx + 신규
│ └── BookTable.jsx − 삭제
└── pages/
└── BookListPage.tsx ~ 수정 (BookTable → DataTable 연결, setSort 구독 추가)개념 정리
08편까지 화면 전환이 끝나면서 BookTable.jsx만 자바스크립트로 남았습니다. 이 컴포넌트는 컬럼 정의, 정렬 버튼, 행 렌더링 로직 자체는 Book 타입에 매여 있지 않은데도, book.title처럼 필드 이름이 코드에 그대로 박혀 있고 내부에서 직접 useUiStore를 구독해 정렬을 처리하고 있어 다른 데이터에는 쓸 수 없었습니다. 이 편에서는 이 표를 어떤 배열에도 쓸 수 있는 제네릭 컴포넌트로 뽑아내고, 스토어 구독은 BookListPage로 옮겨 DataTable 자체는 특정 상태 관리 도구를 몰라도 되게 만듭니다.
제네릭 컴포넌트와 <T,> 문법
일반 함수처럼 컴포넌트도 타입 매개변수를 받을 수 있습니다. 다만 화살표 함수로 제네릭 컴포넌트를 .tsx 파일에 쓸 때는 표기가 조금 다릅니다.
// .tsx 파일에서는 <T>만 쓰면 JSX 태그 시작으로 오인된다
const List = <T>({ items }: { items: T[] }) => null
// 쉼표를 하나 붙이면 타입 매개변수라는 것이 분명해진다
const List = <T,>({ items }: { items: T[] }) => null.tsx 파일의 파서는 <로 시작하는 것을 기본적으로 JSX 태그로 해석하려 합니다. <T,>처럼 쉼표를 붙이거나, 뒤에서 볼 것처럼 extends로 제약을 걸면 타입 매개변수 목록이라는 것이 분명해져 이 오인이 사라집니다. function 선언 형태로 쓰면(function List<T>(...)) 애초에 이 문제가 생기지 않습니다.
extends 제약으로 안전하게 공통 필드 요구하기
표의 각 행에는 React가 요구하는 key가 필요합니다. items 배열의 각 원소가 반드시 id를 가진다고 제약을 걸면, 컴포넌트 내부에서 item.id에 안전하게 접근할 수 있습니다.
interface WithId {
id: string | number
}
// T는 반드시 id를 가진 타입이어야 한다
function pickId<T extends WithId>(item: T): T['id'] {
return item.id
}T extends WithId 없이 T만 썼다면 item.id는 T에 id가 있다는 보장이 없어 오류가 났을 것입니다.
제네릭 props와 렌더 프롭
컬럼마다 셀을 어떻게 그릴지는 표 컴포넌트가 알 수 없습니다. 이런 값은 각 컬럼 정의에 렌더 프롭(함수를 값으로 받아 렌더링을 위임하는 패턴)으로 넘깁니다. 컬럼 정의도 T에 대해 제네릭이라, 렌더 함수의 매개변수 타입이 항상 그 표가 다루는 T로 정확히 맞춰집니다.
실습
1. DataTable 컴포넌트 작성
// src/components/DataTable.tsx
import type { ReactNode } from 'react'
interface WithId {
id: string | number
}
export interface DataTableColumn<T> {
key: keyof T
label: string
sortable?: boolean
render?: (item: T) => ReactNode
}
interface DataTableProps<T extends WithId> {
items: T[]
columns: DataTableColumn<T>[]
sortBy?: keyof T
sortOrder?: 'asc' | 'desc'
onSortChange?: (key: keyof T) => void
}
const DataTable = <T extends WithId>({ items, columns, sortBy, sortOrder, onSortChange }: DataTableProps<T>) => {
return (
<table className="data-table">
<thead>
<tr>
{columns.map((column) => (
<th key={String(column.key)}>
{column.sortable && onSortChange ? (
<button type="button" onClick={() => onSortChange(column.key)}>
{column.label}
{sortBy === column.key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : ''}
</button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
{columns.map((column) => (
<td key={String(column.key)}>{column.render ? column.render(item) : String(item[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
)
}
export default DataTableDataTableProps<T extends WithId>는 extends 제약이 이미 있어 화살표 함수에 쉼표를 따로 붙이지 않아도 .tsx에서 JSX로 오인되지 않습니다. columns.map에서 column.render가 있으면 그 함수로, 없으면 String(item[column.key])로 셀을 그립니다. column.key가 keyof T로 제약돼 있어 item[column.key]는 항상 T의 실제 필드에만 접근합니다. DataTable 어디에도 Book이나 useUiStore를 참조하는 코드가 없습니다.
2. BookListPage에서 DataTable 연결
08편에서 전환한 BookListPage.tsx는 BookTable.jsx가 내부에서 알아서 처리하던 정렬 클릭 로직을 기대하고 있었습니다. DataTable은 정렬 상태를 스스로 구독하지 않으므로, 그 구독을 BookListPage로 옮깁니다.
// src/pages/BookListPage.tsx (08편 코드에서 표 연결 부분만 교체 — 검색·페이지네이션 UI는 그대로 유지)
import { useState, type ChangeEvent } from 'react'
import { useUiStore } from '../store/useUiStore.js'
import { useDebounce } from '../hooks/useDebounce.js'
import { useBooks } from '../hooks/useBooks.ts'
import DataTable, { type DataTableColumn } from '../components/DataTable.tsx'
import StatusBadge, { bookToStatusBadgeProps } from '../components/StatusBadge.tsx'
import type { Book } from '../types/book.ts'
const BOOK_COLUMNS: DataTableColumn<Book>[] = [
{ key: 'title', label: '제목', sortable: true },
{ key: 'author', label: '저자', sortable: true },
{ key: 'pages', label: '쪽수', sortable: true },
{ key: 'status', label: '상태', render: (book) => <StatusBadge {...bookToStatusBadgeProps(book)} /> },
]
function BookListPage() {
const statusFilter = useUiStore((state) => state.statusFilter)
const sortBy = useUiStore((state) => state.sortBy)
const sortOrder = useUiStore((state) => state.sortOrder)
const setSort = useUiStore((state) => state.setSort)
const searchQuery = useUiStore((state) => state.searchQuery)
const setSearchQuery = useUiStore((state) => state.setSearchQuery)
const page = useUiStore((state) => state.page)
const setPage = useUiStore((state) => state.setPage)
const [searchInput, setSearchInput] = useState<string>(searchQuery)
const debouncedSearch = useDebounce(searchInput, 300)
if (debouncedSearch !== searchQuery) {
setSearchQuery(debouncedSearch)
}
const { data, isPending, isError } = useBooks({
status: statusFilter,
sortBy,
sortOrder,
search: searchQuery,
page,
})
if (isPending) return <p>불러오는 중...</p>
if (isError) return <p role="alert">목록을 불러오지 못했습니다</p>
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize))
function handleSearchChange(event: ChangeEvent<HTMLInputElement>) {
setSearchInput(event.target.value)
}
return (
<section>
<input
type="search"
aria-label="책 검색"
value={searchInput}
onChange={handleSearchChange}
placeholder="제목으로 검색"
/>
<DataTable items={data.items} columns={BOOK_COLUMNS} sortBy={sortBy} sortOrder={sortOrder} onSortChange={setSort} />
<nav className="pagination" aria-label="페이지 이동">
<button type="button" disabled={page <= 1} onClick={() => setPage(page - 1)}>
이전
</button>
<span>
{page} / {totalPages}
</span>
<button type="button" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
다음
</button>
</nav>
</section>
)
}
export default BookListPage새로 추가한 줄은 setSort 구독과 BOOK_COLUMNS 정의, DataTable 렌더링뿐입니다. useUiStore는 아직 자바스크립트라 setSort의 정확한 매개변수 타입은 없지만, DataTable의 onSortChange가 요구하는 (key: keyof Book) => void 형태와 실제로 같은 함수라 그대로 연결됩니다. 11편에서 스토어에 타입을 입히면 이 연결이 컴파일 타임에도 검증됩니다. 상태 컬럼은 09편에서 만든 StatusBadge를 render로 그대로 재사용합니다.
3. 다른 데이터 타입으로 재사용 증명
DataTable이 Book에 매여 있지 않다는 것을 코드로 확인합니다. 임시로 아무 파일에나 아래 코드를 붙여 컴파일이 되는지만 봅니다.
// 재사용 확인용 예시 — Book과 무관한 타입에도 그대로 쓰인다
interface Author {
id: number
name: string
bookCount: number
}
const authors: Author[] = [
{ id: 1, name: '로버트 C. 마틴', bookCount: 3 },
{ id: 2, name: '마틴 파울러', bookCount: 5 },
]
const AUTHOR_COLUMNS: DataTableColumn<Author>[] = [
{ key: 'name', label: '저자명' },
{ key: 'bookCount', label: '보유 권수', sortable: true },
]
function AuthorTable() {
return <DataTable items={authors} columns={AUTHOR_COLUMNS} />
}DataTableColumn<Author>의 key는 'id' | 'name' | 'bookCount'로만 제한됩니다. AUTHOR_COLUMNS에 key: 'title'처럼 Author에 없는 이름을 넣으면 그 자리에서 오류가 나, 컴파일이 통과한다는 사실 자체가 재사용이 안전하다는 증거가 됩니다.
4. BookTable.jsx 삭제
rm src/components/BookTable.jsx5. 실행
npm run dev6. 확인
- 책 목록 화면의 표가 08편까지와 동일하게 보이고, 컬럼 헤더 클릭으로 정렬이 그대로 동작합니다.
상태컬럼에 텍스트 대신StatusBadge가 렌더링됩니다.- 3단계의
AuthorTable예시를 프로젝트에 잠깐 붙여npm run build(또는 편집기의 타입 검사)가 오류 없이 통과하는지 확인한 뒤 지웁니다.
직접 해보기
DataTableColumn에align?: 'left' | 'right'를 추가하고,td의style에 반영해 숫자 컬럼(pages)을 오른쪽 정렬해 보세요.AUTHOR_COLUMNS에key: 'email'처럼Author에 없는 필드를 일부러 넣어보고, 어떤 오류 메시지가 나오는지 확인해 보세요.
정답 보기
// src/components/DataTable.tsx (컬럼 정의와 td 렌더링 부분만 수정)
export interface DataTableColumn<T> {
key: keyof T
label: string
sortable?: boolean
align?: 'left' | 'right'
render?: (item: T) => ReactNode
}
// tbody의 td 렌더링
<td key={String(column.key)} style={{ textAlign: column.align ?? 'left' }}>
{column.render ? column.render(item) : String(item[column.key])}
</td>key: 'email'을 넣으면 Property 'email' does not exist on type 'Author'류의 오류가 납니다. DataTableColumn<T>의 key: keyof T 제약이 Author가 실제로 가진 필드 이름('id' | 'name' | 'bookCount')으로만 컬럼을 제한하기 때문입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
.tsx 파일에서 제네릭 화살표 함수가 JSX 태그로 오인돼 문법 오류가 남 | 쉼표나 extends 제약 없이 const Comp = (T)(...) => ... 형태로 씀 | T, 형태로 쉼표를 붙이거나, extends로 제약을 걸거나, function 선언으로 바꾼다 |
item.id에서 프로퍼티가 없다는 오류가 남 | 타입 매개변수에 extends WithId 같은 제약이 없음 | 공통으로 필요한 필드는 extends로 요구한다 |
| 컬럼 배열에 존재하지 않는 필드 이름을 넣어도 오류가 안 남 | key의 타입을 string으로 느슨하게 선언 | key: keyof T로 선언해 실제 필드 이름만 허용한다 |
render 콜백 매개변수에 아무 타입이나 넘겨도 걸러지지 않음 | render?: (item: any) => ReactNode처럼 any로 선언 | render?: (item: T) => ReactNode처럼 컴포넌트의 T를 그대로 참조한다 |
확인 문제
const List = <T>({ items }: { items: T[] }) => null