junyeokk
Blog
HTML Processing·2025. 01. 04

html-escaper

HTML 문서에는 <, >, &, ", ' 같은 특수 문자가 태그 구문으로 해석되는 문제가 있다. 예를 들어 RSS 피드에서 가져온 블로그 제목에 &amp;이 들어 있으면, 이걸 그대로 화면에 렌더링하면 &가 아니라 &amp;이라는 문자열이 보인다. 반대로 사용자가 입력한 텍스트에 <script>가 포함되어 있으면, 이스케이프 없이 HTML에 삽입하면 XSS 공격이 가능해진다.

이 문제를 다루는 방법은 두 가지다:

  • 이스케이프(escape): 특수 문자를 HTML 엔티티로 변환 (<&lt;)
  • 언이스케이프(unescape): HTML 엔티티를 원래 문자로 복원 (&lt;<)

html-escaper는 이 두 가지 변환만을 담당하는 초경량 라이브러리다. sanitize-html처럼 태그를 필터링하거나 DOM을 파싱하는 게 아니라, 순수하게 5개의 HTML 엔티티 문자만 변환한다.


왜 직접 구현하지 않는가

이스케이프 로직은 간단해 보인다. replace 몇 번이면 될 것 같다.

javascript
// 흔한 실수: 순서 문제
function naiveEscape(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

이 코드 자체는 동작한다. 하지만 문제가 되는 건 순서다. &를 먼저 변환하지 않으면 이미 변환된 &lt;&가 다시 &amp;lt;로 이중 이스케이프된다. 반대로 언이스케이프할 때도 순서를 잘못 잡으면 &amp;lt;&lt;가 아니라 <로 잘못 복원된다.

html-escaper는 replace 체이닝 대신 단일 정규식 + 매핑 테이블 방식을 사용해서 이런 순서 문제를 원천 차단한다.


설치와 기본 사용법

bash
npm install html-escaper

API는 딱 두 개다:

javascript
import { escape, unescape } from 'html-escaper';

// 이스케이프: 특수 문자 → HTML 엔티티
escape('<div class="hello">Tom & Jerry</div>');
// '&lt;div class=&quot;hello&quot;&gt;Tom &amp; Jerry&lt;/div&gt;'

// 언이스케이프: HTML 엔티티 → 원래 문자
unescape('&lt;div class=&quot;hello&quot;&gt;Tom &amp; Jerry&lt;/div&gt;');
// '<div class="hello">Tom & Jerry</div>'

변환 대상 문자

html-escaper가 처리하는 문자는 HTML 스펙에서 정의한 5개의 특수 문자뿐이다:

원래 문자HTML 엔티티설명
&&amp;엔티티 시작 구분자
<&lt;태그 시작
>&gt;태그 끝
"&quot;속성값 구분자 (쌍따옴표)
'&#39;속성값 구분자 (홑따옴표)

&middot;(·)이나 &nbsp;(공백) 같은 명명된 엔티티(named entity)는 변환 대상이 아니다. 이런 엔티티까지 처리해야 한다면 별도 로직을 추가해야 한다.


내부 동작 원리

html-escaper의 핵심은 정규식 하나와 매핑 객체의 조합이다. 소스 코드를 살펴보면 구조가 매우 단순하다:

javascript
// escape 내부 구현 (개념)
const ca = /[&<>"']/g;  // 5개 문자를 한 번에 매칭

const esca = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;'
};

function escape(str) {
  return str.replace(ca, (m) => esca[m]);
}
javascript
// unescape 내부 구현 (개념)
const pe = /&(?:amp|lt|gt|quot|#39);/g;

const unes = {
  '&amp;': '&',
  '&lt;': '<',
  '&gt;': '>',
  '&quot;': '"',
  '&#39;': "'"
};

function unescape(str) {
  return str.replace(pe, (m) => unes[m]);
}

핵심 포인트:

  1. 단일 정규식 패스: replace 체이닝이 아니라 하나의 정규식으로 모든 대상 문자를 한 번에 찾는다. 이렇게 하면 이미 변환된 문자가 다음 replace에 의해 이중 변환되는 문제가 발생하지 않는다.

  2. 콜백 함수 매핑: replace의 두 번째 인자로 함수를 넘겨서, 매칭된 문자를 매핑 테이블에서 바로 찾아 치환한다. 조건문이나 분기 없이 O(1) 룩업으로 처리한다.

  3. 정규식 설계 차이: escape에서는 단순히 5개 문자를 문자 클래스([&<>"'])로 매칭하지만, unescape에서는 &amp; 같은 완전한 엔티티 패턴을 매칭한다. & 하나만 보고 치환하면 엔티티가 아닌 일반 &도 잘못 변환되기 때문이다.


이중 이스케이프 문제

실무에서 가장 흔하게 겪는 문제는 이중 이스케이프(double escaping)다.

javascript
import { escape, unescape } from 'html-escaper';

const original = 'Tom & Jerry';
const escaped = escape(original);  // 'Tom &amp; Jerry'

// 실수로 한 번 더 이스케이프
const doubleEscaped = escape(escaped);  // 'Tom &amp;amp; Jerry'

&amp;&가 다시 &amp;로 변환되어 &amp;amp;가 되어버린다. 이 문제는 데이터가 여러 레이어를 거칠 때 자주 발생한다:

  • RSS 피드 원본에서 이미 이스케이프된 데이터를 가져옴
  • 서버에서 한 번 더 이스케이프
  • 프론트엔드 템플릿 엔진이 또 한 번 이스케이프

해결 방법은 데이터 흐름에서 이스케이프 시점을 명확히 정하는 것이다:

javascript
// 패턴 1: 저장 시점에 원본 유지, 출력 시점에 이스케이프
const rawTitle = unescape(feedTitle);  // 원본 복원
db.save({ title: rawTitle });           // 원본으로 저장
// 렌더링 시 프레임워크가 자동 이스케이프 (React JSX 등)

// 패턴 2: 이미 이스케이프된 상태인지 확인
function safeEscape(str) {
  // 먼저 언이스케이프해서 원본으로 만들고, 다시 이스케이프
  return escape(unescape(str));
}

패턴 2는 간단하지만, unescape → escape를 매번 수행하는 오버헤드가 있다. 가능하면 패턴 1처럼 데이터 흐름 자체를 정리하는 게 낫다.


명명된 엔티티 처리

html-escaper는 5개의 기본 엔티티만 처리한다. 하지만 실제 HTML/XML 데이터에는 &middot;(·), &nbsp;(공백), &copy;(©) 같은 명명된 엔티티가 자주 등장한다.

javascript
import { unescape } from 'html-escaper';

unescape('Hello&middot;World');
// 'Hello&middot;World' — 변환되지 않음!

이런 경우 html-escaper만으로는 부족하고, 커스텀 변환 로직을 추가해야 한다:

javascript
function customUnescape(text) {
  const namedEntities = {
    '&middot;': '·',
    '&nbsp;': ' ',
    '&copy;': '©',
    '&mdash;': '—',
    '&ndash;': '–',
  };

  let result = text;
  for (const [entity, char] of Object.entries(namedEntities)) {
    result = result.replace(new RegExp(entity, 'g'), char);
  }
  return result;
}

// 기본 엔티티 + 명명된 엔티티 모두 처리
function fullUnescape(text) {
  return unescape(customUnescape(text));
}

또 다른 방법은 he 라이브러리를 사용하는 것이다. he는 HTML 스펙에 정의된 모든 명명된 엔티티(2,000개 이상)와 숫자 엔티티(&#8203; 등)를 처리한다. 다만 그만큼 번들 크기가 크므로, 5개 기본 엔티티만 필요한 상황에서는 html-escaper가 적합하다.


sanitize-html과의 차이

html-escaper와 sanitize-html은 전혀 다른 목적의 라이브러리다:

html-escapersanitize-html
목적문자 ↔ 엔티티 변환HTML 태그/속성 필터링
입력일반 문자열HTML 마크업
출력이스케이프된 문자열허용된 태그만 남은 HTML
XSS 방지모든 태그를 엔티티로 무력화위험한 태그만 제거
크기~0.5KB~50KB
javascript
import { escape } from 'html-escaper';
import sanitize from 'sanitize-html';

const input = '<b>Bold</b> <script>alert("xss")</script>';

// html-escaper: 모든 태그를 엔티티로 변환
escape(input);
// '&lt;b&gt;Bold&lt;/b&gt; &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'

// sanitize-html: 허용된 태그만 남기고 위험한 태그 제거
sanitize(input, { allowedTags: ['b'] });
// '<b>Bold</b> '

사용 시나리오:

  • 사용자 입력을 텍스트로 표시: html-escaper의 escape() → 모든 HTML이 텍스트로 보임
  • 사용자가 작성한 리치 텍스트를 렌더링: sanitize-html → 안전한 태그만 허용
  • 외부 데이터(RSS 등)의 엔티티를 원본으로 복원: html-escaper의 unescape()

Node.js vs 브라우저

html-escaper는 순수 JavaScript로 작성되어 Node.js와 브라우저 모두에서 동작한다. 하지만 브라우저에서는 DOM API로도 같은 작업이 가능하다:

javascript
// 브라우저 내장 방식
function browserEscape(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

function browserUnescape(str) {
  const div = document.createElement('div');
  div.innerHTML = str;
  return div.textContent;
}

이 방식은 DOM을 생성해야 하므로 Node.js 서버에서는 사용할 수 없다. 또한 innerHTML을 사용하는 unescape는 <script> 태그가 포함된 문자열에서 보안 위험이 있을 수 있다. 서버 사이드에서 안전하게 엔티티를 처리해야 한다면 html-escaper 같은 라이브러리가 필수적이다.


정리

  • 단일 정규식 + 매핑 테이블 방식으로 5개 HTML 엔티티를 한 패스에 변환하며, replace 체이닝의 순서 의존성과 이중 이스케이프 문제를 원천 차단한다
  • 이스케이프 시점을 데이터 흐름에서 한 곳으로 고정하는 게 핵심이고, 저장은 원본, 출력 시점에 이스케이프가 가장 깔끔하다
  • 명명된 엔티티(&middot; 등)가 필요하면 he 라이브러리, 태그 필터링이 필요하면 sanitize-html로 역할을 분리한다

관련 문서