~ / posts / developer

HTML 엔티티 < &   뜻과 이스케이프·XSS 방어

HTML 엔티티는 < & " 같은 문자를 &lt; &amp; &quot;로 바꿔 글자로 보이게 하는 표기예요. 언제 이스케이프하는지, 본문·속성·JS·URL별 규칙, XSS 구조, innerHTML과 textContent 차이, &nbsp; 남용까지 정리했어요.

게시판에 <b>안녕</b>이라고 썼는데 굵은 글씨가 돼 버리거나, 반대로 화면에 &lt;b&gt;가 그대로 찍혀 나온 적 있나요? 둘 다 HTML 엔티티와 이스케이프를 어긋나게 처리해서 생기는 일이에요. 결론부터 말하면 HTML 엔티티는 < > & " '처럼 HTML 문법에 쓰이는 문자를 &lt; &gt; &amp; &quot; &#39;로 바꿔 "태그가 아닌 글자"로 보이게 하는 표기이고, 사용자가 입력한 값을 HTML에 넣을 때는 반드시 이 이스케이프를 거쳐야 XSS를 막을 수 있어요. 다만 이스케이프 규칙은 값이 들어가는 자리(본문, 속성, 자바스크립트, URL)마다 달라서, 한 가지 함수로 다 해결된다고 생각하면 구멍이 생깁니다.

이 글에서는 엔티티의 문법부터 시작해서, 언제 이스케이프해야 하는지, 자리별로 무엇이 다른지, XSS가 실제로 어떻게 생기는지, innerHTML과 textContent의 차이, 그리고 에디터가 만들어 내는 &nbsp; 덩어리가 왜 문제인지까지 실제 실행 결과와 함께 정리했어요.

핵심 요약

HTML 엔티티는 &이름; 또는 &#숫자; 형태로 문자를 표기하는 방법이에요. 꼭 알아야 할 건 &lt; &gt; &amp; &quot; &#39; 다섯 개와 줄바꿈 안 되는 공백 &nbsp;이에요.

사용자 입력은 저장할 때가 아니라 화면에 출력하는 순간, 들어가는 자리에 맞춰 한 번만 이스케이프해요.

HTML 본문은 엔티티, 자바스크립트 안은 JSON.stringify, URL 값은 encodeURIComponent, 링크 주소는 http/https 스킴 검사까지 필요해요.

텍스트를 넣을 땐 innerHTML 대신 textContent를 쓰면 이스케이프 실수 자체가 사라져요.

HTML 엔티티란? &lt; &amp; &nbsp;의 뜻

HTML에서 <는 "여기서 태그가 시작된다"는 뜻이고, &는 "여기서 엔티티가 시작된다"는 뜻이에요. 그래서 이 문자들을 글자 그대로 보여 주려면 다른 방법으로 적어야 하는데, 그게 엔티티(정식 명칭은 문자 참조, character reference)입니다. 형식은 세 가지예요.

형식예의미
이름 참조&lt;미리 정해진 이름
10진 숫자 참조&#60;유니코드 코드 포인트 60
16진 숫자 참조&#x3C;코드 포인트 0x3C

세 가지 모두 브라우저에서 < 한 글자로 보여요. 숫자 참조를 쓰면 이론상 모든 유니코드 문자를 적을 수 있어서, "가"는 &#44032; 또는 &#xAC00;이 됩니다(U+AC00). 이름 참조는 HTML 표준 목록에 정해진 것만 쓸 수 있는데, WHATWG 명세의 목록은 2,231개(세미콜론이 붙은 정식 이름은 2,125개)나 돼요. 하지만 실무에서 외워야 할 건 몇 개뿐이에요.

문자엔티티숫자 참조언제 필요한가
<&lt;&#60;본문·속성 어디서든
>&gt;&#62;관례상 같이 바꿈
&&amp;&#38;본문·속성 어디서든
"&quot;&#34;큰따옴표로 감싼 속성 안
'&#39;&#39;작은따옴표로 감싼 속성 안
줄바꿈 안 되는 공백&nbsp;&#160;숫자와 단위를 붙여 둘 때
©&copy;&#169;키보드로 입력하기 어려울 때

작은따옴표의 &apos;는 HTML5에서는 유효하지만 옛 HTML4에는 없어서, 호환성 때문에 &#39;를 쓰는 라이브러리가 많아요. 파이썬 html.escape는 &#x27;를 씁니다. 셋 다 같은 문자예요.

UTF-8 시대에도 엔티티가 필요할까

예전에는 EUC-KR 페이지에 일본어나 기호를 넣으려고 &#12354; 같은 숫자 참조를 썼어요. 지금은 페이지를 UTF-8로 쓰면 한글, 이모지, ©까지 그냥 입력하면 돼요. 오히려 엔티티로 쓰면 파일이 커집니다.

