A11y 코드 리뷰 #007 · 오늘 도감에 등록할 태그 —
<table>(그리고<caption>,<thead>,<tbody>,<th>,<td>)이번 도감 카드는 경계 그리기의 글입니다. 데이터는 table, 레이아웃은 grid. 이 경계가 흐려진 페이지에서 AI 에이전트는 가격을 못 읽고 AI Overviews는 비교 데이터를 인용하지 못합니다.
TL;DR
- 데이터는
<table>로, 레이아웃은 CSS grid/flex로. 90년대 layout table 시절의 트라우마로 모든 table을 피한 게 현재의 안티패턴입니다. - 진짜 table에는
<caption>,<thead>,<tbody>,<th scope="col|row">가 필요합니다. 다섯 태그가 한 묶음입니다. - div grid로 만든 가격표는 스크린리더가 "셀의 의미"를 모릅니다 — "스탠다드 요금 49,000원"이 그저 텍스트 5개로 들립니다.
- AI Overviews와 Perplexity가 비교나 순위 답변을 만들 때 — table 데이터에서 인용 가능성이 div grid보다 압도적으로 높습니다.
- 한국 가격표·요금제·스펙 비교 페이지에서 가장 자주 손해 보는 SEO·GEO 지점입니다.
1. 어디서나 본 그 가격표
다음 세 패턴은 가격표·요금제·스펙 비교 페이지에서 거의 항상 보입니다.
div + flex로 만든 가격 비교
<div className="pricing-grid">
<div className="plan">
<div className="plan-name">Basic</div>
<div className="price">월 9,900원</div>
<div className="feature">사용자 5명</div>
<div className="feature">저장공간 10GB</div>
</div>
<div className="plan">
<div className="plan-name">Pro</div>
<div className="price">월 29,000원</div>
<div className="feature">사용자 25명</div>
<div className="feature">저장공간 100GB</div>
</div>
</div>
stat 카드 grid
<div className="stats">
<div>
<div>매출 증가</div>
<div>+42%</div>
</div>
<div>
<div>전환율</div>
<div>3.2%</div>
</div>
</div>
caption·thead 없는 table
<table>
<tr>
<td>이름</td>
<td>가격</td>
<td>재고</td>
</tr>
<tr>
<td>상품 A</td>
<td>10,000원</td>
<td>15</td>
</tr>
</table>
세 패턴 중 첫 두 개는 데이터인데 table을 안 썼고, 세 번째는 table을 썼는데 시맨틱이 빠졌습니다. 결과적으로 셋 모두 보조 기술과 AI에 정보 손실을 만듭니다.
2. 한 가지 질문
이 정보를 종이에 인쇄한다면 — 표로 그릴 수 있는가요? 행과 열로 정리되고, 헤더가 있는가요?
답이 예면 <table>입니다. 아니오면 div + CSS grid입니다. 가격표, 비교표, 스펙 시트, 통계 그리드, 데이터 대시보드는 거의 항상 예입니다.
3. table — 점진적 해부
3.1 가장 단순한 table
<table>
<caption>2026년 분기별 매출 (단위: 백만 원)</caption>
<thead>
<tr>
<th scope="col">분기</th>
<th scope="col">매출</th>
<th scope="col">전년 대비</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Q1</th>
<td>1,250</td>
<td>+12%</td>
</tr>
<tr>
<th scope="row">Q2</th>
<td>1,480</td>
<td>+18%</td>
</tr>
</tbody>
</table>
이 구조가 만드는 의미 신호:
<caption>: 표가 무엇을 다루는지 설명. 스크린리더가 표에 진입할 때 가장 먼저 읽음.<thead>/<tbody>: 헤더 행과 데이터 행의 분리.<th scope="col">: 이 셀이 열 헤더임을 명시.<th scope="row">: 이 셀이 행 헤더임을 명시.
scope가 있으면 스크린리더가 데이터 셀을 읽을 때 그 셀의 행/열 헤더를 자동으로 함께 발화합니다.
사용자가 "+18%" 셀에 포커스
→ NVDA: "Q2, 전년 대비, +18%"
세 단어로 어떤 분기의 어떤 지표가 얼마인지를 들려줍니다. div grid에서는 — "+18%"만 들립니다.
3.2 caption — 표의 제목
caption은 표가 무엇에 관한 것인지를 한 줄로 알려줍니다. 시각적으로는 표 위 또는 아래에 텍스트로 보이고, 스크린리더에는 표 진입 시 첫 발화로 들립니다.
<table>
<caption>요금제별 기능 비교 (2026년 6월 기준)</caption>
...
</table>
caption을 시각적으로 안 보이게 하고 싶다면 — caption 안에 visually-hidden을 두지 말고, caption 자체에 CSS로 클립을 걸어 시각적으로만 숨깁니다. 시맨틱은 보존됩니다.
3.3 thead vs tbody vs tfoot
<table>
<caption>월별 활성 사용자 (DAU)</caption>
<thead>
<tr>
<th scope="col">월</th>
<th scope="col">DAU</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">1월</th><td>120,000</td></tr>
<tr><th scope="row">2월</th><td>145,000</td></tr>
...
</tbody>
<tfoot>
<tr>
<th scope="row">합계</th>
<td>~1,500,000</td>
</tr>
</tfoot>
</table>
<tfoot>은 합계·총합 행에 쓰입니다. 스크린리더가 별도 컨텍스트로 처리합니다.
3.4 복잡한 표 — headers 속성
scope만으로 부족한 복잡한 표(예: 다중 헤더, 병합 셀)에서는 headers 속성으로 셀과 헤더를 명시적으로 연결합니다.
<table>
<caption>지역별 분기 매출</caption>
<thead>
<tr>
<th id="region">지역</th>
<th id="q1">Q1</th>
<th id="q2">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th headers="region" id="seoul">서울</th>
<td headers="seoul q1">1,200</td>
<td headers="seoul q2">1,350</td>
</tr>
</tbody>
</table>
이 케이스는 드뭅니다 — 대부분의 표는 scope만으로 충분합니다.
4. 왜 문제인가 — 3중 영향
4a. 스크린리더 사용자
div grid에서 스크린리더가 듣는 것:
<div>Basic</div>
<div>월 9,900원</div>
<div>사용자 5명</div>
<div>저장공간 10GB</div>
<div>Pro</div>
<div>월 29,000원</div>
<div>사용자 25명</div>
<div>저장공간 100GB</div>
→ NVDA: "Basic, 월 9900원, 사용자 5명, 저장공간 10GB, Pro, 월 29000원..."
8개의 텍스트가 나열됩니다. Pro 요금에 사용자 몇 명? 같은 질문에 답하려면 사용자가 텍스트 순서로 추론해야 합니다.
table에서는:
사용자가 "월 29,000원" 셀에 포커스
→ NVDA: "Pro, 가격, 월 29,000원"
직접적이고 명확합니다. table 구조 자체가 행-열의 관계를 정보로 포함합니다.
4b. 키보드 사용자
스크린리더가 아닌 사용자에게도 table은 이득을 줍니다. 일부 브라우저와 보조 도구는 table 안에서 방향키로 셀 이동을 지원합니다. div grid에서는 일반 Tab만 가능합니다.
4c. AI 에이전트와 검색엔진
여기가 SEO/GEO에서 결정적인 단입니다.
AI Overviews의 비교 답변 — "A 요금제와 B 요금제 차이는?" 같은 질문에 AI Overviews가 답할 때, 가장 자주 인용되는 형식이 table입니다. 같은 정보를 div grid로 만들었다면 — AI가 행-열 관계를 추측해야 하고, 추측이 자주 틀립니다.
Perplexity의 데이터 추출 — Perplexity가 페이지에서 비교 표를 만들 때, 원본이 시맨틱 table이면 그대로 추출합니다. div grid면 시각적 배치를 OCR로 분석해서 추측합니다.
구글의 rich result — Table snippet — 구글 검색 결과에 비교표 형태로 노출되는 케이스가 있습니다. 이건 시맨틱 table에서만 작동합니다.
AI 에이전트의 셀 정확도 — 에이전트가 "Pro 요금이 얼마지?"를 묻는 작업을 받으면 — table에서는 한 번에 찾습니다. div grid에서는 시각적 배치로 추측하고, 화면 비율이 바뀌면 틀립니다.
선행 글 구글이 'AI 검색 최적화 가이드'를 냈는데, 정작 핵심은 접근성 트리였습니다에서 본 사실 — accessibility tree에서 table은 명시적인 행-열 구조로 노출됩니다. div grid는 단순한 텍스트 나열입니다.
5. SEO/GEO/AEO 영향
5.1 SEO — table은 SEO 페널티가 아닙니다
90년대 layout table 시절의 트라우마로 table = SEO 나쁨이라는 미신이 있습니다. 사실이 아닙니다. 구글은 layout table과 data table을 구분합니다. data table은 적극적으로 권장됩니다.
WHATWG HTML 명세도 이를 명시합니다 — "table은 다차원 데이터를 표현하는 데 사용해야 한다".
5.2 GEO — AI 시대의 비교 데이터 가치
GEO에서 비교/순위/스펙 비교 정보는 가장 자주 인용되는 카테고리입니다. AI는 사용자의 결정을 도우려 합니다. 결정에는 비교가 필요하고, 비교는 표 형식이 자연스럽습니다.
한국 사이트의 가격표가 div grid면 — 외국 경쟁사 페이지가 시맨틱 table이면, AI Overviews는 외국 페이지를 인용합니다. 콘텐츠의 질이 같아도 시맨틱 차이로 인용 우선순위가 바뀝니다.
5.3 AEO — 표가 답변이 되는 케이스
AI Overviews 답변 중 table snippet은 점점 늘고 있습니다. "세 가지 요금제 비교해줘" 같은 질문에 AI가 표를 그려서 답합니다. 표의 원본은 보통 인용 페이지의 시맨틱 <table>입니다.
6. React에서
6.1 React로 시맨틱 table 만들기
type Plan = {
name: string;
price: number;
users: number;
storage: string;
};
function PricingTable({ plans }: { plans: Plan[] }) {
return (
<table>
<caption>요금제 비교 (2026년 7월 기준)</caption>
<thead>
<tr>
<th scope="col">요금제</th>
<th scope="col">월 요금</th>
<th scope="col">사용자 수</th>
<th scope="col">저장공간</th>
</tr>
</thead>
<tbody>
{plans.map((plan) => (
<tr key={plan.name}>
<th scope="row">{plan.name}</th>
<td>{plan.price.toLocaleString()}원</td>
<td>{plan.users}명</td>
<td>{plan.storage}</td>
</tr>
))}
</tbody>
</table>
);
}
<th scope="row">로 요금제 이름을 행 헤더로 두는 게 자주 빠집니다. 이게 있으면 스크린리더가 각 셀을 읽을 때 어떤 요금제의 어떤 항목인지를 자동으로 발화합니다.
6.2 디자인 자유도 — CSS로 table을 자유롭게
table은 시맨틱이고, 시각 디자인은 CSS로 분리합니다.
table {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 1rem;
}
thead, tbody, tr {
display: contents;
}
display: contents로 table·thead·tbody·tr의 시각적 박스를 없애고, 자식 요소들이 grid의 셀로 직접 배치되게 합니다. 시맨틱은 보존되고 디자인은 grid의 자유도를 그대로 누립니다.
다만 display: contents는 일부 브라우저에서 ARIA 트리 노출에 영향을 준다는 보고가 있어 — 적용 후 NVDA 등으로 검증이 필요합니다.
6.3 TanStack Table 같은 라이브러리
TanStack Table은 headless 라이브러리로 — 정렬·필터·페이지네이션 로직만 제공하고 렌더링은 사용자에게 맡깁니다. 즉 사용자가 직접 <table> 또는 <div>로 렌더 가능합니다.
import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";
function DataTable({ data, columns }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
return (
<table>
<caption>데이터 표</caption>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id} scope="col">
{flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
라이브러리를 쓰더라도 어떤 태그로 렌더할지는 개발자의 결정. 시맨틱 table을 쓰면 a11y가 자동으로 따라옵니다.
6.4 ARIA grid는 거의 필요 없습니다
복잡한 spreadsheet UI(예: Notion·Airtable의 데이터 그리드)에서 role="grid"가 쓰이지만 — 대부분의 사이트는 <table>로 충분합니다. ARIA grid는 키보드 단축키를 직접 구현해야 하는 복잡한 패턴이라 — 단순 비교표에는 과합니다.
7. 한국 맥락 — 가볍게
KWCAG 관련 항목:
- 1.3.1 정보·기능·관계의 인식 가능성: 표의 행-열 관계는 보조 기술이 인식 가능해야 합니다. div grid는 이 항목 위반 가능성.
- 1.3.2 콘텐츠의 선형구조: 콘텐츠가 시각적 순서 외에 논리적 순서로도 의미가 통해야 합니다. table은 이 관계를 자동으로 보존합니다.
한국 사이트에서 자주 보는 패턴 — 디자인 카드 형태의 요금제. 카드 4개가 가로로 나란히 있고 각 카드 안에 요금·기능이 세로로 나열돼 있는 형태. 시각적으로는 카드지만 데이터는 비교 표입니다. 시맨틱 table로 마크업하고 CSS로 카드처럼 보이게 하는 게 권장됩니다.
8. "그래도 div grid가 더 디자인 자유도가 높지 않나요?"
자주 듣는 반론들:
"table은 시각 디자인이 제약돼요" — display: grid 또는 display: contents를 table에 적용 가능합니다. 시맨틱과 시각 디자인은 분리됩니다.
"모바일에서 table이 깨져요" — 반응형 table 패턴이 정립돼 있습니다. 좁은 화면에서 행을 카드로 변환하는 CSS 패턴이 표준. 시맨틱은 유지됩니다.
"애니메이션이 어려워요" — tr/td도 transition·transform이 적용됩니다. 다만 display: contents를 쓰면 일부 transform이 안 됩니다 — 그 경우는 wrapping div를 한 겹 더 두면 해결됩니다.
"테이블 정렬·필터 라이브러리가 div 기반이에요" — TanStack Table 같은 헤드리스 라이브러리는 렌더 태그를 자유롭게 선택 가능합니다. 시맨틱 table로 렌더할 수 있습니다.
"div grid의 a11y를 ARIA로 흉내 내면 되지 않나요?" — role="table"·role="row"·role="cell"을 div에 일일이 박는 건 가능하지만 — WAI-ARIA 첫 번째 규칙: "native HTML을 ARIA로 흉내 내지 마라". 게다가 한 번에 다 박는 게 어렵고 자주 빠집니다.
남은 진짜 케이스는 — 진짜 layout table이었던 90년대 잔재 페이지를 리팩토링할 때 임시로 div로 변환하는 것 정도입니다.
9. 체크리스트
- 가격표·요금제·스펙 비교가
<table>로 마크업됐는가 - table에
<caption>이 있는가 -
<thead>와<tbody>가 명시적으로 구분돼 있는가 - 헤더 셀이
<th scope="col">또는<th scope="row">로 명시돼 있는가 - 데이터 셀이
<td>인가 (<th>가 아닌가) - CSS로 디자인을 자유롭게 하면서 시맨틱은 보존했는가
- 모바일 반응형에서도 table 시맨틱이 유지되는가
- AI Overviews 인용을 노리는 비교 페이지가 시맨틱 table 기반인가
마무리
이 글이 16편 시리즈의 일곱 번째 도감 카드입니다. 다음 카드는 <img>와 alt 속성 — OG 이미지의 alt 자동 생성과 AI Overviews의 이미지 인용 신호를 봅니다.
table의 단 한 줄 규칙 — 종이에 인쇄해서 표로 그려질 수 있으면 <table>. 시각 배치는 CSS, 시맨틱은 table.
가격표·비교 페이지·데이터 시각화의 a11y·SEO 컨설팅이 필요하시면 문의하기로 연락 주세요.
codex:
primary_tag: table
secondary_tags: [caption, thead, tbody, th, td, tfoot]
aria_roles: [table, row, columnheader, rowheader, cell]
aria_attributes: [scope, headers, aria-label, aria-describedby]
related_patterns: [data-table, pricing-comparison, responsive-table, sortable-table]
kwcag: ["1.3.1", "1.3.2"]
wcag: ["1.3.1", "1.3.2"]
ai_agent_impact: high
seo_geo_impact: high
difficulty: intermediate
test_envs:
- "NVDA 2024.x + Chrome 125 (table navigation mode)"
- "VoiceOver macOS 14 + Safari 17 (rotor → Tables)"
references:
- "WHATWG HTML Living Standard, Tables"
- "WAI-ARIA Authoring Practices, Table Pattern"
- "Adrian Roselli, Tables, CSS Display Properties, and ARIA"
- "TanStack Table Documentation"