XML 포맷터
들여쓰고 정렬합니다. 혼합 콘텐츠는 그대로 보존됩니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
위에 문서를 붙여넣으면 입력하는 동안 다시 들여쓰기됩니다. 공백 2칸, 4칸, 또는 탭 중에서 고를 수 있고, 요소가 속성을 3개·4개·6개 이상 가지면 속성을 각자의 줄로 밀어낼 수 있습니다. 결과는 입력 옆의 읽기 전용 창에 나타납니다. 아무것도 업로드하지 않습니다. 파서도 포맷터도 이 탭에서 도는 JavaScript이고, 작업하는 동안 네트워크 패널은 계속 비어 있습니다.
포맷터를 찾게 되는 건 그 XML을 다른 무언가가 만들어 냈을 때입니다. 로그에서 4만 자짜리 한 줄로 뽑아낸 SOAP 응답, 배포 도구가 다시 써 버린 설정 파일, 기계가 생성한 사이트맵 같은 것들이죠. 동시에 이것은 가장 빠른 적격 형식 검사이기도 합니다. 포맷터는 파싱하지 못하는 것을 들여쓸 수 없기 때문입니다.
여기서 다른 점은 혼합 콘텐츠를 다루는 방식입니다. 한 요소가 텍스트와 자식 요소를 함께 담고 있으면 그 사이의 공백은 데이터이고, 그것을 "정리한" 포맷터는 문서를 바꿔 버린 것입니다. 그런 하위 트리는 바이트 단위로 그대로 복제합니다. 온라인 포맷터 대부분은 이렇게 하지 않습니다. 그중 몇몇은 파서조차 아니고 꺾쇠 위에서 하는 문자열 조작입니다.
정렬이 바꿔도 되는 것
XML 문서에서 진짜로 의미가 없는 것은 딱 하나입니다. 요소만을 콘텐츠로 갖는 모델에서 요소와 요소 사이의 공백이죠. 포맷터는 그것을 지우고 자기 것을 만들어도 됩니다. 나머지는 전부 콘텐츠이고, 안전한 접근은 아니라고 증명될 때까지 모든 바이트를 콘텐츠로 취급하는 것입니다.
그래서 형제 요소 사이의 공백만으로 이루어진 텍스트 노드는 버리고 여러분의 들여쓰기 설정으로 다시 만들며, 그 밖에는 아무것도 건드리지 않습니다. 속성 값은 쓰인 그대로 출력합니다. 다시 이스케이프하면 &가 &가 되어 버리고, &companyName;처럼 DTD에 선언된 엔티티를 참조하는 값은 DTD 없이는 해석할 수 없기 때문입니다. 원래의 따옴표 문자도 그대로 둡니다. 작은따옴표로 쓴 값은 큰따옴표를 합법적으로 포함할 수 있으니까요.
텍스트 내부가 다시 줄바꿈되는 일은 없습니다. 텍스트만 담고 있는 요소의 앞뒤 공백만 잘라내므로, <price> 42.00 </price>는 <price>42.00</price>가 되지만 <note>공백 두 칸</note>은 두 칸을 그대로 유지합니다.
혼합 콘텐츠, 그리고 대부분의 포맷터가 망가지는 이유
요소가 혼합 콘텐츠를 가진다는 것은 자식에 텍스트와 마크업이 함께 있다는 뜻입니다. <p>Hello <b>world</b>!</p> 같은 경우죠. "Hello" 뒤의 공백은 문서 안의 한 글자이고, </b> 뒤의 느낌표도 마찬가지입니다. 자식을 각각 들여쓴 줄에 놓으면, 텍스트 노드를 이어 붙이는 쪽은 다른 문자열을 받게 됩니다. 그것은 정리가 아니라 조용한 손상입니다.
모든 요소는 직렬화 전에 검사됩니다. 비어 있지 않은 텍스트나 CDATA 섹션과 나란히 자식 요소를 가지고 있으면, 그 하위 트리는 원본에서 그대로 복사되고 안쪽에는 어떤 규칙도 적용하지 않습니다. 이 맞바꿈은 의도한 것입니다. 혼합된 하위 트리는 보기 흉하더라도 들어온 그대로의 배치를 유지합니다. 다른 선택지는 "틀리는 것"이기 때문입니다.
이것은 희귀한 사례가 아닙니다. CMS 내보내기에 들어 있는 XHTML 조각, DocBook과 DITA, 스키마 안의 xs:documentation, 인라인 마크업이 든 RSS의 description. 여러분의 문서에 그런 게 없다면 이 배려는 아무 비용도 들지 않습니다. 있다면, 그것이 전부입니다.
- 혼합이므로 그대로 복제: <line>합계: <amount>9.99</amount> 부가세 별도</line>.
- 혼합이 아니므로 자유롭게 재정렬: <order><id>1</id><status>open</status></order>.
들여쓰기, 그리고 SOAP·XSD를 위한 속성 줄바꿈
공백 2칸이 기본인 이유는 대부분의 XML 도구가 그렇게 내보내기 때문입니다. 공백 4칸이 있는 이유는 많은 기업 코드베이스가 그것으로 표준을 삼았기 때문입니다. 탭이 있는 이유는 일부 저장소가 .editorconfig에 그렇게 정해 두었고, 또 탭은 1바이트인데 공백 4칸은 4바이트이기 때문입니다.
SOAP와 XSD에서 실제로 중요한 것은 속성 설정입니다. SOAP 엔벨로프의 루트는 보통 네임스페이스 선언을 다섯 개 달고 있고, xs:element 선언은 name, type, minOccurs, maxOccurs, nillable, default를 답니다. 한 줄에 넣으면 아무도 읽지 않을 200자가 됩니다. 기준을 3, 4, 6 중 하나로 두면 그 수 이상인 요소는 한 줄에 속성 하나씩 놓이고, 더 작은 요소는 한 줄에 그대로 남습니다.
이 도구가 하지 않는 일
적격 형식이 아닌 문서는 정렬하지 않습니다. 입력이 그대로 돌아오고, 대신 모든 오류가 줄, 열, 해결 방법과 함께 나열됩니다. 분명히 밝혀 둘 한계가 두 가지 더 있습니다. xml:space="preserve"는 특별 대우를 받지 않으므로, 그것을 단 텍스트 전용 요소도 앞뒤 공백이 잘립니다. 그리고 <a>text<!-- 왜 -->more</a>처럼 주석이 끼어든 텍스트는 이 판정에서 혼합 콘텐츠가 아닙니다. 주석은 요소가 아니기 때문이죠. 그래서 다시 들여쓰기되고 텍스트에 공백이 붙습니다. 둘 다 좁은 조건이지만 둘 다 실제로 일어나며, 조용히 그렇게 처리하는 도구라면 여러분에게 알려 주지 않을 일입니다.
정렬은 멱등입니다. 같은 설정으로 출력을 다시 통과시켜도 같은 바이트가 나오므로 pre-commit 훅에서 써도 안전합니다. 1 MB 문서는 약 200밀리초, 5 MB는 1초 조금 안 되게 걸립니다. 상한은 20 MB이고, 이것은 정책이 아니라 메모리의 제약입니다.
코드로 XML 정렬하기
실제로 XML을 다루는 언어들에서의 같은 작업입니다. 모든 예제는 안전하게 파싱합니다. Java, PHP, 그리고 Python 표준 라이브러리의 기본값이 외부 엔티티를 해석해 버리기 때문입니다. 주석에는 각 라이브러리가 어디서 혼합 콘텐츠를 재배치하는지 표시해 두었습니다.
// Browsers ship a parser and a serialiser but no pretty printer. This walker
// indents only elements whose children are all elements: touching anything
// else would rewrite mixed content.
function indentXml(source, unit = ' ') {
const doc = new DOMParser().parseFromString(source, 'application/xml');
if (doc.querySelector('parsererror')) {
throw new Error(doc.querySelector('parsererror').textContent.trim());
}
const walk = (el, depth) => {
const kids = [...el.childNodes];
const elementOnly =
kids.some((n) => n.nodeType === 1) &&
kids.every((n) => n.nodeType !== 3 || !n.nodeValue.trim());
if (!elementOnly) return; // mixed or text-only: leave the subtree alone
for (const n of kids) if (n.nodeType === 3) el.removeChild(n);
for (const child of [...el.children]) {
el.insertBefore(doc.createTextNode('\n' + unit.repeat(depth + 1)), child);
walk(child, depth + 1);
}
el.appendChild(doc.createTextNode('\n' + unit.repeat(depth)));
};
walk(doc.documentElement, 0);
return new XMLSerializer().serializeToString(doc);
}
// Browsers never resolve external entities, so XXE is not reachable here.
// Internal entity expansion is, so cap the input size before parsing.# ElementTree.indent (3.9+) only adds whitespace where an element has no
# non-whitespace text, so mixed content survives. It does drop comments,
# because the default parser never builds them.
import xml.etree.ElementTree as ET
from defusedxml.ElementTree import fromstring
root = fromstring(source) # safe: no entity expansion, no network
ET.indent(root, space=' ') # four spaces
print(ET.tostring(root, encoding='unicode'))
# lxml keeps comments and processing instructions, and etree.indent applies
# the same mixed-content rule:
#
# from lxml import etree
# parser = etree.XMLParser(resolve_entities=False, no_network=True,
# load_dtd=False, huge_tree=False)
# tree = etree.fromstring(source.encode(), parser)
# etree.indent(tree, space=' ')
# print(etree.tostring(tree, encoding='unicode'))
#
# Do not add remove_blank_text=True unless the document has a DTD. Without
# one, lxml guesses which blank text nodes are ignorable.import 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 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);
dbf.setExpandEntityReferences(false);
Document doc = dbf.newDocumentBuilder().parse(new java.io.File("in.xml"));
// The serialiser adds indentation on top of the whitespace already in the
// tree, so an already-indented file gets deeper on every run. Remove the
// blank text nodes first. The second predicate keeps the blanks that sit
// inside mixed content, where a sibling text node carries 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, "yes");
t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
t.transform(new DOMSource(doc), new StreamResult(System.out));using System.Text;
using System.Xml;
using System.Xml.Linq;
// XDocument.Parse discards whitespace-only text nodes by default, which is
// what you want for element-only content. On mixed content it also removes
// the space in <p>a <b>x</b> <i>y</i></p>, so pass
// LoadOptions.PreserveWhitespace when the document carries prose.
var doc = XDocument.Parse(source);
// DtdProcessing is Prohibit by default for the reader XDocument builds, so
// external entities are never fetched. Say it out loud when you construct
// the reader yourself.
var settings = new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
OmitXmlDeclaration = false,
};
var output = new StringBuilder();
using (var writer = XmlWriter.Create(output, settings))
{
doc.Save(writer);
}
Console.WriteLine(output.ToString());
// XmlWriter stops indenting an element once character data has been written
// into it, so it will not reflow mixed content it is given.<?php
$doc = new DOMDocument();
// Both flags must be set before loading. preserveWhiteSpace = false makes
// libxml2 drop blank text nodes; without a DTD it applies a heuristic, and
// that heuristic keeps blanks whose siblings carry real text, which is what
// protects mixed content. Run it on a copy and diff the first time.
$doc->preserveWhiteSpace = false;
$doc->formatOutput = true;
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);
}
echo $doc->saveXML();# xmllint is part of libxml2 and is almost certainly already installed.
# --nonet stops it fetching a DTD the document references.
xmllint --format --nonet document.xml
# The indent unit comes from an environment variable, not a flag:
XMLLINT_INDENT=' ' xmllint --format --nonet document.xml
# Rewrite in place:
xmllint --format --nonet --output document.xml document.xml
# xmllint refuses to format a document that is not well-formed: it prints the
# first error and exits non-zero, leaving the output file untouched.
# libxml2 will not indent an element that has a text child, which is the same
# mixed-content rule this page applies.공통점을 보세요. libxml2도, .NET의 XmlWriter도, Python의 ET.indent도 문자 데이터를 가진 요소의 들여쓰기를 거부합니다. 잘못되는 방법은 공백 텍스트 노드를 먼저 가리지 않고 제거하는 쪽입니다. 정규식으로 만든 포맷터가 가진 결함도 바로 이것입니다. 정규식은 콘텐츠 모델을 볼 수 없기 때문입니다.
자주 묻는 질문
정렬할 때 제 XML이 업로드되나요?
아닙니다. 파서와 포맷터는 이 탭 안의 Web Worker에서 도는 JavaScript입니다. 무언가를 보낼 서버 쪽 구성요소도, 편집기에 접근하는 분석 도구도, 서드파티 스크립트도 없습니다.
개발자 도구를 열고 네트워크 탭으로 옮긴 다음 문서를 정렬해 보세요. 페이지 자체 자원이 한 번 로드되고 그 뒤로는 아무 일도 없습니다. 여기서 그 점이 중요한 이유는, 다시 정렬해야 할 문서일수록 운영 로그에서 뽑아낸 것이기 때문입니다. 입력은 새로고침해도 잃지 않도록 이 브라우저의 localStorage에 보관되며, 지우기 버튼으로 삭제됩니다.
정렬하면 데이터가 바뀌나요?
데이터는 바뀌지 않습니다. 속성 값은 엔티티 참조와 원래 따옴표까지 쓰인 그대로 복사됩니다. CDATA가 이스케이프된 텍스트로 바뀌는 일은 없습니다. 주석, 처리 명령, 내부 DTD 서브셋도 살아남고, 혼합 콘텐츠 안의 텍스트는 바이트 단위로 그대로입니다.
문자가 제거되는 곳이 한 군데 있습니다. 텍스트만 담은 요소의 앞뒤 공백입니다. 텍스트 노드 중간의 공백이 합쳐지는 일은 절대 없습니다. 이 잘라내기는 xml:space="preserve"를 단 요소에도 적용되므로, 그것에 의존한다면 확인하세요.
2칸 대신 4칸이나 탭으로 정렬할 수 있나요?
가능합니다. 들여쓰기 설정에 공백 2칸, 4칸, 탭이 있고 선택은 문서 전체에 적용됩니다. 속성이 각자의 줄로 넘어갈 때 쓰는 추가 단계에도 같은 설정이 쓰입니다.
목적지에 맞춰 고르세요. .editorconfig가 있는 저장소에 들어갈 파일이라면 거기에 맞추면 됩니다. 어딘가에 삽입하느라 크기가 중요하다면, 탭은 단계당 4바이트가 아니라 1바이트입니다.
왜 문서가 바뀌지 않은 채 돌아왔나요?
적격 형식이 아니었기 때문입니다. 정렬하려면 파싱이 필요하므로, 입력은 손대지 않고 돌려주고 대신 오류를 줄, 열, 그리고 그 자리에 무엇을 써야 하는지와 함께 나열합니다.
흔한 원인은 URL 쿼리 문자열 안의 그대로 쓰인 앰퍼샌드, 닫히지 않은 태그, 여는 태그와 이름이 다른 닫는 태그, 그리고 조각을 이어 붙여 생긴 두 개의 루트 요소입니다. xmllint도 똑같이 동작하며, 그래서 "xmllint가 파일을 정렬해 주지 않는다"는 검색이 그렇게 많은 것입니다.
CDATA 섹션과 주석은 어떻게 되나요?
CDATA 섹션은 손대지 않고 그대로 통과합니다. 구분자는 남고, 그 사이의 바이트는 이스케이프되지도, 잘리지도, 다시 들여쓰기되지도 않습니다. 사용하는 포맷터마다 이 점을 확인하세요. CDATA를 이스케이프된 텍스트로 바꾸는 것을 기본으로 삼는 도구도 있고, 그러면 돌려받은 문서는 붙여넣은 문서가 아닙니다.
주석은 기본적으로 유지되며, 제거하는 체크박스가 있습니다. 누르기 전에 한 번 생각해 보세요. XSLT, Maven, Ant 파일에서 주석은 누군가 적어 둔 유일한 설명인 경우가 많습니다.
얼마나 큰 문서까지 정렬할 수 있나요?
20 MB까지입니다. 1 MB 문서는 대략 200밀리초, 5 MB는 1초 조금 안 되게 걸리며, Web Worker에서 실행되므로 편집기가 멈추지 않습니다.
이 한계가 있는 이유는 모든 것이 이 탭에서 돌기 때문입니다. 큰 파일을 넘길 서버가 없고, 20 MB를 넘어가면 파스 트리가 수백 MB를 차지해 브라우저가 응답을 멈춥니다. 그보다 큰 파일이라면, 여러분 컴퓨터의 xmllint --format이 같은 혼합 콘텐츠 규칙을 적용해 줍니다.