문자를 그대로 쓸 때와 10진 숫자 참조로 쓸 때의 바이트 수
UTF-8 그대로숫자 참조
02.557.510가 · UTF-8 그대로 3바이트가 · 숫자 참조 8바이트가© · UTF-8 그대로 2바이트© · 숫자 참조 6바이트©😀 · UTF-8 그대로 4바이트😀 · 숫자 참조 9바이트😀& · UTF-8 그대로 1바이트& · 숫자 참조 5바이트&< · UTF-8 그대로 1바이트< · 숫자 참조 5바이트<

& 와 < 는 그대로 쓰면 문법이 깨지니 이스케이프가 필수, 나머지는 UTF-8로 그냥 쓰는 게 이득

단위: 바이트 · 자료: 파이썬으로 UTF-8 바이트 수와 &#코드포인트; 문자열 길이를 직접 계산
표로 보기
구분UTF-8 그대로숫자 참조
가38
©26
😀49
&15
<15

그러니 지금 엔티티가 꼭 필요한 건 HTML 문법과 겹치는 < & " ', 그리고 눈에 안 보이는 공백류(&nbsp;)를 명시하고 싶을 때 정도예요. 한글 바이트 구조가 궁금하면 UTF-8과 EUC-KR 한글 바이트 수를 참고하세요.

언제 이스케이프해야 할까

원칙은 하나예요. 신뢰할 수 없는 문자열(사용자 입력, DB 값, 외부 API 응답, URL 파라미터)을 HTML 문서에 끼워 넣는 모든 순간. 그리고 그 시점은 저장할 때가 아니라 출력할 때예요.

저장할 때 미리 이스케이프하면 문제가 두 가지 생겨요.

  1. 이중 이스케이프: DB에 &lt;b&gt;로 저장해 뒀는데 템플릿 엔진이 출력할 때 또 이스케이프하면 &amp;lt;b&amp;gt;가 되고, 화면에는 &lt;b&gt;라는 글자가 보여요. 게시판에 &amp;나 &quot;가 그대로 찍히는 버그 대부분이 이거예요.
  2. 다른 출력 경로: 같은 값을 JSON API, CSV, 앱 푸시로도 내보내는데, 거기엔 HTML 엔티티가 의미가 없어서 &lt;가 그대로 노출됩니다.

그래서 DB에는 원문 그대로, 출력하는 순간 그 자리 규칙에 맞게 한 번이 정답이에요. 이 "마지막에 한 번만" 원칙은 URL 인코딩과 똑같아요.

HTML 이스케이프 전후 글자 수
원문이스케이프 후
010203040<script> · 원문 8자<script> · 이스케이프 후 14자<script>Tom & Jerry · 원문 11자Tom & Jerry · 이스케이프 후 15자Tom & Jerry<b>"안녕"</b> · 원문 11자<b>"안녕"</b> · 이스케이프 후 33자<b>"안녕"</b>a<b && c>d · 원문 10자a<b && c>d · 이스케이프 후 24자a<b && c>d

한 번 더 이스케이프하면 &가 &amp;로 바뀌며 또 늘어나요 (이중 이스케이프)

단위: 자 · 자료: 파이썬 html.escape로 직접 계산
표로 보기
구분원문이스케이프 후
<script>814
Tom & Jerry1115
<b>"안녕"</b>1133
a<b && c>d1024

컨텍스트별 이스케이프: 본문·속성·JS·URL

같은 값이라도 HTML 문서 안의 어디에 들어가느냐에 따라 위험한 문자가 달라요. 브라우저는 HTML 파서, 자바스크립트 파서, URL 파서가 차례로 문자열을 해석하는데, 각 파서마다 "특별한 문자"가 다르기 때문이에요.

사용자 입력이 HTML 본문, 속성값, script 안 자바스크립트, href URL 네 자리에 들어갈 때 각각 필요한 이스케이프 방법과 막아야 할 문자를 비교한 카드
값이 들어가는 자리마다 규칙이 달라요

1) HTML 본문

<p>{{ 값 }}</p> 자리예요. <와 &만 바꿔도 태그가 생기지 않지만, 관례상 < > & " ' 다섯 개를 모두 바꿔요. 대부분의 템플릿 엔진(Jinja2, Thymeleaf의 th:text, Blade의 {{ }}, JSX)이 기본으로 해 주는 게 이 처리예요.

2) HTML 속성값

<input value="{{ 값 }}"> 자리예요. 여기서는 따옴표가 핵심이에요. 값에 "가 있으면 속성이 끝나 버리니까 &quot;로 바꿔야 하고, 속성은 항상 따옴표로 감싸야 해요. 따옴표 없는 속성 value={{ 값 }}에 x onfocus=alert(1) autofocus가 들어가면, 공백에서 속성이 끝나고 새 이벤트 속성이 생깁니다. 다섯 문자를 이스케이프해도 공백은 그대로라 막을 수 없어요.

