junyeokk
Blog
HTML Processing·2025. 01. 04

node-html-parser

서버 사이드에서 HTML을 파싱해야 하는 상황은 생각보다 자주 발생한다. 웹 크롤링, RSS 피드에서 og:image 메타태그 추출, HTML 이메일 본문 분석 등이 대표적이다. 브라우저에서는 document.querySelector()로 간단하게 DOM을 조작할 수 있지만, Node.js 환경에는 브라우저의 DOM API가 없다.

이 문제를 해결하는 방법은 크게 두 가지다.

첫 번째는 jsdom처럼 브라우저 환경 전체를 시뮬레이션하는 방식이다. window, document, navigator 같은 글로벌 객체까지 전부 구현하기 때문에 브라우저와 거의 동일하게 동작한다. 하지만 그만큼 무겁다. jsdom은 수십 MB의 의존성을 끌어오고, 초기화 시간도 오래 걸린다. 단순히 HTML에서 특정 태그의 속성 하나만 읽으면 되는 상황에서 브라우저 전체를 에뮬레이션하는 건 과도하다.

두 번째는 node-html-parser처럼 HTML 텍스트를 파싱해서 가벼운 DOM 트리만 만드는 방식이다. 브라우저 환경을 시뮬레이션하지 않고, 오직 HTML 파싱과 DOM 쿼리에만 집중한다. 결과적으로 jsdom 대비 수십 배 빠르고, 의존성도 가볍다.

기본 사용법

설치:

bash
npm install node-html-parser

parse() 함수에 HTML 문자열을 넘기면 HTMLElement 노드 트리가 반환된다. 이 트리에서 querySelector()querySelectorAll()로 원하는 요소를 찾을 수 있다.

typescript
import { parse } from 'node-html-parser';

const html = `
<html>
<head>
  <meta property="og:image" content="https://example.com/thumb.jpg" />
  <title>My Page</title>
</head>
<body>
  <div class="content">
    <h1>Hello</h1>
    <p>World</p>
  </div>
</body>
</html>
`;

const root = parse(html);

// querySelector - 첫 번째 매칭 요소
const title = root.querySelector('title');
console.log(title?.text); // "My Page"

// querySelectorAll - 모든 매칭 요소
const metas = root.querySelectorAll('meta');
console.log(metas.length); // 1

// 속성 접근
const ogImage = root.querySelector('meta[property="og:image"]');
console.log(ogImage?.getAttribute('content')); // "https://example.com/thumb.jpg"

반환되는 객체는 브라우저의 HTMLElement가 아니라 node-html-parser가 자체 구현한 경량 노드다. 브라우저 DOM API의 서브셋만 지원하지만, 파싱과 쿼리에 필요한 기능은 대부분 갖추고 있다.

주요 API

요소 탐색

CSS 선택자 기반 탐색을 지원한다. 복합 선택자도 동작한다.

typescript
const root = parse(html);

// 태그 선택자
root.querySelector('div');

// 클래스 선택자
root.querySelector('.content');

// 속성 선택자
root.querySelector('meta[property="og:image"]');
root.querySelector('a[href^="https"]');  // href가 https로 시작하는 a 태그

// 복합 선택자
root.querySelector('div.content > h1');

// ID 선택자
root.querySelector('#main');

속성 접근과 조작

typescript
const element = root.querySelector('a');

// 속성 읽기
element.getAttribute('href');
element.attrs;  // { href: "...", class: "..." } 형태의 객체

// 속성 설정
element.setAttribute('target', '_blank');
element.removeAttribute('class');

// 텍스트 접근
element.text;         // 자식 포함 전체 텍스트
element.rawText;      // HTML 엔티티 포함 원본 텍스트
element.textContent;  // text와 동일

// HTML 접근
element.innerHTML;    // 내부 HTML
element.outerHTML;    // 요소 자체 포함 HTML

textrawText의 차이가 중요하다. text&amp;&로 디코딩한 결과를 반환하고, rawText는 원본 그대로 반환한다.

DOM 조작

읽기 전용이 아니라 DOM 구조를 변경할 수도 있다.

