XML 압축기
의미 없는 공백만 제거. 혼합 콘텐츠는 그대로 둡니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
위에 문서를 붙여넣으면 요소 사이의 공백이 제거됩니다. 결과는 입력 옆에 한 줄로 돌아오고, 처리 전 크기와 처리 후 크기, 절약된 비율이 함께 표시됩니다. 체크박스를 켜면 주석도 함께 사라집니다. 모든 처리는 이 탭에서 이루어집니다. 파서와 압축기는 JavaScript이고, 여러분의 문서는 어디로도 전송되지 않습니다.
압축이 의미 있는 때는 문서 자체의 크기가 제약일 때입니다. 데이터베이스 열에 들어가는 수십만 건의 페이로드, JSON 문자열 필드에 박아 넣은 엔벨로프, 브로커의 크기 제한에 맞춰야 하는 메시지. 네트워크를 건너갈 때의 이득은 광고되는 비율보다 훨씬 작으며, 그 이유는 아래 숫자가 말해 줍니다.
규칙은 포맷터의 규칙을 뒤집은 것입니다. 요소 사이의 공백은 레이아웃이고, 혼합 콘텐츠 안의 공백은 데이터입니다. 한 요소가 텍스트와 자식 요소를 함께 담고 있으면 그 하위 트리는 다시 만들지 않고 원본에서 그대로 가져오며, CDATA는 바이트 단위로 복사합니다. 남은 것은 파서가 애플리케이션에 넘기는 내용을 바꾸지 못합니다.
제거해도 안전한 것
XML 파서는 문서의 모든 문자를 공백까지 포함해 애플리케이션에 넘깁니다. 여러분을 대신해 버려 주는 것은 하나도 없습니다. "의미 없음"은 파서가 아니라 콘텐츠 모델에 대한 진술입니다. DTD나 스키마가 어떤 요소를 "요소만 담는 내용"으로 선언했다면 그 바로 안의 공백은 데이터일 수 없고, 압축기가 손댈 수 있는 것은 오직 그 공백뿐입니다.
대부분의 문서는 스키마 없이 오므로 압축기는 휴리스틱을 씁니다. 여기서 쓰는 기준은 좁습니다. 형제 요소들 사이에 있으면서 전체가 공백뿐인 텍스트 노드를 버립니다. 그 외에는 아무것도 버리지 않습니다. libxml2가 --noblanks에서 내리는 것과 같은 판단입니다. 한 가지 변화가 더 있습니다. 빈 요소는 자체 종료 형태로 씁니다. 그래서 <status></status>는 <status/>가 됩니다. 규격을 따르는 파서라면 같은 요소로 보지만, 바이트로는 같지 않습니다.
제거하지 않는 것
혼합 콘텐츠야말로 진짜 압축기와 정규식을 가르는 지점입니다. <p>Hello <b>world</b>!</p>에서 "Hello" 뒤의 공백은 문서 안의 한 글자이고, <p>a <b>x</b> <i>y</i></p>에서 </b>와 <i> 사이의 공백도 마찬가지입니다. 둘 다 살아남습니다. 혼합된 하위 트리는 다시 만들지 않고 원본에서 그대로 복사하기 때문입니다. 그 안쪽은 아무것도 건드리지 않으며, 이것이 유일하게 옹호할 수 있는 입장입니다. 텍스트와 마크업이 뒤섞인 순간, 의미 없다고 증명할 수 있는 공백은 더 이상 남아 있지 않습니다.
- CDATA 섹션: 구분자도 내용도 그대로 복제합니다. 그 안의 공백은 정의상 콘텐츠입니다.
- 속성 값: 쓰인 그대로 출력하며, 원래 따옴표와 엔티티 참조도 손대지 않습니다.
- 잎 요소 안의 텍스트: <name> Ada </name>은 바깥쪽 여백만 잃고, 사이의 글자는 절대 잃지 않습니다.
- 주석: 요청하지 않으면 유지합니다. 처리 명령, XML 선언, 내부 DTD 서브셋은 항상 유지합니다.
- xml:space="preserve": 솔직히 밝히는 한계입니다. 특별 대우를 하지 않으므로, 문서가 이것을 쓴다면 사본을 압축해 비교해 보세요.
실제로 얼마나 줄어드는가
테스트 문서 하나에서 나온 숫자입니다. 네 단계로 중첩된 주문 레코드 2,000건. 공백 2칸 들여쓰기에서는 554 KB이고 압축하면 421 KB로 24퍼센트 줄어듭니다. 공백 4칸이면 679 KB이므로 압축이 36퍼센트를 덜어냅니다. 작은 요소가 깊게 중첩된 문서일수록 효과가 큽니다. 감싸는 내용에 비해 들여쓰기가 크기 때문입니다.
이제 둘 다 압축해 보세요. 공백 2칸 버전은 gzip으로 30,744바이트, 압축한 버전은 29,771바이트. 차이는 3퍼센트입니다. 공백 4칸 들여쓰기에서는 결과가 뒤집혀, 정렬된 쪽이 28,519바이트, 압축한 쪽이 29,771바이트가 됩니다. gzip 이후에는 압축한 파일이 더 큽니다. 반복되는 들여쓰기야말로 압축기가 잘 다루는 것입니다. 압축이 무의미하다는 뜻이 아닙니다. Content-Encoding이 켜진 세상에서 "네트워크를 위해" 압축하는 것이 무의미하다는 뜻입니다.
- 값어치가 있는 경우: 데이터베이스 열에 들어가는 원시 XML, 크기 상한이 있는 큐 메시지, 다른 페이로드에 박아 넣는 문서, 들여쓰기가 diff를 뒤덮는 픽스처.
- 값어치가 없는 경우: 압축이 켜진 HTTP로 전달되는 모든 것. 대부분의 압축기 홍보 문구에 나오는 비율은 바로 여기서 나옵니다.
- 오히려 해로운 경우: 서명된 문서. 정규화 XML은 요소 내용의 공백을 유지하므로, 다이제스트가 여러분이 지금 바꾸려는 바이트를 포함하고 있습니다.
레이아웃 되돌리기, 그리고 한계
출력을 포맷터에 통과시키면 모든 값이 동일한 채로 다시 들여쓴 문서를 얻습니다. 되돌아오지 않는 것은 원래 레이아웃입니다. 섹션 사이의 빈 줄, 특이한 들여쓰기 폭, 두 태그가 붙어 있던 혼합 하위 트리 내부의 간격. 요소만 담는 내용에서는 그중 무엇도 정보가 아니었습니다. 만약 정보였다면, 압축하지 마세요.
적격 형식이 아닌 문서는 압축하지 않습니다. 입력이 그대로 돌아오고 모든 오류가 줄과 열과 함께 나열됩니다. 1 MB 문서는 약 180밀리초, 5 MB는 1초 남짓에 압축되며, Web Worker에서 돌기 때문에 페이지는 계속 반응합니다. 상한은 20 MB입니다. 모든 것이 이 탭의 메모리에 있기 때문입니다.
코드로 XML 압축하기
빌드 단계에서 이것은 파싱과 재직렬화이지, 결코 문자열 조작이 아닙니다. 모든 예제가 안전하게 파싱합니다. Java, PHP, Python 표준 라이브러리의 기본값이 외부 엔티티를 해석하기 때문입니다. 각 라이브러리가 어떤 공백을 무시해도 되는 것으로 판단하는지 보세요. 그 판단이 곧 도구 전체입니다.
// Remove whitespace-only text nodes, but only where the element's children
// are all elements. Anything else is mixed content and belongs to the data.
function minifyXml(source) {
const doc = new DOMParser().parseFromString(source, 'application/xml');
if (doc.querySelector('parsererror')) {
throw new Error(doc.querySelector('parsererror').textContent.trim());
}
const strip = (el) => {
const kids = [...el.childNodes];
const elementOnly =
kids.some((n) => n.nodeType === 1) &&
kids.every((n) => n.nodeType !== 3 || !n.nodeValue.trim());
if (elementOnly) {
for (const n of kids) if (n.nodeType === 3) el.removeChild(n);
}
for (const child of el.children) strip(child);
};
strip(doc.documentElement);
return new XMLSerializer().serializeToString(doc);
}
// XMLSerializer emits <a/> for an empty element, so the output is not
// byte-identical to input that wrote <a></a>. Same document, different bytes.# lxml's remove_blank_text is the closest equivalent, and it is honest about
# being a guess: with no DTD it keeps blank text whose siblings carry real
# characters, which is what saves mixed content.
from lxml import etree
parser = etree.XMLParser(
remove_blank_text=True,
resolve_entities=False, # no entity expansion
no_network=True, # never fetch a DTD or an include
load_dtd=False,
huge_tree=False,
)
tree = etree.fromstring(source.encode('utf-8'), parser)
minified = etree.tostring(tree, encoding='unicode')
# Standard library, no dependency, same rule applied by hand:
#
# from defusedxml.ElementTree import fromstring
# root = fromstring(source)
# for el in root.iter():
# if len(el) and not (el.text or '').strip():
# el.text = None
# if not (el.tail or '').strip():
# el.tail = Noneimport javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.*;
import java.io.StringWriter;
import org.w3c.dom.*;
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setXIncludeAware(false);
Document doc = dbf.newDocumentBuilder().parse(new java.io.File("in.xml"));
// setIgnoringElementContentWhitespace only works when a DTD or schema tells
// the parser which content is element-only, which is usually not the case.
// So select the blank text nodes explicitly. The second predicate leaves
// mixed content alone: it skips blanks whose siblings hold real characters.
XPath xpath = XPathFactory.newInstance().newXPath();
NodeList blanks = (NodeList) xpath.evaluate(
"//text()[not(normalize-space())][not(../text()[normalize-space()])]",
doc, XPathConstants.NODESET);
for (int i = 0; i < blanks.getLength(); i++) {
Node n = blanks.item(i);
n.getParentNode().removeChild(n);
}
TransformerFactory tf = TransformerFactory.newInstance();
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
Transformer t = tf.newTransformer();
t.setOutputProperty(OutputKeys.INDENT, "no");
StringWriter out = new StringWriter();
t.transform(new DOMSource(doc), new StreamResult(out));
System.out.println(out);using System.Text;
using System.Xml;
// IgnoreWhitespace drops every whitespace-only text node the reader sees.
// Without a schema that includes the space between two inline elements in
// mixed content, so use this on element-only documents and set it false when
// the document carries prose.
var readerSettings = new XmlReaderSettings
{
IgnoreWhitespace = true,
DtdProcessing = DtdProcessing.Prohibit, // no external entities
XmlResolver = null,
MaxCharactersFromEntities = 1024 * 1024,
};
var writerSettings = new XmlWriterSettings
{
Indent = false,
NewLineHandling = NewLineHandling.None,
};
var output = new StringBuilder();
using (var reader = XmlReader.Create(new StringReader(source), readerSettings))
using (var writer = XmlWriter.Create(output, writerSettings))
{
writer.WriteNode(reader, defattr: true);
}
Console.WriteLine(output.ToString());<?php
$doc = new DOMDocument();
// preserveWhiteSpace must be false before the load, not after. libxml2 then
// applies its blank-node heuristic, which keeps blanks inside mixed content.
$doc->preserveWhiteSpace = false;
$doc->formatOutput = false;
libxml_use_internal_errors(true);
if (!$doc->loadXML($source, LIBXML_NONET)) {
foreach (libxml_get_errors() as $e) {
fprintf(STDERR, "XML error at line %d, column %d: %s\n",
$e->line, $e->column, trim($e->message));
}
libxml_clear_errors();
exit(1);
}
// saveXML() still ends the document with a newline; trim it if the byte
// count is what you are optimising.
echo rtrim($doc->saveXML());# --noblanks is libxml2's minifier. It removes ignorable whitespace only.
xmllint --noblanks --nonet document.xml > minified.xml
# xmllint has no flag for dropping comments, and sed cannot do it correctly
# (a comment may contain a > character). Use an identity transform with no
# template for comment() nodes:
cat > strip-comments.xsl <<'XSL'
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="no"/>
<xsl:template match="@*|node()">
<xsl:copy><xsl:apply-templates select="@*|node()"/></xsl:copy>
</xsl:template>
<xsl:template match="comment()"/>
</xsl:stylesheet>
XSL
xsltproc --nonet strip-comments.xsl document.xml | xmllint --noblanks --nonet -
# Measure the two questions separately:
wc -c document.xml minified.xml
gzip -9 -c document.xml | wc -c
gzip -9 -c minified.xml | wc -c여기 있는 것들은 모두 파싱하고 다시 직렬화합니다. 꺾쇠 위의 정규식은 하나도 없습니다. 압축기가 내리는 판단("이 공백이 요소만 담는 내용 안에 있는가?")에는 트리가 필요하기 때문입니다. 파싱 없이 압축해 주겠다는 도구는, 혼합 콘텐츠를 틀리겠다고 스스로 말하는 셈입니다.
자주 묻는 질문
압축할 때 문서가 업로드되나요?
아닙니다. 파서와 압축기는 이 탭에서 Web Worker 안의 JavaScript로 돕니다. 보낼 엔드포인트도, 편집기에 접근하는 분석 도구도, 서드파티 스크립트도 없습니다. 네트워크 탭을 열고 작업하는 동안 계속 비어 있는지 보세요.
여기서는 그것이 부수적인 이야기가 아닙니다. 문서를 압축하는 시점은 저장소나 다른 시스템으로 넘어가는 길목이고, 그 말은 주문 레코드와 식별자, 토큰이 들어 있는 진짜 페이로드라는 뜻입니다. 이 검색어 상위의 여러 도구가 문서를 서버에서 처리하고, 가장 큰 곳 중 하나는 저장된 문서를 기본적으로 공개합니다.
제 XML은 얼마나 작아지나요?
보통의 들여쓴 XML에서 대략 10~35퍼센트이며, 거의 전적으로 중첩 깊이와 들여쓰기 폭이 좌우합니다. 2,000건짜리 테스트 문서에서 공백 2칸은 24퍼센트, 4칸은 36퍼센트 줄었습니다. 긴 텍스트가 많은 문서는 훨씬 덜 줄어듭니다.
정직한 비교는 압축 이후이고, 거기서는 약 3퍼센트입니다. gzip한 정렬본이 30,744바이트, gzip한 압축본이 29,771바이트. 공백 4칸 들여쓰기에서는 정렬본이 더 작았습니다. 문서가 어디로 가는지를 기준으로 판단하세요.
압축이 제 XML을 망가뜨릴 수 있나요?
그럴 수 있고, 그래서 이 도구는 보수적입니다. 망가지는 방식은 데이터였던 공백을 제거하는 것입니다. 혼합 콘텐츠, CDATA, 그리고 xml:space="preserve"가 지배하는 부분. 앞의 둘은 처리되어 있습니다. 혼합된 하위 트리는 원본에서 그대로 복사하고 CDATA는 바이트 단위로 복사하기 때문입니다. 한 가지 유의점이 남습니다. xml:space="preserve"는 특별 대우를 받지 않으므로, 그것을 단 텍스트 전용 요소도 앞뒤가 잘립니다.
세 번째 방식은 눈에 보이지 않습니다. 문서에 XML 서명이 붙어 있다면, 정규화 XML은 요소 내용의 공백을 다이제스트에 포함하므로 서명된 문서를 압축하면 서명이 무효가 됩니다. 서명 전에 압축하고, 서명 후에는 절대 하지 마세요.
주석을 제거하나요?
요청할 때만요. 체크박스는 기본적으로 꺼져 있습니다.
주석은 설정 파일에 붙어 있는 유일한 문서인 경우가 많습니다. Maven POM이나 XSLT 스타일시트에서 주석을 지우면, 이상한 요소가 왜 거기 있는지에 대한 설명을 잃습니다. 게다가 그 절약분은 gzip이 어차피 회수해 주었을 것입니다. 내 파일이고 보관용으로 압축한다면 지워도 됩니다. 남의 문서를 그대로 넘기는 것이라면 남겨 두세요.
나중에 정렬된 버전을 되찾을 수 있나요?
있습니다. 압축한 문서를 포맷터에 통과시키면 됩니다. 값도 속성도 엔티티 참조도 CDATA 섹션도 모두 동일하므로, 파서가 보고할 내용은 전혀 바뀌지 않았습니다.
되찾지 못하는 것은 원래 레이아웃입니다. 섹션 사이의 빈 줄, 특정한 들여쓰기 폭, 두 태그가 붙어 있던 혼합 하위 트리 내부의 간격. 요소만 담는 내용에서는 그 어느 것도 정보를 담고 있지 않았습니다. 담고 있었다면 원본 파일을 보관하세요.
얼마나 큰 파일을 처리할 수 있나요?
20 MB까지입니다. 1 MB 문서는 약 180밀리초, 5 MB는 1초 정도에 압축되며, Web Worker에서 돌기 때문에 페이지는 계속 반응합니다.
이 상한은 정책이 아니라 메모리의 제약입니다. 아무것도 업로드하지 않으므로 문서는 이 탭에 머무르고, 20 MB를 넘어가면 파스 트리가 수백 MB를 차지해 브라우저가 응답을 멈춥니다. 그보다 큰 파일이라면 xmllint --noblanks가 무시 가능한 공백에 대한 같은 규칙으로 같은 일을 해 줍니다.