Claude Code로 React 테이블 컴포넌트 만들기: 정렬, 필터, 페이지네이션
Claude Code로 React 테이블을 구현합니다. semantic table, 정렬, 필터, 페이지네이션, 모바일, 접근성, Playwright 확인까지 다룹니다.
테이블은 디자인보다 데이터 관계가 먼저다
관리자 화면, 고객 목록, 청구 내역, 상품 관리, 콘텐츠 분석 화면에는 거의 항상 테이블이 들어갑니다. 처음에는 행을 출력하는 정도로 보이지만 실제 서비스에서는 바로 요구사항이 늘어납니다. 금액으로 정렬하고, 상태로 검색하고, 긴 목록을 페이지로 나누고, 모바일에서 읽기 쉽게 만들고, 키보드로 조작 가능하게 하고, 수정 후에도 깨지지 않았는지 테스트해야 합니다.
Claude Code는 이런 반복 구현을 빠르게 처리할 수 있습니다. 다만 “예쁜 테이블을 만들어줘”라고만 하면 의미 없는div그리드가 나오거나, 정렬 화살표만 바뀌고 실제 데이터 순서는 그대로인 코드가 나올 수 있습니다. 테이블은 행과 열의 관계를 전달하는 UI이므로 HTML 의미, 상태 관리, 모바일 표시, 접근성, 검증 명령을 함께 전달해야 합니다.
이 글에서는 고객 목록 테이블을 예제로, 복사해서 쓸 수 있는 React/TypeScript 구현을 만듭니다. 범위는 semantic table, 정렬 가능한 열, 전역 필터, 페이지네이션, 반응형 모바일 처리, 접근성, TanStack Table을 선택할 기준, Playwright 검사입니다. React 전반은 Claude Code React 개발, 접근성은 Claude Code 접근성 개선도 함께 보면 좋습니다.
공식 문서는 작업 전에 확인합니다. HTML 테이블 구조는 MDN <table>, 정렬 상태는 MDN aria-sort, 복잡한 상태 관리는 TanStack Table 문서, E2E 테스트는 Playwright Writing tests, Claude Code 사용 흐름은 Claude Code overview를 기준으로 합니다.
semantic table 기본
행과 열이 함께 의미를 만들 때는 table을 사용합니다. 고객명, 플랜, 월 반복 매출, 상태, 가입일은 각 열 제목을 통해 해석되는 값이므로 표 형식이 자연스럽습니다. 단순 카드 목록이라면 ul이나 카드 그리드가 더 맞을 수 있습니다.
기본 구조는 caption, thead, tbody, th scope="col"입니다. caption은 표의 목적을 설명합니다. 열 제목은 th로 만들고, 첫 번째 셀이 행의 주어라면 th scope="row"를 사용합니다.
<table>
<caption>고객별 월 반복 매출</caption>
<thead>
<tr>
<th scope="col">고객</th>
<th scope="col">플랜</th>
<th scope="col">MRR</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Northwind</th>
<td>Pro</td>
<td>$1,200</td>
</tr>
</tbody>
</table>
Claude Code에게는 “테이블을 만들어줘”보다 “원본 table 구조를 유지하고, caption과 scope를 넣고, 첫 번째 열을 행 제목으로 처리해줘”라고 말하는 편이 안전합니다.
flowchart TD
A["요구사항"] --> B["semantic table"]
B --> C["정렬, 필터, 페이지 상태"]
C --> D["모바일 표시"]
D --> E["접근성 점검"]
E --> F["Playwright 검증"]
Claude Code 프롬프트 템플릿
테이블은 작은 컴포넌트처럼 보이지만 데이터, CSS, 상태, 테스트가 모두 엮입니다. 그래서 수정 범위와 금지 사항을 먼저 적습니다.
React + TypeScript로 고객 목록 테이블 컴포넌트를 구현해 주세요.
조건:
- src/components/DataTable.tsx 와 src/components/data-table.css 만 변경
- table, caption, thead, tbody, th scope 를 사용
- 데이터 필드는 id, name, plan, mrr, status, signedUpAt
- 전역 필터, 열 정렬, 5개 단위 페이지네이션 추가
- 현재 정렬 중인 열에만 aria-sort="ascending|descending" 설정
- 열 제목 내부의 button 으로 정렬
- 모바일에서는 data-label 로 각 셀의 열 이름 표시
- Playwright 테스트로 필터, 정렬, 페이지네이션, 모바일 라벨 확인
금지:
- 새 UI 라이브러리 추가
- 필요 없이 role="grid" 추가
- 의사코드로 끝내기
이렇게 요청하면 Claude Code가 보기 좋은 마크업만 만드는 것이 아니라, 리뷰 가능한 단위로 구현과 검증을 맞추게 됩니다.
복사해서 쓰는 React/TypeScript 구현
아래 코드는 별도 테이블 라이브러리 없이 동작합니다. Next.js 클라이언트 컴포넌트에서도 쓸 수 있고, Vite React 앱에서도 사용할 수 있습니다.
// src/components/DataTable.tsx
"use client";
import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";
type SortDirection = "asc" | "desc";
type SortState<T> = { key: keyof T; direction: SortDirection } | null;
type Customer = {
id: string;
name: string;
plan: "Free" | "Pro" | "Enterprise";
mrr: number;
status: "active" | "trial" | "paused";
signedUpAt: string;
};
type Column<T> = {
key: keyof T;
label: string;
numeric?: boolean;
render?: (value: T[keyof T], row: T) => ReactNode;
};
const pageSize = 5;
const rows: Customer[] = [
{ id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
{ id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
{ id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
{ id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
{ id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
{ id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const columns: Column<Customer>[] = [
{ key: "name", label: "Customer" },
{ key: "plan", label: "Plan" },
{ key: "mrr", label: "MRR", numeric: true, render: (_, row) => money.format(row.mrr) },
{ key: "status", label: "Status" },
{ key: "signedUpAt", label: "Signed up", render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US") },
];
function compare<T>(a: T, b: T, key: keyof T) {
const left = a[key];
const right = b[key];
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" });
}
export function DataTable() {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sort, setSort] = useState<SortState<Customer>>({ key: "name", direction: "asc" });
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
columns.some((column) => String(row[column.key]).toLowerCase().includes(keyword)),
);
}, [query]);
const sorted = useMemo(() => {
if (!sort) return filtered;
return [...filtered].sort((a, b) => {
const result = compare(a, b, sort.key);
return sort.direction === "asc" ? result : -result;
});
}, [filtered, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
function updateQuery(value: string) {
setQuery(value);
setPage(1);
}
function toggleSort(key: keyof Customer) {
setSort((current) => {
if (!current || current.key !== key) return { key, direction: "asc" };
return { key, direction: current.direction === "asc" ? "desc" : "asc" };
});
}
return (
<section className="table-shell" aria-labelledby="customers-title">
<label>
<span>Filter customers</span>
<input value={query} onChange={(event) => updateQuery(event.target.value)} type="search" />
</label>
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<caption id="customers-title">Monthly recurring revenue by customer</caption>
<thead>
<tr>
{columns.map((column) => {
const isSorted = sort?.key === column.key;
const ariaSort = isSorted ? (sort.direction === "asc" ? "ascending" : "descending") : undefined;
return (
<th key={String(column.key)} scope="col" aria-sort={ariaSort} className={column.numeric ? "numeric" : undefined}>
<button type="button" onClick={() => toggleSort(column.key)}>{column.label}</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => {
const content = column.render ? column.render(row[column.key], row) : String(row[column.key]);
return index === 0 ? (
<th key={String(column.key)} scope="row" data-label={column.label}>{content}</th>
) : (
<td key={String(column.key)} data-label={column.label} className={column.numeric ? "numeric" : undefined}>{content}</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Table pagination">
<button type="button" disabled={currentPage === 1} onClick={() => setPage((value) => value - 1)}>Previous</button>
<span aria-live="polite">Page {currentPage} of {totalPages}</span>
<button type="button" disabled={currentPage === totalPages} onClick={() => setPage((value) => value + 1)}>Next</button>
</nav>
</section>
);
}
모바일 CSS와 접근성
모바일에서는 표를 가로 스크롤로 유지하거나, 행을 카드처럼 쌓을 수 있습니다. 운영용 고객 목록이라면 각 셀에 열 이름을 보여주는 방식이 읽기 쉽습니다.
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
border-top: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.data-table .numeric {
text-align: right;
}
.pagination {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
@media (max-width: 640px) {
.data-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.data-table,
.data-table tbody,
.data-table tr,
.data-table th,
.data-table td {
display: block;
width: 100%;
}
.data-table tr {
border: 1px solid #d8dee8;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
}
.data-table th,
.data-table td {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
}
.data-table th::before,
.data-table td::before {
content: attr(data-label);
font-weight: 700;
}
}
접근성 체크에서는 caption, scope, 정렬 버튼, aria-sort, 필터 라벨, aria-live를 봅니다. 단순 데이터 테이블에는 보통 role="grid"가 필요하지 않습니다. grid는 셀 단위 이동 같은 더 복잡한 키보드 모델을 기대하게 만들기 때문입니다.
TanStack Table을 선택하는 기준
간단한 목록은 직접 구현하는 편이 빠릅니다. 하지만 열 표시 전환, 열별 필터, 행 선택, 서버 사이드 페이지네이션, 고정 열, 가상 스크롤이 필요하면 TanStack Table이 맞습니다. 이 라이브러리는 headless라서 스타일을 강제하지 않고 테이블 상태 관리에 집중합니다.
| 선택지 | 적합한 상황 | 주의점 |
|---|---|---|
| 직접 구현 | 작은 목록, 단순 필터와 정렬 | 기능이 늘면 상태가 흩어질 수 있음 |
| TanStack Table | 복잡한 열 상태, 서버 데이터, 행 선택 | API 학습과 UI 구현이 필요 |
| 엔터프라이즈 Grid | 스프레드시트 같은 편집, 대용량 데이터 | 번들, 설정, 라이선스 확인 필요 |
Claude Code에게 TanStack Table을 쓰게 할 때는 “공식 React 어댑터를 사용하고, 기존 CSS를 유지하고, ColumnDef<Customer>[]로 타입 안전하게 작성하라”고 지시합니다.
Playwright로 확인하기
눈으로 보기만 하면 테이블 버그를 놓치기 쉽습니다. Playwright로 정렬, 필터, 페이지 이동, 모바일 라벨을 확인합니다.
// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";
test("customer table works", async ({ page }) => {
await page.goto("/customers");
await expect(page.getByRole("table", { name: /monthly recurring revenue/i })).toBeVisible();
await page.getByRole("button", { name: /MRR/ }).click();
await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute("aria-sort", "ascending");
await page.getByLabel("Filter customers").fill("north");
await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();
await page.getByLabel("Filter customers").fill("");
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Page 2 of 2")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});
수정 요청을 할 때는 이 테스트를 함께 주고 “실패를 재현한 뒤 수정하라”고 쓰면 좋습니다. 단순 스냅샷보다 실제 사용 흐름을 더 잘 지킵니다.
활용 사례, 함정, 수익 CTA
| 활용 사례 | 필요한 기능 | 효과 |
|---|---|---|
| SaaS 고객 목록 | 플랜, MRR, 상태, 갱신일 | 이탈 위험과 업셀 후보 발견 |
| 쇼핑몰 상품 관리 | 재고, 가격, 공개 상태, 카테고리 | 품절과 가격 오류를 빠르게 찾음 |
| 콘텐츠 운영 대시보드 | PV, 읽기 완료율, CTA 클릭, 수정일 | 리라이트 우선순위와 광고 수익 개선 |
| 청구 내역 | 결제 상태, 금액, 만기일 | 고객 지원 시간을 줄임 |
흔한 함정은 div로 표를 흉내 내는 것, 정렬 화살표만 바꾸고 aria-sort를 잊는 것, 필터 후 페이지를 1로 돌리지 않는 것, 모바일을 마지막에 급하게 붙이는 것, Claude Code가 새 UI 라이브러리를 넣게 두는 것입니다.
테이블은 수익 개선에도 직접 연결됩니다. 콘텐츠 사이트라면 기사별 PV, CTA 클릭, 광고 수익, 수정일을 한 화면에 놓고 다음 리라이트를 고를 수 있습니다. 팀에서 이런 관리자 화면을 Claude Code로 안정적으로 만들고 싶다면 Claude Code 교육 및 도입 상담을 확인하세요. 혼자 학습한다면 교재와 템플릿에서 프롬프트와 체크리스트를 시작점으로 삼을 수 있습니다.
실제로 적용해 본 결과
Masa가 작은 고객 목록에 이 구성을 적용했을 때 가장 먼저 효과가 있었던 것은 필터 변경 시 페이지를 1로 되돌리는 처리였습니다. 이전에는 2페이지에서 검색하면 결과가 있어도 빈 화면처럼 보였습니다. Playwright 테스트를 추가하니 이 문제가 바로 잡혔습니다. 또 처음부터 data-label을 셀에 넣어 두니 모바일 CSS를 나중에 억지로 붙일 필요가 줄었습니다. Claude Code에 시맨틱 구조, 상태 관리, 모바일, 접근성, 테스트를 한 번에 요구하는 방식이 가장 리뷰하기 쉬웠습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.