html-escaper
HTML 문서에는 <, >, &, ", ' 같은 특수 문자가 태그 구문으로 해석되는 문제가 있다. 예를 들어 RSS 피드에서 가져온 블로그 제목에 &이 들어 있으면, 이걸 그대로 화면에 렌더링하면 &가 아니라 &이라는 문자열이 보인다. 반대로 사용자가 입력한 텍스트에 <script>가 포함되어 있으면, 이스케이프 없이 HTML에 삽입하면 XSS 공격이 가능해진다.
이 문제를 다루는 방법은 두 가지다:
- 이스케이프(escape): 특수 문자를 HTML 엔티티로 변환 (
<→<) - 언이스케이프(unescape): HTML 엔티티를 원래 문자로 복원 (
<→<)
html-escaper는 이 두 가지 변환만을 담당하는 초경량 라이브러리다. sanitize-html처럼 태그를 필터링하거나 DOM을 파싱하는 게 아니라, 순수하게 5개의 HTML 엔티티 문자만 변환한다.
왜 직접 구현하지 않는가
이스케이프 로직은 간단해 보인다. replace 몇 번이면 될 것 같다.
// 흔한 실수: 순서 문제
function naiveEscape(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
이 코드 자체는 동작한다. 하지만 문제가 되는 건 순서다. &를 먼저 변환하지 않으면 이미 변환된 <의 &가 다시 &lt;로 이중 이스케이프된다. 반대로 언이스케이프할 때도 순서를 잘못 잡으면 &lt;가 <가 아니라 <로 잘못 복원된다.
html-escaper는 replace 체이닝 대신 단일 정규식 + 매핑 테이블 방식을 사용해서 이런 순서 문제를 원천 차단한다.
설치와 기본 사용법
npm install html-escaper
API는 딱 두 개다:
import { escape, unescape } from 'html-escaper';
// 이스케이프: 특수 문자 → HTML 엔티티
escape('<div class="hello">Tom & Jerry</div>');
// '<div class="hello">Tom & Jerry</div>'
// 언이스케이프: HTML 엔티티 → 원래 문자
unescape('<div class="hello">Tom & Jerry</div>');
// '<div class="hello">Tom & Jerry</div>'
변환 대상 문자
html-escaper가 처리하는 문자는 HTML 스펙에서 정의한 5개의 특수 문자뿐이다:
| 원래 문자 | HTML 엔티티 | 설명 |
|---|---|---|
& | & | 엔티티 시작 구분자 |
< | < | 태그 시작 |
> | > | 태그 끝 |
" | " | 속성값 구분자 (쌍따옴표) |
' | ' | 속성값 구분자 (홑따옴표) |
·(·)이나 (공백) 같은 명명된 엔티티(named entity)는 변환 대상이 아니다. 이런 엔티티까지 처리해야 한다면 별도 로직을 추가해야 한다.
내부 동작 원리
html-escaper의 핵심은 정규식 하나와 매핑 객체의 조합이다. 소스 코드를 살펴보면 구조가 매우 단순하다:
// escape 내부 구현 (개념)
const ca = /[&<>"']/g; // 5개 문자를 한 번에 매칭
const esca = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
function escape(str) {
return str.replace(ca, (m) => esca[m]);
}
// unescape 내부 구현 (개념)
const pe = /&(?:amp|lt|gt|quot|#39);/g;
const unes = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
''': "'"
};
function unescape(str) {
return str.replace(pe, (m) => unes[m]);
}
핵심 포인트:
-
단일 정규식 패스:
replace체이닝이 아니라 하나의 정규식으로 모든 대상 문자를 한 번에 찾는다. 이렇게 하면 이미 변환된 문자가 다음 replace에 의해 이중 변환되는 문제가 발생하지 않는다. -
콜백 함수 매핑:
replace의 두 번째 인자로 함수를 넘겨서, 매칭된 문자를 매핑 테이블에서 바로 찾아 치환한다. 조건문이나 분기 없이 O(1) 룩업으로 처리한다. -
정규식 설계 차이: escape에서는 단순히 5개 문자를 문자 클래스(
[&<>"'])로 매칭하지만, unescape에서는&같은 완전한 엔티티 패턴을 매칭한다.&하나만 보고 치환하면 엔티티가 아닌 일반&도 잘못 변환되기 때문이다.
이중 이스케이프 문제
실무에서 가장 흔하게 겪는 문제는 이중 이스케이프(double escaping)다.
import { escape, unescape } from 'html-escaper';
const original = 'Tom & Jerry';
const escaped = escape(original); // 'Tom & Jerry'
// 실수로 한 번 더 이스케이프
const doubleEscaped = escape(escaped); // 'Tom &amp; Jerry'
&의 &가 다시 &로 변환되어 &amp;가 되어버린다. 이 문제는 데이터가 여러 레이어를 거칠 때 자주 발생한다:
- RSS 피드 원본에서 이미 이스케이프된 데이터를 가져옴
- 서버에서 한 번 더 이스케이프
- 프론트엔드 템플릿 엔진이 또 한 번 이스케이프
해결 방법은 데이터 흐름에서 이스케이프 시점을 명확히 정하는 것이다:
// 패턴 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 데이터에는 ·(·), (공백), ©(©) 같은 명명된 엔티티가 자주 등장한다.
import { unescape } from 'html-escaper';
unescape('Hello·World');
// 'Hello·World' — 변환되지 않음!
이런 경우 html-escaper만으로는 부족하고, 커스텀 변환 로직을 추가해야 한다:
function customUnescape(text) {
const namedEntities = {
'·': '·',
' ': ' ',
'©': '©',
'—': '—',
'–': '–',
};
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개 이상)와 숫자 엔티티(​ 등)를 처리한다. 다만 그만큼 번들 크기가 크므로, 5개 기본 엔티티만 필요한 상황에서는 html-escaper가 적합하다.
sanitize-html과의 차이
html-escaper와 sanitize-html은 전혀 다른 목적의 라이브러리다:
| html-escaper | sanitize-html | |
|---|---|---|
| 목적 | 문자 ↔ 엔티티 변환 | HTML 태그/속성 필터링 |
| 입력 | 일반 문자열 | HTML 마크업 |
| 출력 | 이스케이프된 문자열 | 허용된 태그만 남은 HTML |
| XSS 방지 | 모든 태그를 엔티티로 무력화 | 위험한 태그만 제거 |
| 크기 | ~0.5KB | ~50KB |
import { escape } from 'html-escaper';
import sanitize from 'sanitize-html';
const input = '<b>Bold</b> <script>alert("xss")</script>';
// html-escaper: 모든 태그를 엔티티로 변환
escape(input);
// '<b>Bold</b> <script>alert("xss")</script>'
// 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로도 같은 작업이 가능하다:
// 브라우저 내장 방식
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 체이닝의 순서 의존성과 이중 이스케이프 문제를 원천 차단한다
- 이스케이프 시점을 데이터 흐름에서 한 곳으로 고정하는 게 핵심이고, 저장은 원본, 출력 시점에 이스케이프가 가장 깔끔하다
- 명명된 엔티티(
·등)가 필요하면he라이브러리, 태그 필터링이 필요하면 sanitize-html로 역할을 분리한다