DEV Community

Cover image for 중첩 테이블과 Rowspan 처리하기 (HTML 테이블 파싱의 어려운 부분)
circobit
circobit

Posted on

중첩 테이블과 Rowspan 처리하기 (HTML 테이블 파싱의 어려운 부분)

HTML 테이블 파싱은 실제 데이터를 만나기 전까지는 간단해 보입니다. 위키피디아 테이블에는 네비게이션 행이 있습니다. 금융 사이트는 복잡한 rowspan을 사용합니다. 스포츠 통계 사이트는 헤더를 두 단계로 중첩합니다.

수천 개의 다른 사이트에서 사용되는 테이블 추출 도구 HTML Table Exporter를 만든 후, 대부분의 파서를 깨뜨리는 엣지 케이스를 정리했습니다. 각각의 처리 방법을 살펴보겠습니다.

문제 1: Rowspan 확장

rowspan="3"인 셀은 현재 행과 다음 두 행에서 수직 공간을 차지합니다. row.cells를 단순하게 반복하면 열이 어긋납니다.

깨진 출력:

| 국가    | 2020 | 2021 | 2022 |    <- 헤더
| 미국    | 100  | 200  | 300  |    <- 예상
| 150     | 250  | 350  |          <- "미국" 누락 (rowspan 계속)
Enter fullscreen mode Exit fullscreen mode

해결 방법: 가상 그리드에서 차지된 위치를 추적합니다.

function expandRowspans(table) {
  const rows = Array.from(table.rows);
  const grid = [];

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    let colIndex = 0;

    Array.from(rowEl.cells).forEach(cell => {
      // 다음 비어있는 열 찾기
      while (grid[rowIndex][colIndex] !== undefined) {
        colIndex++;
      }

      const text = cell.textContent.trim();
      const rowSpan = parseInt(cell.rowSpan, 10) || 1;
      const colSpan = parseInt(cell.colSpan, 10) || 1;

      // 이 요소가 걸치는 모든 셀 표시
      for (let r = 0; r < rowSpan; r++) {
        const targetRow = rowIndex + r;
        if (!grid[targetRow]) grid[targetRow] = [];

        for (let c = 0; c < colSpan; c++) {
          grid[targetRow][colIndex + c] = text;
        }
      }

      colIndex += colSpan;
    });
  });

  // 행 길이 정규화
  const maxCols = Math.max(...grid.map(r => r.length));
  return grid.map(row => {
    const normalized = new Array(maxCols).fill("");
    row.forEach((val, i) => normalized[i] = val ?? "");
    return normalized;
  });
}
Enter fullscreen mode Exit fullscreen mode

핵심 인사이트: 가상 그리드가 진실의 원천입니다. DOM 셀은 그것을 채우기 위한 지시사항일 뿐입니다.

문제 2: 중첩 테이블

위키피디아 인포박스에는 종종 테이블 셀 안에 테이블이 있습니다. 재귀적 접근법은 쓸모없는 결과를 추출합니다:

<table>
  <tr>
    <td>국가</td>
    <td>
      <table>  <!-- 중첩! -->
        <tr><td>인구</td><td>3.3억</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

감지 전략: 테이블의 상위 요소가 테이블인지 확인합니다.

function isNestedTable(table) {
  let parent = table.parentElement;

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;
    }
    parent = parent.parentElement;
  }

  return false;
}

// 페이지 스캔 시
function getTopLevelTables() {
  const all = document.querySelectorAll("table");
  return Array.from(all).filter(t => !isNestedTable(t));
}
Enter fullscreen mode Exit fullscreen mode

그런데 중첩 테이블의 내용은?

외부 테이블의 경우, 중첩 테이블을 텍스트 콘텐츠로 평탄화합니다:

function extractCellText(cell) {
  const clone = cell.cloneNode(true);

  // 중첩 테이블 제거 (텍스트는 textContent를 통해 이미 포함됨)
  clone.querySelectorAll("table").forEach(t => t.remove());

  // 보이지 않는 요소 제거
  clone.querySelectorAll("style, script").forEach(el => el.remove());

  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

문제 3: 위키피디아 네비게이션 행

위키피디아 테이블은 종종 네비게이션 행으로 시작합니다:

| v t e  국가별 인구 목록              |
| 순위 | 국가   | 인구         |
| 1    | 중국   | 14억         |
Enter fullscreen mode Exit fullscreen mode

"v t e" 행(보기/대화/편집 링크)은 데이터가 아니라 UI입니다. 이것을 헤더 행으로 처리하는 파서는 쓸모없는 결과를 만듭니다.

위키피디아 테이블 처리에 대한 실용 가이드는 테이블 내보내기를 위한 Chrome 최고의 확장 프로그램 5선을 참조하세요.

감지:

function isWikipediaNavRow(row) {
  const firstCell = row[0] || "";

  // 네비게이션 행의 일반적인 패턴
  const patterns = [
    /^v\s+t\s+e\s/i,           // "v t e "
    /^\s*v\s*\|\s*t\s*\|\s*e/i, // "v | t | e"
    /^\[v\]\s*\[t\]\s*\[e\]/i   // "[v] [t] [e]"
  ];

  return patterns.some(p => p.test(firstCell));
}

function detectHeaderRowIndex(matrix) {
  for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
    if (isWikipediaNavRow(matrix[i])) {
      return i + 1;  // 헤더는 다음 행
    }
  }
  return 0;  // 기본값: 첫 번째 행이 헤더
}
Enter fullscreen mode Exit fullscreen mode

문제 4: 타이틀 행 (전체 열에 걸침)

일부 테이블에는 전체 너비에 걸치는 타이틀 행이 있습니다:

<table>
  <tr><td colspan="4">분기별 매출 (백만 달러)</td></tr>
  <tr><td>Q1</td><td>Q2</td><td>Q3</td><td>Q4</td></tr>
  <tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
Enter fullscreen mode Exit fullscreen mode

rowspan 확장 후 첫 번째 행은 ["분기별 매출...", "분기별 매출...", ...]—같은 값이 반복됩니다.

감지:

function isTitleRow(row, nextRow) {
  if (!row || !nextRow) return false;

  const uniqueValues = new Set(row.filter(v => v.trim()));
  const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));

  // 타이틀 행의 특성:
  // 1. 고유 값이 하나만 있음 (colspan으로 반복)
  // 2. 다음 행에는 여러 고유 값이 있음 (실제 헤더)
  // 3. 단일 값이 긴 텍스트 (보통 30자 이상)

  return (
    uniqueValues.size === 1 &&
    nextUniqueValues.size > 2 &&
    row[0] && row[0].length > 30
  );
}
Enter fullscreen mode Exit fullscreen mode

문제 5: 그룹화된 열 헤더 (FBREF 스타일)

FBREF 같은 스포츠 통계 사이트는 2단계 헤더를 사용합니다:

|        |        | 출전 시간         | 성적           |
| 선수   | 국적   | MP | 선발 | 분  | 골 | 어시스트 | xG |
| 홀란드 | 노르웨이 | 35 | 33  | 2950| 36 | 8      | 32 |
Enter fullscreen mode Exit fullscreen mode

첫 번째 행에는 그룹 이름이 있습니다. 두 번째 행에는 실제 열 이름이 있습니다. 둘 다 "헤더"입니다.

과제: colspan 확장 후 0번째 행은 이렇게 됩니다:

["", "", "출전 시간", "출전 시간", "출전 시간", "성적", "성적", "성적"]
Enter fullscreen mode Exit fullscreen mode

감지 휴리스틱:

function isGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length !== nextRow.length) return false;

  // 이웃과 같은 값을 가진 셀 수 세기
  let repeatCount = 0;
  for (let i = 1; i < row.length; i++) {
    if (row[i] && row[i] === row[i-1]) repeatCount++;
  }

  const repeatRatio = repeatCount / (row.length - 1);

  // 그룹 헤더 행은 보통 40% 이상 반복 값을 가짐
  // 그리고 다음 행이 더 많은 고유 값을 가짐
  const uniqueInRow = new Set(row.filter(v => v.trim())).size;
  const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;

  return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
Enter fullscreen mode Exit fullscreen mode

그룹 + 하위 헤더 병합:

function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
  return subHeaderRow.map((subHeader, idx) => {
    const group = (groupRow[idx] || "").trim();
    const sub = (subHeader || "").trim();

    if (!group) return sub;
    if (!sub) return group;
    if (sub.toLowerCase() === group.toLowerCase()) return sub;

    return `${group} - ${sub}`;
  });
}

