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>
이걸 파싱하는 건 간단합니다:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
끝? 전혀 아닙니다.
문제 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>
단순하게 파싱하면 이렇게 됩니다:
Row 1: ["2024년 1분기", "1월", "$100k"]
Row 2: ["2월", "$120k"] // 첫 번째 열 누락!
Row 3: ["3월", "$90k"] // 첫 번째 열 누락!
해결 방법: 위치 매트릭스 구축
이전 행의 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;
}
이건 단순화된 버전입니다—실제 구현에서는 colspan 내의 중첩된 rowspan을 처리해야 하는데, 꽤 복잡해집니다.
문제 2: 데이터 테이블이 아닌 테이블
모든 <table>이 데이터를 담고 있는 것은 아닙니다. 많은 사이트에서 (네, 2024년에도) 테이블을 레이아웃용으로 사용합니다:
<table>
<tr>
<td><nav>여기 메뉴</nav></td>
<td><main>여기 콘텐츠</main></td>
</tr>
</table>
또는 폼용으로:
<table>
<tr>
<td><label>이메일:</label></td>
<td><input type="email"></td>
</tr>
</table>
해결 방법: 휴리스틱
"진짜" 데이터 테이블을 감지하기 위해 여러 신호를 사용합니다:
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;
}
이 중 완벽한 것은 없습니다. 항상 엣지 케이스가 있습니다.
문제 3: 숨겨진 콘텐츠
셀에는 보이는 텍스트 이상의 내용이 있는 경우가 많습니다:
<td>
<span class="value">1,234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
위키피디아가 정렬 가능한 테이블에서 이걸 많이 합니다. 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();
}
문제 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;
}
이것으로 약 90%의 경우를 처리할 수 있습니다. 나머지 10%는 늘 놀라움을 줍니다.
문제 5: 문자 인코딩 지옥
UTF-8이 이 문제를 해결했을 거라 생각하시죠. 아닙니다.
실제 테이블에는 다음이 포함됩니다:
- 공백처럼 보이지만 공백이 아닌 비줄바꿈 공백 (
/\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();
}
그리고 Excel용 CSV로 내보낼 때는 UTF-8 BOM을 앞에 추가합니다:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
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>
해결 방법: 전략 결정
옵션:
- 평탄화: 중첩 테이블을 텍스트로 변환 ("사이즈 S: $10, 사이즈 M: $12")
- 개별 추출: 중첩 테이블을 별도 내보내기로 처리
- 행 확장: 중첩 행당 하나의 부모 행을 생성
저는 옵션 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)