XML 비교

두 문서를 비교합니다. 어느 쪽도 브라우저를 벗어나지 않습니다.

원본
변경본
대기 중문서를 붙여넣으면 검사합니다. 입력하는 동안 검증이 실행됩니다.

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

왼쪽에 한 문서를, 오른쪽에 다른 문서를 붙여넣으면 차이가 줄 단위로 나타납니다. 추가된 줄은 오른쪽 문서 기준으로, 삭제된 줄은 왼쪽 문서 기준으로 번호가 매겨집니다. 변경이 없는 긴 구간은 표시 하나로 접혀서 바뀐 부분을 계속 찾기 쉽습니다. 두 문서 모두 이 탭 안에 머무릅니다. 이 검색어 상위 도구 대부분은 그렇지 않습니다.

비교는 텍스트가 아니라 구조를 대상으로 합니다. 두 문서를 먼저 같은 형식(공백 2칸 들여쓰기, 속성은 알파벳순)으로 다시 정렬한 뒤, 그 결과끼리 비교합니다. 들여쓰기만 다르거나 속성 순서만 다른 두 문서는 동일하게 나오고, 결과 패널이 바로 그 표현으로 알려 줍니다.

기대한 페이로드와 실제 페이로드를 비교할 때 원하는 것이 그것이고, 이 도구는 그 용도로 만들어졌습니다. 공백이 의미를 지니는 경우에는 잘못된 기본값이며, 그 경계가 정확히 어디인지는 아래 절들이 말해 줍니다.

더 이상 차이가 아니게 되는 것

무엇을 비교하기 전에 양쪽 모두 같은 포맷터를 통과합니다. XML은 속성 순서도, 혼합 콘텐츠 바깥의 들여쓰기도 의미 있는 것으로 취급하지 않습니다. 따라서 둘 중 하나를 차이로 보고하는 diff는 데이터가 아니라 직렬화기를 설명하고 있는 셈입니다. Jackson이 쓴 문서와 같은 데이터를 .NET의 XmlWriter가 쓴 문서를 바로 비교할 수 있는 이유입니다.

  • 들여쓰기, 줄바꿈, 태그가 줄의 어디에 놓이는지.
  • 속성 순서: 양쪽 모두 이름 기준 알파벳순으로 정렬합니다.
  • 빈 요소 표기: <status></status>와 <status> </status>는 둘 다 <status/>가 됩니다.
  • 텍스트만 있는 요소의 앞뒤 공백. 그래서 <name> Priya </name>는 <name>Priya</name>와 일치합니다.
  • 선언에서 version, encoding, standalone의 순서.

일부러 차이로 남겨 두는 것

포맷터는 안전하게 바꿀 수 없는 것은 바꾸지 않습니다. 속성 값은 원래 따옴표까지 포함해 쓰인 그대로 출력합니다. 다시 이스케이프하면 &amp;가 &amp;amp;가 되어 버리고, 디코딩도 불가능합니다. &companyName;처럼 DTD에 선언된 엔티티를 참조하는 값에는 그 DTD가 필요하기 때문입니다.

혼합 콘텐츠, 즉 텍스트와 자식 요소를 함께 담은 요소는 바이트 단위로 그대로 복제합니다. 거기서는 텍스트와 마크업 사이의 공백이 데이터의 일부이기 때문입니다. 들여쓰기가 diff에 실제로 나타나는 곳은 그 한 군데뿐이며, 거기서는 나타나는 것이 맞습니다.

  • 따옴표 스타일: id='A-991'과 id="A-991". 참조를 적는 방식도 마찬가지로 &amp;와 &#38;는 다릅니다.
  • CDATA와 이스케이프된 텍스트: <note><![CDATA[a<b]]></note>와 <note>a&lt;b</note>는 같은 문자를 전달하지만 서로 다른 것으로 보고됩니다.
  • 주석. 지우지 않고 유지합니다. 설정 파일에서는 바뀐 주석이야말로 찾던 변경인 경우가 많습니다.
  • 네임스페이스 접두사. 같은 URI를 유지한 채 soap:을 s:로 바꾸는 것은 의미상 동일하지만, 문서 전체의 차이로 표시됩니다.

정규화가 잘못된 기본값이 되는 경우

어떤 문서는 구조가 아니라 바이트입니다. XML 전자서명은 정규화된 바이트열을 다이제스트하므로, 이 도구가 없애는 들여쓰기를 포함해 어떤 변경이든 서명을 깨뜨립니다. 서명된 어서션 두 개가 동일하다고 말하는 diff는 내용이 일치한다는 뜻이지, 둘 다 여전히 검증을 통과한다는 뜻이 아닙니다.

다른 경우는 xml:space="preserve"를 선언한 문서나, 미리 정렬된 텍스트를 담은 문서입니다. 혼합 콘텐츠 하위 트리는 안전하지만, 앞쪽 공백이 중요한 텍스트 전용 요소는 안전하지 않습니다. 그 공백은 잘립니다. 문서가 그것에 의존한다면 일반 텍스트 diff를 쓰세요.

기대값 대 실제값

이 도구가 존재하는 이유 그 자체인 상황입니다. 통합 테스트가 실패했고, 손에는 기대하던 픽스처와 서비스가 실제로 돌려준 페이로드가 있는데, 하나는 손으로 쓴 것이고 다른 하나는 회선에서 압축된 채 와서 서식이 전혀 다릅니다. 그 둘의 일반 텍스트 diff는 쓸 수가 없습니다. 둘 다 먼저 정규화하면 실제로 바뀐 세 줄로 줄어듭니다.

두 문서 모두 먼저 적격 형식이어야 합니다. 한쪽이라도 파싱되지 않으면 도구는 멈추고 그 사실을 알리며, 왼쪽 문서의 오류는 줄과 열과 함께 편집기에 표시됩니다. 잘려 나간 응답이 구조 변경처럼 보이는 대신 그 자리에서 진단되는 셈입니다.

이것은 줄 단위 diff이지 트리 diff가 아닙니다

비교는 줄에 대한 최장 공통 부분수열 diff이며, git이 쓰는 것과 같은 알고리즘입니다. 따라서 요소를 부모 안에서 옮기면 한쪽에서는 삭제로, 다른 쪽에서는 추가로 나타나지 "이동"으로 나타나지 않습니다. 형제의 순서를 바꾸는 것도, 스키마가 순서를 무의미하게 취급하는 곳에서조차 변경으로 표시됩니다. XML에서 요소 순서는 기본적으로 의미가 있기 때문입니다. 그것이 문제가 될 때의 답은 아래 코드에 있는 XMLUnit의 노드 매처입니다.

상한이 있습니다. 정렬 후 3,000줄을 넘으면 도구는 처리를 거절하고 구간을 나눠 비교하라고 요청합니다. 최장 공통 부분수열 표는 제곱으로 커지므로, 큰 문서 두 개는 느려지는 정도가 아니라 탭을 멈추게 합니다.

코드로 같은 일 하기

테스트 스위트나 빌드 단계에서의 같은 발상입니다. 양쪽을 정규화한 다음 비교합니다. 각 예제는 외부 엔티티 해석을 끕니다. 보통은 여러분이 만들지 않은 페이로드를 향해 쓰기 때문입니다.

// Browsers do not resolve external entities, so DOMParser is safe here. It
// does not throw on malformed input: it returns a document containing a
// <parsererror> element, which is why so much code accepts broken XML.
function parse(source, label) {
  const doc = new DOMParser().parseFromString(source, 'application/xml');
  const err = doc.querySelector('parsererror');
  if (err) throw new Error(label + ': ' + err.textContent.trim());
  return doc;
}

// Canonical text: two spaces per level, attributes sorted by name, empty
// elements written one way. This is what makes the comparison structural.
function canonicalise(node, depth, out) {
  const pad = '  '.repeat(depth);
  if (node.nodeType === Node.TEXT_NODE) {
    const t = node.data.trim();
    if (t) out.push(pad + t);
    return out;
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return out;

  const attrs = Array.from(node.attributes)
    .sort((a, b) => a.name.localeCompare(b.name))
    .map((a) => ' ' + a.name + '="' + escapeAttr(a.value) + '"')
    .join('');

  const kids = Array.from(node.childNodes).filter(
    (c) =>
      c.nodeType === Node.ELEMENT_NODE ||
      (c.nodeType === Node.TEXT_NODE && c.data.trim() !== ''),
  );

  if (kids.length === 0) {
    out.push(pad + '<' + node.nodeName + attrs + '/>');
    return out;
  }
  out.push(pad + '<' + node.nodeName + attrs + '>');
  for (const c of kids) canonicalise(c, depth + 1, out);
  out.push(pad + '</' + node.nodeName + '>');
  return out;
}

function escapeAttr(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

const left = canonicalise(parse(expected, 'expected').documentElement, 0, []);
const right = canonicalise(parse(actual, 'actual').documentElement, 0, []);
// Equality is now a string compare. For a rendered diff, feed the two arrays
// to a line differ; this page runs an LCS over exactly these lines.
console.log(left.join('\n') === right.join('\n') ? 'identical' : 'different');
from lxml import etree
import difflib

# resolve_entities=False and no_network=True are the two that matter: without
# them a payload from an untrusted source can read local files (XXE).
PARSER = etree.XMLParser(resolve_entities=False, no_network=True,
                         load_dtd=False, huge_tree=False)

def canonical_lines(path: str) -> list[str]:
    with open(path, 'rb') as fh:
        doc = etree.parse(fh, PARSER)

    # C14N 2.0 sorts attributes, normalises namespace declarations and writes
    # empty elements one way. strip_text drops insignificant whitespace, which
    # is what makes indentation irrelevant to the comparison. Note that it
    # strips whitespace inside mixed content too, which is lossy.
    canon = etree.canonicalize(etree.tostring(doc), strip_text=True)

    reparsed = etree.fromstring(canon.encode(), PARSER)
    etree.indent(reparsed, space='  ')          # lxml 4.5 and later
    return etree.tostring(reparsed, encoding='unicode').splitlines(keepends=True)

diff = difflib.unified_diff(
    canonical_lines('expected.xml'),
    canonical_lines('actual.xml'),
    fromfile='expected.xml',
    tofile='actual.xml',
)
for line in diff:
    print(line, end='')
// XMLUnit 2 compares trees, not lines, so it can tell you "attribute 'total'
// differs at /order[1]/total[1]" rather than showing two lines and leaving
// you to spot it.
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.builder.Input;
import org.xmlunit.diff.DefaultNodeMatcher;
import org.xmlunit.diff.Diff;
import org.xmlunit.diff.ElementSelectors;

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setNamespaceAware(true);

Diff diff = DiffBuilder.compare(Input.fromFile("expected.xml"))
    .withTest(Input.fromFile("actual.xml"))
    .withDocumentBuilderFactory(dbf)
    .ignoreComments()
    .ignoreWhitespace()        // drops whitespace-only text nodes
    .normalizeWhitespace()     // collapses runs inside the text that remains
    // byNameAndText pairs repeated elements up by content rather than by
    // position, so a reordered list is not reported as every row changing.
    .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText))
    .checkForSimilar()         // "similar" ignores attribute order and prefixes
    .build();

if (diff.hasDifferences()) {
    diff.getDifferences().forEach(d -> System.out.println(d));
    System.exit(1);
}
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;

// Load() without LoadOptions.PreserveWhitespace drops insignificant
// whitespace, so indentation never reaches the comparison. Prohibiting DTDs
// and nulling the resolver closes XXE.
static XDocument LoadSafely(string path)
{
    var settings = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null,
    };
    using var reader = XmlReader.Create(path, settings);
    return XDocument.Load(reader);
}

var expected = LoadSafely("expected.xml");
var actual = LoadSafely("actual.xml");

// XNode.DeepEquals already ignores attribute order. Element order is
// significant to it, as it is to XML itself.
if (XNode.DeepEquals(expected, actual))
{
    Console.WriteLine("Identical.");
    return;
}

// Not equal: write both out normalised so a text diff is readable.
static void SortAttributes(XElement e)
{
    var sorted = e.Attributes()
                  .OrderBy(a => a.Name.NamespaceName)
                  .ThenBy(a => a.Name.LocalName)
                  .ToList();
    e.RemoveAttributes();
    e.Add(sorted);
    foreach (var child in e.Elements()) SortAttributes(child);
}

SortAttributes(expected.Root!);
SortAttributes(actual.Root!);
File.WriteAllText("expected.norm.xml", expected.ToString());
File.WriteAllText("actual.norm.xml", actual.ToString());
Console.Error.WriteLine("Documents differ. Diff the two .norm.xml files.");
# xmllint ships with libxml2 and is almost certainly already installed.
# --c14n implements Canonical XML 1.0: attributes sorted, empty elements
# expanded to a start/end pair, namespace declarations normalised.
# --nonet stops it fetching a DTD the document points at.

xmllint --nonet --c14n expected.xml > /tmp/a.c14n
xmllint --nonet --c14n actual.xml   > /tmp/b.c14n

# C14N does not re-indent, so pretty-print afterwards or the whole document
# arrives on one line and the diff is useless. --format leaves an element
# alone when it contains text of its own, so mixed content is not reflowed.
xmllint --nonet --format /tmp/a.c14n > /tmp/a.xml
xmllint --nonet --format /tmp/b.c14n > /tmp/b.xml

diff -u /tmp/a.xml /tmp/b.xml
# Exit status 1 from diff means "they differ" and is not an error. Guard for
# it explicitly in CI, or set -e will kill the job on a successful comparison.

# C14N converts to UTF-8 and drops the XML declaration, so this will not tell
# you the two files declared different encodings. Check that with head -c 100.

구분을 분명히 해 둘 가치가 있습니다. 정규화 + 줄 diff는 사람이 읽을 수 있는 결과를 주고, XMLUnit 같은 트리 비교는 테스트가 단언할 대상과 쓸모 있는 실패 메시지를 줍니다.

자주 묻는 질문

비교하려면 두 문서가 업로드되나요?

아닙니다. 두 편집기도, 포맷터도, diff 알고리즘도 모두 이 탭에서 도는 JavaScript이고, 무언가를 보낼 서버 쪽 구성요소가 없습니다.

이 도구가 존재하는 이유가 그것입니다. 실제 페이로드는 진짜 운영 트래픽입니다. 진짜 고객 이름, 진짜 주문 금액, 그리고 흔히 SOAP 헤더의 베어러 토큰까지. 이 검색어 상위의 여러 도구는 두 파일을 업로드로 받습니다. 붙여넣는 동안 네트워크 패널을 열어 두면 계속 비어 있습니다.

두 문서는 새로고침해도 작업을 잃지 않도록 이 브라우저의 localStorage에 보관됩니다. 그것은 여러분의 컴퓨터를 벗어나지 않으며, 지우기를 누르면 둘 다 즉시 삭제됩니다.

분명히 다른데도 두 문서가 동일하다고 나오는 이유는?

바이트가 아니라 구조를 비교하기 때문입니다. 양쪽을 먼저 같은 들여쓰기로 다시 정렬하고 속성을 정렬하므로, 압축된 문서와 같은 데이터를 공백 4칸으로 정렬한 것이 같게 나오고, <order id="A-991" total="64.85">와 <order total="64.85" id="A-991">도 같게 나옵니다.

XML은 속성 순서도 혼합 콘텐츠 바깥의 들여쓰기도 의미 있는 것으로 취급하지 않으므로, 둘 중 하나를 차이로 보고하는 diff는 데이터가 아니라 직렬화기를 설명하는 것입니다. 바이트 단위 비교가 필요하다면, 그리고 서명된 문서에는 필요합니다, 일반 텍스트 diff를 쓰세요. 결과 위의 배너는 들여쓰기와 속성 순서를 정규화한 뒤 비교했음을 항상 명시하므로, 이것이 조용히 일어나는 일은 없습니다.

두 문서가 유효한 XML이어야 하나요?

적격 형식이어야 하며, 이는 유효한 것과 다릅니다. 적격 형식이란 구문이 올바르다는 뜻입니다. 태그가 닫히고 제대로 중첩되어 있고, 루트 요소가 하나이고, 특수 문자가 이스케이프되어 있고, 속성 값이 따옴표로 묶여 있는 것. 유효하다는 것은 거기에 더해 스키마에 부합한다는 뜻인데, 여기에는 스키마가 관여하지 않습니다.

파싱되지 않는 문서로는 비교를 실행할 수 없습니다. 정규화할 구조가 없기 때문입니다. 한쪽이 실패하면 도구는 멈추고 그 사실을 알립니다. 텍스트 diff로 물러나 그럴듯해 보이는 결과를 주지 않습니다. 그것만으로도 실제 버그 한 부류를 잡아냅니다. 잘려 나간 응답이 빨간 줄의 벽이 아니라, 줄과 열이 붙은 파싱 실패로 나타나기 때문입니다.

네임스페이스 접두사가 다른 문서끼리 비교할 수 있나요?

다르다고 보고하며, 이것은 진짜 한계입니다. 같은 URI에 바인딩된 soap:Envelope와 s:Envelope는 네임스페이스를 아는 소비자에게는 동일하지만, 접두사는 쓰인 그대로의 요소 이름의 일부이고 포맷터는 접두사를 다시 쓰지 않습니다.

다시 쓰는 것은 일반적으로 안전하지 않습니다. 접두사는 속성 값 안, xsi:type 안, 스타일시트의 XPath 식 안, WSDL의 QName 안에도 나타날 수 있고, 그곳은 포맷터가 볼 수 없습니다. 접두사 차이를 넘어서 봐야 한다면 정규화를 동반한 비교를 쓰세요. 위 XMLUnit 예제의 checkForSimilar가 그것을 처리하고, 셸과 파이썬 예제의 C14N도 마찬가지입니다.

문서 한 쌍이 얼마나 커도 되나요?

각각 3,000줄까지이며, 붙여넣은 상태가 아니라 정렬 후의 줄 수로 셉니다. 그래서 한 줄로 도착한 압축 문서라도 펼쳐서 8,000줄이 되면 한도를 넘습니다.

이 한도는 의도한 것입니다. 최장 공통 부분수열 diff는 두 줄 수의 곱에 비례하는 표를 만들기 때문에, 2만 줄짜리 문서 두 개는 수억 개의 칸이 필요해져 느려지는 정도가 아니라 탭을 멈추게 합니다. 그 정도 크기의 파일에는 xmllint --format의 출력에 대한 git diff나, 위의 셸 레시피가 브라우저 탭이 시도해서는 안 될 일을 대신해 줍니다.

관련 도구

참고 자료