SOYOYU
기술 SEO

관리자 table은 OK, 가격표는 div grid — table 코드 리뷰

데이터는 table이고 레이아웃은 grid입니다. 이 한 줄을 거꾸로 적용한 페이지는 AI가 가격을 못 읽고, 스크린리더가 행과 열을 못 잡습니다. 두 케이스의 경계를 코드로 그어봅니다.

전승엽2026년 7월 18일9 min read
A11y 코드 리뷰시맨틱 HTMLaccessibility treeAI 에이전트웹 접근성table 태그데이터 테이블KWCAG

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 tabledata 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/tdtransition·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"

다른 글 읽기

기술 SEO

Radix Dialog도 깨지는 그 케이스 — focus trap 코드 리뷰

포커스 트랩은 native <dialog>가 무료로 해주는 일이지만, 라이브러리·SPA 라우팅·autoFocus가 섞이면 미세하게 깨집니다. 깨지는 다섯 시나리오를 코드로 짚어봅니다.

10 min read
기술 SEO

구글에 우리 회사 정보가 잘못 표시되는 문제 해결법

주소가 틀리거나 폐업 표시가 뜨거나 옛 로고가 보일 때. 구글 비즈니스 프로필·지식 패널·검색 결과 3채널 각각의 수정 경로와 우선순위를 정리했습니다.

6 min read
기술 SEO

Toast로 띄운 에러는 누구에게 안 들립니다 — form 에러 메시지 코드 리뷰

폼 에러를 toast나 alert로만 띄우면 스크린리더 사용자는 못 듣고, AI 에이전트는 폼 자동 입력에서 실패합니다. aria-describedby와 aria-live의 차이부터 코드로 짚습니다.

9 min read
기술 SEO

사이트맵을 제출했는데도 구글이 내 페이지를 무시하는 이유

사이트맵 제출 성공 = 색인 보장이 아닙니다. 구글이 제출된 URL을 무시하는 8가지 진짜 이유와 각각의 해결 순서를 실무 체크리스트로 정리했습니다.

6 min read
기술 SEO

OG 이미지에 alt를 자동 생성한다는 발상 — alt와 이미지 텍스트 코드 리뷰

alt 속성은 시각장애 사용자만을 위한 게 아닙니다. AI Overviews가 이미지를 답변에 포함하고, 검색엔진이 이미지를 인덱싱하는 가장 강한 신호입니다. 빈 alt와 누락된 alt의 차이부터 봅니다.

9 min read
기술 SEO

구글 서치콘솔에 "색인 생성 안 됨"이 뜨는 진짜 이유

크롤링됨·감지됨·제외됨의 차이부터 실제로 손볼 포인트까지 — 서치콘솔 색인 오류의 숨은 원인과 회복 순서를 실무 관점에서 정리했습니다.

6 min read

검색 최적화가 필요하신가요?

무료 상담을 통해 비즈니스에 맞는 최적화 전략을 확인하세요.