3) 자바스크립트 안

<script>const name = "{{ 값 }}";</script> 자리는 가장 헷갈리는 곳이에요. <script> 안은 HTML 파서가 엔티티를 해석하지 않아서, &quot;로 바꾸면 자바스크립트 문자열에 &quot;라는 글자가 그대로 들어가요. 게다가 문자열 안이어도 </script>가 나오면 HTML 파서가 거기서 스크립트를 끝내 버립니다.

// 안전한 방법: JSON으로 직렬화하면서 < > & ' 를 \u003c 같은 이스케이프로 바꿔 넣기
// (Jinja2의 tojson 필터가 이 처리를 해 줘요)
const data = {{ data | tojson }};
// 더 나은 방법: HTML 속성에 담고 JS에서 읽기
// <div id="app" data-name="{{ 값 (속성 이스케이프) }}"></div>
const name = document.getElementById('app').dataset.name;

onclick="..." 같은 인라인 이벤트 속성은 더 까다로워요. 브라우저가 속성값의 엔티티를 먼저 풀고 그다음 자바스크립트로 실행하기 때문에, &#39;로 바꾼 작은따옴표가 실행 시점엔 진짜 '가 돼요. 인라인 핸들러에 사용자 값을 넣지 않는 게 최선입니다.

4) URL 자리

<a href="/search?q={{ 값 }}">처럼 URL 일부에 들어가는 값은 먼저 encodeURIComponent(퍼센트 인코딩)를 하고, 그 결과를 속성 이스케이프해요. 그리고 href="{{ 값 }}"처럼 URL 전체를 사용자가 정하는 경우엔 이스케이프로는 못 막는 공격이 있어요. javascript:alert(1)에는 이스케이프할 문자가 하나도 없거든요. 이때는 http:나 https:로 시작하는지 허용 목록으로 검사해야 합니다.

자리해야 할 처리이스케이프만으로 안 되는 것
HTML 본문< > & " ' → 엔티티—
속성값엔티티 + 따옴표로 감싸기따옴표 없는 속성
script 안JSON.stringify + < → <인라인 이벤트 핸들러
URL 일부encodeURIComponent 후 속성 이스케이프—
URL 전체(href, src)스킴 허용 목록 검사javascript: data:
style 안사용자 값 넣지 않기대부분

XSS 기본: 이스케이프를 빼먹으면 생기는 일

XSS(Cross-Site Scripting)는 공격자가 넣은 문자열이 남의 페이지에서 코드로 실행되는 취약점이에요. 실행되면 그 사이트 권한으로 무엇이든 할 수 있어요. 화면을 바꾸고, 사용자를 대신해 글을 쓰고, localStorage에 있는 토큰을 가져갑니다. 토큰 저장 위치가 중요한 이유는 JWT 보안 실수에서 다뤘어요.

유형은 세 가지로 나눠요.

흔한 오해가 "<script> 태그만 막으면 된다"는 거예요. <img src=x onerror=alert(1)>는 script 태그가 없지만, 이미지 로드가 실패하면서 onerror가 실행돼요. <svg onload=...>, <a href="javascript:...">도 마찬가지고요. 정규식으로 위험한 태그를 걸러내는 방식은 거의 항상 우회돼요. 대소문자, 공백, 인코딩 변형이 끝도 없거든요. 정규식 검증의 한계는 정규식 검증의 함정에 정리했어요.

innerHTML vs textContent 차이

프런트엔드에서 DOM 기반 XSS를 막는 가장 쉬운 방법은 이거예요.

const comment = '<img src=x onerror=alert(1)>';
el.innerHTML = comment;    // 이미지 태그가 만들어지고 onerror 실행
el.textContent = comment;  // 화면에 <img src=x onerror=alert(1)> 글자가 그대로 보임

textContent(또는 innerText)는 넣은 문자열을 무조건 텍스트로 취급해서, 브라우저가 알아서 엔티티 처리를 한 것과 같은 결과를 내요. 이스케이프 함수를 직접 부를 필요조차 없어요. 참고로 innerHTML로 넣은 <script> 태그는 HTML 표준상 실행되지 않지만, 위처럼 이벤트 속성은 실행되니 안심할 수 없어요.

방법태그 해석XSS 위험용도
textContent안 함없음사용자 텍스트 표시 (기본값으로)
innerText안 함없음위와 같음, 레이아웃 계산 비용 있음
innerHTML함높음신뢰할 수 있는 HTML만
createElement + setAttribute직접 조립낮음 (href 스킴은 검사)동적 요소 생성
DOMPurify 후 innerHTML허용 태그만낮음에디터 HTML 표시

