JSON을 XML로 변환
XML로 변환하고, 무엇을 왜 바꿨는지 보여 줍니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
위에 JSON을 붙여넣으면 옆에 적격 형식의 XML이 나타납니다. 선언이 붙고, 실제로 들여쓰기되며, 특수 문자는 모두 이스케이프됩니다. JSON이 파싱되지 않으면 빈 창 대신 파서 자신의 메시지가 나오고, 합법적인 XML 요소 이름으로 만들기 위해 키를 바꿔야 했다면 어떤 키가 무엇이 되었는지 알려 줍니다.
이것이 필요한 때는 아래쪽의 무언가가 XML만 말할 때입니다. SOAP 엔드포인트, 레거시 ERP 임포트, XSD로 검증되는 교환 형식, 모킹 중인 서비스용 픽스처 같은 것들이죠. 짝을 이루는 두 방향 중 덜 화려한 쪽이자 모서리가 더 날카로운 쪽입니다. XML은 JSON에 없는 제약을 부과하고, 그 제약은 어딘가에서 반드시 해소되어야 하기 때문입니다.
모든 처리는 이 탭에서 이루어집니다. 아무것도 업로드하지 않으며, 이 점은 말해 둘 가치가 있습니다. 사람들이 변환기에 붙여넣는 JSON은 대개 캡처한 API 응답이고, 캡처한 API 응답에는 토큰과 계좌번호, 고객 레코드가 들어 있기 때문입니다.
XML에는 루트가 정확히 하나 필요합니다. JSON은 아닙니다.
RFC 8259는 JSON 문서의 최상위에 어떤 값이든 허용합니다. 객체, 배열, 문자열, 숫자, true, false, null. XML 1.0은 나머지 전부를 담는 루트 요소를 정확히 하나 요구하므로, 이 어긋남은 변환할 때마다 해소됩니다.
키가 정확히 하나인 객체에는 이미 자연스러운 루트가 있으므로, 그 키가 루트 요소가 되고 아무것도 지어내지 않습니다. {"order": {...}}는 감싸는 것 없이 <order>...</order>가 됩니다. 이것이 흔한 경우인데, XML을 JSON으로 바꿨을 때 돌아오는 모양이 바로 그것이기 때문입니다. 그 밖의 것은 감싸이고, 메모 창이 그 사실을 알려 줍니다.
- 최상위 키가 둘 이상인 객체는 하나의 요소로 감싸입니다. 기본 이름은 root이고 컨트롤 줄에서 바꿀 수 있습니다.
- 최상위가 배열이면 두 번 감싸입니다. 배열에는 자기 요소 이름이 없기 때문에, 각 멤버는 <root> 안의 <item>이 됩니다.
- 최상위가 스칼라이면 루트 요소의 텍스트가 되므로, JSON 문서 42는 <root>42</root>가 됩니다.
- 최상위가 null이면 빈 루트 요소 <root/>가 됩니다.
배열은 요소 이름을 반복합니다. 감싸는 요소는 생기지 않습니다.
이것이 대부분의 변환기가 거꾸로 하는 결정입니다. {"line": ["a", "b"]}는 형제 관계인 <line> 요소 두 개가 되지, <item> 자식 둘을 가진 <line> 요소가 되지 않습니다. 반복이야말로 XML이 목록을 표현하는 방식이며, XML→JSON 방향에 단수 문제가 있는 이유이기도 합니다. 감싸는 요소를 지어내면 기존 어떤 스키마도 받아들이지 않을 XML이 나오고, 왕복도 되지 않습니다.
여기서 두 가지가 따라옵니다. 빈 배열은 아무것도 만들지 않으므로 키가 통째로 사라집니다. 요소를 0번 반복하면 요소는 0개입니다. 그리고 배열의 배열은 평평해집니다. 안쪽 배열에는 바깥과 구분되는 이름이 없어서, 키 a 아래의 [[1,2],[3]]은 <a> 요소 세 개가 됩니다. 둘 중 무엇이든 문제가 된다면 JSON 구조를 먼저 바꾸세요.
{
"order": {
"@_id": "00042",
"line": [ "Widget", "Gasket" ],
"note": null,
"meta": {},
"tags": []
}
}
<?xml version="1.0" encoding="UTF-8"?>
<order id="00042">
<line>Widget</line>
<line>Gasket</line>
<note/>
<meta/>
</order>JSON 키는 합법적인 XML 이름이 아닌 경우가 많습니다
XML 1.0의 2.3절은 Name을 NameStartChar 뒤에 NameChar가 오는 것으로 정의합니다. NameStartChar는 글자, 밑줄, 콜론이며 숫자도, 공백도, 앰퍼샌드도, 달러 기호도 아닙니다. JSON 키에는 그런 제한이 없어서 "2024 total", "user@email", "$ref"는 평범한 키이고 그중 어느 것도 합법적인 요소 이름이 아닙니다.
.NET과 XSD 세계는 이스케이프를 택해 공백을 _x0020_로 바꿉니다. 정확하지만 읽을 수 없습니다. 이 도구는 대신 정리하고 보고합니다. 불법 문자는 지우지 않고 하나씩 치환하며, 그러고도 숫자로 시작하는 이름에는 접두사를 붙입니다. 그것이 핵심입니다. 지우면 서로 다른 키가 같은 이름으로 뭉개지지만 치환은 그렇지 않기 때문입니다. 모든 개명은 메모 창에 나타납니다.
- "2024 total"은 _2024_total이 됩니다. 공백이 치환되고, 그다음 맨 앞의 숫자가 접두사를 강제합니다.
- "2024-total"은 _2024-total이 됩니다. 하이픈은 이미 합법이라 접두사가 필요한 것은 맨 앞 숫자뿐입니다. 둘은 서로 다른 채로 남는데, 지웠다면 바로 이것을 잃었을 것입니다.
- "user@email"은 user_email, "$ref"는 _ref, 빈 키는 밑줄 하나가 됩니다.
- 이미 합법인 키는 그대로 통과합니다. 콜론이 든 것도 마찬가지여서 "soap:Body"는 "soap:Body"로 남습니다. 그 결과 접두사는 있는데 xmlns 선언이 없는 요소가 되며, 적격 형식이기는 하지만 네임스페이스 관점에서는 올바르지 않습니다.
속성, 텍스트, 그리고 JSON이 가장 먼저 잃는 것
@_로 시작하는 키는 감싸는 요소의 속성이 되고, #text라는 키는 텍스트 내용을 제공합니다. 둘 다 XML→JSON 방향과 맞물리므로, 그 페이지의 출력은 그대로 되돌릴 수 있습니다. 속성 값은 텍스트보다 더 강하게 이스케이프합니다. &, <, " 외에 탭, 줄바꿈, 캐리지 리턴도 숫자 문자 참조로 씁니다. XML 1.0의 3.3.3절이 다시 파싱할 때 속성 값 안의 리터럴 공백을 스페이스로 정규화하기 때문입니다.
두 가지 손실은 이 도구가 관여하기 전에 JSON 안에서 이미 일어나며, 변환 버그처럼 보입니다. JSON 숫자는 IEEE 754 배정밀도이므로, 19자리 식별자를 맨숫자로 쓴 시점에서 텍스트가 파싱될 무렵이면 아랫자리는 이미 사라져 있습니다. 그리고 중복된 키는 파서가 해결하며 마지막 것이 이깁니다. JavaScript 특유의 버릇도 있습니다. 배열 인덱스처럼 보이는 키가 먼저, 그것도 숫자 오름차순으로 열거되므로 "2", "10", "name"을 섞은 객체는 작성한 순서대로 요소를 내보내지 않습니다.
null과 빈 객체는 둘 다 <x/>가 되므로 구분할 수 없고, 둘 다 빈 문자열로 돌아옵니다. 구분이 필요하다면 "존재하지만 null"을 표현하는 표준적인 방법은 xsi:nil="true" 하나뿐이며, 조상 어딘가에 xsi 네임스페이스를 선언해 두어야 합니다.
코드로 같은 일 하기
XML을 가장 많이 다루는 네 언어에 PHP와 셸 한 줄을 더한 같은 변환입니다. 보안 플래그는 돌아오는 길에서 중요해집니다. JSON을 파싱하는 것 자체는 위험이 아니지만, JSON을 XML로 바꾸는 코드는 거의 항상 그 XML을 어딘가에서 다시 파싱하고, Java와 .NET의 기본값은 DOCTYPE이 나타나면 해석해 버립니다.
import { XMLBuilder } from 'fast-xml-parser';
const builder = new XMLBuilder({
ignoreAttributes: false, // default is true: @_ keys would be dropped
attributeNamePrefix: '@_',
textNodeName: '#text',
format: true,
indentBy: ' ',
suppressEmptyNode: true, // write <note/> rather than <note></note>
processEntities: true, // escape &, < and " in values
});
const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build(data);
// XMLBuilder does not sanitise keys. A key of "2024 total" is written
// verbatim and produces XML that will not parse, so check before building:
const illegal = Object.keys(flatten(data))
.filter((k) => !/^[A-Za-z_][\w.\-]*(:[A-Za-z_][\w.\-]*)?$/.test(k));
if (illegal.length) throw new Error('Illegal XML names: ' + illegal.join(', '));import json
import re
import xmltodict
def legal_name(key):
"""Replace illegal characters rather than stripping them, so that
distinct keys stay distinct. Prefix a leading digit."""
name = re.sub(r'[^\w.\-:]', '_', key, flags=re.UNICODE)
return name if re.match(r'^[A-Za-z_:]', name) else '_' + name
def sanitise(node):
if isinstance(node, dict):
return {legal_name(k): sanitise(v) for k, v in node.items()}
if isinstance(node, list):
return [sanitise(v) for v in node]
return node
data = json.loads(json_source)
if not isinstance(data, dict) or len(data) != 1:
data = {'root': data} # xmltodict.unparse requires a single root
print(xmltodict.unparse(
sanitise(data),
pretty=True, indent=' ',
attr_prefix='@_', cdata_key='#text',
full_document=True, # emit the <?xml ...?> declaration
))
# xmltodict raises ValueError("Document must have exactly one root.") rather
# than guessing, which is correct behaviour and the reason for the wrap.import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
JsonNode tree = new ObjectMapper().readTree(jsonSource);
XmlMapper xml = new XmlMapper();
xml.enable(SerializationFeature.INDENT_OUTPUT);
// JSON has no root name and Jackson will not invent one, so supply it.
String out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ xml.writer().withRootName("root").writeValueAsString(tree);
// Two things Jackson will not do for you:
// 1. It does not sanitise names. A key with a space throws
// IllegalArgumentException at write time, which is at least loud.
// 2. It writes every value as a child element. There is no attribute
// convention on a JsonNode, so @_ keys become elements unless you bind
// to a class annotated with @JacksonXmlProperty(isAttribute = true).using System.Xml;
using Newtonsoft.Json;
// The second argument is the root element name, used when the JSON does not
// already have exactly one top-level property. Without it, multi-key JSON
// throws JsonSerializationException rather than producing invalid XML.
XmlDocument? document = JsonConvert.DeserializeXmlNode(jsonSource, "root");
if (document is null) throw new InvalidOperationException("Empty JSON.");
var settings = new XmlWriterSettings { Indent = true, IndentChars = " " };
using var writer = XmlWriter.Create(Console.Out, settings);
document.Save(writer);
// Json.NET uses "@" for attributes and "#text" for text, so retarget the
// keys if your JSON came from a converter using "@_". It does not sanitise
// names either: a property called "2024 total" throws XmlException("The ''
// character, hexadecimal value 0x20, cannot be included in a name").<?php
$data = json_decode($source, true, 512, JSON_THROW_ON_ERROR);
function legal_name(string $key): string {
$name = preg_replace('/[^\w.\-:]/u', '_', $key);
return preg_match('/^[A-Za-z_:]/', $name) ? $name : '_' . $name;
}
function write_node(XMLWriter $w, string $name, mixed $value): void {
if (is_array($value) && array_is_list($value)) {
foreach ($value as $v) write_node($w, $name, $v); // repeat, no wrapper
return;
}
$w->startElement(legal_name($name));
if (is_array($value)) {
foreach ($value as $k => $v) {
if (str_starts_with((string) $k, '@_')) {
$w->writeAttribute(legal_name(substr((string) $k, 2)), (string) $v);
} elseif ($k === '#text') {
$w->text((string) $v);
} else {
write_node($w, (string) $k, $v);
}
}
} elseif ($value !== null) {
$w->text(is_bool($value) ? ($value ? 'true' : 'false') : (string) $value);
}
$w->endElement();
}
$single = count($data) === 1;
$w = new XMLWriter();
$w->openMemory();
$w->setIndent(true);
$w->setIndentString(' ');
$w->startDocument('1.0', 'UTF-8');
write_node($w, $single ? (string) array_key_first($data) : 'root',
$single ? reset($data) : $data);
$w->endDocument();
echo $w->outputMemory();# yq v4 (Mike Farah). JSON is a subset of YAML, so -p=json works directly.
yq -p=json -o=xml '.' payload.json
# Set the root and the key conventions to match this page:
yq -p=json -o=xml \
--xml-attribute-prefix='@_' \
--xml-content-name='#text' \
'{"root": .}' payload.json
# yq writes no XML declaration, so prepend one if a consumer expects it, and
# check the result: yq does not sanitise element names.
{ echo '<?xml version="1.0" encoding="UTF-8"?>'
yq -p=json -o=xml '{"root": .}' payload.json; } | xmllint --noout --nonet -위 라이브러리 중 어느 것도 하지 않는 일을 보세요. 키를 합법적인 XML 이름으로 정리하는 일입니다. Jackson과 Json.NET, XMLBuilder는 예외를 던지거나 파싱되지 않을 XML을 내보내고, yq는 조용히 내보냅니다. JSON 키가 사용자 입력이나 데이터베이스 열 목록, 스프레드시트 머리글 행에서 온다면 정리 단계는 여러분이 써야 합니다. 그리고 문자를 지우는 대신 치환하는 것이, 비슷한 두 키가 한 요소가 되는 것을 막아 줍니다.
자주 묻는 질문
제 JSON이 브라우저 밖으로 나가나요?
아닙니다. JSON 파서도, 이름 정리기도, XML 작성기도 모두 이 탭에서 도는 JavaScript이고, 이야기를 나눌 서버 쪽 구성요소가 없습니다. 네트워크 패널을 열고 무엇이든 변환해 보세요. 요청은 하나도 나가지 않습니다.
이 방향에서 그 점이 가장 중요합니다. 변환기에 붙여넣는 JSON은 대개 디버깅 중 운영 API에서 캡처한 응답이고, 액세스 토큰이나 고객 레코드 전체가 그대로 들어 있습니다. 이 검색어 상위의 여러 도구가 그 페이로드를 서버로 보내고, 한 곳은 저장된 문서를 추측 가능한 URL로 공개합니다.
왜 제 JSON이 <root> 요소로 감싸였나요?
XML이 루트 요소를 정확히 하나만 허용하는데, 여러분의 JSON에 최상위 키가 둘 이상이었거나, 배열이었거나, 맨 스칼라였기 때문입니다. XML에서 형제인 루트를 둘 쓸 방법은 없습니다.
키가 정확히 하나인 객체는 그대로 둡니다. 그 키가 루트가 되고 감싸는 요소는 붙지 않으므로, {"order": {...}}는 <order>가 되고 최상위 키를 하나 더 넣으면 <root>가 됩니다. 감싸는 이름은 컨트롤 줄에서 바꿀 수 있습니다. XML이 검증하는 곳으로 간다면 스키마가 기대하는 이름으로 맞추세요.
JSON 배열은 어떻게 변환되나요?
멤버마다 요소 이름을 한 번씩 반복하며, 감싸는 요소는 붙이지 않습니다. {"line": ["a", "b"]}는 <line> 요소 두 개를 나란히 만듭니다. XML이 목록을 표현하는 방식이 그것이고, 출력이 왕복 가능한 이유입니다. 어떤 변환기는 대신 <line><item>a</item><item>b</item></line>를 내놓는데, JSON과 더 비슷해 보이지만 진짜 XML을 위해 쓰인 어떤 스키마에서도 검증에 실패합니다.
여기서 두 가지 경계 사례가 나옵니다. 빈 배열은 아무것도 내보내지 않아 키가 사라지고, 배열 안에 배열이 바로 들어 있으면 평평해집니다. 안쪽에 자기 이름이 없기 때문입니다.
유효한 XML 요소 이름이 아닌 키는 어떻게 되나요?
이름이 바뀌고, 모든 개명은 출력 옆 메모 창에 나열됩니다. 불법 문자는 하나씩 밑줄로 치환되고, 그러고도 숫자로 시작하는 이름에는 앞에 밑줄이 붙습니다.
지우지 않고 치환하는 것은 의도한 선택입니다. 지우면 "2024 total"과 "2024total"이 같은 요소가 되어 서로 다른 두 필드가 합쳐집니다. 그렇다고 완벽한 단사는 아닙니다. "first name"과 "first_name"은 둘 다 first_name이 되는데, 후자에서는 밑줄이 이미 합법이었기 때문입니다.
자식 요소 대신 속성을 얻으려면?
키 앞에 @_를 붙이세요. {"user": {"@_id": "7", "name": "Alice"}}는 <user id="7"><name>Alice</name></user>가 됩니다. 접두사는 편집기 위에서 바꿀 수 있고, 비우면 속성으로 쓰이는 것이 전혀 없어집니다.
거기에는 스칼라만 두세요. 값은 문자열로 변환되므로 @_ 키 아래에 객체를 두면 쓸모없는 [object Object] 텍스트가 됩니다. 이 작성기가 대부분과 다른 점이 하나 있습니다. 속성 값 안의 탭, 줄바꿈, 캐리지 리턴을 숫자 문자 참조로 쓰기 때문에, 여러 줄짜리 값이 스페이스로 뭉개지지 않고 재파싱을 견딥니다.
다시 JSON으로 바꾸면 처음 것을 되찾을 수 있나요?
대부분의 문서는 그렇습니다. XML→JSON 페이지에서 @_와 #text를 같은 설정으로 두면 됩니다. 그 기본값이 선택된 이유가 바로 이 짝 때문입니다.
네 가지는 살아남지 못합니다. null과 {}는 둘 다 <x/>가 되어 빈 문자열로 돌아옵니다. 빈 배열은 완전히 사라집니다. XML에는 타입이 없으므로 JSON의 숫자 타입은 사라지고, 타입 변환을 켜지 않는 한 42는 "42"로 돌아옵니다. 그리고 키 순서는 두 형식 어디에서도 의미가 없습니다. 정확한 왕복이 요구 사항이라면 XML을 원본으로 삼고 XPath로 읽어 내세요.