DEV Community

Cover image for HTML 테이블의 숨겨진 복잡성 (파싱이 생각보다 어려운 이유)
circobit
circobit

Posted on

HTML 테이블의 숨겨진 복잡성 (파싱이 생각보다 어려운 이유)

HTML 테이블은 간단해 보입니다. <table>, <tr>, <td>. 뭐가 잘못될 수 있을까요?

수천 개의 실제 테이블을 처리하는 테이블 내보내기 도구 HTML Table Exporter를 만든 후에 말할 수 있습니다: 많이 잘못됩니다. 이 글에서는 단순한 파서를 깨뜨리는 엣지 케이스와 그 해결 방법을 다룹니다.

속이는 듯한 단순한 케이스

완벽한 테이블은 이렇게 생겼습니다:

<table>
  <thead>
    <tr>
      <th>이름</th>
      <th>매출</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Acme Inc</td>
      <td>$1.2M</td>
    </tr>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

이걸 파싱하는 건 간단합니다:

const rows = table.querySelectorAll('tr');
const data = [...rows].map(row => 
  [...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Enter fullscreen mode Exit fullscreen mode

끝? 전혀 아닙니다.

문제 1: 병합된 셀 (colspan/rowspan)

실제 테이블에는 병합된 셀이 많습니다.

<tr>
  <td rowspan="3">2024년 1분기</td>
  <td>1월</td>
  <td>$100k</td>
</tr>
<tr>
  <td>2월</td>
  <td>$120k</td>
</tr>
<tr>
  <td>3월</td>
  <td>$90k</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

단순하게 파싱하면 이렇게 됩니다:

Row 1: ["2024년 1분기", "1월", "$100k"]
Row 2: ["2월", "$120k"]          //  번째  누락!
Row 3: ["3월", "$90k"]           //  번째  누락!
Enter fullscreen mode Exit fullscreen mode

해결 방법: 위치 매트릭스 구축

이전 행의 rowspan이 차지하는 셀을 추적해야 합니다:

function parseTableWithMergedCells(table) {
  const rows = table.querySelectorAll('tr');
  const matrix = [];
  const rowspanTracker = []; // 열별 활성 rowspan 추적

  rows.forEach((row, rowIndex) => {
    matrix[rowIndex] = [];
    let colIndex = 0;

    // 이전 rowspan이 차지하는 열 건너뛰기
    while (rowspanTracker[colIndex] > 0) {
      matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
      rowspanTracker[colIndex]--;
      colIndex++;
    }

    row.querySelectorAll('td, th').forEach(cell => {
      // 차지된 열 건너뛰기
      while (rowspanTracker[colIndex] > 0) {
        matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
        rowspanTracker[colIndex]--;
        colIndex++;
      }

      const colspan = parseInt(cell.getAttribute('colspan')) || 1;
      const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
      const value = cell.textContent.trim();

      // colspan 채우기
      for (let c = 0; c < colspan; c++) {
        matrix[rowIndex][colIndex] = value;

        // 향후 행을 위한 rowspan 추적
        if (rowspan > 1) {
          rowspanTracker[colIndex] = rowspan - 1;
        }
        colIndex++;
      }
    });
  });

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

이건 단순화된 버전입니다—실제 구현에서는 colspan 내의 중첩된 rowspan을 처리해야 하는데, 꽤 복잡해집니다.

문제 2: 데이터 테이블이 아닌 테이블

모든 <table>이 데이터를 담고 있는 것은 아닙니다. 많은 사이트에서 (네, 2024년에도) 테이블을 레이아웃용으로 사용합니다:

<table>
  <tr>
    <td><nav>여기 메뉴</nav></td>
    <td><main>여기 콘텐츠</main></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

또는 폼용으로:

<table>
  <tr>
    <td><label>이메일:</label></td>
    <td><input type="email"></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

해결 방법: 휴리스틱

"진짜" 데이터 테이블을 감지하기 위해 여러 신호를 사용합니다:

function isDataTable(table) {
  const rows = table.querySelectorAll('tr');
  const cells = table.querySelectorAll('td, th');

  // 행이나 셀이 너무 적음
  if (rows.length < 2 || cells.length < 4) return false;

  // 폼 요소 포함 (폼 레이아웃일 가능성)
  if (table.querySelector('input, select, textarea, button')) return false;

  // 주로 네비게이션 링크
  const links = table.querySelectorAll('a');
  const textContent = table.textContent.length;
  const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
  if (linkText / textContent > 0.7) return false;

  // 열 일관성 확인
  const colCounts = [...rows].map(row => 
    row.querySelectorAll('td, th').length
  );
  const variance = Math.max(...colCounts) - Math.min(...colCounts);
  if (variance > 3) return false; // 일관성 없는 열 = 아마도 레이아웃

  return true;
}
Enter fullscreen mode Exit fullscreen mode

이 중 완벽한 것은 없습니다. 항상 엣지 케이스가 있습니다.

문제 3: 숨겨진 콘텐츠

셀에는 보이는 텍스트 이상의 내용이 있는 경우가 많습니다:

<td>
  <span class="value">1,234</span>
  <span class="sort-key" style="display:none">1234</span>
</td>
Enter fullscreen mode Exit fullscreen mode

위키피디아가 정렬 가능한 테이블에서 이걸 많이 합니다. textContent만 가져오면 "1,234 1234"가 됩니다.

해결 방법: 보이는 텍스트만 추출

function getVisibleText(element) {
  // 원본 수정 방지를 위해 복제
  const clone = element.cloneNode(true);

  // 숨겨진 요소 제거
  clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());

  // 동적으로 숨겨진 요소는 computed style도 확인
  // (비용이 더 높으므로 필요할 때만 사용)

  return clone.textContent.trim();
}
Enter fullscreen mode Exit fullscreen mode

문제 4: 숫자가 아닌 숫자

"$1,234.56"은 숫자입니다. "1.234,56"(유럽 형식)도 마찬가지입니다. "(1,234)"(회계 음수)도 그렇습니다. "1,234 M"(접미사 포함)도 마찬가지입니다.

스프레드시트에서 계산하려면 실제 숫자가 필요합니다.

해결 방법: 로케일 인식 파싱

function parseNumber(value) {
  if (!value || typeof value !== 'string') return value;

  // 통화 기호와 공백 제거
  let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();

  // 회계 음수 처리: (1,234) -> -1234
  if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
    cleaned = '-' + cleaned.slice(1, -1);
  }

  // 접미사 처리: 1.5M, 2.3B, 100K
  const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
  const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
  if (suffixMatch) {
    cleaned = suffixMatch[1];
    var multiplier = suffixes[suffixMatch[2].toUpperCase()];
  }

  // 유럽식 vs 미국식 형식 감지
  // 유럽식: 1.234,56 (점은 천 단위, 쉼표는 소수점)
  // 미국식: 1,234.56 (쉼표는 천 단위, 점은 소수점)
  const lastComma = cleaned.lastIndexOf(',');
  const lastDot = cleaned.lastIndexOf('.');

  if (lastComma > lastDot && lastComma > cleaned.length - 4) {
    // 유럽식 형식
    cleaned = cleaned.replace(/\./g, '').replace(',', '.');
  } else {
    // 미국식 형식
    cleaned = cleaned.replace(/,/g, '');
  }

  let num = parseFloat(cleaned);
  if (multiplier) num *= multiplier;

  return isNaN(num) ? value : num;
}
Enter fullscreen mode Exit fullscreen mode

이것으로 약 90%의 경우를 처리할 수 있습니다. 나머지 10%는 늘 놀라움을 줍니다.

문제 5: 문자 인코딩 지옥

UTF-8이 이 문제를 해결했을 거라 생각하시죠. 아닙니다.

실제 테이블에는 다음이 포함됩니다:

  • 공백처럼 보이지만 공백이 아닌 비줄바꿈 공백 (&nbsp; / \u00A0)
  • 문자열 비교를 깨뜨리는 제로 너비 문자
  • UTF-8로 잘못 변환된 Windows-1252 문자
  • 구형 파서를 깨뜨리는 이모지
  • 다국어 테이블의 오른쪽에서 왼쪽으로 쓰는 표시

해결 방법: 모든 것을 정규화

function normalizeText(text) {
  return text
    // 유니코드 정규화 (합성 vs 분해 문자 처리)
    .normalize('NFC')
    // 비줄바꿈 공백을 일반 공백으로 교체
    .replace(/\u00A0/g, ' ')
    // 제로 너비 문자 제거
    .replace(/[\u200B-\u200D\uFEFF]/g, '')
    // 공백 정규화
    .replace(/\s+/g, ' ')
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

그리고 Excel용 CSV로 내보낼 때는 UTF-8 BOM을 앞에 추가합니다:

const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Enter fullscreen mode Exit fullscreen mode

BOM 없이는 Excel이 UTF-8 파일을 Windows-1252로 해석하여 특수 문자를 망가뜨릴 수 있습니다.

문제 6: 중첩 테이블

네, 테이블 안의 테이블입니다. 보통은 레이아웃용이지만, 때로는 데이터용으로도 사용됩니다:

<table>
  <tr>
    <td>제품 A</td>
    <td>
      <table>
        <tr><td>사이즈 S</td><td>$10</td></tr>
        <tr><td>사이즈 M</td><td>$12</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

해결 방법: 전략 결정

옵션:

  1. 평탄화: 중첩 테이블을 텍스트로 변환 ("사이즈 S: $10, 사이즈 M: $12")
  2. 개별 추출: 중첩 테이블을 별도 내보내기로 처리
  3. 행 확장: 중첩 행당 하나의 부모 행을 생성

저는 옵션 2(개별 추출)를 선택하고, 깊게 중첩된 경우에 대해서는 옵션 1을 폴백으로 사용했습니다. 완벽한 답은 없습니다—사용 사례에 따라 다릅니다.

현실

이 모든 케이스를 처리한 후, 제 테이블 파서는 약 800줄의 JavaScript입니다. 그리고 여전히 모든 것을 완벽하게 처리하지는 못합니다.

몇 가지 냉혹한 현실:

  • 완벽한 파서는 없습니다. 실제 HTML은 지저분합니다.
  • 휴리스틱은 실패합니다. 사용자를 위한 탈출구가 항상 필요합니다.
  • 성능이 중요합니다. 일부 페이지에는 50개 이상의 테이블이 있습니다. 파싱은 빨라야 합니다.
  • 엣지 케이스는 무한합니다. 95%의 경우에 작동하는 것을 먼저 출시하고, 반복 개선하세요.

도구와 리소스

비슷한 것을 만들고 있다면:

  • SheetJS (xlsx) — Excel 파일 생성을 위한 견고한 라이브러리
  • Papa Parse — 빠른 CSV 파싱 및 생성
  • Chrome DevTools — 콘솔에서 $('table')로 빠르게 테이블 검사

또는 아무것도 만들지 않고 테이블만 내보내고 싶다면: 저는 일회용 스크래퍼 작성에 지쳐서 HTML Table Exporter를 만들었습니다. 위의 모든 엣지 케이스를 처리합니다.

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

테이블 내보내기에 대한 더 자세한 가이드는 HTML 테이블 스크래퍼: Chrome 최고의 확장 프로그램 5선을 참고하세요.


어떤 이상한 테이블 엣지 케이스를 만나보셨나요? 저는 항상 파서를 깨뜨릴 새로운 테스트 케이스를 찾고 있습니다.

Top comments (0)