컨텍스트가 전부다: 어떤 문자를 어디서 이스케이프하는가
이스케이프는 문자열의 속성이 아니라, 그 문자열이 도착하는 위치의 속성입니다. HTML에는 다섯 개의 구조 문자 — & < > " ' — 가 있지만, 렌더링 컨텍스트마다 문제가 되는 부분집합이 다릅니다. 요소 콘텐츠(태그 사이)에서 파싱을 바꿀 수 있는 건 &와 <뿐입니다. <는 태그를 열고, &는 character reference를 시작합니다. 큰따옴표 속성 값 안에서는 파서가 "에서만 값을 끝내므로 &와 "를 이스케이프해야 하고, 작은따옴표 속성은 &와 '가 필요합니다. 진짜 위험한 경우는 따옴표 없는(unquoted) 속성 값입니다. 스페이스, 탭, 개행, form feed에서 종료되고 /, =와도 나쁘게 상호작용하기 때문에, 'x onmouseover=alert(1)' 같은 순진해 보이는 payload가 다섯 문자 중 하나도 쓰지 않고 탈출합니다. 모든 보안 가이드가 '속성은 항상 따옴표로 감싸라'고 말하는 이유입니다 — 이스케이프 대상이 두 문자로 줄어듭니다.
엔티티 인코딩에는 명확한 한계도 있습니다. 엔티티 인코딩된 문자열도 <script> 블록 안에서는 여전히 완전히 위험합니다. HTML 파서는 script 데이터 안의 character reference를 디코딩하지 않지만, 문자열이 블록을 조기 종료시키거나 JS로 해석될 수 있기 때문입니다. href에 들어간 javascript: URL은 엔티티 인코딩을 완전히 통과합니다 — 브라우저가 URL을 해석하기 전에 엔티티를 디코딩하기 때문입니다. 인라인 이벤트 핸들러는 JS 파싱 위에 HTML 디코딩이 겹치므로 두 단계 이스케이프가 모두 필요합니다. OWASP XSS Prevention Cheat Sheet가 이 컨텍스트들을 정확히 열거합니다. 규칙은 데이터가 아니라 목적지에 맞는 인코더를 고르는 것입니다.
<!-- element content: escape & and < -->
<p>Tickets < $20 & free entry</p>
<!-- double-quoted attribute: escape & and " -->
<input value="say "hi" & wave">
<!-- UNQUOTED attribute: breakout with none of the five chars -->
<input value=x onmouseover=alert(1)>
<!-- entity encoding does NOT help here -->
<a href="javascript:alert(1)"> <!-- decoded before URL resolution -->Named 엔티티 vs 숫자 엔티티 — 그리고 XML 함정
WHATWG HTML Living Standard는 정확히 2,231개의 named character reference를 정의합니다 — & — 같은 주력부터 ∳ 같은 희귀종까지. 어떤 유니코드 code point든 숫자로도 쓸 수 있습니다. 10진수 →와 16진수 →는 둘 다 오른쪽 화살표를 만들고, 16진수 형태는 유니코드 차트에서 보는 U+2192 표기와 그대로 대응하므로 검증하기 더 쉽습니다.
함정은 이 2,231개 이름이 HTML 전용 어휘라는 점입니다. XML은 딱 다섯 개만 사전 정의합니다: & < > " '. 를 XML 파서에 먹이면 — application/xhtml+xml로 서빙되는 XHTML 페이지, RSS/Atom 피드, 독립 XML로 처리되는 SVG 파일 — 경고가 아니라 치명적인 파스 에러가 납니다. 이식 가능한 표기는  입니다. 반대 방향으로, '는 XML과 HTML5에는 정의되어 있지만 HTML 4에는 없었기 때문에 오래된 파서와 일부 이메일 렌더러는 문자 그대로 표시합니다. 어디서나 통하는 어퍼스트로피는 '이고, PHP의 htmlspecialchars 같은 직렬화기가 ' 대신 '를 내놓는 이유입니다.
직접 디코딩할 때는 named reference가 대소문자를 구분한다는 점을 기억하세요. Ä은 Ä이고 ä은 ä이며, &는 존재하지만 &AMp;는 없습니다. 테이블 조회 전에 입력을 소문자로 바꾸는 디코더는 텍스트를 조용히 훼손합니다.
Rightwards arrow (U+2192):
named: → (HTML only)
decimal: →
hex: → (matches U+2192)
Safe everywhere (HTML + XML/RSS/SVG):
& < > " '  
Fatal parse error in XML: — © ...세미콜론 누락, URL 속의 &, 그리고 이중 인코딩
HTML 파서는 세미콜론 없이 쓰인 레거시 named reference 목록 — &, <, © 등 — 을 여전히 인식합니다. HTML5 이전 콘텐츠가 여기에 의존했기 때문입니다. 이 관용이 고전적인 URL 버그를 만들었습니다. ?page=1©=2에서 © 시퀀스가 역사적으로 ©로 디코딩되어 쿼리 스트링을 조용히 훼손했습니다. 현대 스펙은 속성 값 예외(레거시 reference 뒤에 = 또는 영숫자가 오면 그대로 둠)로 이를 고쳤지만, 교훈은 남았습니다. href나 src 안의 모든 raw &는 &로 써야 합니다. 검증기가 URL의 맨 앰퍼샌드를 여전히 지적하는 이유가 정확히 이것이고, 브라우저는 요청을 보내기 전에 &를 &로 되돌리므로 비용도 없습니다.
이중 인코딩은 반대 방향의 실패입니다. <를 한 번 더 인코딩하면 &lt;가 되고, 이는 < 대신 문자 그대로의 < 텍스트로 렌더링됩니다. 페이지에서 &quot;나 &amp;를 본다면 두 레이어가 각각 '친절하게' 이스케이프한 것입니다. 보통은 이미 손으로 인코딩한 출력을 템플릿 엔진이 다시 자동 이스케이프했거나, DB에 쓰기 전에 엔티티 인코딩한 HTML을 렌더링 시점에 또 인코딩한 경우입니다. 이 클래스 전체를 막는 황금률: 원본 텍스트를 저장하고, 출력 시점에, 목적지 컨텍스트에 맞는 인코더로, 정확히 한 번만 인코딩하세요. 이 도구 같은 디코더는 망가진 문자열이 몇 겹인지 진단하는 가장 빠른 방법입니다 — 출력이 더 이상 변하지 않을 때까지 반복 디코딩하며 횟수를 세면 됩니다.
?a=1©=2 legacy parsing: "a=1©=2" (broken)
?a=1&copy=2 safe: browser requests "a=1©=2"
"<b>" encoded once → "<b>" (correct)
encoded twice → "&lt;b&gt;" (renders "<b>")UTF-8 시대, 엔티티는 생각보다 훨씬 덜 필요하다
페이지가 <meta charset="utf-8">를 선언한다면 — 2026년이라면 당연히 그래야 하고, 웹의 98% 이상이 UTF-8로 서빙됩니다 — em dash, 둥근 따옴표, 화살표, CJK, 이모지에 엔티티가 필요 없습니다. — 대신 —를 직접 입력하면 소스가 작아지고(7바이트 대신 1~3바이트), 더 중요하게는 읽을 수 있게 됩니다. →로 가득한 diff는 리뷰가 불가능합니다. 여전히 엔티티가 필요한 문자는 두 그룹입니다. 첫째, 구조 문자 다섯 개 — 영원히. 둘째, 에디터에서 보이지 않거나 구별 불가능해서 리터럴로 남기기 위험한 문자들: no-break space( — 리터럴 U+00A0은 일반 스페이스와 똑같이 보여서 다음 사람의 에디터가 '고쳐'버립니다), soft hyphen ­, 이모지 시퀀스와 페르시아어·인도계 문자 조합에 쓰이는 zero-width joiner ‍와 zero-width non-joiner ‌, 그리고 bidi 제어 문자. 이런 문자를 엔티티로 쓰는 것 자체가 문서화입니다.
한 가지 주의: —를 입력했는데 페이지에 — 같은 깨진 문자가 보인다면 파일이 잘못된 인코딩으로 저장되거나 서빙된 것입니다. 엔티티는 그 버그를 가릴 뿐이고, charset을 고치는 게 진짜 치료입니다 — —로 덮어둔 mojibake는 누군가 non-ASCII 콘텐츠를 붙여넣는 순간 다시 터집니다.
textContent와 innerHTML, 그리고 새니타이저가 필요한 순간
DOM에서 가장 안전한 인코더는 아예 호출하지 않는 인코더입니다. element.textContent = userInput은 문자열을 순수 텍스트로 삽입합니다 — 파싱도 인코딩도 필요 없고, 어떤 중첩 깊이에서도 안전합니다. element.innerHTML = userInput은 교과서적인 XSS sink입니다. 문자열이 HTML 파서를 통째로 거치므로 그 안의 마크업이 전부 실행됩니다. (setAttribute도 값에 대해서는 마찬가지로 안전하지만 속성 자체가 중요합니다 — setAttribute('href', 'javascript:...')는 여전히 살아있는 URL입니다.)
템플릿 프레임워크는 이 교훈을 내재화했습니다. Svelte의 {expression}, React의 JSX, Vue의 이중 중괄호 보간은 모두 기본으로 이스케이프됩니다. 그래서 컴포넌트에 넘기기 전에 데이터를 손으로 인코딩하는 건 안전 강화가 아니라 이중 인코딩 버그입니다. 각 프레임워크에는 명시적 탈출구도 있습니다 — Svelte의 {@html ...}, React의 dangerouslySetInnerHTML, Vue의 v-html. 일부러 불편하게 지은 이름들이 책임이 당신에게 넘어왔음을 표시합니다.
여기서 마지막 구분이 나옵니다. 인코딩과 새니타이징은 다른 문제를 풉니다. 인코딩은 마크업을 보이는 텍스트로 바꿉니다 — 사용자가 HTML을 입력했고 그걸 그대로 봐야 할 때 정답입니다. 새니타이징(DOMPurify, 또는 부상 중인 네이티브 Sanitizer API)은 마크업을 파싱해 안전한 태그의 allowlist만 남기고 스크립트 벡터를 제거합니다 — 사용자가 정당하게 리치 텍스트를 작성했고 그것이 마크업으로 렌더링되어야 할 때 정답입니다. 댓글 필드는 인코딩하고 WYSIWYG 게시물 본문은 새니타이징하는 게 둘 다 옳으며, 서로 바꾸면 표시가 망가지거나 구멍이 뚫립니다.
// safe: never parsed as markup
el.textContent = userInput;
// XSS sink: parsed and executed
el.innerHTML = userInput;
// frameworks escape by default; escape hatches opt out
// Svelte: {comment.text} vs {@html post.trustedHtml}
// React: {comment.text} vs dangerouslySetInnerHTML
// Vue: interpolation vs v-html
// rich text: sanitize, don't encode
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml);