XML 이스케이프와 해제

특수 문자를 이스케이프하거나, 엔티티를 텍스트로 되돌립니다.

미리 정의된 5개 엔티티와 10진수·16진수 문자 참조를 처리합니다.
입력
출력
대기 중문서를 붙여넣으면 검사합니다. 입력하는 동안 검증이 실행됩니다.

모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.

왼쪽에 텍스트를 붙여넣으면, XML이 마크업으로 취급하는 문자가 모두 엔티티 참조로 바뀌어 돌아옵니다. 방향을 뒤집으면 참조가 그것이 가리키는 문자로 다시 디코딩됩니다. 입력은 문서가 아니라 평범한 텍스트이므로 파싱이 될 필요가 없습니다. 조각이든, 속성 값 하나든, URL 하나만이든 모두 동작합니다.

이 도구를 찾게 되는 건 파서가 앰퍼샌드에 대해 불평한 다음입니다. Xerces의 "The entity name must immediately follow the '&'", libxml2의 "EntityRef: expecting ';'", Expat의 "invalid character in entity name"은 모두 같은 문제입니다. 텍스트나 속성 안의 그대로 쓰인 &, 보통은 ?a=1&b=2 같은 쿼리 문자열을 요소 안에 붙여넣은 결과입니다.

여기서 다른 점은 이 도구가 "하지 않는 일"입니다. XML이 정의하는 이름 있는 엔티티는 다섯 개뿐이고 그 외에는 없습니다. 웹에 있는 escape 도구 대부분은 XML이라는 이름표를 단 HTML escape 도구여서, 어떤 XML 파서도 받아들이지 않는  를 내놓습니다. 이 도구는 다섯 개와 10진수·16진수 숫자 참조만 알고, 그 밖의 것은 쓰인 그대로 두며, 아무것도 업로드하지 않습니다.

미리 정의된 엔티티는 다섯 개, 그 이상은 없습니다

XML 1.0의 4.6절은 이름 있는 엔티티를 정확히 다섯 개 정의합니다. 그게 전부입니다. 물려받은 HTML 엔티티 표 같은 것은 없으며, HTML 덩어리를 XML 설정 파일이나 RSS의 description으로 옮길 때 대개 여기서 놀라게 됩니다.

DTD가 없는 문서에  라고 쓰는 것은 문체의 문제가 아니라 적격 형식 오류입니다. 파서에게는 이름만 있고, 그 이름이 무엇을 뜻하는지 알려 주는 것이 아무것도 없습니다. 대신  이나  을 쓰세요. ©와 é도 마찬가지입니다. DTD는 추가 이름을 선언할 수 있고, DocBook에서 &companyName;이 동작하는 이유가 그것입니다. 그래서 이스케이프 해제 방향은 알지 못하는 이름을 쓰인 그대로 남겨 둡니다.

  • &는 &
  • &lt;는 <
  • &gt;는 >
  • &quot;는 "
  • &apos;는 '. HTML 4는 &apos;를 정의한 적이 없어서, 혼합 파이프라인에 값을 넣는 직렬화기들은 대신 &#39;를 내보내는 일이 잦습니다.

숫자 문자 참조

적법한 문자는 모두 코드 포인트로 쓸 수 있습니다. 10진수는 &#233;, 16진수는 &#xE9;. 둘은 같은 문자이고 앞자리 0도 허용되며, x는 소문자여야 합니다. 그래서 &#X41;은 문자 참조가 아니라 선언되지 않은 엔티티로 거부됩니다.

이스케이프 해제 방향은 두 형식을 모두 다루고 결과가 유니코드 범위 안인지 확인합니다. 형식이 잘못되었거나 범위를 벗어난 참조는 물음표로 바꾸지 않고 쓰인 그대로 둡니다. 이스케이프 방향은 숫자 참조를 절대 내보내지 않습니다. UTF-8은 악센트도 CJK도 이모지도 그 자체로 실어 나르므로, 그것들을 이스케이프하면 가독성만 잃고 얻는 것이 없습니다.

각 문자를 실제로 이스케이프해야 하는 경우

규칙은 많은 사람이 짐작하는 것보다 좁고, 남의 문서를 읽으며 "이게 깨진 건가" 판단할 때 이 점이 중요해집니다. &와 <는 어디서나 필수입니다. >가 요구되는 곳은 단 하나입니다. XML 1.0의 2.4절은 내용 안에 리터럴 ]]> 라는 연속이 나타나고 그것이 CDATA 섹션을 닫는 것이 아닐 때 이스케이프해야 한다고 말합니다. 그래서 <code>if (a]]&gt;b)</code>는 필수이고 <note>a > b</note>는 적법합니다.

이 도구는 다섯 개 모두를 조건 없이 이스케이프합니다. 어떤 문맥이 요구하는 것보다 넓은 집합이므로, 출력은 지금 내가 어느 문맥에 있는지 따지지 않고 어디에 넣어도 안전합니다. 최소한의 이스케이프는 아니며, 어떤 문자를 되돌리면 되는지는 이제 아실 겁니다.

  • &와 <: 요소 내용에서도 속성 값에서도 언제나 필수.
  • >: 선택. 단 ]]> 연속 안에서는 필수.
  • ": 큰따옴표로 묶인 속성 값 안에서만 필수.
  • ': 작은따옴표로 묶인 속성 값 안에서만 필수. 요소 내용에서는 어느 따옴표도 이스케이프할 필요가 없습니다.

아예 이스케이프할 수 없는 문자들

XML 1.0은 문서가 담을 수 있는 문자를 Char 생성 규칙으로 정의하는데, C0 제어 문자 대부분은 거기에 들어 있지 않습니다. U+0001부터 U+0008, U+000B, U+000C, 그리고 U+000E부터 U+001F까지. 살아남는 것은 탭, 줄바꿈, 캐리지 리턴뿐입니다. U+0000은 XML 어느 버전에서도 불법이고, 홀로 남은 서로게이트도 불법입니다.

그러므로 &#x1B;은 ESC 문자의 이스케이프가 아닙니다. Legal Character 제약을 위반합니다. 그 참조가 가리키는 문자는 어떤 방법으로도 XML 1.0 문서에 나타날 수 없고, 참조로 적는다고 세탁되지 않기 때문입니다. ANSI 이스케이프 시퀀스로 가득한 로그 파일에 대해 파이썬이 "not well-formed (invalid token)"이라고 보고하는 것이 바로 이것입니다. 임의의 바이트에는 base64가 답입니다.

이 도구는 영리한 척하는 대신 정직하게 굽니다. 이스케이프할 때 제어 문자는 그대로 통과시키고, 이스케이프를 해제할 때 &#1;은 진짜 U+0001이 됩니다. 참조가 그렇게 말하고 있기 때문입니다. 결과를 문서로 되돌리기 전에 구문 검사기에 한 번 통과시키세요.

코드로 같은 일 하기

모든 언어가 이 용도의 무언가를 제공하지만, 대부분은 엉뚱한 것을 제공합니다. 가장 눈에 띄는 함수가 HTML escape 도구이기 때문입니다. 아래 예제들은 각 생태계에서 XML 전용 경로를 사용하며, 각각이 무엇을 틀리는지도 함께 적었습니다.

// XML defines exactly five named entities. A single regex pass avoids the
// classic bug of replacing & after < and turning &lt; into &amp;lt;.
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };

// Element content: & and < are mandatory, > only inside the sequence ]]>.
function escapeText(s) {
  return s.replace(/[&<]/g, (c) => ESCAPES[c]).replace(/]]>/g, ']]&gt;');
}

// Attribute value: escape the delimiter you are using, plus tab, newline and
// carriage return. Attribute-value normalisation turns a literal one of those
// into a plain space, so a reference is the only way to keep it.
function escapeAttribute(s) {
  return s
    .replace(/[&<"]/g, (c) => ESCAPES[c])
    .replace(/\t/g, '&#9;')
    .replace(/\n/g, '&#10;')
    .replace(/\r/g, '&#13;');
}

// The reverse. Note the deliberate absence of an HTML entity table: &nbsp; is
// undefined in XML, so leaving it alone is more honest than decoding it.
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };

function unescapeXml(s) {
  return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/g, (whole, body) => {
    if (body[0] === '#') {
      const hex = body[1] === 'x';
      const cp = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
      return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : whole;
    }
    return NAMED[body] !== undefined ? NAMED[body] : whole;
  });
}
from xml.sax.saxutils import escape, quoteattr, unescape
import re

# escape() handles & < > only and knows nothing about quotes. The > is not
# strictly required but it is harmless and covers the ]]> case for free.
text = escape('Terms & conditions <see clause 4>')
# Terms &amp; conditions &lt;see clause 4&gt;

# The two quote entities have to be supplied yourself.
FIVE = {'"': '&quot;', "'": '&apos;'}
text = escape(source, FIVE)

# quoteattr() returns the value WITH its delimiters, picking single quotes when
# the value contains a double quote, and turning tab, newline and carriage
# return into numeric references so normalisation cannot flatten them.
attr = quoteattr('say "hello" then stop')
# 'say "hello" then stop'   <- single-quoted, so the " needs no escape

# unescape() reverses & < > plus whatever mapping you pass. It does NOT decode
# numeric character references, so &#233; comes back unchanged. html.unescape()
# does decode them, but it also decodes &nbsp; and 250 other HTML names XML
# never defined, which silently rewrites your data. Do it explicitly instead:
def unescape_xml(s: str) -> str:
    s = unescape(s, {'&quot;': '"', '&apos;': "'"})
    return re.sub(
        r'&#(x[0-9a-fA-F]+|[0-9]+);',
        lambda m: chr(int(m.group(1)[1:], 16) if m.group(1)[0] == 'x' else int(m.group(1))),
        s,
    )
// The JDK has no public XML escaper for a bare string. Two right answers.

// 1. Let a writer do it. XMLStreamWriter escapes for the context it is
//    writing into, the only approach that gets attribute values right.
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamWriter;

XMLStreamWriter w = XMLOutputFactory.newInstance().createXMLStreamWriter(out);
w.writeStartElement("note");
w.writeAttribute("href", "https://example.com/?a=1&b=2");  // escaped for you
w.writeCharacters("Terms & conditions <apply>");           // escaped for you
w.writeEndElement();
w.close();

// 2. Escape a standalone string with commons-text. Do NOT use commons-lang3's
//    deprecated StringEscapeUtils.escapeXml, which knew nothing about the
//    characters XML cannot represent.
import org.apache.commons.text.StringEscapeUtils;

String safe = StringEscapeUtils.escapeXml10("Terms & conditions <apply>");
// Terms &amp; conditions &lt;apply&gt;

// escapeXml10 does something its name does not advertise: it DELETES the
// characters XML 1.0 cannot hold (U+0001 to U+0008, U+000B, U+000C,
// U+000E to U+001F, unpaired surrogates) rather than escaping them, because
// no escape for them exists. escapeXml11 writes the control characters as
// numeric references instead, which is only legal if the document declares
// version="1.1", and it still drops U+0000 and unpaired surrogates.

String back = StringEscapeUtils.unescapeXml(safe);
// The five predefined entities plus decimal and hex character references.
// Names it does not know are left as written.
using System.Linq;
using System.Security;
using System.Xml;

// SecurityElement.Escape does the five predefined entities in one call:
// < > & " ' every time, a superset of what any single context needs.
string safe = SecurityElement.Escape("Terms & conditions <apply>");
// Terms &amp; conditions &lt;apply&gt;

// It does not remove the characters XML cannot carry, so check those yourself.
// XmlConvert.IsXmlChar implements the Char production from XML 1.0. Keep
// surrogates or astral characters (emoji, rarer CJK) are destroyed.
static string StripIllegal(string s) =>
    string.Concat(s.Where(c => XmlConvert.IsXmlChar(c) || char.IsSurrogate(c)));

// In practice prefer a writer. It escapes for the context and throws on a
// character the document cannot hold, instead of producing a file that fails
// to parse somewhere downstream.
var settings = new XmlWriterSettings { Indent = true, CheckCharacters = true };
using var writer = XmlWriter.Create(Console.Out, settings);
writer.WriteStartElement("note");
writer.WriteAttributeString("href", "https://example.com/?a=1&b=2");
writer.WriteString("Terms & conditions <apply>");
writer.WriteEndElement();

// There is no framework unescaper, and WebUtility.HtmlDecode is the wrong
// tool: it decodes &nbsp; and the rest of the HTML set. Read a fragment
// instead, which decodes exactly what XML defines and nothing else.
static string Unescape(string escaped)
{
    var rs = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null,
    };
    using var r = XmlReader.Create(new StringReader("<r>" + escaped + "</r>"), rs);
    r.ReadToFollowing("r");
    return r.ReadElementContentAsString();
}
<?php
// ENT_XML1 is the flag that matters and the one everybody omits. Without it
// you get the HTML entity table: an apostrophe becomes &#039; (harmless), and
// with ENT_HTML5 you can get names XML will reject outright.
$safe = htmlspecialchars(
    $text,
    ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8'
);
// & < > " ' become &amp; &lt; &gt; &quot; &apos;
//
// ENT_SUBSTITUTE replaces invalid UTF-8 with U+FFFD. Before PHP 8.1 the
// default was to return an empty string on invalid input, which is very easy
// to miss in a feed generator fed by a legacy database.

$back = html_entity_decode($safe, ENT_XML1 | ENT_QUOTES, 'UTF-8');
// With ENT_XML1 this decodes the five predefined entities and numeric
// character references and leaves &nbsp; alone. Drop the flag and it decodes
// &nbsp; into U+00A0, silently rewriting your data.

// Building a document rather than a string: DOMDocument escapes on save and
// rejects characters XML cannot represent, which htmlspecialchars passes
// straight through.
$doc = new DOMDocument('1.0', 'UTF-8');
$note = $doc->createElement('note');
$note->appendChild($doc->createTextNode($text));
$doc->appendChild($note);
echo $doc->saveXML();

다섯 가지에 공통된 형태는 이렇습니다. 문자열 수준의 escape 함수는 편한 답이고, writer가 올바른 답입니다. 요소 내용을 쓰는지 속성 값을 쓰는지 아는 것은 writer뿐이고, 아예 이스케이프할 수 없는 문자를 거부할 수 있는 것도 writer뿐이기 때문입니다.

자주 묻는 질문

왜 &nbsp;가 제 XML을 깨뜨리나요?

XML이 그것을 정의한 적이 없기 때문입니다. 미리 정의된 엔티티는 &amp;, &lt;, &gt;, &quot;, &apos; 다섯 개이고 그게 전부입니다. HTML이 주는 나머지는 XML이 의도적으로 물려받지 않은 엔티티 표에서 옵니다.

유효 범위에 DTD가 없는 상태에서 &nbsp;를 만난 파서는 선언되지 않은 엔티티로 보고합니다. 이름은 있는데 그 이름이 무엇을 뜻하는지 알려 주는 것이 없기 때문입니다. 대신 &#160;이나 &#xA0;을 쓰세요. 예외는 DTD가 그 이름을 선언한 문서입니다. 같은 마크업이 XHTML에서는 되고 평범한 XML 설정 파일에서는 실패하는 이유가 그것입니다.

부등호(>)도 이스케이프해야 하나요?

거의 필요 없습니다. XML 1.0이 요구하는 상황은 하나입니다. 요소 내용 안에서 >가 리터럴 ]]> 연속에 나타나면서 CDATA 섹션을 닫는 것이 아닐 때입니다. 그렇지 않으면 CDATA 섹션의 끝을 찾던 파서가 헷갈리게 됩니다.

그 밖의 모든 곳에서는 속성 값 안을 포함해 선택 사항이므로, <note>a > b</note>는 그대로도 적격 형식입니다. 이 도구는 출력이 어떤 문맥에 붙여 넣어도 안전하도록 어쨌든 이스케이프합니다. 최소 형태를 원한다면 ]]> 연속 안을 제외하고 >를 모두 되돌리면 됩니다.

이스케이프 대신 CDATA를 쓰는 게 나을까요?

CDATA는 <와 &를 마크업으로 인식하는 것을 막습니다. 하는 일은 그게 전부이고, 사람이 손으로 내용을 편집할 때는 올바른 도구입니다. 삽입된 소스 코드, 꺾쇠로 가득한 XSLT 식, 손으로 관리하는 SQL 같은 경우죠.

나머지 경우에는 잘못된 도구입니다. ]]> 연속을 담을 수 없는데, 그것이 CDATA를 끝내 버리기 때문이며, 신뢰할 수 없는 콘텐츠에는 실제 주입 경로가 됩니다. 엔티티도 확장하지 않아서 <![CDATA[&amp;]]>는 리터럴 여섯 글자가 되고, 불법 문자를 허용해 주지도 않습니다. 대부분의 직렬화기는 CDATA를 별도 노드로 보존하지도 않으므로, "CDATA임"에 의미를 부여하지 마세요.

붙여넣은 텍스트가 어딘가로 전송되나요?

아닙니다. 이스케이프는 이 탭에서 도는 JavaScript의 문자열 치환입니다. 보낼 요청 자체가 없으니, 그것을 받을 서버도 없습니다.

여기서는 그것이 보이는 것보다 중요합니다. 이 도구는 페이로드 중에서 깨진 부분에 대해 쓰이는데, 실제로는 쿼리 문자열에 토큰이 든 URL, 접속 문자열, 로그에서 뽑아낸 오류 텍스트 같은 것들입니다. 입력하는 동안 네트워크 패널을 열어 두면 계속 비어 있습니다. 입력은 새로고침해도 잃지 않도록 이 브라우저의 localStorage에 보관되고, 절대 전송되지 않으며, 지우기 버튼으로 즉시 삭제됩니다.

왜 제 &amp;가 &amp;amp;로 변했나요?

텍스트가 두 번 이스케이프되었기 때문입니다. 보통은 writer가 이미 이스케이프한 문자열에 수동 이스케이프를 한 번 더 적용했거나, 출력 시 이스케이프하는 템플릿에 입력 시 이미 이스케이프된 값을 넣은 경우입니다.

이스케이프 해제 방향은 한 겹을 벗깁니다. 한 번 돌리면 &amp;로, 한 번 더 돌리면 &로 돌아옵니다. 운영 환경에서 &amp;amp;가 계속 나타난다면, 원인은 거의 언제나 이미 제 일을 하고 있는 DOM 또는 스트림 writer 앞에 놓인 손수 만든 이스케이프입니다. 순서를 잘못 잡으면 반대로 실패합니다. &보다 <를 먼저 치환하면 &lt;가 &amp;lt;가 되어 버리므로, JavaScript 예제는 정규식 한 번으로 처리합니다.

관련 도구

참고 자료

이 도구로 해결되는 오류