typescript
const root = parse('<div><p>Old</p></div>');
const div = root.querySelector('div');

// 자식 추가
div.appendChild(parse('<span>New</span>'));

// 내부 HTML 교체
div.set_content('<p>Replaced</p>');

// 요소 제거
const p = root.querySelector('p');
p.remove();

// 전체 자식 노드
div.childNodes;      // 모든 자식 (텍스트 노드 포함)
div.children;        // HTMLElement 자식만 (legacy)
div.getElementsByTagName('span');

parse() 옵션

parse() 함수의 두 번째 인자로 옵션을 넘길 수 있다. 기본값만으로도 대부분 동작하지만, 성능 튜닝이나 특수한 파싱 요구사항이 있을 때 유용하다.

typescript
const root = parse(html, {
  lowerCaseTagName: false,    // 태그명 소문자 변환 여부
  comment: false,             // 주석 노드 파싱 여부
  fixNestedATags: true,       // 중첩 <a> 태그 자동 수정
  parseNoneClosedTags: false, // 닫히지 않은 태그 파싱 허용
  voidTag: {
    tags: ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 
           'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'],
    closingSlash: true        // <br/> 형태 허용
  },
  blockTextElements: {
    script: true,
    noscript: true,
    style: true,
    pre: true
  }
});

lowerCaseTagName

기본값은 false다. true로 설정하면 모든 태그명을 소문자로 정규화한다. SVG 같은 대소문자 구분이 필요한 마크업이 아니라면 true로 설정하는 게 쿼리할 때 편하다.

comment

기본값은 false다. true로 설정하면 <!-- 주석 -->도 노드 트리에 포함된다. 주석 내용을 분석해야 하는 경우가 아니면 false로 두는 게 메모리 효율적이다.

blockTextElements

script, style, pre 같은 블록 텍스트 요소 안의 내용을 하나의 텍스트 노드로 처리한다. 이 안에 있는 HTML 태그들은 파싱하지 않고 원본 텍스트 그대로 유지한다. style 태그 안의 < 기호가 태그로 해석되는 것을 방지한다.

fixNestedATags

HTML에서 <a> 태그 안에 <a> 태그를 넣는 건 스펙 위반이지만, 실제로는 종종 발생한다. 이 옵션을 true로 설정하면 중첩된 <a> 태그를 자동으로 분리해준다.

성능 특성

node-html-parser가 빠른 이유는 구현 방식에 있다. jsdom은 W3C DOM 스펙을 최대한 따라서 구현하기 때문에 이벤트 시스템, CSSOM, 네트워크 요청 시뮬레이션 등 파싱과 무관한 기능까지 포함한다. node-html-parser는 이런 것들을 전부 제거하고 HTML 텍스트 → 트리 변환 → CSS 선택자 쿼리에만 집중한다.

벤치마크 결과를 보면 차이가 명확하다:

라이브러리파싱 속도 (상대)메모리 사용량
node-html-parser1x (기준)낮음
htmlparser2~1.2x낮음
jsdom~20-40x 느림높음
cheerio~3-5x 느림중간

htmlparser2도 빠르지만 SAX 스타일 파서라서 이벤트 기반으로 동작한다. DOM 트리를 자동으로 만들어주지 않기 때문에 querySelector()를 바로 쓸 수 없다. cheerio는 내부적으로 htmlparser2를 쓰면서 jQuery 스타일 API를 제공하는데, 그 추상화 레이어 때문에 약간 느리다.

결론적으로 "HTML 파싱 + DOM 쿼리"라는 목적에 한정하면 node-html-parser가 가장 실용적인 선택이다.

실전 패턴: OG 이미지 추출

외부 웹 페이지에서 OpenGraph 메타태그의 이미지 URL을 추출하는 패턴은 링크 프리뷰, 썸네일 수집 등에서 자주 쓰인다.

typescript
import { parse } from 'node-html-parser';

async function extractOgImage(pageUrl: string): Promise<string> {
  const response = await fetch(pageUrl, {
    headers: { Accept: 'text/html' },
  });

  if (!response.ok) {
    throw new Error(`GET 요청 실패: ${response.status}`);
  }

  const html = await response.text();
  const root = parse(html);

  const metaImage = root.querySelector('meta[property="og:image"]');
  let thumbnailUrl = metaImage?.getAttribute('content') ?? '';

  if (!thumbnailUrl.length) {
    return '';
  }

  // 상대 경로인 경우 절대 경로로 변환
  if (!/^https?:\/\//.test(thumbnailUrl)) {
    const origin = new URL(pageUrl).origin;
    thumbnailUrl = origin + thumbnailUrl;
  }

  return thumbnailUrl;
}

핵심 포인트:

  1. HTML 전체를 파싱하지만 속도가 빠르다 — 수 MB짜리 HTML도 수십 ms 내에 파싱된다
  2. CSS 속성 선택자로 정확히 타겟팅meta[property="og:image"]로 og:image 메타태그만 정확히 찾는다
  3. 상대 경로 처리가 필수 — 일부 사이트는 og:image에 상대 경로(/images/thumb.jpg)를 넣는다. 이 경우 원본 URL의 origin을 붙여서 절대 경로로 변환해야 한다

한계와 주의사항

완전한 DOM이 아니다

node-html-parser의 노드는 브라우저 DOM의 서브셋이다. addEventListener(), style 객체, classList 같은 브라우저 전용 API는 없다. DOM 조작보다는 읽기 위주의 작업에 적합하다.

JavaScript 실행 불가

SPA(Single Page Application)처럼 JavaScript가 실행된 후에야 콘텐츠가 렌더링되는 페이지에서는 원하는 데이터를 추출할 수 없다. 이런 경우에는 Puppeteer나 Playwright 같은 헤드리스 브라우저가 필요하다.

잘못된 HTML 처리

실제 웹에서 가져오는 HTML은 잘못된 경우가 많다. 닫히지 않은 태그, 중첩 오류 등이 빈번하다. node-html-parser는 어느 정도 관용적으로 처리하지만, 복잡하게 깨진 HTML에서는 예상과 다른 트리가 만들어질 수 있다. 크롤링 대상 페이지의 HTML 품질이 불확실하다면 파싱 결과를 항상 검증해야 한다.

인코딩

parse()는 문자열을 받기 때문에 인코딩은 호출자가 처리해야 한다. fetch()로 가져온 경우 response.text()가 자동으로 UTF-8 디코딩하지만, Buffer에서 직접 변환할 때는 올바른 인코딩을 지정해야 한다.

typescript
// EUC-KR 인코딩된 페이지를 처리하는 경우
import iconv from 'iconv-lite';

const buffer = await response.arrayBuffer();
const html = iconv.decode(Buffer.from(buffer), 'euc-kr');
const root = parse(html);

대안 비교

도구스타일장점단점
node-html-parserDOM 트리빠름, querySelector 지원완전한 DOM 아님
cheeriojQuery API친숙한 API ($, find 등)약간 느림, 의존성 많음
htmlparser2SAX 이벤트매우 빠름, 스트리밍 가능DOM 트리 직접 구축 필요
jsdom완전한 DOM브라우저와 동일매우 느림, 무거움
Puppeteer헤드리스 브라우저JS 실행 가능, 완벽한 렌더링리소스 소모 큼

선택 기준은 명확하다:

  • 단순 파싱 + 쿼리 → node-html-parser
  • jQuery 스타일 선호 → cheerio
  • 대용량 HTML 스트리밍 → htmlparser2
  • JS 실행이 필요 → Puppeteer/Playwright
  • 테스트에서 브라우저 환경 필요 → jsdom

정리

  • parse() 한 줄로 HTML → DOM 트리 변환, querySelector/querySelectorAll로 브라우저와 동일한 방식으로 탐색할 수 있다
  • jsdom 대비 수십 배 빠르고 의존성이 가벼워서, 메타태그 추출이나 크롤링처럼 읽기 위주 작업에 적합하다
  • JavaScript 실행이 필요한 SPA나 완전한 DOM API가 필요한 경우에는 Puppeteer/jsdom을 써야 한다

관련 문서