프레임워크도 기본은 안전해요. React의 JSX {값}, Vue의 {{ 값 }}는 자동으로 텍스트 처리해요. 위험한 건 이름부터 경고하는 dangerouslySetInnerHTML, Vue의 v-html 같은 탈출구예요. 위지윅 에디터처럼 HTML을 꼭 보여 줘야 하면 DOMPurify 같은 검증된 새니타이저로 허용 태그만 남기고, 서버에서도 한 번 더 걸러요. 추가 방어선으로 CSP(Content-Security-Policy) 헤더를 걸어 인라인 스크립트 실행을 막아 두면, 이스케이프를 한 군데 빼먹어도 피해를 줄일 수 있어요.

&nbsp; 남용 문제: 공백이 아닌 공백

&nbsp;(non-breaking space, U+00A0)는 "여기서 줄을 바꾸지 마"라는 뜻의 공백이에요. HTML은 연속 공백을 하나로 합치기 때문에 공백을 여러 칸 넣고 싶을 때 &nbsp;&nbsp;&nbsp;를 쓰는 습관이 생겼는데, 이게 여러 문제를 만들어요.

워드나 블로그 에디터에서 붙여 넣은 HTML에 &nbsp;가 수십 개씩 들어 있는 것도 흔해요. 간격은 CSS(margin, padding, gap)로, 공백을 그대로 보존해야 하면 white-space: pre-wrap으로 처리하고, &nbsp;는 10&nbsp;kg, 제&nbsp;3조처럼 떨어지면 어색한 짧은 묶음에만 쓰는 게 좋아요. 서버에서 입력을 정리할 땐 U+00A0을 일반 공백으로 바꾸는 정규화 단계를 넣어 두면 검색·비교 문제가 사라집니다.

자주 묻는 질문

&lt;와 &#60;는 차이가 있나요?

결과는 똑같이 < 한 글자예요. 이름 참조는 읽기 쉽고, 숫자 참조는 이름이 없는 문자까지 모두 표현할 수 있다는 차이뿐이에요. 이스케이프 라이브러리마다 &#39;와 &#x27;처럼 표기가 다를 수 있지만 브라우저에는 같은 문자로 보입니다.

화면에 &amp;나 &quot;가 글자 그대로 보여요.

이중 이스케이프예요. 저장할 때 한 번, 출력할 때 또 한 번 이스케이프했거나, 이미 이스케이프된 API 값을 템플릿이 다시 처리한 경우예요. DB에는 원문을 저장하고 출력할 때 한 번만 이스케이프하도록 정리하세요.

이스케이프만 하면 XSS가 완전히 막히나요?

값이 들어가는 자리에 맞는 이스케이프를 빠짐없이 하면 대부분 막혀요. 하지만 href에 들어가는 javascript: 주소, 인라인 이벤트 속성, innerHTML로 넣는 에디터 HTML은 이스케이프만으로 부족해서 스킴 검사, 새니타이저, CSP를 함께 써야 해요.

React를 쓰면 XSS 걱정이 없나요?

JSX의 {값}은 자동으로 텍스트 처리돼서 기본은 안전해요. 다만 dangerouslySetInnerHTML, 사용자 입력이 들어간 href, ref로 직접 innerHTML을 만지는 코드는 여전히 위험하니 그 부분은 직접 검사해야 합니다.

&nbsp;를 일반 공백으로 바꾸려면?

자바스크립트에서는 str.replace(/\u00a0/g, ' '), 파이썬에서는 s.replace('\xa0', ' ')로 바꿔요. 입력을 저장하기 전에 이 정규화를 해 두면 검색과 비교가 안정적이에요.

정리

HTML 엔티티는 HTML 문법과 겹치는 문자를 글자로 보이게 하는 표기이고, 지금 꼭 필요한 건 &lt; &gt; &amp; &quot; &#39; 다섯 개예요. 사용자 값은 출력하는 순간, 들어가는 자리에 맞게, 한 번만 이스케이프하고, 프런트에서는 textContent를 기본으로 쓰세요. 링크 주소는 스킴 검사, 에디터 HTML은 새니타이저, 그리고 CSP까지 겹겹이 두면 XSS는 충분히 막을 수 있어요.

문자열을 엔티티로 바꾸거나 거꾸로 풀어 보려면 HTML 엔티티 변환기를, URL에 넣을 값을 인코딩하려면 URL 인코더/디코더를 써 보세요.

#HTML 엔티티#HTML 이스케이프#&nbsp; 뜻#XSS 방어#innerHTML textContent 차이#특수문자 변환#컨텍스트별 이스케이프
← 이전 글MD5 SHA-1 SHA-256 차이, 해시와 암호화·체크섬 확인법 정리다음 글 →리눅스 chmod 755 644 의미, rwx 권한 숫자 계산법 총정리

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →