XML을 JSON으로 변환

JSON으로 변환합니다. 모든 대응 규칙을 직접 정합니다.

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

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

위에 XML을 붙여넣으면 입력하는 동안 JSON이 나타납니다. 문서는 먼저 적격 형식인지 검사합니다. 깨진 마크업을 추측으로 헤쳐 나가는 변환기는 명백히 틀린 JSON이 아니라 조용히 틀린 JSON을 내놓기 때문입니다. 구문이 통과하지 못하면 반쪽짜리 객체 대신 줄, 열, 해결 방법을 받게 됩니다.

이 도구가 필요해지는 때는, 대화 상대인 서비스는 XML로 말하는데 그 아래로는 전부 JSON으로 말할 때입니다. 테스트에서 단언하고 싶은 SOAP 응답, 스크립트로 끌어오는 공급사 피드, 임포터를 쓰기 전에 들여다봐야 하는 ONIX 파일 같은 것들이죠. 아무것도 업로드하지 않으며, 엔벨로프에 베어러 토큰이 실려 있을 때 이 점이 중요해집니다.

여기서 다른 점은 대응 규칙을 감추지 않는다는 것입니다. XML을 JSON으로 바꾸는 유일한 정답은 없고, 모든 변환기가 여러분 대신 여섯 가지쯤 결정을 내리지만 그것이 무엇인지 말해 주는 곳은 거의 없습니다. 이 페이지는 결정 하나하나에 이름을 붙이고, 해당 스위치를 보여 주며, 변환으로 무엇이 사라졌는지를 출력 옆 메모 창에 알려 줍니다.

정답이 없고 선택만 있는 이유

XML의 데이터 모델은 JSON의 것보다 엄밀히 더 풍부합니다. XML에는 순서 있는 자식, 속성, 요소 사이에 섞인 텍스트, 네임스페이스, 주석, CDATA가 있습니다. JSON에는 순서 없는 객체, 배열, 문자열, 숫자, 불리언, null이 있습니다. 앞의 것에서 뒤의 것으로 가는 어떤 함수든 무언가를 버리거나 무언가를 지어내야 합니다.

Michael Kay는 스키마 인식 변환에 관한 Balisage 논문에서 이렇게 잘라 말했습니다. 범용 변환기는 "어휘적 XML 뒤에 있는 객체 모델의 의미가 무엇인지 추측하고 있으며, 그 추측은 틀렸다". 추측해야 하는 지점은 다음 일곱 곳입니다.

  • 속성이냐 자식 요소냐. <user id="7"/>와 <user><id>7</id></user>는 서로 다른 문서지만, 대부분의 사람은 같은 JSON이 되기를 바랍니다. 합쳐 버리면 <user id="7"><id>8</id></user>는 중복 키를 만들고, RFC 8259는 그것을 예측 불가능하다고 부릅니다.
  • 한 번 나오는가 여러 번 나오는가. <items><item>a</item></items> 어디에도 item이 반복될 수 있다는 말은 없으므로, 변환기는 개수를 세어 추측합니다.
  • 혼합 콘텐츠. <p>Some text <b>is important</b>.</p>에는 순서 있는 자식이 셋 있고, JSON 객체는 그 순서를 표현할 수 없습니다.
  • 공백뿐인 텍스트. 정렬된 XML에서 요소 사이의 줄바꿈과 들여쓰기는 진짜 텍스트 노드이며, 그대로 두면 충실하지만 쓸모가 없습니다.
  • 네임스페이스. 접두사는 이름이 아니고 URI가 이름인데, JSON에는 네임스페이스 개념 자체가 없습니다.
  • 주석과 처리 명령. 둘 다 JSON에는 존재하지 않습니다.
  • 빈 요소. <e/>는 null, "", {}, 빈 텍스트 키 어디로든 그럴듯하게 대응되고, <e/>와 <e></e>는 같은 문서입니다.

여기서 쓰는 규약, 그리고 그것을 바꾸는 스위치

기본값은 Stefan Goessner가 2006년에 설명한 속성 접두사 규약이며, fast-xml-parser, xml2js, AWS SDK가 모두 그 변형을 씁니다. 속성에는 @_ 접두사가 붙어 같은 이름의 자식 요소와 충돌하지 않습니다. 속성이나 자식과 한 요소를 나눠 쓰는 텍스트는 #text 아래로 들어갑니다. 한 번 나오는 요소는 값이고, 두 번 나오면 배열이 됩니다. 텍스트만 있는 요소는 평범한 문자열로 접히므로 <name>Alice</name>는 "Alice"입니다.

루트 요소는 가장 바깥 키로 남고, 주석은 버려지며, 공백은 다듬어지고, 엔티티 참조는 해석됩니다. 그래서 "A &amp; B"로 쓰인 속성은 "A & B"로 도착합니다. 편집기 위의 컨트롤로 속성 접두사, 텍스트 키, "항상 배열"로 둘 요소 이름 목록, 그리고 체크박스 세 개(네임스페이스 접두사 제거, 속성 버리기, 타입 변환)를 설정합니다. 셋 다 꺼져 있습니다.

<order id="00042">
  <total currency="GBP">19.90</total>
  <line sku="0071">Widget</line>
  <line sku="0072">Gasket</line>
  <note/>
</order>

{
  "order": {
    "@_id": "00042",
    "total": { "@_currency": "GBP", "#text": "19.90" },
    "line": [
      { "@_sku": "0071", "#text": "Widget" },
      { "@_sku": "0072", "#text": "Gasket" }
    ],
    "note": ""
  }
}
기본 설정으로, 까다로운 네 가지 경우를 모두 담은 문서를 변환한 결과.

타입 변환이 기본으로 꺼져 있는 이유

스키마 없는 XML은 끝까지 텍스트입니다. "123"을 숫자로 바꾸는 것은 편리합니다. 식별자를 망가뜨리는 그 순간까지는요. 그리고 XML 연동을 오가는 것의 대부분이 식별자입니다.

변환을 켰을 때도 그 범위는 일부러 좁게 잡았습니다. 값이 숫자가 되는 것은 엄격한 JSON 숫자 문법에 맞고, 그다음 왕복을 견뎠을 때뿐입니다. 파싱한 결과를 다시 직렬화해 원본과 한 글자씩 비교합니다. 이 검사가 다른 변환기들이 안고 출시하는 실패를 막아 줍니다. 변환을 켜도 다음은 문자열로 남습니다.

  • 앞자리 0. "00042"와 "01730"은 문법에서 곧바로 걸립니다. JSON 숫자는 0 뒤에 숫자를 더 붙일 수 없기 때문입니다. 우편번호, 은행 코드, SKU가 살아남습니다.
  • 소수점 뒤 끝자리 0. "19.90"은 19.9로 파싱되고 다시 "19.9"로 직렬화되므로 문자열을 유지합니다. "1.10"이 1.1이 되는 일은 없습니다.
  • double이 담지 못하는 정수. "9007199254740993"은 끝이 992인 값으로 파싱되어 왕복에 실패하므로 문자열로 남습니다. 다른 곳에서 19자리 주문번호를 망가뜨리는 것이 바로 이것입니다.
  • 정규형이 아닌 지수 표기. "1e5"는 100000으로 파싱되는데 그것은 "1e5"가 아니므로 텍스트로 남습니다.
  • 정확히 true, false, null이 아닌 모든 것. "TRUE", "yes", "Y"는 문자열로 남습니다.

무엇이 사라지는가, 그리고 이름 있는 대안들

세 가지는 살아남지 못합니다. 이름이 다른 형제들 사이의 문서 순서는 사라지므로, <line>과 <discount>가 번갈아 나온다 해도 JSON은 그 순서에 대해 아무 말도 하지 않습니다. 혼합 콘텐츠는 평평해집니다. 텍스트 조각은 이어 붙고 사이의 요소들은 각자의 키로 옮겨 가므로, <root>35<nested>34</nested>46</root>의 텍스트 값은 "3546"이 됩니다. 주석은 버려집니다.

네임스페이스는 해석하지 않고 그대로 두므로 soap:Body는 키 "soap:Body"가 됩니다. 접두사를 떼면 "Body"가 되지만, 그러면 서로 다른 네임스페이스의 같은 지역 이름을 가진 두 요소가 한 키에서 충돌합니다. JSON에는 제3의 선택지가 없습니다.

이 규약이 여러분의 소비자가 기대하는 것이 아니라면, 대안들에는 이름이 있습니다. BadgerFish는 텍스트를 $ 아래, 속성을 @이름 아래에 두고 유효 범위의 모든 네임스페이스를 함께 나릅니다. 왕복성은 좋고 가독성은 거의 없습니다. Parker는 속성을 버리고 루트를 흡수해, 가장 간결하고 가장 단방향인 출력을 냅니다. JsonML은 각 요소를 [이름, 속성, 자식]으로 적으며, 혼합 콘텐츠와 자식 순서를 온전히 지키는 유일한 통용 규약입니다.

코드로 같은 일 하기

실제로 XML을 다루는 언어들에서의 같은 변환입니다. 모든 예제가 엔티티 해석을 끕니다. 이 스택들 중 몇몇은 기본값이 DOCTYPE에 적힌 URL을 가져오는데, 그것이 XXE 취약점이기 때문입니다. 각 예제에는 그 라이브러리가 여러분 대신 내리는 대응 결정도 적어 두었습니다.

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({
  ignoreAttributes: false,       // default is true: attributes are DROPPED
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  trimValues: true,
  parseTagValue: false,          // keep values as strings
  parseAttributeValue: false,
  processEntities: false,        // do not expand DOCTYPE-declared entities
  // FXP cannot know whether a tag repeats, so tell it which ones are lists.
  isArray: (name) => ['line', 'item', 'entry'].includes(name),
});

const json = parser.parse(xmlSource);

// fast-xml-parser never fetches anything over the network, so XXE is not
// reachable. It does expand entities declared in an internal DTD unless you
// set processEntities: false, so leave that off for untrusted input and cap
// the input size before parsing.
import json
import xmltodict

# disable_entities=True is the default in current xmltodict and blocks the
# expat entity-expansion attacks. Pass it explicitly so a downgrade of the
# dependency cannot silently re-enable them.
doc = xmltodict.parse(
    xml_source,
    disable_entities=True,
    attr_prefix='@_',
    cdata_key='#text',
    force_list=('line', 'item', 'entry'),   # the singleton fix
)

print(json.dumps(doc, indent=2, ensure_ascii=False))

# xmltodict returns dicts in document order, but that ordering has no meaning
# once serialised: JSON objects are unordered. Values are always strings.
# There is no coercion, which is the right default.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlFactory;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import javax.xml.stream.XMLInputFactory;

XMLInputFactory input = XMLInputFactory.newFactory();
// Neither of these is off by default. Both must be, for untrusted XML.
input.setProperty(XMLInputFactory.SUPPORT_DTD, false);
input.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);

XmlMapper xml = new XmlMapper(new XmlFactory(input));
JsonNode tree = xml.readTree(xmlSource);

String json = new ObjectMapper()
    .writerWithDefaultPrettyPrinter()
    .writeValueAsString(tree);

// Jackson's XML module merges attributes in with child elements: there is no
// prefix, so <user id="7"><id>8</id></user> loses one of the two. If your
// documents put data on attributes, bind to a class annotated with
// @JacksonXmlProperty(isAttribute = true) instead of reading a tree.
using System.Xml;
using Newtonsoft.Json;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
    MaxCharactersInDocument = 20L * 1024 * 1024,
};

using var reader = XmlReader.Create(new StringReader(xmlSource), settings);
var document = new XmlDocument { XmlResolver = null };
document.Load(reader);

// omitRootObject: false keeps the root element as the outer key.
string json = JsonConvert.SerializeXmlNode(
    document, Newtonsoft.Json.Formatting.Indented, omitRootObject: false);

// Json.NET prefixes attributes with "@" and uses "#text" for text, close to
// the convention on this page. It has the singleton problem and no isArray
// hook: the only fix is a json:Array="true" attribute in the source XML,
// which you usually do not control.
<?php
libxml_use_internal_errors(true);

// LIBXML_NONET blocks network access for any DTD the document references.
// LIBXML_NOENT is deliberately NOT passed: it would substitute entities.
$xml = simplexml_load_string($source, 'SimpleXMLElement', LIBXML_NONET);

if ($xml === false) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "line %d col %d: %s\n", $e->line, $e->column, trim($e->message));
    }
    exit(1);
}

echo json_encode($xml, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";

// Two things to know first. json_encode() puts attributes under an
// "@attributes" object, not a prefix. And when an element has both text and
// child elements, SimpleXML drops the text entirely: mixed content does not
// survive this route at all.
# yq v4 (Mike Farah) reads XML and writes JSON with no Python dependency.
yq -p=xml -o=json '.' document.xml

# Attributes are prefixed with + by default; match this page's convention:
yq -p=xml -o=json --xml-attribute-prefix='@_' '.' document.xml

# yq resolves nothing over the network. It also has the singleton problem and
# no per-tag array option, so a list of one comes back as a scalar. Normalise
# on the way into jq:
yq -p=xml -o=json '.' document.xml \
  | jq '.order.line |= (if type == "array" then . else [.] end)'

위의 모든 라이브러리는 최소 한 가지 대응 결정을 말없이 내립니다. Jackson은 속성을 자식과 합치고, SimpleXML은 혼합 콘텐츠의 텍스트를 버리며, Json.NET과 yq는 어떤 요소가 목록인지 알려 줄 방법이 없습니다. 이것은 어느 라이브러리의 버그가 아니라 모호함이 드러난 것입니다. 무엇을 고르든, 항목이 하나인 응답과 여럿인 응답을 같은 코드 경로에 통과시키는 테스트를 작성하세요.

자주 묻는 질문

제 XML이 서버로 전송되나요?

아닙니다. 스캐너도, 매퍼도, JSON 직렬화기도 모두 이 탭에서 도는 JavaScript이고, 변환이 닿을 수 있는 백엔드가 없습니다.

개발자 도구를 열고 네트워크 탭으로 옮긴 뒤 붙여넣고 지켜보세요. 이 페이지의 자체 자원이 한 번 로드되고 그 뒤로는 아무것도 이어지지 않습니다. 여기서는 그 점이 대부분의 변환기보다 더 중요합니다. 사람들이 변환하는 XML은 대개 연동 페이로드이고, WS-Security 헤더나 API 키가 그대로 들어 있기 때문입니다.

<item>이 하나면 객체, 둘이면 배열이 되는 이유는 무엇인가요?

스키마가 없으면 XML에는 개수 정보가 없기 때문입니다. 문서 어디에도 item이 반복될 수 있다는 말이 없으니 변환기는 개수를 세어 추측하고, 그 결과 JSON의 모양이 계약이 아니라 데이터에 좌우됩니다. XML 연동이 깨지는 가장 흔한 방식이 이것입니다. 항목 세 개짜리 테스트 응답을 보고 짠 코드가 items.item.map()을 호출하고, 주문이 하나뿐인 고객이 나타나기 전까지는 잘 돌아갑니다.

해법은 편집기 위의 "항상 배열" 항목입니다. 코드에서도 같은 일을 하세요. fast-xml-parser에는 isArray가, xmltodict에는 force_list가 있고, xml2js는 바로 이 이유로 explicitArray를 기본 true로 둡니다.

JSON으로 변환할 때 XML 속성을 유지하려면?

기본으로 유지됩니다. @_ 접두사가 붙은 키 아래로 들어가므로 <user id="7"/>는 {"user": {"@_id": "7"}}가 됩니다. 이 접두사는 같은 이름의 속성과 자식 요소가 서로 덮어쓰지 못하도록 존재합니다.

접두사를 바꾸거나 비워서 속성을 자식들 사이에 섞을 수도 있습니다. 섞은 출력은 읽기에 낫지만, <user id="7"><id>8</id></user>처럼 이름이 부딪히면 값 하나가 조용히 사라집니다. 속성을 통째로 버리는 체크박스도 있는데, 이는 Parker 규약과 같은 동작입니다. 단방향 추출에는 괜찮지만 되돌릴 용도에는 맞지 않습니다.

soap: 같은 네임스페이스와 접두사는 어떻게 되나요?

접두사가 붙은 이름을 그대로 쓰므로 soap:Body는 키 "soap:Body"가 됩니다. 아무것도 해석하지 않습니다. JSON은 네임스페이스 URI를 담을 수 없기 때문이며, 선언된 네임스페이스가 몇 개인지는 메모 창이 알려 줍니다.

"네임스페이스 접두사 제거"를 켜면 "Body"가 되고, SOAP 응답에서 값 하나를 꺼낼 때는 보통 그것이 원하는 바입니다. 위험도 실재합니다. 문서에 soap:Header와 wsse:Header가 함께 있으면 제거하는 순간 한 키로 합쳐지고 한쪽이 이깁니다.

"숫자와 불리언 변환"을 켜야 하나요?

데이터에 식별자가 없다고 확신할 때만요. 여기서의 변환은 대부분보다 엄격해서 엄격한 숫자 문법과 왕복 검사를 요구하므로, "00042", "19.90", "1.10", 그리고 double에 담기지 않는 정수는 망가지지 않고 문자열로 남습니다.

그래도 감당할 수 없는 것은, 샘플에서는 "1"이었다가 다음 주 화요일에는 "N/A"가 되는 필드입니다. 합리적인 절충은 이 옵션을 끄고, 정말 필요한 두세 개 필드만 여러분의 코드에서 변환하는 것입니다. 그러면 그 결정이 코드에 남습니다.

주석, CDATA, 빈 요소는 어떻게 처리하나요?

주석과 처리 명령은 버립니다. JSON에는 둘을 둘 자리가 없고, 정의상 데이터가 아닌 내용을 위해 키를 지어내면 출력만 다루기 어려워집니다.

CDATA는 텍스트로 취급합니다. <![CDATA[a < b]]>와 a &lt; b는 같은 내용을 두 가지로 쓴 것입니다. 빈 요소는 빈 문자열이 되므로 <note/>와 <note></note> 모두 "note": ""가 됩니다. null을 쓰지 않은 이유는, 그것이 "존재하지만 비어 있음"이 아니라 "값을 알 수 없음"으로 읽히기 때문입니다.

관련 도구

참고 자료