// 결과: ["선수", "국적", "출전 시간 - MP", "출전 시간 - 선발", ...]
Enter fullscreen mode Exit fullscreen mode

문제 6: 수평으로 복제된 테이블

위키피디아 인구 테이블에는 종종 이런 구조가 있습니다:

| 순위 | 이름  | 인구  | 순위 | 이름   | 인구  |
| 1    | 도쿄  | 3700만 | 11  | 파리  | 1100만 |
| 2    | 델리  | 3200만 | 12  | 카이로 | 1000만 |
Enter fullscreen mode Exit fullscreen mode

이것은 수직 공간을 절약하기 위해 두 열로 표시된 하나의 논리적 테이블입니다.

감지:

function detectHorizontalDuplication(headers) {
  const half = Math.floor(headers.length / 2);
  if (half < 2) return null;

  const firstHalf = headers.slice(0, half);
  const secondHalf = headers.slice(half, half * 2);

  // 두 번째 절반이 첫 번째 절반과 일치하는지 확인
  const matches = firstHalf.every((h, i) => 
    h.toLowerCase() === secondHalf[i]?.toLowerCase()
  );

  if (matches) {
    return { detected: true, repeatCount: 2, baseColumns: half };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

정규화: 각 행을 분할하고 수직으로 쌓기:

function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
  const header = matrix[0].slice(0, baseColumns);
  const normalizedRows = [header];

  for (let i = 1; i < matrix.length; i++) {
    const row = matrix[i];
    // 첫 번째 절반
    normalizedRows.push(row.slice(0, baseColumns));
    // 두 번째 절반 (비어있지 않은 경우)
    const secondHalf = row.slice(baseColumns, baseColumns * 2);
    if (secondHalf.some(cell => cell.trim())) {
      normalizedRows.push(secondHalf);
    }
  }

  return normalizedRows;
}
Enter fullscreen mode Exit fullscreen mode

통합 알고리즘

실제 파싱에서는 이 모든 케이스를 순서대로 확인해야 합니다:

function parseTable(table) {
  // 1. rowspan/colspan을 가상 그리드로 확장
  let matrix = expandRowspans(table);

  // 2. 네비게이션/타이틀 행 감지 및 건너뛰기
  const headerIndex = detectHeaderRowIndex(matrix);
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 3. 그룹화된 헤더 처리 (FBREF 스타일)
  const groupedHeaders = detectGroupedColumnHeaders(matrix);
  if (groupedHeaders) {
    const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
    matrix = [mergedHeaders, ...matrix.slice(2)];
  }

  // 4. 수평 복제 처리
  const duplication = detectHorizontalDuplication(matrix[0]);
  if (duplication) {
    matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
  }

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

이 엣지 케이스 테스트

위의 모든 패턴은 실제 버그 리포트에서 나왔습니다. 각각에 대한 HTML 픽스처가 포함된 테스트 스위트를 유지하고 있습니다:

// 테스트: 위키피디아 스타일 네비게이션 행
const navRowHtml = `
  <table>
    <tr><td colspan="3">v t e 국가</td></tr>
    <tr><td>순위</td><td>국가</td><td>인구</td></tr>
    <tr><td>1</td><td>중국</td><td>14억</td></tr>
  </table>
`;

const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "순위");  // 헤더 올바르게 식별
assert(result[1][1] === "중국");  // 데이터 올바르게 정렬
Enter fullscreen mode Exit fullscreen mode

테스트 스위트에는 이 패턴들의 조합을 다루는 24개의 케이스가 있습니다. 새로운 버그 리포트가 새로운 테스트 케이스가 됩니다.

직접 사용해 보세요

테이블 추출을 만들고 있다면, 이 글이 디버깅 시간을 절약해주기를 바랍니다. 코드를 작성하지 않고 테이블만 내보내고 싶다면, HTML Table Exporter가 이 모든 케이스를 자동으로 처리합니다.

gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 무료로 사용해 보세요.


파서를 깨뜨리는 테이블을 찾으셨나요? URL을 공유해 주세요. 저는 이런 엣지 케이스를 수집하고 있습니다.

Top comments (0)