HTML 엔티티: 왜 필요하고 어디까지 변환해야 하는가
HTML 엔티티는 브라우저가 마크업으로 해석할 문자를 '글자 그대로 보여 달라'고 표시하는 방법입니다. < 는 화면에 < 로 렌더링되지만 태그의 시작으로 취급되지 않습니다. 이 구분은 단순한 표기 문제가 아니라 XSS 방어의 핵심 원리이기도 합니다.
반드시 변환해야 하는 다섯 문자
HTML에서 특별한 의미를 갖는 문자는 사실 몇 개뿐입니다. 이 다섯 개만 정확히 처리하면 대부분의 문맥에서 안전합니다.
| 문자 | 엔티티 | 숫자 참조 | 변환해야 하는 이유 |
|---|---|---|---|
| < | < | < | 태그의 시작으로 해석됨 |
| > | > | > | 태그의 끝으로 해석됨 |
| & | & | & | 엔티티의 시작으로 해석됨 |
| " | " | " | 속성 값을 감싼 큰따옴표를 조기 종료시킴 |
| ' | ' | ' | 속성 값을 감싼 작은따옴표를 조기 종료시킴 |
' 를 쓰지 않는 이유
작은따옴표의 이름 있는 엔티티 ' 는 HTML5와 XML에는 정의되어 있지만 HTML4에는 없습니다. 그래서 오래된 파서나 일부 이메일 클라이언트에서는 그대로 문자열 ' 로 표시될 수 있습니다. 숫자 참조 ' 는 모든 버전에서 동작하므로, 라이브러리들이 관례적으로 ' 를 씁니다.
사용 방법
- 변환할 텍스트, 또는 되돌릴 엔티티가 포함된 문자열을 입력합니다.
- Encode는 특수문자를 엔티티로 바꾸고, Decode는 엔티티를 원래 문자로 되돌립니다.
- HTML 소스에 붙여 넣을 문자열이라면 Encode 결과를 사용합니다.
- 웹에서 긁어온 데이터에 &lt; 같은 값이 섞여 있다면 Decode로 정리합니다.
이스케이프는 문맥에 따라 달라진다
엔티티 변환은 'HTML 본문' 문맥에서만 충분합니다. 같은 값이 다른 문맥에 들어가면 다른 규칙이 필요하고, 이를 혼동하면 이스케이프를 했는데도 취약점이 남습니다.
- HTML 본문: 위 다섯 문자를 엔티티로. 이 도구가 하는 일입니다.
- 속성 값: 따옴표로 반드시 감싸고 그 따옴표 문자를 이스케이프. 따옴표 없이 쓰면 공백만으로도 새 속성을 주입할 수 있습니다.
- <script> 내부: 엔티티가 해석되지 않습니다. JavaScript 문자열 이스케이프나 JSON.stringify를 써야 하며, </script> 시퀀스도 따로 막아야 합니다.
- URL 속성(href, src): 엔티티만으로는 javascript: 스킴을 막지 못합니다. 스킴 허용 목록 검증이 필요합니다.
- CSS 안: 별도의 CSS 이스케이프 규칙을 씁니다.
이중 인코딩 문제
&lt; 처럼 &가 한 번 더 인코딩된 형태를 흔히 보게 됩니다. 이는 이미 엔티티로 변환된 문자열을 다시 변환했을 때 생깁니다. 화면에는 < 라는 글자가 그대로 나타나 사용자 눈에 바로 띕니다.
원인은 대개 계층이 겹치는 것입니다. 템플릿 엔진이 자동으로 이스케이프하는데 애플리케이션 코드에서 한 번 더 이스케이프하거나, DB에 이스케이프된 값을 저장한 뒤 출력 시 또 이스케이프하는 경우입니다. 원칙은 '저장은 원본으로, 이스케이프는 출력 직전에 한 번만'입니다. React, Vue, Django, Rails 같은 현대 프레임워크는 기본적으로 자동 이스케이프하므로 수동 변환을 덧붙일 필요가 없습니다.
이런 곳에 씁니다
- 코드 예제를 블로그 글에 넣을 때 태그가 실제로 렌더링되지 않게 처리
- 웹 스크래핑 결과에 남은 &#39; 같은 잔여물 정리
- 이메일 HTML 템플릿에 사용자 이름 등 동적 값을 삽입
- CSV나 XML로 내보낼 데이터에서 &와 < 처리
- 레거시 시스템이 남긴 이중 인코딩 데이터의 원본 복구
자주 묻는 질문
- 엔티티 변환만 하면 XSS를 막을 수 있나요?
- HTML 본문에 텍스트를 넣는 문맥에서는 대체로 충분하지만, 그것만으로 XSS 전체가 막히지는 않습니다. 속성 값, <script> 내부, href의 URL, 인라인 CSS는 각각 다른 이스케이프 규칙을 요구합니다. 게다가 사용자가 제공한 HTML을 그대로 렌더링해야 하는 기능(리치 텍스트 에디터 등)이라면 이스케이프가 아니라 DOMPurify 같은 새니타이저로 허용 태그만 남기는 방식이 맞습니다.
- 는 그냥 공백과 다른가요?
- 다릅니다. non-breaking space(U+00A0)는 줄바꿈이 일어나지 않는 공백이고, 브라우저가 연속된 일반 공백을 하나로 합치는 것과 달리 그대로 유지됩니다. 그래서 간격 조절에 남용되곤 하는데, 간격은 CSS margin이나 padding으로 처리하는 편이 옳습니다. 또 텍스트 비교나 검색에서 일반 공백과 다른 문자로 취급되어 버그의 원인이 되기도 합니다.
- 이름 있는 엔티티와 숫자 참조 중 무엇을 쓸까요?
- HTML 문서라면 < 같은 이름 엔티티가 읽기 쉽습니다. XML이나 그 계열 형식으로 내보낼 때는 < 같은 숫자 참조가 안전합니다. XML이 기본으로 정의하는 이름 엔티티는 lt, gt, amp, quot, apos 다섯 개뿐이고 등은 정의되어 있지 않아 파싱 오류가 납니다.
- 한글이나 이모지도 엔티티로 바꿔야 하나요?
- 필요 없습니다. 문서가 UTF-8이고 <meta charset="utf-8">이 선언되어 있다면 한글, 일본어, 이모지 모두 그대로 쓰면 됩니다. 엔티티로 바꾸면 용량만 크게 늘고 가독성이 떨어집니다. 문자 인코딩을 지정할 수 없는 아주 오래된 환경에서만 고려할 문제입니다.
- Decode하면 원래 문자열이 정확히 복원되나요?
- 대개 그렇지만 완전한 왕복 보장은 아닙니다. 예를 들어 A 와 A 는 디코딩 후 같아지므로, 어느 쪽이 원본이었는지는 알 수 없습니다. 또 이중 인코딩된 문자열은 몇 번 디코딩해야 원본인지 데이터만 보고 판단하기 어렵습니다.
- React에서 직접 이스케이프해야 하나요?
- 아닙니다. JSX에 넣은 문자열은 React가 자동으로 이스케이프합니다. 오히려 수동으로 엔티티를 넣으면 < 라는 글자가 화면에 그대로 보입니다. 예외는 dangerouslySetInnerHTML을 쓸 때인데, 이때는 이스케이프가 아니라 새니타이징이 필요합니다.
💡 참고: 화면에 < 나 & 같은 글자가 그대로 보인다면 이스케이프가 두 번 적용된 것입니다. 이스케이프를 하는 계층이 몇 곳인지부터 확인하세요.